From a855b0b142854741a64302ba55704c7b26ceb9cb Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 11 Oct 2026 18:55:48 +0000 Subject: [PATCH 1/2] chore(deps): upgrade uv.lock and pre-commit hooks --- .pre-commit-config.yaml | 2 +- uv.lock | 2641 ++++++++++++++++++++++----------------- 2 files changed, 1465 insertions(+), 1178 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e1f290dcb..de7cf0da1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.16.9" + rev: "v0.16.10" hooks: - id: ruff-check args: ["--fix"] diff --git a/uv.lock b/uv.lock index c00fe5945..f3e53a276 100644 --- a/uv.lock +++ b/uv.lock @@ -8,12 +8,9 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform == 'emscripten'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -132,7 +129,7 @@ wheels = [ [[package]] name = "aiobotocore" -version = "3.9.1" +version = "3.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -143,9 +140,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/0d/98230a1320e78f2c35277ca194c494690dce3161ad05200556417ab9e816/aiobotocore-3.9.1.tar.gz", hash = "sha256:617d4a78da5adb6ae7d532835727ad388f73555f2ef129b9de1b1add73a43ee6", size = 516440, upload-time = "2026-09-07T17:14:07.099Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/95/4f7e0453d5e81bdb1b40ecf09f135bb88e12ce27fdb3efa125022dcca5d7/aiobotocore-3.9.2.tar.gz", hash = "sha256:8e32238c5bd77717ab1ac90077ff51ab141d025528115ccc7cc9b50b2d176e5f", size = 524343, upload-time = "2026-10-01T02:01:59.522Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/f5/20b5c946fdcfafa8e0538d4d278abf10dda4b4012f29aa98a32fe09a46c5/aiobotocore-3.9.1-py3-none-any.whl", hash = "sha256:23aa448b4daff3fdce0714c962a2050b63c0222b5f2b5c17d0c874eb76a50ac0", size = 102044, upload-time = "2026-09-07T17:14:05.224Z" }, + { url = "https://files.pythonhosted.org/packages/09/ab/c8f982c4dfaab49200e31820a4ffe6275356f7dcaaa1024735d8f8b4e048/aiobotocore-3.9.2-py3-none-any.whl", hash = "sha256:363b4892423b272eb84afbb58000da0be6ccd0cf8a68b98438c957d05b841efc", size = 102571, upload-time = "2026-10-01T02:01:57.883Z" }, ] [[package]] @@ -168,7 +165,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.3" +version = "3.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -180,108 +177,150 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, - { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, - { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, - { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, - { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, - { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, - { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, - { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, - { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, - { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, - { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, - { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, - { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, - { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, - { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, - { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, - { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, - { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, - { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, - { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, - { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, - { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, - { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, - { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, - { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, - { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, - { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, - { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, - { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, - { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, - { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, - { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, - { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, - { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, - { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, - { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, - { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, - { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, - { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, - { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, - { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, - { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, - { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, - { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, - { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, - { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, - { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, - { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, - { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, - { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, - { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, - { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, - { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, - { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, - { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, - { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, - { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, - { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/f3/8997f18890a92c79f77fcfbb4f78ca17c2d4ab109e9eb6d31b4e9de194a0/aiohttp-3.14.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f", size = 832358, upload-time = "2026-10-11T00:59:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/8e/42/084651e9efb5cadb99265f786df60f2a7b353cead7bfd2c87003cb4867ad/aiohttp-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a", size = 559636, upload-time = "2026-10-11T00:59:43.677Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/92838944e601f0fe195fd3f3ada37e29fbee3d19bfd323fe15937ec79d36/aiohttp-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593", size = 552588, upload-time = "2026-10-11T00:59:45.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/dd9b95c5c20ceae1b47738e656bd79e9883e3117a9cc8b143901eb604e9a/aiohttp-3.14.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4", size = 1928224, upload-time = "2026-10-11T00:59:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/c6/47/70010cd2ba8746968d53d433ff328c33f06a66c99da5f3de759c64a6b2eb/aiohttp-3.14.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f", size = 1877674, upload-time = "2026-10-11T00:59:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a1/b026f071dbd87f0bdba86b92d47e46f3899e3ad7143b6e0fe7e7887090b4/aiohttp-3.14.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12", size = 1985850, upload-time = "2026-10-11T00:59:51.296Z" }, + { url = "https://files.pythonhosted.org/packages/36/d1/f7b6f6f8c3cb5a71b53baeddc2e70c224c28b12a23a55c42994c504019b7/aiohttp-3.14.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939", size = 2105471, upload-time = "2026-10-11T00:59:53.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/2a2b22953de15c6c8a80debf26db3047e4e8a5a5db6dd7c2c6c01eec0605/aiohttp-3.14.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045", size = 1929915, upload-time = "2026-10-11T00:59:55.609Z" }, + { url = "https://files.pythonhosted.org/packages/33/ad/f80e8d33933d0eacd0217efa3b7fb32c9f48ed480ed0db53cf9948bf474f/aiohttp-3.14.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1", size = 1773832, upload-time = "2026-10-11T00:59:57.611Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/0273d239f3e3bb69438f209c64c82e1f98dc60d5db264795e41f7dd05dcb/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e", size = 1901001, upload-time = "2026-10-11T01:00:00.605Z" }, + { url = "https://files.pythonhosted.org/packages/16/ed/7dd439d26c654645bc34ca3c9a822fc11c1c51801fed1cefdfe1cc41c1f6/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e", size = 1907218, upload-time = "2026-10-11T01:00:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/dd/86249c3b8248562a17fd3e22ee0c164378d65766f01aceef62cf52783710/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793", size = 1958650, upload-time = "2026-10-11T01:00:04.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/ca4d53d68f683ce195807fd0aeb063bf6d1c6b39343fff234c9524e8d5a9/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20", size = 1760057, upload-time = "2026-10-11T01:00:06.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/42/005437ffd7fa56c2ce3347add40404b654a32754ce91f2e2a14b9e5ab2bc/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703", size = 1978081, upload-time = "2026-10-11T01:00:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/3a5b66fe1b7b23d3c6524350817ed55de5feb7fbb4cccba7cbe044d118dd/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e", size = 1922954, upload-time = "2026-10-11T01:00:11.035Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/d08a554f281d42fd7c6b2b6dd7738f3e3a7a9f657ddd87e11ca2091cdfe3/aiohttp-3.14.5-cp311-cp311-win32.whl", hash = "sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329", size = 488101, upload-time = "2026-10-11T01:00:12.933Z" }, + { url = "https://files.pythonhosted.org/packages/0f/15/52b5e65f02b33686ae49f1518548ae4f5a7adaf31d9d3c54fa12a143137b/aiohttp-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc", size = 512575, upload-time = "2026-10-11T01:00:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/bb75012635f3defb0f7cbb7c4ae391e36bb9cb3fbdca3b9944e2ebebf743/aiohttp-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755", size = 493179, upload-time = "2026-10-11T01:00:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" }, + { url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" }, + { url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" }, + { url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" }, + { url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" }, + { url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" }, + { url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" }, + { url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" }, + { url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" }, + { url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" }, + { url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" }, + { url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" }, + { url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/3184a1d34a8be665569eadb7e9e764b4629e4f3413e241cb2e4d6fecf3b3/aiohttp-3.14.5-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5", size = 540673, upload-time = "2026-10-11T01:01:41.354Z" }, + { url = "https://files.pythonhosted.org/packages/60/2a/d35f3ba4cf157b072e3b674bf9983047ca5ea5173c995d32d877e1191d36/aiohttp-3.14.5-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343", size = 554087, upload-time = "2026-10-11T01:01:43.719Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d2/61a33880ca4eaca95a9c60ca3f6beed15555af1028652dfaac601627787b/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738", size = 522613, upload-time = "2026-10-11T01:01:46.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/de579b299d2225dc2c6fd99d579d91f16c02a913fb5af9a3cf2fe9bd88ba/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c", size = 529682, upload-time = "2026-10-11T01:01:48.477Z" }, + { url = "https://files.pythonhosted.org/packages/f4/4a/ddb923564e15e053b6e060b0036e1694dcadcb13aa476c5a87dcad20e336/aiohttp-3.14.5-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3", size = 540120, upload-time = "2026-10-11T01:01:50.474Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/a640fbecaa53727a5900b892bdbe17b5f3e8cc88903e22864fe41b654def/aiohttp-3.14.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9", size = 818795, upload-time = "2026-10-11T01:01:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b6/d52ca608859e271b5fa7944074802dc45f60e52c318a4ddc34edbf73586e/aiohttp-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db", size = 549667, upload-time = "2026-10-11T01:01:54.65Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/05b8ac39a76ff4bca89f42a4c2471560c71f894ff6e4bc16158c951874e3/aiohttp-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba", size = 548922, upload-time = "2026-10-11T01:01:56.547Z" }, + { url = "https://files.pythonhosted.org/packages/19/b0/5aa186d56ce2334dabe29b70bd99dc8ae926ee44184c0de64a53d415a4f3/aiohttp-3.14.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382", size = 1882422, upload-time = "2026-10-11T01:01:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/db/f7/7d5c91bb9620db300c8ddb05337a9014301acb626223faff3abcb8ea47d7/aiohttp-3.14.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4", size = 1843621, upload-time = "2026-10-11T01:02:01.417Z" }, + { url = "https://files.pythonhosted.org/packages/30/0a/b208953b96d8f24b75f6da704f508e6c5cf3022f52b60c61933df082e89c/aiohttp-3.14.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1", size = 1927915, upload-time = "2026-10-11T01:02:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/f6/79/90ebcccb55e2d1e11a1fed581d83bb966e38fb35fb4b2577fdc980f8707a/aiohttp-3.14.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677", size = 2036339, upload-time = "2026-10-11T01:02:06.046Z" }, + { url = "https://files.pythonhosted.org/packages/a6/66/55a8904b3a129fafdf94f9cc0a2e4ca09a3c914650be52355db7ad0bbdb6/aiohttp-3.14.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c", size = 1888041, upload-time = "2026-10-11T01:02:08.384Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b8/96b25da7329a52e42c812b1e8b076386039ec4fc312afa043d173d8147fc/aiohttp-3.14.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622", size = 1703849, upload-time = "2026-10-11T01:02:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/1b/43/fbf976e3ae4c038d6f5c84945ab2150298c2d71201674d4e53d158063e75/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3", size = 1849703, upload-time = "2026-10-11T01:02:13.15Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7d/218e912f4c1d89bde7ac551409be57ad2f6121638e56942d395a7cb1fa58/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0", size = 1852695, upload-time = "2026-10-11T01:02:15.295Z" }, + { url = "https://files.pythonhosted.org/packages/5a/42/252a1b9287e3b6e393a1c3bd1776f36af30f5f25f071bbf2b0cb7eba9116/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480", size = 1893493, upload-time = "2026-10-11T01:02:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/78/97/71cae83d5100556fad1521684f7cd1e3e578432644f850245ed3bd969310/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f", size = 1693468, upload-time = "2026-10-11T01:02:20.666Z" }, + { url = "https://files.pythonhosted.org/packages/1f/69/73d88e97a8b5f0ca7a946d0011c0de99fb188b1687c7948ecd0553dc5bf0/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115", size = 1915398, upload-time = "2026-10-11T01:02:23.011Z" }, + { url = "https://files.pythonhosted.org/packages/05/f0/881644bcb15d4b258daea9b720a0af9dc4330496cc8d6ade9090cdd0cffc/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a", size = 1871470, upload-time = "2026-10-11T01:02:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/7f/de/19d9ebbcce5aedaa3242d8a99ff8816a60bdb7629fb0084bf4a45bfd1f62/aiohttp-3.14.5-cp314-cp314-win32.whl", hash = "sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2", size = 485517, upload-time = "2026-10-11T01:02:27.579Z" }, + { url = "https://files.pythonhosted.org/packages/a9/74/8cdaf0e58c2588371670d5a9a8215bbb971d36940b6e5d051967dd05c07d/aiohttp-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67", size = 511755, upload-time = "2026-10-11T01:02:29.685Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6b/e0100e25502430a531c7cf1482a378d0b65bf728ab60c01ee270e56bc469/aiohttp-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7", size = 495151, upload-time = "2026-10-11T01:02:32.163Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c2/ca2ead7b655688c53c03aeeb6e96e6851c9ff08d6be7b13802f53a6ae8fd/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed", size = 855396, upload-time = "2026-10-11T01:02:34.443Z" }, + { url = "https://files.pythonhosted.org/packages/a7/70/22206fea409255a240c926ce11de48de354ae2bb90ca44f709c05497585d/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781", size = 567734, upload-time = "2026-10-11T01:02:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e5/79a36c118308b56f8667d67e05d2fb6dc638ab45985704cdb199631bedaa/aiohttp-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550", size = 567379, upload-time = "2026-10-11T01:02:38.757Z" }, + { url = "https://files.pythonhosted.org/packages/63/a3/2ebec7dece3b1f02c30d2e484647f6f7b13952b1bb40a4cb285b849e8432/aiohttp-3.14.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d", size = 2050686, upload-time = "2026-10-11T01:02:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/7e4d093db2f4450482652e7ef19a9e19919028f5135aa52bc4078c3beb80/aiohttp-3.14.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863", size = 1915077, upload-time = "2026-10-11T01:02:43.563Z" }, + { url = "https://files.pythonhosted.org/packages/a6/88/bd40d09958442a0a1df67da81de496361d6e2afc04f9db2950d835da750b/aiohttp-3.14.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99", size = 2027204, upload-time = "2026-10-11T01:02:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/3a601c1f8d3103c1a60ea981f20924855da9f575d2007fc38f8898b792fb/aiohttp-3.14.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5", size = 2149520, upload-time = "2026-10-11T01:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/22/d0/4e41bfe1b1ce1cb6f6d2e59fa7a88ef5cf92c402b2d07e9018778ba9edbc/aiohttp-3.14.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f", size = 2002039, upload-time = "2026-10-11T01:02:51.293Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/72067019545c502b881b031153c437752ecef48d7d213bc0218ccebb4bfb/aiohttp-3.14.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0", size = 1786164, upload-time = "2026-10-11T01:02:53.533Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fe/7741efd6119bfb7a00827fe6f7b84b4409de58d888ae21adc5a9a6824992/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d", size = 1970689, upload-time = "2026-10-11T01:02:55.888Z" }, + { url = "https://files.pythonhosted.org/packages/43/e7/342a13bf67f34d269bf2f7e870ecd72b99c832cc6f0a271a9210c0ebfb84/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658", size = 1951967, upload-time = "2026-10-11T01:02:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/fdb3b27340617e5e64df18a70fa89097778652ea5c7c7e2f79def76999c3/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84", size = 1968511, upload-time = "2026-10-11T01:03:00.883Z" }, + { url = "https://files.pythonhosted.org/packages/83/b2/e8f88298de78d1a951f36f9f966d38ec6ed1d4721303ba02064a545e8aa6/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441", size = 1778497, upload-time = "2026-10-11T01:03:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/22/68/9ccdb93d664345c546be7f34480b921774d8c0d98f47e70d7e03b115d475/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210", size = 1999676, upload-time = "2026-10-11T01:03:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/57/4a/a33cfa6dcb00e94194ae4fe710432ca4ed111e016356b4ba4d2f4c3a124c/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d", size = 1963437, upload-time = "2026-10-11T01:03:08.196Z" }, + { url = "https://files.pythonhosted.org/packages/f4/20/eacbecfea3b5c3dcbfc9b023e3a5460f43e5dda16d06a2877ebe7184c3f3/aiohttp-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab", size = 503973, upload-time = "2026-10-11T01:03:10.481Z" }, + { url = "https://files.pythonhosted.org/packages/ff/78/18eec294f6c8c5dc845dcf6d730a0147d8d0f17e86138a7bdb85e43a30fa/aiohttp-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3", size = 531190, upload-time = "2026-10-11T01:03:12.716Z" }, + { url = "https://files.pythonhosted.org/packages/9d/39/e53f8169acc85271ebd12b5b32ad7f1541b35639ccbe0f49034c64785d10/aiohttp-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e", size = 510360, upload-time = "2026-10-11T01:03:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1c/06d89f58b2db3ee92dd377217659d587e06f973e57bf0a97a0d8a4586c0c/aiohttp-3.14.5-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:788ecaa9c10533b786ce5ba70c4f2df78ad41819fd00a6c99d92b66f9a32e1da", size = 540674, upload-time = "2026-10-11T01:03:17.483Z" }, + { url = "https://files.pythonhosted.org/packages/18/39/5e822e038f496f0540ada91e27099d48f9e7f919b6c6deb4cf6db36cc706/aiohttp-3.14.5-cp315-cp315-android_24_x86_64.whl", hash = "sha256:5c76f1802bab718a68ac3cce447160605c734551f95c67ae90fa1132b215cb29", size = 554183, upload-time = "2026-10-11T01:03:19.71Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/0cf2619d902b5b160762422a7e5e02091295766a7fe4fcf5b9a655e386c1/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a6d02b4c38de03d9c7617813433e6a0fb6b522797974177d69d9dad431900833", size = 523160, upload-time = "2026-10-11T01:03:22.193Z" }, + { url = "https://files.pythonhosted.org/packages/86/99/3553abfc53a40849dbaacc3f54c730ec58410809ffa2d05885ae56108f2d/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:20726f9782d5c2744c1c66255842d1d163bb3edcf768b8de25216bf47f7b6ccf", size = 530311, upload-time = "2026-10-11T01:03:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a4/5d25f73754bc1e8f983ba704e86d641aea290c967f195f2aeebdaed2bd84/aiohttp-3.14.5-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:248d779ad720b49d4fb355720e60c9e5f444f95887bc16974fea48fc56c41789", size = 540274, upload-time = "2026-10-11T01:03:26.698Z" }, + { url = "https://files.pythonhosted.org/packages/29/a4/07eda5db2e3ee017d9590f36c12a94ec6f1f50516e8df78672373dfc7185/aiohttp-3.14.5-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0a8ea271867e360ac985ae607f4a23ad9a38414b9aca1d49ec98839ae660e49f", size = 819239, upload-time = "2026-10-11T01:03:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/a8723dd987a696dd48d4cf2f0088e589ce77caebff0b96f2a78c20424380/aiohttp-3.14.5-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:823c910f046f23f4c713b8d99a2242dc65f591cb45ee86418fa11762a3c2963c", size = 549832, upload-time = "2026-10-11T01:03:32.02Z" }, + { url = "https://files.pythonhosted.org/packages/29/5c/969a1b72692055fd2a419590c41847ec9144fefb97b75ff1cde5b6372891/aiohttp-3.14.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9b42db919715e91eb76acf3bc492a9a7ccd8bd9adc6745c1412b689735269f14", size = 549234, upload-time = "2026-10-11T01:03:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/8e3e07fd8a0d33d06955ff4e54a1cb92f4bce347ff55e441dc3e25a7b5e5/aiohttp-3.14.5-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3112585250b199296c26ca6e0131640b6a8d01bab8b232d2eb3763ed469de11", size = 1891314, upload-time = "2026-10-11T01:03:36.899Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b3/05a79ce2e25f024e93de30c94f39dc6aa6e2bc1e9c531a5c4b18dc61b7a4/aiohttp-3.14.5-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c147451b4a58e7050f7f7394e6c467867c84161560001f9ad4fb2d1446743946", size = 1884372, upload-time = "2026-10-11T01:03:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/94/52/0fd8af0717db109eea258191b326b5cb5847fb88928bfa6c862fd78b9ad3/aiohttp-3.14.5-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bf163cc701f3d4ac43ba7d97771bf5fd955220ef5500ef3ee847bc0ecfbf4ec1", size = 1930957, upload-time = "2026-10-11T01:03:42.172Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b3/fa78733da88812bf9fb193913fb0ce1548f8b6912047633fdf88a758ff8c/aiohttp-3.14.5-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d40ce41991e9d56fab4f5dc4a51fe59bc3b5c77c27f4b148963064d00232e8", size = 2038886, upload-time = "2026-10-11T01:03:44.646Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f5/2fcc5e30053a938286f17d0edf3f0850b8061b984256fa7c26850b9c8809/aiohttp-3.14.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:276a4fc00b1d9ae492b802763a789c5b86328b989c5ea169f2faa447d6a11c7c", size = 1894470, upload-time = "2026-10-11T01:03:47.304Z" }, + { url = "https://files.pythonhosted.org/packages/62/2a/f87feb42abe8e6a7c03814dbcb711540849a1e90aa392055f3183c643610/aiohttp-3.14.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50983e3be33d8c0942ab88cec3905b10602f64c469b20153c48c5d4e558dd016", size = 1716273, upload-time = "2026-10-11T01:03:50.121Z" }, + { url = "https://files.pythonhosted.org/packages/81/b2/adf1f960dd977722ed1347d33da512a1624807114f57a3b91f5cc828e081/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:16c8abd5bca220a47efe667d26f8460124c81810787e79ee87b242677563d9dd", size = 1857513, upload-time = "2026-10-11T01:03:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/ef8d910e641de4160026a513ace5888b7f92826bbc3fd90ced05d55a828e/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0790ec66fa4013e83c53b9025a45d454723da1a2fce28b3208c9b32d08af162f", size = 1887704, upload-time = "2026-10-11T01:03:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/7e/db/6c9142f941cba8d35be8e1fae6ea2bd390e754fc14076b8147aee1a4592f/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:cb11a971a3aea10f9b8373be628f1df932964fc6c6b174516d318a48c3ac4412", size = 1895684, upload-time = "2026-10-11T01:03:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/e6/7c/6a6bd9a72e576d376c668333b00c51c6147aba4fb863ed6c94996d497eca/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:932ce7e694bbc29b2bf6f64f2343c27d148d4997c771d01bdade4639b6749ff4", size = 1706299, upload-time = "2026-10-11T01:04:00.8Z" }, + { url = "https://files.pythonhosted.org/packages/69/ec/d2cc494242f8c4d3d1cd70baf591742818a74386dc3b84af3195c5c4fced/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:a7d470cf7b206e6359fc77b1b860632fde400d5a2ed59cd0181b93a686bc81ee", size = 1916645, upload-time = "2026-10-11T01:04:03.892Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6e/e852c53647e815db09a1b6b5ab634f54bd736412397e11940feef4d2c89a/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6e1d8637cf73eebc92eba2e11d4cfff98a3b562f2505bd75bba766d908926e8d", size = 1875408, upload-time = "2026-10-11T01:04:06.922Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f6/72ab6ef20c332399be593bac543d2c24a6d241e39d3540ce9f95a63e4bd2/aiohttp-3.14.5-cp315-cp315-win32.whl", hash = "sha256:fbdc5ec49f9ca3cd24955cf3520b10a4d4c901ba2572094c84274e9e7eb30534", size = 485590, upload-time = "2026-10-11T01:04:09.626Z" }, + { url = "https://files.pythonhosted.org/packages/06/eb/e9de75b8c6d2170c42c08ff303abf857ea8a6d9d9b6e99b5aba40f15e962/aiohttp-3.14.5-cp315-cp315-win_amd64.whl", hash = "sha256:a9d3983bd6ab7aa1cfd573544ae98df9b6cb6912a5185a198263e024a636861d", size = 511740, upload-time = "2026-10-11T01:04:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/fc/25/455f3c2785eb0d50748cffd0abd07500815f419a9495b14610b7622d8d2d/aiohttp-3.14.5-cp315-cp315-win_arm64.whl", hash = "sha256:e29347c142cf6e99e0dff5e2995ead1d50fa3b51bf37a7c726a7ccfe5419745a", size = 495171, upload-time = "2026-10-11T01:04:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6c/497f0a98782eebfcf0f02a7fbdef5428148bd426027140cbad494cf842b5/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9bf1d5dcc15204d9ec8b8ea4c18fd66e6b80e5de1f4ecbafb3a2f2740f8039d4", size = 855638, upload-time = "2026-10-11T01:04:17.154Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c5/55c0cef2572af9b1ee81608f7c0a1bf74e6c9151a73b04915d933f192335/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:14f04769cfefe4734016a856a83af36133cd17779cef9ae817f812b8ba9d6d51", size = 567455, upload-time = "2026-10-11T01:04:19.702Z" }, + { url = "https://files.pythonhosted.org/packages/4d/47/e1a0e39f4a2b881f6071225afaf94a6547001b5aefc1566734d73c685934/aiohttp-3.14.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6e4251c0ba4624a68a2c11471a1ac54c3306876c21f0ae86de085cc9241c8905", size = 567836, upload-time = "2026-10-11T01:04:22.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1d/817d85836f52b687160064a326e62e037dc42f1ad5b6b5188a70e5c134b5/aiohttp-3.14.5-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4f5cf4dc72a71c4cfa9751b4950be22f733626670230d46e7d606592aa22d59", size = 2051409, upload-time = "2026-10-11T01:04:24.821Z" }, + { url = "https://files.pythonhosted.org/packages/f7/25/e8ea6fc212a9aabce982917346ce8ecab0929c74b60232a056dd11c99da0/aiohttp-3.14.5-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dbf53ae2601b7fd5a93c3944deea3a78d40f495226d582c35ef7a433425ce2b2", size = 1936493, upload-time = "2026-10-11T01:04:27.456Z" }, + { url = "https://files.pythonhosted.org/packages/24/33/de0517f71f1a19feea4aff78a2ec4ec2d98634129ec30ead78fce2f8f81b/aiohttp-3.14.5-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:657291433bf4dd3142f3abac495764cd47d0c7c92087751e6666c6447e65fcef", size = 2021405, upload-time = "2026-10-11T01:04:30.276Z" }, + { url = "https://files.pythonhosted.org/packages/c4/11/ddaf2e7930543e9f0cad3a1c54e1af0c1bd2fae3973d568c4263d3b010e9/aiohttp-3.14.5-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e51a27980c3788e6e6b3325d694fdd4898087fa8a86b2763af77b39353da41e", size = 2142017, upload-time = "2026-10-11T01:04:33.022Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7b/58784353c06de8adc20f426daad3d85dd86fd55331c713a3f4b638c94573/aiohttp-3.14.5-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c7583cd3dfdc7dcc927835b4f6c7faae7ecc1ba3ca5879321621ae2e6f8e84", size = 2002482, upload-time = "2026-10-11T01:04:35.94Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f0/417d9535caa9e165ffe6a53e2347a78107e7c87dcb0e10aca315c3af0344/aiohttp-3.14.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6fdcd6af7e2e51d1ba1b4bea16e97b074bcb7b5dd0246a9d8201341bb28085a0", size = 1791774, upload-time = "2026-10-11T01:04:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/98/01/25e49c2e8a01b9f0e19ca0a8448ad50aa2bdf96c8cd41e92bd45af044784/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c8859a013ae0de1074660992139a1a440df3e6b219b86cf0d3f11c2692bb4fe3", size = 1970358, upload-time = "2026-10-11T01:04:41.66Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fc/c132fd3465b6c7e4ce0193154f602c3e6c46b680e4da7eb3bd0d8a40d1c7/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:8966ecac808dd5f473c9c4cefd10cd3ffda71c18a4d3493b7c7d2ae1803bf2cc", size = 1969192, upload-time = "2026-10-11T01:04:44.66Z" }, + { url = "https://files.pythonhosted.org/packages/95/4e/d58b45e7dba4eb607eca11fc0a4aa77ae0f39c11afa804635a61ce18cff4/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3093b72c215bda16ce961a6d073f6e71d46e022962a9d5d457c5d4d421c78b57", size = 1963618, upload-time = "2026-10-11T01:04:47.505Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d9/f6ac50946efb3490428ef52b56e62c1c6b7f9c6ff3ec6083535526c83e60/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:149fb56caf7acb67073126f675d0958d9c4b3125fcd3f6d4877df98aa8a97ce9", size = 1784817, upload-time = "2026-10-11T01:04:50.287Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2f/f255eb63da788cd8a452fe350869c03266ccad7d884925f6963c87808f24/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:293d3ae7c6a0ed176a42e59a1b5fde825ead65c835360f734148e96729f928d2", size = 1995956, upload-time = "2026-10-11T01:04:53.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/241aa3393eaafda0034470add1625f82a4c252901108723b283a9b0b32ca/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3f2dcc00191fd563e9075181a14ec31d7dd63223ced7582cc70a15a499de0c79", size = 1967794, upload-time = "2026-10-11T01:04:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/a68e689288c9e4bfcf8d0979f2b12b774bf01861985420df6150eba5448e/aiohttp-3.14.5-cp315-cp315t-win32.whl", hash = "sha256:7779cd97e61ebe583ec2f1c5616cdd038aa08a4453b1848c67842176d054948e", size = 503876, upload-time = "2026-10-11T01:04:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/a4/78/49b0299da6d54de19fc6fdc6889d50233ac47d192a461624f4fc010fde83/aiohttp-3.14.5-cp315-cp315t-win_amd64.whl", hash = "sha256:0e6f16f5e49c4b8267988c05ab07760d7064cea57d077c3d068d04b0fbb992cb", size = 530957, upload-time = "2026-10-11T01:05:02.23Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/b0afc936aeb6d2f93157e3408b069ec5d7934429ae7d023de5e0953b1887/aiohttp-3.14.5-cp315-cp315t-win_arm64.whl", hash = "sha256:1aead151c3abbac6b32942e452020cb66d7efc099d253cc6c20f748e926c858b", size = 510408, upload-time = "2026-10-11T01:05:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, ] [[package]] @@ -369,200 +408,223 @@ wheels = [ [[package]] name = "arrow-odbc" -version = "10.4.2" +version = "10.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, { name = "pyarrow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/2a/74b808b77ee5ea16246c5bbc15032acb6f2ebf4019160d0bbf5f04b8a836/arrow_odbc-10.4.2.tar.gz", hash = "sha256:93c2eee84e5c5a404f0c43c4a5d7b1da4a2606031d0d9b5e7059a62685b82fb8", size = 106212, upload-time = "2026-06-19T12:19:36.824Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/2e/621da34d93b50666b0b096e3a61dd2118a31778181eb693955cd57c93e4d/arrow_odbc-10.6.0.tar.gz", hash = "sha256:02ab4dd902bb42dd37a104753f4224b745d4a4f437fab7a6d3182865c4f70c2e", size = 106916, upload-time = "2026-10-07T12:29:54.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/18/0b5e7c9561444646dd9bfa3b7bb59b36296eded77e68361c68eb34c696da/arrow_odbc-10.4.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:263c56ed9bfb79a9fc147039872e0936c64199fa05962211351fabfb28c3f494", size = 805372, upload-time = "2026-06-19T12:20:15.44Z" }, - { url = "https://files.pythonhosted.org/packages/75/d4/5c5bc16094a57047eb977c84c5b937ee300ee4ed6170c55e68313447e294/arrow_odbc-10.4.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6cba785730bd13a1d2ca56f95d8e306272bc0f17363bb5baf5d4d864fd0194e", size = 781294, upload-time = "2026-06-19T12:19:35.745Z" }, - { url = "https://files.pythonhosted.org/packages/32/39/5bb72e5a81ebe00dfd99a951b10760c88a9cdcbe94a4818a13dd55d980d5/arrow_odbc-10.4.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:badbba502039436894a2116369fd807158a41fcb6fffcfea02a6fb43ba9bf12a", size = 892739, upload-time = "2026-06-19T12:20:01.038Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/0fc53b3b1dfefea2de96c8f103a23487e2eb00061b0fed1ee75a11b89927/arrow_odbc-10.4.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:0cc8dbaae69d92011306c084d986c2e497fa23f853e819f126ba7926d9613ad1", size = 922584, upload-time = "2026-06-19T12:19:33.727Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b7/ad4893e30d0adff29a151b476190df84756943a77fb8ac9b15ce78f66f00/arrow_odbc-10.4.2-py3-none-win_amd64.whl", hash = "sha256:4db649fc4430029b8f7a6d1eb5133f26c44f45b63f6b08e3b5b655ee37b65234", size = 688317, upload-time = "2026-06-19T12:20:47.399Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f7/d2f80aba2eafcf69eae6bee99cc171e663817041060ff70fd85c23cda354/arrow_odbc-10.6.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:94aba247e2300d4afdcfd8957c44fa8c64bdde4831ea4c91bb67038aeb3886af", size = 796971, upload-time = "2026-10-07T12:32:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f5/5022c69e7aff7524f0f391cef8e9c23e18eafbc27058940bc5447c460888/arrow_odbc-10.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6726932e3790b6448aea82df258eb1cf2d8e6c34045d15554da523ea3521d2e5", size = 773014, upload-time = "2026-10-07T12:29:53.261Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/3833166b4d19a75025b9cd4aee5b7d31eaf98f6cd460bcd04e6f0e658960/arrow_odbc-10.6.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1d8eedba30458ef1e5c22831d4d37df27b2bceddeff1ea106a51c54f1adcdae0", size = 882410, upload-time = "2026-10-07T12:28:32.103Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c1/72a3d03d66befec63b490d1533c9a2f3f4b7a12ef16cb99c8cd672d5211e/arrow_odbc-10.6.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:037f304b85ef825a16c01a0e53850be7ba2791f1f1085e6f80e0b67a38d30512", size = 914702, upload-time = "2026-10-07T12:29:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/86/13/591a07f4a77fa9ac6f6458905466ba5c80fd539eb8e1454bc9d8f0c9515d/arrow_odbc-10.6.0-py3-none-win_amd64.whl", hash = "sha256:9d05d7e8ed3f4af62d33790e3fbec190a0ead112ab898ddd3566843ae09954ff", size = 693289, upload-time = "2026-10-07T12:29:32.481Z" }, ] [[package]] name = "ast-serialize" -version = "0.11.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" }, - { url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" }, - { url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" }, - { url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" }, - { url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" }, - { url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" }, - { url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" }, - { url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" }, - { url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" }, - { url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" }, - { url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" }, - { url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" }, - { url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" }, - { url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" }, - { url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" }, - { url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" }, - { url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" }, - { url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" }, - { url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" }, - { url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" }, - { url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" }, - { url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" }, - { url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" }, - { url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" }, - { url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" }, - { url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" }, - { url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" }, - { url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" }, - { url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" }, - { url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" }, - { url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" }, - { url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" }, - { url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" }, - { url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" }, - { url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" }, - { url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" }, - { url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" }, - { url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" }, - { url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" }, - { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" }, + { url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" }, + { url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, + { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, + { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, ] [[package]] name = "asyncmy" -version = "0.2.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/59/0c275a3b5bfca49958283a6a2b10cf0ea5d6dbe9861acc08f4b58decc391/asyncmy-0.2.15.tar.gz", hash = "sha256:5f6c6fb6543c4c4190d6f3d3db44bc4cae2f925a9df2ba79855edba3db899733", size = 94066, upload-time = "2026-09-21T13:57:06.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/d1/18147bd8694da82dec87b8740270da460aacd6288cffb6f8ae0b5b5df8db/asyncmy-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6132f3568cdcf653a58a71b2a8ce402956fba30ecb12663ce1e02dae2aa17ad", size = 2256948, upload-time = "2026-09-21T13:55:23.914Z" }, - { url = "https://files.pythonhosted.org/packages/ae/5b/59e19241492f00388730e15a4db8b9fb7e899bfab2ff428550f9350504b9/asyncmy-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e878e102530a7e8e7cb60038d1247ab08d275a8de6e43d374e5b931e7186ab00", size = 2220137, upload-time = "2026-09-21T13:55:26.065Z" }, - { url = "https://files.pythonhosted.org/packages/0e/22/db58bb734446818f1b364210e017b8fb502e35dfcd13c4ac701b8109ba3c/asyncmy-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16589ccc6baa0f1f99077e78fb4a95b1f11df8ae624d47a134debbe8a7d14bde", size = 6879572, upload-time = "2026-09-21T13:55:27.55Z" }, - { url = "https://files.pythonhosted.org/packages/cf/93/2c9ae762c6ea73e5a9f4099e1d38a6b91edf8b2e7f446d119f541e17b0fe/asyncmy-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:931856be1637f93393c03e7d7e10603b395dbe5c7c724a4638421556329baee0", size = 6912541, upload-time = "2026-09-21T13:55:29.856Z" }, - { url = "https://files.pythonhosted.org/packages/a7/a2/b36a0d25f7e59a2a14ee1bf0937b7643e3526a106d756647c6ea78cd5685/asyncmy-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2e6fa714c9424472e71960592f68ef72801db3c8ddbd21949cef3e6ee497c89b", size = 6658577, upload-time = "2026-09-21T13:55:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e9/149fafc72ef34c9463a90c13ddfe99bb96b299bb6fc379de48d639a5de2a/asyncmy-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:92855b35567a2baa16516f62d43dedfbf8027465dc84ee34bcf4670709f732b4", size = 6748314, upload-time = "2026-09-21T13:55:33.777Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/d7a0cf6033dbfc340463908496e3792ef332648d97ae73c338c479781c51/asyncmy-0.2.15-cp311-cp311-win32.whl", hash = "sha256:87225513ffdfd589f1e95257395dca77d8ced3e296d5c4e41232d175991cb581", size = 2006321, upload-time = "2026-09-21T13:55:35.301Z" }, - { url = "https://files.pythonhosted.org/packages/a2/47/e8ec1737d91d6cb59ba81b8eadf6f051df9027c2dc546a83516097303198/asyncmy-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:f8757246b4ff5b3ee3df1ad74371c421bb95b7d8f4816cfd1796760b78a34b27", size = 2107507, upload-time = "2026-09-21T13:55:36.564Z" }, - { url = "https://files.pythonhosted.org/packages/de/d6/f7896a4058f6759c4027bb920255d6e14543695eeab57e31cf0a462ca4d4/asyncmy-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3e08d3ee06eb01560db24390dce342ada2c1c6a22b2381cba4db16deac24f948", size = 2226915, upload-time = "2026-09-21T13:55:37.946Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/8b4b770c18d86d5292ee697858294539142aac96d69c46ff5393e2333cf5/asyncmy-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9663d6ace79eecbfb5a4ef5393e7ccea883541954ea60da7cb37c386d53fc428", size = 2206656, upload-time = "2026-09-21T13:55:39.485Z" }, - { url = "https://files.pythonhosted.org/packages/33/73/291b9502b13ea38e75e4257461773b95b7cccfadb226b586c15438d9ba1b/asyncmy-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b94f1ad3b593981d65fd4cb1458db2806c9aea4d455600b378b9c2a466487580", size = 6763597, upload-time = "2026-09-21T13:55:41.02Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8e/f90ddc391067ad2236e96cb21aa642f95952d10a7e9367caf97100567794/asyncmy-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4ac016595754427d99c6830518ff78048be4f1a639ae2e87c34bc722f615819", size = 6829818, upload-time = "2026-09-21T13:55:42.55Z" }, - { url = "https://files.pythonhosted.org/packages/92/7f/f058dc048a0abcd068b8f147ce7d856251d42aa9f9248d25398aa3ddf832/asyncmy-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:05bd7d64a688e2174a0c033690242f973ad850771b9b9befd4c4528c9f75b4ef", size = 6478548, upload-time = "2026-09-21T13:55:44.379Z" }, - { url = "https://files.pythonhosted.org/packages/70/ef/bb9d7a6277240f018e229e8a8bcd8285fe8aa1d147d93ab755e9c82f1da4/asyncmy-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7433636becbcdd64f483d3e5e2e377e993ffeedf3ee678c0049e9941ba584ef", size = 6608127, upload-time = "2026-09-21T13:55:46.191Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/fff3b3682816af77390848f904035e32ac2bc21de25790a618affb4fb8db/asyncmy-0.2.15-cp312-cp312-win32.whl", hash = "sha256:b9055f81d25d35dc9a3cefd626430ddb7bd27fd617bfa172669bc62d361d9d81", size = 1986295, upload-time = "2026-09-21T13:55:47.667Z" }, - { url = "https://files.pythonhosted.org/packages/fc/29/ece2e511f64d1cd1590f57baf42e79cafed180922c00765956f3da8d8ec0/asyncmy-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:fb33a4d88ee542400976f125479c330628cefbe2b9d42693d71fe215b7392e55", size = 2088091, upload-time = "2026-09-21T13:55:49.289Z" }, - { url = "https://files.pythonhosted.org/packages/66/6c/ad0b38d87241a0465867e9f5978f4b8cccbd3d2adb63ae2cd9fc55dd7e8b/asyncmy-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80a5d3a4d622847d77877b751857ba3b5893a38201963b048f4535ab32791821", size = 2229443, upload-time = "2026-09-21T13:55:50.573Z" }, - { url = "https://files.pythonhosted.org/packages/84/ea/e15fccdbf8e917b3dab9f9c599b491016f4eeef118d0fbc22dd291de5c10/asyncmy-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:694808a0bc29456e7406fc97847c1f706ba5ffe544359f2a9cf4e201c6245682", size = 2206539, upload-time = "2026-09-21T13:55:52.105Z" }, - { url = "https://files.pythonhosted.org/packages/00/d7/ddde3f97647f0a1297715f2a889ab8424efacb8b63641311af7455ed84d9/asyncmy-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8fbeed4a933efdf86391a468642fefce86bf6324112eed7fa57e057eed1ed9", size = 6709365, upload-time = "2026-09-21T13:55:53.78Z" }, - { url = "https://files.pythonhosted.org/packages/ed/97/2b063f72c26a535bf23169f2e35542f1f1ebea226bb8fdcd49bff07bf2af/asyncmy-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:865540d52de7321920836df1fa228d653c648f22b486756a041d32957574c468", size = 6766354, upload-time = "2026-09-21T13:55:55.341Z" }, - { url = "https://files.pythonhosted.org/packages/97/d3/54f03e44b49043e7bc66d9a40e35b6ba56b17869f58a006d62f874b55f0c/asyncmy-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6fb4803234716214b7ca0e6c30a44bbf6879ff7bf274b6448d58693452dc23b1", size = 6436241, upload-time = "2026-09-21T13:55:57.287Z" }, - { url = "https://files.pythonhosted.org/packages/59/22/42eb97c5adf10b94016b2bdc9fadf7dd6420ae0f37838d153533e30ae462/asyncmy-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3da758ba12988f056818a0e99766119190bc6f7f158b476b00b0b81170835cb", size = 6562438, upload-time = "2026-09-21T13:55:59.044Z" }, - { url = "https://files.pythonhosted.org/packages/25/ea/556e42de74523b5184cabc65c45b49cf78765d6f6369a6425797e81f168f/asyncmy-0.2.15-cp313-cp313-win32.whl", hash = "sha256:d3c684fb7fcd3cfe781cf1edcba02fe68847aaa11e970b94739135d6b32d7261", size = 1984097, upload-time = "2026-09-21T13:56:00.594Z" }, - { url = "https://files.pythonhosted.org/packages/6a/40/cabc3e8e2d2c56911539cea10b8914ed5d115f2317406582c85ce8c79b8c/asyncmy-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:82e399299fadcce52b4d55b73e370c3f5e9d9ee7be08c71dd75af841eece4143", size = 2083685, upload-time = "2026-09-21T13:56:02.018Z" }, - { url = "https://files.pythonhosted.org/packages/89/46/5c78b8084c23b9f52458e2850f41d487015fb59353daf738ada50f9ab20f/asyncmy-0.2.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:26727e6d8217a99add1b0318e059eaba46186297f6c95debac8424883c8f0a4e", size = 3050936, upload-time = "2026-09-21T13:56:04.191Z" }, - { url = "https://files.pythonhosted.org/packages/3d/5d/d0de2615c608fb4be8cd7c6a28eca448bbeb53dc447dcc49b6c3f920d16b/asyncmy-0.2.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17d9516e6c4dafdf748e07e188230d169e6091d1162e80fedff093b58ab49d40", size = 3021030, upload-time = "2026-09-21T13:56:06.113Z" }, - { url = "https://files.pythonhosted.org/packages/3c/10/bc52b5e298b4e6a56209121f338d14cd7b9877a55c811d0a956226da73d4/asyncmy-0.2.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1759a1ca7ea2fd41205c876fa373907ac88a7a17b5d983f88113238275bb9b80", size = 12254920, upload-time = "2026-09-21T13:56:08.153Z" }, - { url = "https://files.pythonhosted.org/packages/95/ad/1e33df451227dda0c77a4f7046efb83543539ced0fa814a275ead663714d/asyncmy-0.2.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:842f5290f63de6e3506df6ca524dd06c64e6a189a3387a888a6df9a493298eeb", size = 11957817, upload-time = "2026-09-21T13:56:10.705Z" }, - { url = "https://files.pythonhosted.org/packages/8a/74/409e3cca43042a21bf450de0f7470210b311fb57161bc2172bad58a8dc57/asyncmy-0.2.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd6b291829e4cd89be9e24d0950e7d5d8e92f8497f2a6fcef6e0a12836277e6d", size = 11600334, upload-time = "2026-09-21T13:56:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/a2/63/bcad813ab176b530469e2a273b4026668b40acb302a320271d7305b03e77/asyncmy-0.2.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:633f894a3d64cefe98ff21f186e488f54213802f0e2dcd4c7fe7fd7221d5afad", size = 11576345, upload-time = "2026-09-21T13:56:15.708Z" }, - { url = "https://files.pythonhosted.org/packages/4d/21/013419723c7ed1793e31ffe68b7dca79a993c77c348056513ea82614a149/asyncmy-0.2.15-cp313-cp313t-win32.whl", hash = "sha256:b352363aace7bbdf245f79f925837a2a9540d360d89bc699ca21f1cbc29170a9", size = 2550134, upload-time = "2026-09-21T13:56:17.86Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9a/4d9810419e6693d0ab19b0ecad4380a4963487ae2dc80dad70280b79557a/asyncmy-0.2.15-cp313-cp313t-win_amd64.whl", hash = "sha256:5e9e20614481ea87cd05b1a7ece56d0d5fb855f9cf5c5308572055e6f4dfe803", size = 2748546, upload-time = "2026-09-21T13:56:19.337Z" }, - { url = "https://files.pythonhosted.org/packages/f4/6e/7b0cac0cb7171feef4882a7bd52a928a5023ad890b251d6aab7362f6d24a/asyncmy-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0381e45a674847751d7b26e6a22534fd27ff5c91ae8bf3618113b605decd2489", size = 2236272, upload-time = "2026-09-21T13:56:20.965Z" }, - { url = "https://files.pythonhosted.org/packages/88/a1/ecf010bdde2327fa7d3fa09e212f7044f22834cd0980f6e3c44f9679c5c4/asyncmy-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b24b8a93269fd60948f70fdf5ee093f2eafc8c9fa121b86f294ef63eba885d7", size = 2218893, upload-time = "2026-09-21T13:56:22.701Z" }, - { url = "https://files.pythonhosted.org/packages/71/75/517d6e61efb35c7352e45dd8ca722977a7451f242c97c07b90aa00be3d9e/asyncmy-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:467112e8e79ab4d0418b47765b73c3242ca22e7e12555e24e4eb3151dbf5d0e9", size = 6702941, upload-time = "2026-09-21T13:56:24.294Z" }, - { url = "https://files.pythonhosted.org/packages/49/07/a3b3fb8ad2dc050e2a8fa87a1769ca855595b6ccd8f8d815904afae8611d/asyncmy-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6654f88f11becb6b41aa1b0467eb94797f3a9afb341c15de6f52816957a06ba7", size = 6715034, upload-time = "2026-09-21T13:56:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/96/2b/494be28ff68493898bb93f16a30a6bc1edfbae0733bb652ce218e18eb9ae/asyncmy-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:765d7ba88c6fdf82cace82fc24812f2d459f1139cd1a77be1c98ce39109174b0", size = 6449100, upload-time = "2026-09-21T13:56:28.085Z" }, - { url = "https://files.pythonhosted.org/packages/23/80/d5f30c1d82a34f1bdfb19b132d4d8907b2740fa0a25772d13c09681161c5/asyncmy-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:52cb2b8976d651a3f33574b0d63dd291521fd34850b661e0eda698150e02c8ad", size = 6501653, upload-time = "2026-09-21T13:56:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/30/c3/4737b25912359dacc0e31262643f53a5d52d0eaa871744073dc4ddc27390/asyncmy-0.2.15-cp314-cp314-win32.whl", hash = "sha256:2daa3dfd055c58b38417c05219113658e79950e7de3f842264c9450ea186fda5", size = 1979257, upload-time = "2026-09-21T13:56:32.413Z" }, - { url = "https://files.pythonhosted.org/packages/4b/66/54326af737c046ebda2019ad0873cc3443db6c0a89d1667bf1dd2ef4a92a/asyncmy-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:bd2940a8dcafdbe33b44e9182ea89ac915df55d7bc262458c55767f93536d687", size = 2083458, upload-time = "2026-09-21T13:56:34.217Z" }, - { url = "https://files.pythonhosted.org/packages/98/1e/993bb3c898061171b090eb489c9fb70d7cb3ea4d04966abe07d8593c2faf/asyncmy-0.2.15-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d6df56688874c0425be8a5b1a5cbfe5c6569ba7b1c83751877285de2345258d9", size = 3064575, upload-time = "2026-09-21T13:56:35.918Z" }, - { url = "https://files.pythonhosted.org/packages/26/ab/51ef2220bafd42194bf892b19af8163f2f9ab02b96ff40a766442528dc33/asyncmy-0.2.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14f8dffa1e33487161e3184c19230619b58b2b44b422d82c7a11f34657e97cd6", size = 3041961, upload-time = "2026-09-21T13:56:37.437Z" }, - { url = "https://files.pythonhosted.org/packages/05/a6/56059ddaf209ea161d08b9b1433b86d9ce05e2f0dac76748905b7a5dde0d/asyncmy-0.2.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5664f8c84b0df923a3d8da6c37b35e89e1a79033b062caf51ed3696440cc0e77", size = 12260240, upload-time = "2026-09-21T13:56:39.749Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0b/eab69a613d89c1c572535c8d05f6ea066a84052dfa5391653fc4725fd0e7/asyncmy-0.2.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2262a9cc261f963817650888569cfd1f4190b52edb6d8a552f58e81eaf325ad5", size = 11918734, upload-time = "2026-09-21T13:56:42.095Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ac/974d076c9a4d40286446e90c93ab681806e6f87b9d58f53719c35feca67d/asyncmy-0.2.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:044f7b23574560ab2aeb0a6f9609ea4071064cdb8f3a036d59e0381624f750d7", size = 11628689, upload-time = "2026-09-21T13:56:44.828Z" }, - { url = "https://files.pythonhosted.org/packages/3b/11/9a0f08e6b10b2881387124e8a464ffcf31491b0da7313f633a69d0961f22/asyncmy-0.2.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b1c5aa1b18e7b56baa9af2c5d8f44cb030579f4375ab363cb884abdf20fe3134", size = 11528050, upload-time = "2026-09-21T13:56:47.429Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ae/9bce18e533d58a167d918a716963edeb882fef96b8ca8674bf30b4cc61cc/asyncmy-0.2.15-cp314-cp314t-win32.whl", hash = "sha256:d912f4f66b1841f1c4649b4438b834e1223685f7fb2f2b86c6b659fba8f57192", size = 2562589, upload-time = "2026-09-21T13:56:49.802Z" }, - { url = "https://files.pythonhosted.org/packages/67/de/08ae6a155a4018b9d688e5afcf370c513e5a5ed0d021474030eb5ae35d90/asyncmy-0.2.15-cp314-cp314t-win_amd64.whl", hash = "sha256:c21caa044f5b016cb24e161db612509deefed5e96debfe6cb0042a369a6d8a83", size = 2770470, upload-time = "2026-09-21T13:56:51.311Z" }, +version = "0.2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/a2/cf891f7c05b6292e0966c3870332d7778c14de912b33db4a895ac5151b9e/asyncmy-0.2.16.tar.gz", hash = "sha256:92a9c5d1ddb143783360b92f8abdc72612d7a2b2efb2a07482d2a816c9223be8", size = 94368, upload-time = "2026-10-06T10:52:58.263Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/f4/880a3392c756cf488ee60b656e57a8fee50252e469daf9d061a4d30a82dd/asyncmy-0.2.16-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd2016f01d67b4d8fe8ec04e2705c93740db3c6d111bdf4a15630116e2c6fa20", size = 2263246, upload-time = "2026-10-06T10:51:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/76/44/4313af9b1401f8c418a4f4cceea75467551e119ea52e6ef9bb8aa0cc8e46/asyncmy-0.2.16-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b36f27c18a349928242ecdcae101ef4ff130897038b7e7e6a6677f42a396129c", size = 2225909, upload-time = "2026-10-06T10:51:37.208Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2d/b28c7cd0a774c8e8f88466b9ab5992c932971bb4ee9923bf88cdff5c1d7d/asyncmy-0.2.16-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2feec5a05ea43eab2b9f3419208dfeace182d9a2291e0cb2a8a60e6284d72", size = 6899414, upload-time = "2026-10-06T10:51:38.422Z" }, + { url = "https://files.pythonhosted.org/packages/3a/60/0c33f36f1fcbf60a18adc31c993c6655c22de901f89a2c0dfe11076a5755/asyncmy-0.2.16-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e658bd49d94f322ebd36f7e687cc88972ec667b7b6f8dda29a78fb8da675123c", size = 6933200, upload-time = "2026-10-06T10:51:39.829Z" }, + { url = "https://files.pythonhosted.org/packages/24/86/1da36a00a1fe1faca1fe109fe878e9b8087f0c828af4e22b7861d31faad8/asyncmy-0.2.16-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b46824fea69b1cc6d94c15adbe351ecbfb2fa663ea50d61c6ca618f4bf92f03f", size = 6678147, upload-time = "2026-10-06T10:51:41.201Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2e/206ac3d2d7e08dbc43e78c4accfff54a5cfa7646eafe087e566f49fb945c/asyncmy-0.2.16-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bd3c8a94a646b0c28e97a599f25c327a9633a3c6738b7a7914869c758560b45f", size = 6771523, upload-time = "2026-10-06T10:51:42.956Z" }, + { url = "https://files.pythonhosted.org/packages/54/5c/a4d6db6c8429b7d161c77680224bc2bb0efdb8eac83db1367afd281697c3/asyncmy-0.2.16-cp311-cp311-win32.whl", hash = "sha256:ffa76b94895afdcfdd7f6043de2818dda5d5132ccd54a86f94801f163e760999", size = 2011557, upload-time = "2026-10-06T10:51:44.486Z" }, + { url = "https://files.pythonhosted.org/packages/af/70/d87838161b89a07cc4a21883e348e9e8d6eb0e294adc2842ad53a12c6a39/asyncmy-0.2.16-cp311-cp311-win_amd64.whl", hash = "sha256:7ec630f802c861f1300c4a30e30d294a1836f46271b820ff9b6b109588758db6", size = 2112860, upload-time = "2026-10-06T10:51:45.995Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/6cc46efe1d4693724ff5e76b50a60a78571efa1439133d0bb78ded8217aa/asyncmy-0.2.16-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0faad88c3c8fdffe3de6d626f58d2af47fa47531cb6d2100859b8fddd9685847", size = 2233315, upload-time = "2026-10-06T10:51:47.197Z" }, + { url = "https://files.pythonhosted.org/packages/21/72/a8b2e8feafcf3dadd48bd364ddc40d5d2125ffa1d3fd61a0fb715fcb553d/asyncmy-0.2.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:20f148342baccae2a7995e745414f999bf116062975b7635bed9557895423681", size = 2212570, upload-time = "2026-10-06T10:51:48.588Z" }, + { url = "https://files.pythonhosted.org/packages/58/73/4fe290478d4898b5c34a46374e9c0604574f503d7d388d853710a4c07305/asyncmy-0.2.16-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f32ef4f8746a2b9073d63950be8a87466426da9bcbc8339943c62b4de34e70a1", size = 6787634, upload-time = "2026-10-06T10:51:49.961Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/ee3052e0b12737e1ea2293ac4b888f69c5a27c3c225a5054ba5e691091fa/asyncmy-0.2.16-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc5b0fba7feec70bfc0a4c571f2e0071e040d052f46447c491f28649a1b70c15", size = 6848958, upload-time = "2026-10-06T10:51:51.522Z" }, + { url = "https://files.pythonhosted.org/packages/76/d4/e1fb370a4dd2f9a295e1189f68afd975c6ad385056e9696e653ca76ffe6a/asyncmy-0.2.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6429983256fc41de0bae3782e2f89ed330b84baa2dfd398a87d9913b27c74620", size = 6505431, upload-time = "2026-10-06T10:51:53.286Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b8/c1d82f08f482272d06c2572645c0af13a2af2f2309b600ffe98dd2ab8cd8/asyncmy-0.2.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e0acb7aa6cea90f454df9be4fd5e402bea2d30d1d3dab8f70d48031e8627095", size = 6624495, upload-time = "2026-10-06T10:51:54.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/1a/9e0876385c282c308793619a6a05646918904d42270e6229a468f5c77fb8/asyncmy-0.2.16-cp312-cp312-win32.whl", hash = "sha256:c2798f09a62c4dad559951c40f8e89a87ad41758ad19376efe80e9dc0f1ac2d1", size = 1991358, upload-time = "2026-10-06T10:51:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/91/cb/b5d617b87709c17f9de409eb55cbdce4c3c2849d8babe1c54bcc4d413557/asyncmy-0.2.16-cp312-cp312-win_amd64.whl", hash = "sha256:6dd4997a060a2bebe90ac8420e3b6a490b75f5c0a62cafbe7d19acd3f4c2fc9f", size = 2093206, upload-time = "2026-10-06T10:51:57.241Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ca/8b3d3fd98c68c0c244bafc3560b7869c0db98e46d4befb51001dc51befa8/asyncmy-0.2.16-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c16a1b3710b98077f1d2cf7fd54387b182a42abb2d49ea9f2dcdb41c46b77ee", size = 2235912, upload-time = "2026-10-06T10:51:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/21/ed/1e28cd1b6915670be596d266913773b8d2c4bac32516446a2d614225fb6d/asyncmy-0.2.16-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0431d9dafdf3a143674dbc22300d28ee42f82b30948430e870994a1f7d1700ed", size = 2212008, upload-time = "2026-10-06T10:51:59.681Z" }, + { url = "https://files.pythonhosted.org/packages/61/dd/086f85cc2a25e4d010bc0e34da9b4b43f433416b8f804a6fcc2f216bdbc0/asyncmy-0.2.16-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea88549833b99192612d23ce2678cda7cf3bd1c7c548b482d75d7de7be990f7f", size = 6724880, upload-time = "2026-10-06T10:52:01.193Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0c/d80c38f534b88c5cbc8937607b2facd965405bb84f790585ed07ec0a533b/asyncmy-0.2.16-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb9ef0552df7f3857cf58cbea9896fcc0f5db4cfbcc8d98bd89fcf2963f65759", size = 6787251, upload-time = "2026-10-06T10:52:02.478Z" }, + { url = "https://files.pythonhosted.org/packages/fb/42/0ebfc96405b03d77fc6b58930000f832107addec334b4c658b950572f9b7/asyncmy-0.2.16-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2ed8a3073f03cfde57ea401181a97f818cda8eab85470c9d65591664fe9aa42a", size = 6454354, upload-time = "2026-10-06T10:52:04.186Z" }, + { url = "https://files.pythonhosted.org/packages/37/d5/86c165ff1dd47919feb71fdcdfd949edc577a1fb52f71862c7a789e09894/asyncmy-0.2.16-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8c08c47fd0acfa647a108d065236ff91f6f48cfdf618dfee7ade10dbfba8daf7", size = 6584637, upload-time = "2026-10-06T10:52:05.604Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/aac5a35ecbb4f8c8081c8c91486897a7b719d75aa9cc27b1489dac0cc824/asyncmy-0.2.16-cp313-cp313-win32.whl", hash = "sha256:74ae4c8a001bd041d1bcdbc5a72c63b204806a09327819a354f99c973499ccda", size = 1989029, upload-time = "2026-10-06T10:52:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/ce/1c/0187d66ff58855d817616214c5220810f66d5070029773789dc0786af5eb/asyncmy-0.2.16-cp313-cp313-win_amd64.whl", hash = "sha256:091cdff819737e419e7e168d63f3df48d1ec77e196b8275b6b5ac4d19b2cb768", size = 2088793, upload-time = "2026-10-06T10:52:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/55/02/cd8513fc99ce4dc8c25c1c2a1f6d7cb74d64d107f23b3da6e5e5fa6e49e3/asyncmy-0.2.16-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e7fb933dcff03616dc36a7de9cdea85a67a1b2158684af3b5e6e0bd8858bcfdd", size = 3059809, upload-time = "2026-10-06T10:52:09.548Z" }, + { url = "https://files.pythonhosted.org/packages/45/5e/6cc381d7b8921466d1a2049b9a07e6a60420744200ea669c08eafbb1d184/asyncmy-0.2.16-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c79efdc3f6632b80c60900ae9605495a49bd0b81e586e7d837042d5dfd4d1ee1", size = 3028194, upload-time = "2026-10-06T10:52:10.804Z" }, + { url = "https://files.pythonhosted.org/packages/87/24/26bd110fc530d82f6f181f51562bda6574bca302518caf0ac0d050d43cba/asyncmy-0.2.16-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71504dd8d59cb912a84fb54cb3cf5aac094581875b6e53630077dcffad7d282", size = 12288234, upload-time = "2026-10-06T10:52:12.243Z" }, + { url = "https://files.pythonhosted.org/packages/3a/e9/c14a947c437ee362e655826f5510ae0f42263bfe0deae825cd7943cda55c/asyncmy-0.2.16-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:594cee61496c840611f82c5b6b0607c19aa155442420d16b2c47f2c860a090bc", size = 11996856, upload-time = "2026-10-06T10:52:14.18Z" }, + { url = "https://files.pythonhosted.org/packages/14/f1/f43741a156332428c23e356eed3162015872d01a102f64d523ade3dba383/asyncmy-0.2.16-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80baaa4da31b64b57b0a266656fa4693f1a6c6c0f00ad1dd1e74f76dd9d280cd", size = 11636182, upload-time = "2026-10-06T10:52:16.126Z" }, + { url = "https://files.pythonhosted.org/packages/54/2e/f4158af50e6c38c9a4323c33a9f8f8e16850e7fdd7408a4c9501ef40ff64/asyncmy-0.2.16-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d1677191ba3faf318a7da52cad1f367ccea3301572ab49472e124ab962037f26", size = 11612894, upload-time = "2026-10-06T10:52:18.132Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/4b3d6f18a0e27cbec4fa25b4eab4d5496ef5e6e9c58bf5418aa1e8a2c826/asyncmy-0.2.16-cp313-cp313t-win32.whl", hash = "sha256:f5f9b8484a63261c86322bad878b11a07fd4229b17557bdd72a38fad424b8ffe", size = 2556686, upload-time = "2026-10-06T10:52:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/be/17/e79d2c410c704a11e57bbc037407383c5cbf99b9bbad2733ba862568d7d4/asyncmy-0.2.16-cp313-cp313t-win_amd64.whl", hash = "sha256:9fa9c6d94f8887d89c65b1a3ca8899a1c580e4f0776136a5aa0d6240177d2650", size = 2755976, upload-time = "2026-10-06T10:52:21.011Z" }, + { url = "https://files.pythonhosted.org/packages/1a/30/1bffef5f0c961adcabb1846ffc83677edfbe0f04aa5b1825c8ed3b5f8506/asyncmy-0.2.16-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:75f4ad92c6e81e7e9660dc93d1720a5a318059304eb9ded112ca49dffa4f7ee9", size = 2242626, upload-time = "2026-10-06T10:52:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8c/d43362017e8e946f8ef28da3434a0105a4a33127cf367755553919273da5/asyncmy-0.2.16-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cf36db8a319f1e1ca4facc0b55aa0521528ba850359e5b8120b2dd483e15cde1", size = 2224787, upload-time = "2026-10-06T10:52:23.291Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/a21ae6aaebeb5045c758818c4c6a605c426814fd70b8b6afa697e059add2/asyncmy-0.2.16-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3266def84b8b2ae6e71ff4ccaf1577e00030d0eec66a0c2aff0aa5589fdfa1cc", size = 6723050, upload-time = "2026-10-06T10:52:24.462Z" }, + { url = "https://files.pythonhosted.org/packages/2f/fd/3beee4e556e1f62014c64ef3784ad80eefdfa752d25dae842f28d099a799/asyncmy-0.2.16-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31674278284ab9054fc8b69ac24d99748338269949cf79dd7c8cec9bd0cd0c2e", size = 6736165, upload-time = "2026-10-06T10:52:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/05/89/43fc5ac81887527ed50c532d3c6858dd9b4a97481cf00fa746da1eb515e4/asyncmy-0.2.16-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f4001c803c370ebd989d39febb8834fef4f66202549bd1e08513bd36d14df8c", size = 6467590, upload-time = "2026-10-06T10:52:27.172Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3a/bd12f7ecc3be153d06ed8e42414ea3cda8a193ca703499b04fe15d17e8cd/asyncmy-0.2.16-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23884d17d593a1e1adc0d797a0c2778bb40c081b3ed951186f0798206cfa8e0a", size = 6520994, upload-time = "2026-10-06T10:52:28.689Z" }, + { url = "https://files.pythonhosted.org/packages/83/71/5dd22fe0484c7ccd8636bdbf8c4a7a381de51d6ec44aa118e381f674d7b1/asyncmy-0.2.16-cp314-cp314-win32.whl", hash = "sha256:fa5711c9f31c4f7061bdd508265a08b9770e87a64fbb0d3adc5314c4adef84b7", size = 1984266, upload-time = "2026-10-06T10:52:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/65/cc/b8d9a3ce3efcc860bddb8ada67af4b5f5a748fb64820c8a0ad17c95b5963/asyncmy-0.2.16-cp314-cp314-win_amd64.whl", hash = "sha256:d6bbb409f2829d9bca9a53599a9d8ef8429f7368d5b8ba30ecb8b13762e760d8", size = 2088335, upload-time = "2026-10-06T10:52:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/01/43/e5f40d2959f508b5b0eae0f78a1e06f711480cf787b1cd127984c4c92fd7/asyncmy-0.2.16-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5c56c535960002fe28464db2803dc765f009793f5c159d2bdb27789d95822197", size = 3073133, upload-time = "2026-10-06T10:52:32.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/b1c16ce3bcc620d5ba6dcd8353b0ca1a42e9debd71de7d0d56b4ec525f49/asyncmy-0.2.16-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05b49abf8de143b7f809dc26116caf1d16a818510f6324ebc2d1b36edd3f7bf4", size = 3049620, upload-time = "2026-10-06T10:52:33.684Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/0083427f2ef6aa5c5d5be9dfcba2b33507b5707a481f8a545584a50f374b/asyncmy-0.2.16-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ae8bdb8a4dfae7c210a863aa1cff3ca467da7269d98d120501d0528081f531", size = 12297510, upload-time = "2026-10-06T10:52:35.368Z" }, + { url = "https://files.pythonhosted.org/packages/11/12/00bd8ae2e1b1a5a2993b9498b24d38a9889a52e5db33eb6e88347e5a9ff3/asyncmy-0.2.16-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e175a4286774a14fd9c5e9301882033583e234cf75b874e80c8025a439e2c4c7", size = 11958268, upload-time = "2026-10-06T10:52:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/dd/97/00c2270bdbb6a721c0038bc586f0c3733e3f223d1864b5342b9b9d95b48b/asyncmy-0.2.16-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:09c2e97cdddd68355aa9f26a22dacc06f48d56ec75778c614f130f32e6016193", size = 11660058, upload-time = "2026-10-06T10:52:39.855Z" }, + { url = "https://files.pythonhosted.org/packages/49/bb/55d74e719860d00846baaedf52cbfd619527eeaa402f249545a5cf14b021/asyncmy-0.2.16-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1246506141dd5d2782096118f2c76ccb2d332cbfd56f611e6c652def4feca721", size = 11567753, upload-time = "2026-10-06T10:52:42.213Z" }, + { url = "https://files.pythonhosted.org/packages/78/7f/11afcc252c161d7f3e6125c4dbaac42805fa90751d2af3f9ab7bf798db86/asyncmy-0.2.16-cp314-cp314t-win32.whl", hash = "sha256:ddc8b367e2d50bfaaeb1d00da260182f332fbb7ce420057cee69abd83f01f5ad", size = 2568979, upload-time = "2026-10-06T10:52:44.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/438b1a6c0bdb125b96dd8f388e053e2d66b7c723d7111721560e37d47976/asyncmy-0.2.16-cp314-cp314t-win_amd64.whl", hash = "sha256:e9a89971bd7f5aa743d8a7121b2cb4a4b82b85361c14e5770375693600add878", size = 2778482, upload-time = "2026-10-06T10:52:45.654Z" }, ] [[package]] name = "asyncpg" -version = "0.31.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, - { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, - { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, - { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, - { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, - { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, - { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, - { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, - { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, - { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, - { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, - { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, - { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, - { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, - { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, - { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, - { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, - { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, - { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, - { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, - { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, - { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, - { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, - { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, - { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, - { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, - { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, - { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/4e/59dc964f962f09e3ed472e5d2d3ba670a41a2be25080dc62ab3db507ff5e/asyncpg-0.32.0.tar.gz", hash = "sha256:45e64e56714d888330b884aad1dfb363d0bf43fb343e3d1a8968525f3bade478", size = 1075156, upload-time = "2026-10-06T20:32:40.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/27/1a7970f1ece6c205b03c79f45b89420dee9655ffb66bd2c11be8f40c248a/asyncpg-0.32.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5789340b9bcdab94a19eb8ff119322a09991e3626d131b55828535b373e285d4", size = 686071, upload-time = "2026-10-06T20:30:39.115Z" }, + { url = "https://files.pythonhosted.org/packages/2b/47/085934d0290806a92789eee860109c44bea71ff8bc7850a9d3a30da7a819/asyncpg-0.32.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:057ed2455e4e14ad9949f1ac1829112c7d0454c9810b124f36de1486febe6824", size = 692193, upload-time = "2026-10-06T20:30:40.563Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2c/d92524b9e860aecd119c0ebe43f3b9eca26dc2b75c4dfe1be3e999e3f6b1/asyncpg-0.32.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c938c4da9166ac1ef330475e314e2b94c68bde2795be0f4e8a1e00ccd806cadd", size = 3196713, upload-time = "2026-10-06T20:30:42.123Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/3ac7cb86aa287e5bbceaeb783ee6e4f51cd2a001f1747ef4f1236a20bde6/asyncpg-0.32.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:968c570c5913b7ce0995953d7239bd2367142d1af4359f87699f7a6ca75c4382", size = 3260618, upload-time = "2026-10-06T20:30:43.552Z" }, + { url = "https://files.pythonhosted.org/packages/e3/08/618ac36b2970b437d45523f50b5580dba0c34756bbf2153306f82a2697e5/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96c8226d2026e025852facb5a05035ea5e11b14bebb6b42e4e43948ef8f0d075", size = 3132973, upload-time = "2026-10-06T20:30:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/54db41b3d5fe26b0401a49327ffce439195c5f6073d8afbbdc9758cb35c3/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d3f745f4947df9004e2637753ff81d52f305f790f49d67f72e1677db12b07a7b", size = 3251612, upload-time = "2026-10-06T20:30:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e0/ed1e7536ce949896de29ee955b473659b3daa7887e7081030dba2b15ea5d/asyncpg-0.32.0-cp311-cp311-win32.whl", hash = "sha256:469e6520a839957304582eb8a708d874985914500b64517155f80e6fec00e742", size = 538739, upload-time = "2026-10-06T20:30:48.355Z" }, + { url = "https://files.pythonhosted.org/packages/df/eb/52c4bddad17ff1bee485ae83e08c752a998ef04ac5df76f03fef6430d0ed/asyncpg-0.32.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a1e671e67f4b0bef3c03f37a896d61706f769a83922c119070f1f04e415dc17", size = 610534, upload-time = "2026-10-06T20:30:50.003Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/9af12f2b3300c425a151ef8f85f47c0db76135827c549031858954805ff7/asyncpg-0.32.0-cp311-cp311-win_arm64.whl", hash = "sha256:901bc87b94539f32853bd73a9b02fa78f7feed4cf628824caad3093ec6662f58", size = 574363, upload-time = "2026-10-06T20:30:51.489Z" }, + { url = "https://files.pythonhosted.org/packages/73/06/d5f956db9c936c90cd3289cf948a86c3efc9849e26354356c23da29f6a2d/asyncpg-0.32.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7cb31f7a8472ddc6b6f5c9da1290e901d5c77c8441c7213bd13b13ef6fe6359c", size = 681566, upload-time = "2026-10-06T20:30:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/09/93/ea55f3b26fd40ec90e5b6d6c53b9ff52633cf6b87a468d9c033a727832f4/asyncpg-0.32.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:643d8d6e955a355045dddfe827d74f4f0d1dc4a18e06963a08260af838fbf093", size = 704359, upload-time = "2026-10-06T20:30:54.608Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/a3704e8675d37b168f3584661fc9f64f3021659c9b94e51cf9ab957b2bc5/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:14ff79ca2574182ce258159c48978a086f9026fc121d935017b5d10c64fa3c72", size = 3707008, upload-time = "2026-10-06T20:30:56.326Z" }, + { url = "https://files.pythonhosted.org/packages/30/30/4fd8d1155b3d7a32a2c241dcb9c5d9e9bd74a59ae71ed25ef8ddb8e038e1/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:54851411bee2aa51a30d0911524201fbb05f82cc0f7c248b140203db637c723d", size = 3810163, upload-time = "2026-10-06T20:30:58.114Z" }, + { url = "https://files.pythonhosted.org/packages/c1/25/5b0992d45661e1488aba775cf17a2e6c82c7d1d7e10acc71efd394760a00/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8592f0ed9c315b2117dbdc707cf3292f09a89d5b07661016a84dd881326965cf", size = 3600446, upload-time = "2026-10-06T20:30:59.946Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/1c82c6feacec813423401b5aef1a43baea951694157f4d405b2d14e80e6d/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4dbe0982cb3ded878de0867dfaeae3116faf471d484ea28b3e3da942f01fb778", size = 3764563, upload-time = "2026-10-06T20:31:01.462Z" }, + { url = "https://files.pythonhosted.org/packages/84/f5/5a3796088f0c3f7d22aaf7c48536f40b27e44b7c9603d4d7abfeca2ed97e/asyncpg-0.32.0-cp312-cp312-win32.whl", hash = "sha256:fbe1f8c788fb5df18ea8a5432dfa2473fd8f7f088025fb83d089a7c7b37e37b0", size = 551810, upload-time = "2026-10-06T20:31:03.248Z" }, + { url = "https://files.pythonhosted.org/packages/af/42/f4d333a3f67b0e7cf58ea855f9d5d9104ce38c21f2a2f22bf7dce524428c/asyncpg-0.32.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd7157a86817730c3239bc687abf8186a471525d695e225c187b9a523a808a98", size = 626763, upload-time = "2026-10-06T20:31:04.927Z" }, + { url = "https://files.pythonhosted.org/packages/a8/82/9d82e16e1d0b4e2a639a2db649d4b444b8a479cd52553a9c36ba0d6320a8/asyncpg-0.32.0-cp312-cp312-win_arm64.whl", hash = "sha256:9509e21fc526f1fc27cf80ad9f9b8dde3f3e21935d46be66d649635321d3407c", size = 577288, upload-time = "2026-10-06T20:31:06.776Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/b6b5870b51e004880d9a216313ea7d4f180961c5869f32e58e8cb9b71e96/asyncpg-0.32.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c032869fd9c3c9fd1a86ad67e53f63906159068087c2674dd1e19be3cffff571", size = 683362, upload-time = "2026-10-06T20:31:08.078Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/1f450742bc6eab0c015cae26aef94fac2ff29433e3f18a019126c3912c49/asyncpg-0.32.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:0c764dce865b41878396e736d4d2c6c6ce3a8e1b61d1f6bb292e30d265ae7ca6", size = 706652, upload-time = "2026-10-06T20:31:09.524Z" }, + { url = "https://files.pythonhosted.org/packages/05/dc/13f3c0ef7e867bafdccd470e5cfae1f2fd9a7085c771546bd4b94018e043/asyncpg-0.32.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:925ce1cc54419d468bfb77632d91e5e2be5be0fdf9d43680c68fe7cedf87051a", size = 3698244, upload-time = "2026-10-06T20:31:10.894Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/b00ef3fc0d861c28a1937f08d2c7f6e6119c152b414d50fa800c3aee83b5/asyncpg-0.32.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cec40b66a36b14921c155db78631cd96ed00e225fdf38dd5532e9aef350a498", size = 3801314, upload-time = "2026-10-06T20:31:12.964Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/215067d97a13206ce1565da920ddbefe5a1e5f89903e6de862fdd0a034a1/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fba43a9a230ce4d2b4593b761b8e03630c613c282b24566e27c7f53695273b1", size = 3598650, upload-time = "2026-10-06T20:31:14.797Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/2bfcb5c9b04df3f17fd367647c9f3ee9fe64ea0612b509a6b1832afcedae/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a8f7fa8304f757e23cccb8ffef6a6fce0b6320ffc565a884ee3cd0dfad1ac5", size = 3762739, upload-time = "2026-10-06T20:31:17.186Z" }, + { url = "https://files.pythonhosted.org/packages/08/45/e6b37756e6c8979fe070e9821654244f38319493f5b0589e549d9a40c001/asyncpg-0.32.0-cp313-cp313-win32.whl", hash = "sha256:d809399022e244eb86bb532a4ae9a45746e0f6dc5154fd6aa2f6ad63fa3f5373", size = 551065, upload-time = "2026-10-06T20:31:18.812Z" }, + { url = "https://files.pythonhosted.org/packages/ee/46/0a4e92f4310da644b28595b22ef2fff1ffd3dab84953dc8b4c5eef72b764/asyncpg-0.32.0-cp313-cp313-win_amd64.whl", hash = "sha256:38640b106705fef8b0f46cdb5fd9dcf6a638eed5cadb0f441714a21405ca8a0a", size = 625571, upload-time = "2026-10-06T20:31:20.571Z" }, + { url = "https://files.pythonhosted.org/packages/35/f4/48ed4b580b99b1fabc480c707229bb8f1e4ba0f5b24a50822b339efe1e48/asyncpg-0.32.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78145adedfe51dc2fda623e6602cf816dabc2eafcff693bd50484321a1c9034", size = 576342, upload-time = "2026-10-06T20:31:22.29Z" }, + { url = "https://files.pythonhosted.org/packages/25/25/a30ca6417f9142c6a63a7caf5f33717902b2d0ca8a8ff8fc72c6cc2fa77d/asyncpg-0.32.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ac18d9ee7a8ca70aed276f79b249d9f37e4d55e3525db1002b5f0b62ddec4f5", size = 691699, upload-time = "2026-10-06T20:31:24.168Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b5/59f10f2381a073c199cd868fce0d8f7aa448b08412de4dc4dbe4118bcee9/asyncpg-0.32.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:e1120ef2ae3a5e514c9ea9fce83519ba692710ea5f38434eadbbf12789073dfe", size = 715194, upload-time = "2026-10-06T20:31:25.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/59/79a5aebd58250bedefa6dcd43b22b037d9cf0054ceb4c718c53ebf04e63f/asyncpg-0.32.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fa68acb42f22436597016e5d7feef7b0b5c49b4c56aece3fdb3ba0da2326cb2", size = 3729978, upload-time = "2026-10-06T20:31:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/68/db/fc91b503b3ec66cf242d83c799388285ea5f0ee238435d53dd9c1a8648a9/asyncpg-0.32.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63417b8f7369c54f6754c1fbd5a2968fbe632ff55bfbedd56a0177b6a96bd251", size = 3794539, upload-time = "2026-10-06T20:31:29.617Z" }, + { url = "https://files.pythonhosted.org/packages/40/bd/7359320499fdb2733206191b8fd15b7ec602656cbc1444bff7a8c66a365c/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c6366841a792d0a4d16991de240a8053b7c4772a18a5f27fa6fad09c0e359fb", size = 3632884, upload-time = "2026-10-06T20:31:31.298Z" }, + { url = "https://files.pythonhosted.org/packages/18/75/dd3c3dd99f1db55b9736d23a44da29501f07f852bf4df91507f37b156fb1/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3ef1dfd11919280e011ffd1c873323c5088a94fd2c3f77946a5250cf306e2eb", size = 3764931, upload-time = "2026-10-06T20:31:32.916Z" }, + { url = "https://files.pythonhosted.org/packages/38/4f/161b275759725a774d170a383c1208996865ebad50d6891e60d35461a3e6/asyncpg-0.32.0-cp314-cp314-win32.whl", hash = "sha256:77cf9d7023f063ae6f9e443077b55af0dc1807dd9afff1ae656b93ee0cddedc9", size = 557690, upload-time = "2026-10-06T20:31:34.856Z" }, + { url = "https://files.pythonhosted.org/packages/b5/03/880d0db1faedf8b740a57a7ba50e115651a0f05c5905140195813879b086/asyncpg-0.32.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f87452025b47ce80dcc3a0be2b5d1f8aab5deec2516d266f1643d4e53cc40d5", size = 634859, upload-time = "2026-10-06T20:31:36.512Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/2e86b462a2a2a795eaa7838266db019876b8e7a12c465b903517a4e87fd0/asyncpg-0.32.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0e4508a3d62b0f42d7a99c030c364050b11e75f61c9dd4861e5fdda7cb60636", size = 594013, upload-time = "2026-10-06T20:31:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/20/1d/5369c4438496e654121cbda75be2e8043d1fcae3552b856d44011a19b723/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:afec11e0b9c001e69966becacd2f948cc8949b4916ec4c0f4dc9b52e47de4528", size = 743832, upload-time = "2026-10-06T20:31:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/60/b0/4b92582c2339a164275a6418ccaeeb0453b72f2e0d7003702379cb50e852/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:418d266a553e932bf961bb43bfd610ee6c5425fb1b9a599a5828fd12bae8f5c4", size = 769568, upload-time = "2026-10-06T20:31:40.691Z" }, + { url = "https://files.pythonhosted.org/packages/3d/88/919d9ff7ca3c3b96aa404b88b6a53e142b4422623c5ee5a69c4b733240ce/asyncpg-0.32.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1666e1b747ebbc75c87cb31972704ae8a3ca15b950f94456e97d26781c67d10", size = 3948962, upload-time = "2026-10-06T20:31:42.456Z" }, + { url = "https://files.pythonhosted.org/packages/27/8b/e9f412ae9a3e3f0eb23415249e8d5933e7aeb01068b4083fc86714043d1f/asyncpg-0.32.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83510bb25d38f0415e155aa3a7af78621369891f5ecd8730d012d9cb26143ffc", size = 3874815, upload-time = "2026-10-06T20:31:44.094Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/24364e9ff7bb9860548452513f295306b12f5b24e8fb0b78f1605c443946/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:87957755d11639cf248c6aaa094eee9d150f07065866d1710c9427e02dfc0790", size = 3762465, upload-time = "2026-10-06T20:31:45.908Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/33cb7e805ec6806b196473e2c7a2ba9d5af3ad2928930aa06359c8eeef87/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:764227423bf30a3001d3da6df90e82d30a2a097d762e4ee5fa074236eda262f4", size = 3797285, upload-time = "2026-10-06T20:31:47.53Z" }, + { url = "https://files.pythonhosted.org/packages/be/e7/85eb86d6040725f5c191fd6af9f10769c60ed971634b47f4b4bcab293d44/asyncpg-0.32.0-cp314-cp314t-win32.whl", hash = "sha256:f2342b1f3e87b2096320a77edcbb830fbd23b1d4d4842c57567764430b95e4fc", size = 594006, upload-time = "2026-10-06T20:31:49.197Z" }, + { url = "https://files.pythonhosted.org/packages/f9/aa/ea75defe55718457bcf41cde42248db5bbee65fce8c6f0a0e43d9eca1723/asyncpg-0.32.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5c3a48908cb0a02393e5bdab7fa92aefd700f2a93212bf91f04aa9657b4f554d", size = 674647, upload-time = "2026-10-06T20:31:50.547Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0b/078d362872c6c72dd5d11c214dde8dac65b1c87ece96fd2fc2f786a8f66c/asyncpg-0.32.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f8eadd207c26850a2e15f3c2a1096b5d051ea6758a26f2f3e65ce16f84297ed8", size = 624589, upload-time = "2026-10-06T20:31:52.291Z" }, + { url = "https://files.pythonhosted.org/packages/5c/83/e0145d19197b965438693179c88dd99cfc69bc1bf954815f44762ab88843/asyncpg-0.32.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58975b1a51a100c4716ebf22f84c249d27140f7b9385b64ad9b676836f1db9ab", size = 689708, upload-time = "2026-10-06T20:31:55.809Z" }, + { url = "https://files.pythonhosted.org/packages/2f/13/f394919a59f104288b1b17fb6c7a3ac4738b8c555690a63caf603f91ca83/asyncpg-0.32.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:6b95fc2ebdb4af072bfa8b64c6d0397b49242d17bef1c0337857904f9267dab2", size = 714408, upload-time = "2026-10-06T20:31:57.504Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/1123cf41bff78fdfd80e6fd143cc86bf1ef2875af8f5d8742c03f471e913/asyncpg-0.32.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a759f98c5652443db501b20041aeee548e9a04fe7ae939067321acd207218447", size = 3733440, upload-time = "2026-10-06T20:31:59.308Z" }, + { url = "https://files.pythonhosted.org/packages/de/24/ff4b045e85d7bdf6f61f67c285800abd6e82f26319671d7f0dfadadc1aa0/asyncpg-0.32.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ceea1064500d0d7a46c092cdbe9752064c23b720ab0e0bff83d1030fffe7a50a", size = 3824312, upload-time = "2026-10-06T20:32:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/1ec7eb6e20f7e8ae120a41aad9669044cce964f39773baf644897a046aee/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:543f02790d086244c7cdc849e4b671b6c2048be0242b78d943494da6e80c0001", size = 3637212, upload-time = "2026-10-06T20:32:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/79/68/528e362eb5adbc1a7defe4c5f157756a031346d3efa9920467b245e4ce41/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f24d20a68f0e37ca6fc490388e7eeb48abab3da0dbf06248135ed6179f5f521d", size = 3791355, upload-time = "2026-10-06T20:32:04.415Z" }, + { url = "https://files.pythonhosted.org/packages/38/e3/22f443f456bf93d1806f43a820da8ee463dfe9b93a9d77a3f00fedcdaad6/asyncpg-0.32.0-cp315-cp315-win32.whl", hash = "sha256:110f72d33c8b944ab421ca383db0b8849cfeb861547fee6cbb61f65a6bcd0985", size = 557457, upload-time = "2026-10-06T20:32:06.52Z" }, + { url = "https://files.pythonhosted.org/packages/54/d5/ccb76555a333f543c4d6ad6422b616efc0811dbbde5054fda071e249c7bf/asyncpg-0.32.0-cp315-cp315-win_amd64.whl", hash = "sha256:6d1d1cd1348ebb9b204b5f56f977c5d4380674c25cc094064bf32bd9c3b7273d", size = 635573, upload-time = "2026-10-06T20:32:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/38/70/dff17e837ba0eb4347bb33da33f54df87230d3d176793d4bb2ad7786b1b8/asyncpg-0.32.0-cp315-cp315-win_arm64.whl", hash = "sha256:cd5d16b3a5db37c1e6e445e362952b4af569f85f94e162f947bfa8ea25a45fa5", size = 594218, upload-time = "2026-10-06T20:32:09.717Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b8/c5506dbde0cfb213963210fd0c80e60036ddaaa883ac0d3c55d05a10ebe8/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4ea1a72a00fe705b68a9727c3d538c4c56690af9bb1cbbf3c089f5d3ddcccea0", size = 741693, upload-time = "2026-10-06T20:32:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/23/98/9f998c651aa5d66b59ab6c13da71a15d74ccb1ddc4d65290ea5e2e5aedc1/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:ed3ae4c3659aea1fb0e3a6c1061fc4c64d9b7a2a8f4a27443dc43d74fa84cf03", size = 768101, upload-time = "2026-10-06T20:32:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ce/d8c63a71e908f5d80de1a3a057c8407aaea07cf19980d4b24ab624943c99/asyncpg-0.32.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db69b9cf879bddeea41210c80b8c8877bfe2709e2bee9d18d5a5c00e7eb75972", size = 3940715, upload-time = "2026-10-06T20:32:14.544Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a5/5d2b17682e297e39206eda1dfe0120fc239e84d3440b39ff7c9cc7ec83db/asyncpg-0.32.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bee7bb5394bf55fc3bf4144625c33f298949961acdb1e0d67e60f958ac9a2e6", size = 3907504, upload-time = "2026-10-06T20:32:16.212Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/38ec7277f31f26267a0a0547d0997d936850d05007d1e0e1041bf8070e1d/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d74eabd68e68861333e3fcb92b520a2a851f6485abf4b723887590399d4980c1", size = 3750324, upload-time = "2026-10-06T20:32:18.061Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/089e80eda7d543a49875687a84121e2ad61a7c69698963623ee77372c4e9/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6af2af292a93d5ef800007c8f8f66b85af2a49b49e4b56a10685a0dc24a6af83", size = 3826457, upload-time = "2026-10-06T20:32:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/38104e60cda6131977f95b634d45536ddc1cde53ef8bc765f9056e3e17ee/asyncpg-0.32.0-cp315-cp315t-win32.whl", hash = "sha256:d148cb6a9081ed999ca3cd0d95fb9eaf79bf17d885bba93c83de52273d2fe0af", size = 592437, upload-time = "2026-10-06T20:32:21.668Z" }, + { url = "https://files.pythonhosted.org/packages/95/09/85cba249db0910708826ea428b32a4a05630df993621c369bdb8d42c73c5/asyncpg-0.32.0-cp315-cp315t-win_amd64.whl", hash = "sha256:e101801b4124e905da0732cf2b0d838f682a9ea5273d7cced3d54bdbe744e6f7", size = 672417, upload-time = "2026-10-06T20:32:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/38/11/ec5f7f306dd361aa9558f002cbb6acfa1e9ba32fa59b8f53135fbdfa14f1/asyncpg-0.32.0-cp315-cp315t-win_arm64.whl", hash = "sha256:3bbf08c08e31f43be858255614518e78cdfb343571e557e818e9fe736334f4c8", size = 622767, upload-time = "2026-10-06T20:32:24.64Z" }, ] [[package]] name = "asyncpg-stubs" -version = "0.31.3" +version = "0.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asyncpg" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/9e/562ab47de58afc6fa4e34530c0ed49343e2c7623a578cccbcb076fa0c8e8/asyncpg_stubs-0.31.3.tar.gz", hash = "sha256:37968e766d1e63a4993084ee1ed897e49a6c06a031767b2a3473b4ab81618b38", size = 20685, upload-time = "2026-07-10T00:18:19.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/f7/75d46480be90d491509a18953be519609916d2316405c39d4de3067f160f/asyncpg_stubs-0.32.0.tar.gz", hash = "sha256:0c6644e41e2756b49ba0ce95eba289450e3a3a6ee54cc4faf20d218705734630", size = 20865, upload-time = "2026-10-06T21:00:14.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/f9/08d8ce5d312e05f3c53368ce993d2f6346519d212e8801fa9c5cdbd299a3/asyncpg_stubs-0.31.3-py3-none-any.whl", hash = "sha256:1ca28026dd36808363ece589fc3cd047f2ef1d431b18d8dd6bd9a36d539de26e", size = 27631, upload-time = "2026-07-10T00:18:20.154Z" }, + { url = "https://files.pythonhosted.org/packages/94/b5/e916c6faa7276edeba0f4f3d16c3fac64754777e5be86f577a40ce73edbc/asyncpg_stubs-0.32.0-py3-none-any.whl", hash = "sha256:a7fb5e07caeb5c4e8c53306525190cc48b64a09fba19d4b7db0d95baa19691ea", size = 27905, upload-time = "2026-10-06T21:00:15.018Z" }, ] [[package]] @@ -620,7 +682,7 @@ wheels = [ [[package]] name = "azure-identity" -version = "1.25.3" +version = "1.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core" }, @@ -629,9 +691,9 @@ dependencies = [ { name = "msal-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/87/e88098a024d74b7434de5b110c261da024fe5d6c139f1847f6f8a2b76678/azure_identity-1.26.0.tar.gz", hash = "sha256:61b4538b87df10d2d6d44b77cd5ea28a8929fd4929d58b45bf3f230f7daba02e", size = 310151, upload-time = "2026-10-01T14:17:47.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/ae45ce8009d0d015304b206c7c4a6cbab02c03f8f07eedefb32b099ccee3/azure_identity-1.26.0-py3-none-any.whl", hash = "sha256:3c60d9682b3ac01507388d474cb5a5ffbe54152a96b6cdcaa7013da9315cf186", size = 195958, upload-time = "2026-10-01T14:17:49.705Z" }, ] [[package]] @@ -663,30 +725,30 @@ wheels = [ [[package]] name = "botocore" -version = "1.43.75" +version = "1.43.106" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/80/5e39eda5dfb66df691d71212799a9f9d35aa9e447511604030447a00e9d6/botocore-1.43.75.tar.gz", hash = "sha256:e8ed6b0f3cd398dfb9e08d7ca3a0b964152166a317a04e89c45ec91003327ffe", size = 15973868, upload-time = "2026-08-19T19:54:12.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/b9/10ca68d0092895d5ea60f485a61a9840d5aff9d732c66ed60da53a20b1d4/botocore-1.43.106.tar.gz", hash = "sha256:006870b3b4e40547232ad12c3bb4faec91bbbe0659aafaa3b7fa48a112c4ee97", size = 16271399, upload-time = "2026-09-30T19:36:35.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/46/19d3178e70f37fda251f3d9560f29654f09c6eedbe1c8b790adeee84da49/botocore-1.43.75-py3-none-any.whl", hash = "sha256:121abc8b0b529bc4e29a28d1e8b096b20f26708cabe3d5ae4b3446747712aece", size = 15667237, upload-time = "2026-08-19T19:54:08.802Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/c51d3b54dfaa9267a687359d946dc2a1ca6a27fb4d381773e59b7bd72bf4/botocore-1.43.106-py3-none-any.whl", hash = "sha256:c1fb8818f9957cb5037f73db34ac1a12ba43562e31d6e7f9cb7e5fc64e1dba78", size = 15965506, upload-time = "2026-09-30T19:36:32.625Z" }, ] [[package]] name = "bracex" -version = "3.0.1" +version = "3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/01/5f394b8bcd6e5b92f73130990960423bbb19711f906bd9fe9ea5557c667c/bracex-3.0.1.tar.gz", hash = "sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4", size = 44019, upload-time = "2026-07-20T13:43:00.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/60/e2a6409041cb3f8ce7ac3d6917b5b30c39f81a0a5b2d414ec30f36fd96bf/bracex-3.1.tar.gz", hash = "sha256:35c69fc105f08d77c02722e8ae707c656ff298dd4d26c89bcb3c98fca8bc3614", size = 29040, upload-time = "2026-10-11T15:26:07.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/8f/6f7273a7adb8d73fc8d21ede4376a3e475e52f98435c6007f69100dec8ca/bracex-3.0.1-py3-none-any.whl", hash = "sha256:6523ad83aeb5098a4ee597cff0f964442ff74e460bd3fafaffab6a013ff2288c", size = 11940, upload-time = "2026-07-20T13:42:59.268Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/c1ba46e1acd235affd36ce1d3346320ddcda663c9482a9e5f34727db1d11/bracex-3.1-py3-none-any.whl", hash = "sha256:52002b8455658c21f3b6e5eb0f662b57ec9085f42709e58bc86eeab5bc4c37d7", size = 11925, upload-time = "2026-10-11T15:26:05.683Z" }, ] [[package]] name = "bump-my-version" -version = "1.5.1" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -699,9 +761,9 @@ dependencies = [ { name = "tomlkit" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/09/5b09ac74962eca809cbf7010a08ea6ad405852bdd53489209a9f473d775c/bump_my_version-1.5.1.tar.gz", hash = "sha256:5079e443ab8c9a9903f140b427ff9f6fe8dd54013a55a4cf48b89326f3a71c07", size = 1132060, upload-time = "2026-08-06T14:26:38.9Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/75/8f7d22e08f304cc3923adcdaa184edad857f174519cef3667acfa31bd9d3/bump_my_version-1.6.0.tar.gz", hash = "sha256:ba2c81ed0c17bf0a95e24f9676b8ffaf3b24d29fcb978f06b4ef0f57ee9f39ad", size = 1163463, upload-time = "2026-10-11T12:22:09.442Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/0b/5885530f79d4400368b9d4dcb9b39274c0d52e633f7871e7fc6feceea1e3/bump_my_version-1.5.1-py3-none-any.whl", hash = "sha256:df3e2989d0d7fe704718feb24a5880f089b6b6369e427a4445b89c3adebfcff1", size = 65090, upload-time = "2026-08-06T14:26:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/0e/13/7df5ce9a79ffd8f1a3f79b337fbdf2e1902d407986d3f503523f69219373/bump_my_version-1.6.0-py3-none-any.whl", hash = "sha256:e01a80fcbe265d3d3d4e6060e21bf5bb9c5521986e6582a122a0e5dfd48f74ee", size = 65686, upload-time = "2026-10-11T12:22:04.706Z" }, ] [[package]] @@ -1277,11 +1339,11 @@ wheels = [ [[package]] name = "dnspython" -version = "2.8.0" +version = "2.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, + { url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" }, ] [[package]] @@ -1368,23 +1430,23 @@ wheels = [ [[package]] name = "extra-platforms" -version = "13.10.1" +version = "13.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/10/5803927617f21359f940fe611db04ead99b0fee23b0be3300d0e4094ebb5/extra_platforms-13.10.1.tar.gz", hash = "sha256:adada8aadf654b92964b369c7fb5c548467fc1616ef15f6c5943ec0141efa96d", size = 484544, upload-time = "2026-09-19T11:32:28.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/e9/805ffa0a5d7bbb2eee2297a2fe1c22e41b2900daa2f1144d335e827e2a1b/extra_platforms-13.11.0.tar.gz", hash = "sha256:79bd4a79df48a873c6cd46e2c7318a7604f3d07c7a14b405b22f63b1368eef11", size = 492121, upload-time = "2026-10-01T16:06:47.514Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/92/6164146cb8572c9bdc85763410624776109bff33a3201dd53f197963f48f/extra_platforms-13.10.1-py3-none-any.whl", hash = "sha256:22c67b0a4a84bb1d123b0884cb93c6928159239f4df313fec63bc6adcb05fa32", size = 104594, upload-time = "2026-09-19T11:32:26.508Z" }, + { url = "https://files.pythonhosted.org/packages/65/51/faf97cdf3da209138b9b4f265bfb9bcc23394a9e5a405f380cca494586f3/extra_platforms-13.11.0-py3-none-any.whl", hash = "sha256:6ee4e9972dccc82951140c8acb166714a49e45b79c38157b4141203ff73abbc0", size = 107041, upload-time = "2026-10-01T16:06:45.824Z" }, ] [[package]] name = "faker" -version = "40.40.0" +version = "40.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/43/7f5870d05331cb1649704040f51332d37f2468ed857de1e226482e2948b0/faker-40.40.0.tar.gz", hash = "sha256:803ee7f920679e7367c3f7b7f97752f5a9780aa5566c71723fbe57d591beafe1", size = 2029985, upload-time = "2026-09-29T00:56:19.983Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/6c/b8793efc2f00a912ef17cf0b61b717cddf34607499efcb8a32238c119368/faker-40.43.0.tar.gz", hash = "sha256:02fae4327c03a4a6315e1b428a3878f435bfc276c93435ea349b95c0c9372361", size = 2032713, upload-time = "2026-10-09T20:06:29.652Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/d2/58beb2b8355f89b483fa21b99fe9934ee7312e3ccf228bb7cefe9e8d979c/faker-40.40.0-py3-none-any.whl", hash = "sha256:cd45ebdd1363f92a45740ac49945e49fa18f7e10771884a83c796a235550d7b7", size = 2066663, upload-time = "2026-09-29T00:56:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/0d6d0d6467ae3d009fb82fb411867c7fcfeadbdd25602cab0d7a7963f400/faker-40.43.0-py3-none-any.whl", hash = "sha256:9dd7c0ddfaf30c842b05502d3cf641c135e0120a3a19047008ba8525b72953ed", size = 2069082, upload-time = "2026-10-09T20:06:27.166Z" }, ] [[package]] @@ -1475,11 +1537,11 @@ wheels = [ [[package]] name = "filelock" -version = "4.0.12" +version = "4.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/c8/1d457d9150ff948f2ce6ada7715e0eeebbe5d3b58a45271a1e222474bcd3/filelock-4.1.1.tar.gz", hash = "sha256:7ba0927482c5a814b0a7f391d029ccdb8010f576f0a74c0dcde1811e8bc4c1b6", size = 563430, upload-time = "2026-10-11T16:11:54.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/f837f52905395ba4510fe61f753c24833fb0a9c76e21267bb9f828b664a9/filelock-4.1.1-py3-none-any.whl", hash = "sha256:3f4a557945a7b0f95efeb1f432267affe5d45ac8ddde2aed1b97ebb62382c089", size = 132460, upload-time = "2026-10-11T16:11:52.753Z" }, ] [[package]] @@ -1620,7 +1682,7 @@ s3 = [ [[package]] name = "google-adk" -version = "2.10.0" +version = "2.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -1647,9 +1709,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/2e/3f5a2ed6b50da6c4420ddc4fbc9f2be0fc2ba2e25b83345b622fcdf8a033/google_adk-2.10.0.tar.gz", hash = "sha256:9e8d64f18e91780e70bf32726250595a9ca38fc8d51d90750f7a71b44922432f", size = 4127907, upload-time = "2026-09-25T19:02:06.67Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/50/7e9d83adc67df40752598bddcc24d9b6696aae4d61c45b6271d53ec2df53/google_adk-2.11.0.tar.gz", hash = "sha256:59258cec8d79306192bf65e0d43fa64b764f831e3b74ed3b8e6fe9df16a1e2c8", size = 4197119, upload-time = "2026-10-02T00:46:25.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/8b/ae4a9750874ec35e4d88c2a8fd416884cb0695141691689e5affcaad17a7/google_adk-2.10.0-py3-none-any.whl", hash = "sha256:7550fecbb428a8a6de15ab88ba3c1d4704c752fe0b5594dc6d67ff440f370886", size = 4792014, upload-time = "2026-09-25T19:02:04.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/b4f54c46f1c6a32318891ff2be25d854f4c5b5b9ed8ac507ed9466f1ccc8/google_adk-2.11.0-py3-none-any.whl", hash = "sha256:4774e1fc7bb37727e56142e7d41e9035949ebfc876a2b15d5505969b02798bcd", size = 4871441, upload-time = "2026-10-02T00:46:23.264Z" }, ] [[package]] @@ -1676,15 +1738,15 @@ grpc = [ [[package]] name = "google-auth" -version = "2.59.1" +version = "2.61.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/e3/9f752a968e487fbc10b2c2a1dd561b62489f923972911009577bb88fe7bb/google_auth-2.59.1.tar.gz", hash = "sha256:ce50fc533ac02f489a2b183a0c156672c376ecb2091b1127bc7efba2975fff27", size = 384298, upload-time = "2026-09-30T17:26:25.738Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0b/9788e913f2202da49068c27ce821eebcf96319240a89d7bb11f206d6471f/google_auth-2.61.0.tar.gz", hash = "sha256:37f0815967322e8c32b12bf422531e8b637cafdaae0acbb9141117cfe6a96f23", size = 398379, upload-time = "2026-10-07T20:13:14.827Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/38/311ac322f46ecd789f1342bb7adb0a14b940cf54b5db0e92d4f449aa6cf8/google_auth-2.59.1-py3-none-any.whl", hash = "sha256:89c3f931683a482ac97e61df7eb9da5e08a91703f3c752b2377d72cfb7d69e6a", size = 263519, upload-time = "2026-09-30T17:26:24.017Z" }, + { url = "https://files.pythonhosted.org/packages/1e/68/c2ff6043d93fda69e5c12a834d0be3b3fa6c762e89732160532566be621b/google_auth-2.61.0-py3-none-any.whl", hash = "sha256:ca60266a37475ae68b63bac007272f46094b3d571abe92aded329b6cfb568025", size = 268021, upload-time = "2026-10-07T20:13:13.2Z" }, ] [package.optional-dependencies] @@ -1697,7 +1759,7 @@ requests = [ [[package]] name = "google-cloud-alloydb" -version = "0.11.2" +version = "0.11.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1707,9 +1769,9 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/8a/28def8acb711f59771473a0549bd085f5f0c719a351b04187961bfc5d5ae/google_cloud_alloydb-0.11.2.tar.gz", hash = "sha256:492ce15cd4a2a47e00d6ea3661daa7d1873fa4c51d124a5db8e895421c9a71a9", size = 692064, upload-time = "2026-09-14T18:52:48.227Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/42/09a16b70b1ca1c2f4b2aec3ec335e6f4d2ce17a517f41affe9df1ee7c941/google_cloud_alloydb-0.11.3.tar.gz", hash = "sha256:99a28fb494237570757841ab07885aa9febb7c446274f16ff0ad008ac91c370f", size = 691918, upload-time = "2026-10-01T18:14:33.881Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/22/75540f7280c760aa1862bdcf76ad3c3d20ae444fcbb3640d4c131a260c80/google_cloud_alloydb-0.11.2-py3-none-any.whl", hash = "sha256:13f482f1ce6520eca6d9d4324cd630863edcf60613baa77571454db4d5fed80f", size = 532445, upload-time = "2026-09-14T18:52:25.775Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0c/5a5bb24da42e7a452307623eecf6b8991cdca0bf2020cc9daa9f8bdfd949/google_cloud_alloydb-0.11.3-py3-none-any.whl", hash = "sha256:b4456bc1a53fb3d88631b282d201f81007623c91a1dbbe1e39337b4dce0961cf", size = 532447, upload-time = "2026-10-01T18:07:49.423Z" }, ] [[package]] @@ -1732,7 +1794,7 @@ wheels = [ [[package]] name = "google-cloud-bigquery" -version = "3.45.2" +version = "3.46.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1743,14 +1805,14 @@ dependencies = [ { name = "python-dateutil" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/d2/8a1d3b94ebab86003661b5264882f2f2d9596b33c77a5c994f3761f4e0ef/google_cloud_bigquery-3.45.2.tar.gz", hash = "sha256:9342158e1a413c66b44b676a42c79cb8d1750da3971b957b267c9327749b4723", size = 529264, upload-time = "2026-09-17T20:30:15.507Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/80/9a6f1e7368ee0bc73dd5d6f26005d4e05ce18ffca1ef0de63fa41357253e/google_cloud_bigquery-3.46.1.tar.gz", hash = "sha256:c5564a58fc3c99b003cd334a8f302c6224bd34cb55c87476a3c445be1d00538b", size = 529893, upload-time = "2026-10-02T10:37:38.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/64/fc977715bb54d78e4863d705eb6013241ade24247f1373a2be20e05cf6e3/google_cloud_bigquery-3.45.2-py3-none-any.whl", hash = "sha256:6920be2d7199810e2472be58204d7787dcf3cf677a21e682d5c636bae1b082ea", size = 267839, upload-time = "2026-09-17T20:29:41.9Z" }, + { url = "https://files.pythonhosted.org/packages/41/57/b9bc61a4bad2f1f59b61191d0c099c4d75a4f65d3a5649a6c164610defab/google_cloud_bigquery-3.46.1-py3-none-any.whl", hash = "sha256:ee0e73d06a624c26445458e75f04deed894ed50a0c46bdc876f30c50e05ec6c2", size = 268065, upload-time = "2026-10-02T10:37:35.771Z" }, ] [[package]] name = "google-cloud-bigquery-storage" -version = "2.41.0" +version = "2.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1759,9 +1821,9 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/de/d499a74cad60fe548986504da00e20177cb1d3c8bfbb1fc2347e518f8cbd/google_cloud_bigquery_storage-2.41.0.tar.gz", hash = "sha256:b999295f08ce7e2d6a260d245b5b9843ba1603f6164515c9f0cb5d81ee71a5d2", size = 315252, upload-time = "2026-08-25T19:18:34.837Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/bd/d1d0e6aeb92e339715d99db149fb5ae5b9adb7ba904fdaec273fc7af7a7f/google_cloud_bigquery_storage-2.42.0.tar.gz", hash = "sha256:98f6c870f4a61f73d29ee12e30e64e9bc651ab8aa6d487c0c13c296f67878e7c", size = 310972, upload-time = "2026-10-01T18:15:15.111Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/c9/1dc5941062fd196959ab343174d91f132cb2308cc3c80deb7dd194543977/google_cloud_bigquery_storage-2.41.0-py3-none-any.whl", hash = "sha256:a7511f05aedb533e01f029e30b26ff073ca66d672f701bb736024553697b589c", size = 308013, upload-time = "2026-08-24T21:55:17.697Z" }, + { url = "https://files.pythonhosted.org/packages/a5/05/737e43878f63d07c19bc26b8d7763dfa482cdd440b221d9dbefe22af352e/google_cloud_bigquery_storage-2.42.0-py3-none-any.whl", hash = "sha256:eebb5751125eb692cde0a7f22b9432eb656662daa95bde9439ad3252d5e19cc5", size = 309652, upload-time = "2026-10-01T18:08:41.351Z" }, ] [[package]] @@ -1779,7 +1841,7 @@ wheels = [ [[package]] name = "google-cloud-monitoring" -version = "2.31.0" +version = "2.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", extra = ["grpc"] }, @@ -1788,9 +1850,9 @@ dependencies = [ { name = "proto-plus" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/9d/9522e169db3887e7f354bb9aa544a6e26c435ce19337e32432598db18c6f/google_cloud_monitoring-2.31.0.tar.gz", hash = "sha256:b4c9d3528c8643d4eb4b9d688cbb3c5914bc5f69b314ff7c5e1b47bdc073a9ae", size = 404747, upload-time = "2026-06-03T15:28:24.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/94/68b1d25f99f8e61c8d4d4403caab3746c9e4aa629e1a8aef22f7f099c95c/google_cloud_monitoring-2.32.0.tar.gz", hash = "sha256:507501ce1c39161c071754f7ed14cd527788b4ea44a1d052bd815c115a70ea48", size = 388895, upload-time = "2026-10-01T18:17:17.249Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/30/aa6635296da9c1c14d2e64f64e1cacd4f4debf8ab7e646c0559545f0f70d/google_cloud_monitoring-2.31.0-py3-none-any.whl", hash = "sha256:64f3d56ead48f0a0674f650cb2828c47b936582a02a27c55f2836681a86281c3", size = 391010, upload-time = "2026-06-03T15:27:39.536Z" }, + { url = "https://files.pythonhosted.org/packages/89/97/86870c4f046bba0dcd5ca2099f30413653b84d0fbef611e5f39f8a94e3a8/google_cloud_monitoring-2.32.0-py3-none-any.whl", hash = "sha256:b6c026a5780330aa26c6151279194daa241369b324c96fd6c52c703137bc6b5b", size = 384477, upload-time = "2026-10-01T18:11:09.382Z" }, ] [[package]] @@ -1871,7 +1933,7 @@ wheels = [ [[package]] name = "google-genai" -version = "2.25.0" +version = "2.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1885,9 +1947,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/0a/a3b7856ca840031d4393dbdd97b67fe811b20061315ed68b67b5c85ca80d/google_genai-2.25.0.tar.gz", hash = "sha256:ab603baa5eee0205926ad0f8d7f93e0400df6d67650e99c33e01ab228ea16ad6", size = 699564, upload-time = "2026-09-22T17:23:01.238Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/2b/977616ef350e51d3d263eb43be23a91be793544cd937b51c0fac313be233/google_genai-2.29.0.tar.gz", hash = "sha256:124d3eef9ff6b9e4ea548f95ff5b7721683e7990c0a936b006d9e2ab61ea897d", size = 706902, upload-time = "2026-10-07T22:22:57.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a8/178dbb9d1d6cac721b01592e291146a024bae5ee3224e36569348921dd6c/google_genai-2.25.0-py3-none-any.whl", hash = "sha256:2e8f3a5f76ed00d5ffc3153f6089bc7d3511054eed53ad9cfc6095a7dc59b028", size = 1159631, upload-time = "2026-09-22T17:22:59.291Z" }, + { url = "https://files.pythonhosted.org/packages/b8/27/a3dede4a1deb21f7fde6a171daa039bf0ea149a5e7826a785e8f109a6e13/google_genai-2.29.0-py3-none-any.whl", hash = "sha256:7adda55cb6746fb46d38ad3202c3372840b96eede964b5fb3df1469cd4998570", size = 1167603, upload-time = "2026-10-07T22:22:55.215Z" }, ] [[package]] @@ -2183,45 +2245,101 @@ wheels = [ [[package]] name = "httptools" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, - { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, - { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, - { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, - { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, - { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, - { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, - { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, - { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, - { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, - { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, - { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, - { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, - { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, - { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, - { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/85/1b1e9e6f2f769dc48610f5e71b9a7d50d5a9532985fbd1f1a8b579f62b0e/httptools-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0fd73d0bbf700a30dd87e4412adf41cfa71542a533d6b390c7244bbb8a1152bb", size = 119077, upload-time = "2026-10-09T19:54:10.012Z" }, + { url = "https://files.pythonhosted.org/packages/e4/30/72d0caf79e54eb1356527c870daac40f8d06f86cd078fdf73c6bf3f7d100/httptools-0.9.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:d2b095129b9a98eb46a271ee9631089529c4e40354576b4aa74e24de9d2bf2f7", size = 114529, upload-time = "2026-10-09T19:54:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/6498fe8218db1ed5f785010c303bfef50516d0988e64988bb8f9f59d70ab/httptools-0.9.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b68fb053b37c258a473ab67f4965c3b439500dc160fe364667035a6833eaf50a", size = 499139, upload-time = "2026-10-09T19:54:13.002Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a9/81795025aa1ac0ca5346917571756c3e77ae3d0aa11d70fee11b5f89f713/httptools-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2780e33a58a93f27cc3bb74a55bae6f9a8278a1dbabdff392940d30d381671", size = 498141, upload-time = "2026-10-09T19:54:14.725Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e9/f9070a752f6efb42381c0c63fec08385bfbc6d63be15191c424f6d740575/httptools-0.9.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:272db0c51e8b71e953c1f2ecbe63402b819680e4564be2ef285cfd4584ee8355", size = 524457, upload-time = "2026-10-09T19:54:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/0a/29/201ca4636ebe7cb2c931d6545ed4be0acd5fb90f7351ea0458b5ab7319ec/httptools-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22ab1b10b06d357f01092e60f5e6856a0d479ed79b0ec2166a339ea26c699be2", size = 455418, upload-time = "2026-10-09T19:54:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/24/97/2cc1ad7a28243e35002dcd9c4dd98074bc47c9a0a72be12de01fff10e2a5/httptools-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8a59c749a73fbdbc8e63b895a3079825fa085d752e75bc0a500042cb8a801e48", size = 483407, upload-time = "2026-10-09T19:54:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/88/98/c7ca6a34d92010561eb5af95bf0d2b667ce4ea3e83ff7fda68da52e037bf/httptools-0.9.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6ac1414556b910a879c108d79736f77e797871f9919ed0d2c3cf8cf3ecca986", size = 506748, upload-time = "2026-10-09T19:54:21.886Z" }, + { url = "https://files.pythonhosted.org/packages/ef/62/6aec88e4d1da59005184f1038f5abfaad6143499fbf4baa46c2fed8e5b59/httptools-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:13873eb8aef5972fcfee614f63d47064312ad4efbfe65ade15b8a3b77f8c8659", size = 450253, upload-time = "2026-10-09T19:54:23.863Z" }, + { url = "https://files.pythonhosted.org/packages/5a/06/4be91efa577ccae9a16694a413bb8a7c30cb0ec2dc972627b64e108517b8/httptools-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5042aa1c7e2b1a24c17dab31d8770b63a5101c9abc25f832c6aef6b201e1ca4f", size = 485588, upload-time = "2026-10-09T19:54:25.592Z" }, + { url = "https://files.pythonhosted.org/packages/10/eb/3224a5e3145b784e7344a370a8cc9a0d448035a913ddece6e4c35067315f/httptools-0.9.0-cp311-cp311-win32.whl", hash = "sha256:a4d1ecad62e83cc65b411ea0125972cf3af98821e8117129947fd1e3a113f8d2", size = 85785, upload-time = "2026-10-09T19:54:28.216Z" }, + { url = "https://files.pythonhosted.org/packages/9c/41/214e2da998e6348eb68fd0883ffa9774c83ab7a5da12d97595aafb07d0ba/httptools-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c4fa57d3c31889722f64bfa785545a5e603a893b6f29ac1a41bfa830abeaefd5", size = 92421, upload-time = "2026-10-09T19:54:29.574Z" }, + { url = "https://files.pythonhosted.org/packages/96/af/d8fc6b8581045899a780018842a3db0365c4b8ca46519a528e9b8bc6095e/httptools-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:ecfeee649184ffd800955068be9a6b579a0f33fc3c98535d685d5779cb59347f", size = 89093, upload-time = "2026-10-09T19:54:31.269Z" }, + { url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" }, + { url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" }, + { url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" }, + { url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" }, + { url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" }, + { url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" }, + { url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" }, + { url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" }, + { url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" }, + { url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" }, + { url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" }, + { url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" }, + { url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" }, + { url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" }, + { url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" }, + { url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" }, + { url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" }, + { url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" }, + { url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" }, + { url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" }, + { url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" }, + { url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" }, + { url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" }, + { url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" }, + { url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" }, + { url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" }, + { url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" }, + { url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" }, + { url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" }, + { url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" }, + { url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" }, + { url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" }, + { url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" }, + { url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" }, + { url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" }, ] [[package]] @@ -2326,11 +2444,11 @@ wheels = [ [[package]] name = "iniconfig" -version = "2.3.0" +version = "2.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { 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]] @@ -2565,76 +2683,130 @@ wheels = [ [[package]] name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, ] [[package]] @@ -3134,7 +3306,7 @@ wheels = [ [[package]] name = "mypy" -version = "2.3.1" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, @@ -3143,53 +3315,53 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, - { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, - { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, - { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, - { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, - { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, - { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, - { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, - { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, - { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, - { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, - { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, - { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, - { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, - { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, - { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, - { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, - { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, - { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, - { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, - { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, - { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, - { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, - { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, - { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, - { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, - { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, - { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, - { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, - { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, - { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, - { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, - { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, - { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, - { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, - { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/67/e53965e67d23d48e78dd603aa7525a8d77e7818c23954cc217ccde5cd5c0/mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd", size = 14315745, upload-time = "2026-10-01T20:39:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5d/a588ccd57625822d8484d7aff4e9c70f88d042a91c2ec84324a2bc14b3f3/mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74", size = 14465652, upload-time = "2026-10-01T20:38:57.19Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/b4978a5566cb829ca563dc08e3ad8d944f116078786ad54ba3dff2bcb64d/mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1", size = 15419180, upload-time = "2026-10-01T20:40:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/52c71f31c54d0a749d832cc73b933c6e38c52d9b270d5503e031c73723dc/mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4", size = 15669212, upload-time = "2026-10-01T20:39:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/e8/55/ba63121494404fd7d3787ec176ccd02820d6ec125ac4e7c4c0b79f2cc6c7/mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b", size = 11299572, upload-time = "2026-10-01T20:40:03.627Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/385c81b5c0d3a2917f077c4da660d0493f9521ccfd4b058e33b860f72d02/mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd", size = 10869751, upload-time = "2026-10-01T20:40:25.483Z" }, + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395, upload-time = "2026-10-01T20:40:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009, upload-time = "2026-10-01T20:39:58.86Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557, upload-time = "2026-10-01T20:39:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673, upload-time = "2026-10-01T20:38:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803, upload-time = "2026-10-01T20:39:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060, upload-time = "2026-10-01T20:39:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, ] [[package]] @@ -3343,7 +3515,7 @@ wheels = [ [[package]] name = "numpy" -version = "2.5.3" +version = "2.5.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -3352,80 +3524,77 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, - { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, - { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, - { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, - { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, - { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, - { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, - { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, - { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, - { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, - { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, - { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, - { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, - { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, - { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, - { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, - { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, - { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, - { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, - { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, - { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, - { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, - { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, - { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, - { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, - { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, - { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, - { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, - { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, - { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, - { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, - { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, - { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, - { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, - { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, - { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, - { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, - { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, - { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, - { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, - { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, - { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +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]] @@ -3612,65 +3781,60 @@ wheels = [ [[package]] name = "orjson" -version = "3.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/1a/a7075a8e8b0d3f5097d17ac3099017104b6b7b42012041147995d5b2da05/orjson-3.12.0-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a94f0f0c6fcbb2b5bd9734c57a489c7584a732bbdf04a39e8c83b861e9d03e92", size = 223409, upload-time = "2026-08-14T16:12:12.654Z" }, - { url = "https://files.pythonhosted.org/packages/05/34/c2eb3b2900e5597db7841a4c6416ac2d90081bd956b02d4dd1833fa2b96b/orjson-3.12.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:a696529ec96a90d9a5f9570207efe403c8b08f8e4aa2783ee3403511e2fdfa10", size = 124015, upload-time = "2026-08-14T16:12:14.025Z" }, - { url = "https://files.pythonhosted.org/packages/1c/df/b49081766a75b6a37b3d33bdc0a39e492abab8441dd25e3e1998e7b83fcb/orjson-3.12.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:e4ac5059baab4b3acbd99485de019ff8cda0fdf34b61fa74f7197a53db78bfe8", size = 113471, upload-time = "2026-08-14T16:12:15.81Z" }, - { url = "https://files.pythonhosted.org/packages/48/d4/58ea28eeef95c2a27358ed927380a621162cf20bd740bbccf9c3f09a200a/orjson-3.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e29957429c35bbb5a185a119c523aa2428b7bbf1a293724c7b9375ed8f892a3", size = 129998, upload-time = "2026-08-14T16:12:17.503Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f4/1e82aa2efc9916422d804697876ce433c907a1abd7c7e5c6d3d48565e5f9/orjson-3.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dce0166feb0a737ab84f598c9a338cbc0b764a036617aa686194f53c7eba0c3e", size = 130891, upload-time = "2026-08-14T16:12:18.762Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e1/15169e9d22b59a406264f99d6db387c0b0b12b6357a8a0169917c2a713eb/orjson-3.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9caf3d09f47c3c70c4451ada20ef9bc4a4cdffa26f49862cf0a253b329aae2d5", size = 131285, upload-time = "2026-08-14T16:12:20.251Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3a/763dbd426290d044ec3e615a05e70adb6d8b6f95bf17dc355c0081a5e8b6/orjson-3.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b9dca132b1fda5565088e65a6b6e742285e0aeceb6fae549fa8863e16c7d3998", size = 135707, upload-time = "2026-08-14T16:12:21.652Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/3b2038ed168d22e14182ed715d6963f9c073a83a2ba43cfe918a4fc43c64/orjson-3.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a791f793b287bbc135b8e87c34e35c8bfc693e2a8a620fab1ae682b925f9a32e", size = 127669, upload-time = "2026-08-14T16:12:22.926Z" }, - { url = "https://files.pythonhosted.org/packages/88/ae/b84b3d3e65f5629ada0edcb1d2bccc55d7c5f89d8b981537ecdc3d6f31ec/orjson-3.12.0-cp311-cp311-win32.whl", hash = "sha256:31ed278a36304390adc3eec5d7f6fd593a7c3e99e5a06cd07866396c4b1b4710", size = 128043, upload-time = "2026-08-14T16:12:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/35/24/2ed0e6f51ea3d0af45d807233a851175af75bec83ef5fd0d6a2601904ec0/orjson-3.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb2539159dfe8d371914f354360fa50e4a577cc89222a3828b9650a5e5040252", size = 122084, upload-time = "2026-08-14T16:12:25.813Z" }, - { url = "https://files.pythonhosted.org/packages/21/dd/95d25fcfbc9471799ef6bb01c552d64ee5cde93ee40ba2f423dd3442c708/orjson-3.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:61318b6de893c7a9d9f3e5ecbadccbfc26a7eb417ccc7bbf0771de3b4d72f868", size = 127035, upload-time = "2026-08-14T16:12:27.201Z" }, - { url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" }, - { url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" }, - { url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" }, - { url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" }, - { url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" }, - { url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" }, - { url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" }, - { url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" }, - { url = "https://files.pythonhosted.org/packages/54/cb/d7b78218a987eb8a8ce4eeae0286b1bb679333eb631ea0eeaf6371680bfc/orjson-3.12.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900", size = 223397, upload-time = "2026-08-14T16:12:44.003Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4a/bc87c45e7ec639d35ebefd62618e01939531ac8e171426606a01bda05914/orjson-3.12.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03", size = 123662, upload-time = "2026-08-14T16:12:45.433Z" }, - { url = "https://files.pythonhosted.org/packages/94/ee/c9a4ff3f2dbedbbe9e635d0fa72c8866adede09b6335ef9644f53752f0d8/orjson-3.12.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8", size = 113374, upload-time = "2026-08-14T16:12:46.755Z" }, - { url = "https://files.pythonhosted.org/packages/75/09/3f330a026a796c8b4c97a6f429652a5e912e7065039bf96ed25e42aa7b25/orjson-3.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94", size = 130029, upload-time = "2026-08-14T16:12:48.06Z" }, - { url = "https://files.pythonhosted.org/packages/7d/40/094cc53126a3d22f76cdf83b6ea67338bed01d774037621a785aa8e6e5ea/orjson-3.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806", size = 130528, upload-time = "2026-08-14T16:12:49.362Z" }, - { url = "https://files.pythonhosted.org/packages/bc/74/89bb236deb9565f99434b13052bb40ddfcce4adf3afbfa3132ee7e421468/orjson-3.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df", size = 131075, upload-time = "2026-08-14T16:12:50.692Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ac/1176360d762c01b5bd34acd56fc098e936c491363d8b6b397ad4aa475547/orjson-3.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978", size = 135321, upload-time = "2026-08-14T16:12:52.114Z" }, - { url = "https://files.pythonhosted.org/packages/7a/02/bbd881c8b9276d50b998de38b4e97de8ace1aac940b0ee545aedbf65ed00/orjson-3.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222", size = 127472, upload-time = "2026-08-14T16:12:53.517Z" }, - { url = "https://files.pythonhosted.org/packages/8e/02/a0934d7503e6dcbedd6afac3e7f3f8597fd09389949ad94d0f7540e9dbca/orjson-3.12.0-cp313-cp313-win32.whl", hash = "sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1", size = 128000, upload-time = "2026-08-14T16:12:55.14Z" }, - { url = "https://files.pythonhosted.org/packages/52/87/69f98f8d40faff103a965a5fbb83f08241b01beaf92badb5413fbc9358cc/orjson-3.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2", size = 121841, upload-time = "2026-08-14T16:12:56.507Z" }, - { url = "https://files.pythonhosted.org/packages/e6/07/b83046a4e3cadcc0987d0f160696107c4af706a619b56e4ad01940cadadf/orjson-3.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e", size = 126765, upload-time = "2026-08-14T16:12:57.806Z" }, - { url = "https://files.pythonhosted.org/packages/12/9d/3931253e6f3148abf2cbe14830367042a4806b362ea520df2303db188fb9/orjson-3.12.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d", size = 223391, upload-time = "2026-08-14T16:12:59.184Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0e/b4a4f1e305367245877b967a0bad70fcf001d77c54ac4339a120b66fdae4/orjson-3.12.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647", size = 123659, upload-time = "2026-08-14T16:13:00.548Z" }, - { url = "https://files.pythonhosted.org/packages/96/f3/6782c6fa85e2702bc66be183c3b421486167dcf266ee4dc1403fe3824870/orjson-3.12.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c", size = 113337, upload-time = "2026-08-14T16:13:02.009Z" }, - { url = "https://files.pythonhosted.org/packages/bf/79/b32ab64bacda9d0fa4942ef483bd03cabf0eaf2be819ca9fb7ff610c559d/orjson-3.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc", size = 130112, upload-time = "2026-08-14T16:13:03.404Z" }, - { url = "https://files.pythonhosted.org/packages/ee/49/6e6142999ca01509219be5e5a9c338a3e5ea011f63e91ff473fbbf3734ed/orjson-3.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1", size = 130520, upload-time = "2026-08-14T16:13:04.798Z" }, - { url = "https://files.pythonhosted.org/packages/49/d0/3745af0a4cc9867784f29722929cec4d10bd1c877cd754b01ba6d96eb21a/orjson-3.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a", size = 131053, upload-time = "2026-08-14T16:13:06.14Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/6fe5a22fa478fffb190e65c338c84df5c311ef597b363150a17cc57063c0/orjson-3.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e", size = 135321, upload-time = "2026-08-14T16:13:07.544Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/b1b0ec30289646a81a76e2dbaae2686b96fcccb7cb0323dc1dd78cbc7875/orjson-3.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f", size = 127485, upload-time = "2026-08-14T16:13:08.88Z" }, - { url = "https://files.pythonhosted.org/packages/bf/2b/277404bdcc21c93b112b963655b76443ebfe828f8a3ff1de7d90f8850eb3/orjson-3.12.0-cp314-cp314-win32.whl", hash = "sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92", size = 128048, upload-time = "2026-08-14T16:13:10.305Z" }, - { url = "https://files.pythonhosted.org/packages/41/2b/395b36fa2b4ce7af70b651d715e88f80d884b2c2b14a6b53e84d554fb5f0/orjson-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed", size = 121858, upload-time = "2026-08-14T16:13:11.634Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a3/833e895ff452859eebe75093d26691fe9108f1a7a6a08435d7a5780ea652/orjson-3.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7", size = 126749, upload-time = "2026-08-14T16:13:13.117Z" }, - { url = "https://files.pythonhosted.org/packages/58/64/99c8947ece10c17176af9aae85c4948f1d109da77440ec14d87239efaf73/orjson-3.12.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e", size = 223398, upload-time = "2026-08-14T16:13:14.694Z" }, - { url = "https://files.pythonhosted.org/packages/3e/30/cf983fe09f2731420fda097a9f7ef4343f47fa216c228961ad8f6da44f3d/orjson-3.12.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517", size = 123655, upload-time = "2026-08-14T16:13:16.221Z" }, - { url = "https://files.pythonhosted.org/packages/11/50/9cb8ae73fa4749dbbc20f617004213b5ff01c20aaeec34c3f31124f2c1d8/orjson-3.12.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38", size = 130515, upload-time = "2026-08-14T16:13:17.601Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0a/adb6ce1a5b5fbf9cb1790f9961bb668a0dd5429aadaf6cee044724681795/orjson-3.12.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d", size = 113327, upload-time = "2026-08-14T16:13:18.927Z" }, - { url = "https://files.pythonhosted.org/packages/51/5c/d17f61581d8dbdde7048f87a330fa24915edec38db4d72b381fec14fbb56/orjson-3.12.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13", size = 130105, upload-time = "2026-08-14T16:13:20.317Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b7/938befcf33bee4704a92ecec6a2731224c539d939bf9429fd39396d28931/orjson-3.12.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328", size = 131049, upload-time = "2026-08-14T16:13:21.719Z" }, - { url = "https://files.pythonhosted.org/packages/b0/15/cfa2021d64d5aa8bb5c9f604ef375e00ec8b657651b5dd650b1b7ad13df1/orjson-3.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c", size = 135320, upload-time = "2026-08-14T16:13:23.415Z" }, - { url = "https://files.pythonhosted.org/packages/1a/50/3e75dfe357c1e8f9e287c7a5740260ef15bd23a5299eae8d0835dcad5375/orjson-3.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a", size = 127488, upload-time = "2026-08-14T16:13:24.791Z" }, - { url = "https://files.pythonhosted.org/packages/11/a6/79aed402eb3ab284dc5b4791a7ad62c5875127de01b8e3f04bd92d551298/orjson-3.12.0-cp315-cp315-win32.whl", hash = "sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55", size = 128048, upload-time = "2026-08-14T16:13:26.217Z" }, - { url = "https://files.pythonhosted.org/packages/64/f7/2723e264aab7248c1ed6ecaad8e5d0cb866c0cffde75442102ffa7491aba/orjson-3.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578", size = 121860, upload-time = "2026-08-14T16:13:27.577Z" }, - { url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" }, +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/72/380b97dc45bd162d23afe5194721ef678d9eac7cfaa549fe2873f7f0a518/orjson-3.13.0.tar.gz", hash = "sha256:d1de5eb04485110c5da4c657e49168995d55e076b1ce60f1a042e254f4186c4f", size = 2732604, upload-time = "2026-10-07T14:09:25.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a3/0be3b115907fea61ed340639fb0e1562cd18969bad5b3f486f808197aaff/orjson-3.13.0-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:948bad47f2e2e43527f14248364a0e5dee26dd3184691010ec4a1ebeb0fd6771", size = 223146, upload-time = "2026-10-07T14:08:06.474Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f7/665935edb16163f8b764182e29a30cf056947a66893ed032191e5f01eb3d/orjson-3.13.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:1807c2fa49d393c7ee95fd1ef1b39cbb24aa3ccd81f30b84503ba59407666960", size = 123546, upload-time = "2026-10-07T14:08:08.324Z" }, + { url = "https://files.pythonhosted.org/packages/67/ec/e7cde480c0e212594d17ba2b2bd210c002052e9147fc1a1aeafaabe722fb/orjson-3.13.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:637dbca1fccffe83780e806fbc0f17427c0c59bf822528eb0acc8f0aa9f19acb", size = 113290, upload-time = "2026-10-07T14:08:09.816Z" }, + { url = "https://files.pythonhosted.org/packages/36/59/4455fb11a297af73611dfc437f0f89456220227ed1cb1544a5a0ee9d6c03/orjson-3.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:554948becd1110123ef9f6a6e1310fd92b2d07d2cbac6dbf65df3de75702e736", size = 130342, upload-time = "2026-10-07T14:08:11.253Z" }, + { url = "https://files.pythonhosted.org/packages/ca/80/0eec5fbde2e52407646b4cb3118f63175bdcee1e2390c2759dc96e0bc62a/orjson-3.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9d9a101bd8dbfad112170f009cd155e52bb8c936468821a0d03cbb96c0e426", size = 129138, upload-time = "2026-10-07T14:08:12.814Z" }, + { url = "https://files.pythonhosted.org/packages/cd/cc/c0874f13819ae346d69ca00d074d464710b494abd4442bdebf75ac404a98/orjson-3.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89bcf2d4bc6c9a7e1763c8cf534f38712e66b76a0fefda7fb7785462f0d635e4", size = 130518, upload-time = "2026-10-07T14:08:14.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/ab/140dd9adff84bf64b862c4fcfe2d055af6014d5ba03a075f95c9addb2ec7/orjson-3.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a79cdc4934fe81f593072c94e13da3095e9d41c2deef8f6ff2901794ca1c5042", size = 134924, upload-time = "2026-10-07T14:08:16.09Z" }, + { url = "https://files.pythonhosted.org/packages/08/0a/e8f6deb032b1d98a39043cf99b863d8b9e842e2ffc2d2067d2e2a88c18e4/orjson-3.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50a5202ba388b3850ba24437951727d3aa6d79a21964a30ae8dc6a059a5fd34c", size = 126704, upload-time = "2026-10-07T14:08:17.439Z" }, + { url = "https://files.pythonhosted.org/packages/af/cf/be64b99ff75f7983488390d4ef5df72115119770eed295691c0a715d492a/orjson-3.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:a0377d6962fa431c93ecd78fdea771bb62ec545b24ee0c5d4e32acf2260af259", size = 121287, upload-time = "2026-10-07T14:08:18.843Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ab/1b8ca186baf3420f12db1f2819fcc5f2cae69e4cf051168501726a64c0fa/orjson-3.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d84820b2ec4ac975cba482214032de5b0dbdd17046170c98e642ef9c4a4ee4b", size = 126314, upload-time = "2026-10-07T14:08:20.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/ed65f84ed5ed6a1e06eb628611b4172e7480fc4ad92594856751a6363cac/orjson-3.13.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb8644dc6d705e1269ed2842bf4dbe2b4e50d670de503bf79d5cef3a5148a4c7", size = 223063, upload-time = "2026-10-07T14:08:21.979Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4d/9332eb96d2e379384be0f211f543835eebc81f460c9403b84abe1294c431/orjson-3.13.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6ff2a2c67f35202f7d823753d38ad371a9b7fc297567cdfff4420e763cb9f6f8", size = 123364, upload-time = "2026-10-07T14:08:24.026Z" }, + { url = "https://files.pythonhosted.org/packages/b4/06/558456b7da27e974a8c9ea09117b07119f6fa131cd62b8b9ecad9eea94e1/orjson-3.13.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:65c4e0e106ccc7265b488385659117a6805c37d042f737558ecd68aa0c67ad8f", size = 113199, upload-time = "2026-10-07T14:08:25.476Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/1187a9c09965620348262ec0f406868f6d7c234b2e9b5ee51020bdde5748/orjson-3.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:fbbad6b9b1da43f25c1f5b20cd5a268e028a2fc95d5a8d1ade6059973bc71584", size = 130329, upload-time = "2026-10-07T14:08:26.877Z" }, + { url = "https://files.pythonhosted.org/packages/46/07/5d1a151bc11600434fe799e73abfc6a4d463d02e149a20e47c59d3a985ae/orjson-3.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae1d895cf7bbfd50ef34bb63bb727b14514f259f3e3f8dd010783bd38e864c6e", size = 129072, upload-time = "2026-10-07T14:08:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8c/bb07c368abbf4021c4cd01c12edb526e00090f7f750ff1b88da6e6b6c7a6/orjson-3.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bceadfd314bd238f584fc229a4bbaf0e573597e7a026dec5429fbf29fd66c641", size = 130612, upload-time = "2026-10-07T14:08:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/4b66d19619ed344ac000ffea7c006477d0061d580646e736ef0e203759e8/orjson-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b74c30e56346aad067937d766846ee74c231d1d18aad3f324e9b9261de3b2d5e", size = 134632, upload-time = "2026-10-07T14:08:31.474Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/f8221f6593e37eb26ec4706e185b9ac6f38ff0c8f7bad5459844031ffd2d/orjson-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4329c19b8a25693f60a77b867c9d2a3ab637b20e36f5b7bea7f5acb492b44b15", size = 126807, upload-time = "2026-10-07T14:08:32.914Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/a1ca7321eeafd7d72e174cdc388cc96301f41516d863e7b1f64f0a1735be/orjson-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:b571236d8393edcd3236e07423f762bfcf571f852aad667a3bce9e7b755e0790", size = 121538, upload-time = "2026-10-07T14:08:34.325Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/1f19b4779c910104370932fceb9ed436b47ac077f297db74008062525c04/orjson-3.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:8594956a75223f657e1e68c568c0eeb3dd145f02cd6b78a47fd9a8095dbc4eae", size = 126259, upload-time = "2026-10-07T14:08:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/a9/56/f8ad2546150168858c16915c452b00eecb79597597524d1ad6ae14ad4eab/orjson-3.13.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:64e8f345048d988c8b68d3882e5d41028fca1219a9939b32e4a77be34c8ae8e3", size = 222892, upload-time = "2026-10-07T14:08:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/1f/19/725d23160b2471a3f27026c55bb79af34687652d8be8f5f583cee5dcd42f/orjson-3.13.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ded33b972cffdaf4ca0ac917338ab61d2bb10d68987dbcae641c313fbfdbf499", size = 123319, upload-time = "2026-10-07T14:08:38.989Z" }, + { url = "https://files.pythonhosted.org/packages/ac/08/e5d81a00b22c73dfcb60d80da3bd92d5a7684346593536565f184dbae3c9/orjson-3.13.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:45e34deb3437509f4ec9888dd9ee5dc426cfe21be10f1eb4ea3a9e4d33034f9e", size = 113196, upload-time = "2026-10-07T14:08:40.383Z" }, + { url = "https://files.pythonhosted.org/packages/67/78/fda6117c69a43e470b1e9dff38dd8c5f0bc6fd8a47e4d4561ab023039335/orjson-3.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9825b954155b345c4759f24e5f8d652b9aec2261bb5d4e1abe06bba0a1200535", size = 130245, upload-time = "2026-10-07T14:08:41.878Z" }, + { url = "https://files.pythonhosted.org/packages/6d/31/d0cfebd456defb234414795ae7599696bf124843dfe077d0c9ece0c93554/orjson-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b081f0e7b600ff24513dec4ca75507fa05e904607847e386e8310d5b7b96b6c7", size = 128981, upload-time = "2026-10-07T14:08:43.716Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/f8d83189ff5b7b2ff225a58c5908618cc4e86afe09e65d17a30ac68c9da4/orjson-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbed5f4c4b88d94bcc36115f4c3bb3aa25da1563a5c3328aa3acebce2b083040", size = 130370, upload-time = "2026-10-07T14:08:45.132Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6a/d6344c305003ea826b3fa0482645a897a3cd6d477ed74e1fe15d3322cb23/orjson-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9b61676116f755126b90e740a9cff36b91562f47ec330056cc88cc3b9f02f4b", size = 134595, upload-time = "2026-10-07T14:08:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/d73fa44f88d53e02d10de1cf77c16ed13204ff5bca47e1692da6b406619c/orjson-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ef75ed7e81dae34a3649f82df52cd85f9ac839a7d6ec78ab355b33b3b27ef7f", size = 126513, upload-time = "2026-10-07T14:08:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f8/bcfc50b4ab851c4f9c0ee62f52bf3b28f0bcd0d9fe08e0ad98d4585148db/orjson-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ee06e53b998c71ce3eb93b86222912fdd9dcced685ac64d4525d36fac338ea4", size = 121371, upload-time = "2026-10-07T14:08:49.549Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7a/d6927845712ec2b1e89263cd12d7203531db185dbad67f914226f2fca156/orjson-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:89efecad02515df7f318d0613b5dfd6d2a1acd323a2b8294712789a715945525", size = 126134, upload-time = "2026-10-07T14:08:51.118Z" }, + { url = "https://files.pythonhosted.org/packages/f0/10/98b5a3cdc086abf78d8cd20bb0cba124485d4b6a745722197bd209d967a5/orjson-3.13.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a7bfc7db961c7d96cb75889dc6a1e4ae1e91d87ee61da564f582bd742b8dfeef", size = 222889, upload-time = "2026-10-07T14:08:52.673Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/7728c5280ab5202f4891ff4b0b96e2e1dbd5520dfee53edf083c54409a64/orjson-3.13.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:91d933e668ff0ffe164d7c2daec36beba6d1ce7fadb71538fbe142a71f8a1e6e", size = 123312, upload-time = "2026-10-07T14:08:54.25Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a5/d9a44321e6f66c0f64b45be587395f87ad94cb447bce7d92286f6b97d46a/orjson-3.13.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:6c8bfe728b81b0fd58a3c7f3f9c5a113f87f2992c9948e0f28707aafd737c0bc", size = 113146, upload-time = "2026-10-07T14:08:55.803Z" }, + { url = "https://files.pythonhosted.org/packages/80/da/d95c80d413f288feb471e16d82e5c1512d2439728e3bac917d058c31f098/orjson-3.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8e05549f3b30f9d8a8e28c5aba11cc2a4b90b90961ec685ca58444b0815fc09", size = 130348, upload-time = "2026-10-07T14:08:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/04/0f/36fdfb32ad1852997bac00e3ce52c7888d8a1094ba9dcdcbb22fcc6b953a/orjson-3.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c749ab3ac30b5ab1ffb7677f8b92eacfdfdc5260210baa398f845bc3714c05d8", size = 128971, upload-time = "2026-10-07T14:08:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/25/de/a82acf93bdcca0c79ccff25ef0c6868d24ccbc2e72f21fae39c8cabce4f1/orjson-3.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58a9619d88f8818d9ab6b39d70d203789457ba13c1ed5d274f33ce9ae7e81a36", size = 130359, upload-time = "2026-10-07T14:09:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/71/ca/2bc4f7697cb9f6897bf61aca11803df096a5d971bf69ef5538b243bb1fa8/orjson-3.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2715c4808d1571029ed18fd07a82140bf3ba7def0dc89f8d015c416e3649bf87", size = 134583, upload-time = "2026-10-07T14:09:02.047Z" }, + { url = "https://files.pythonhosted.org/packages/23/b3/12b1af9b87ff9fa0aaf4e5724c87672b30bb5de76f275f7fac64e8219c1b/orjson-3.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08bf722f923d2100bc5e5a5dcf72c656db557049c1bea26582fdd5dd9d5395a1", size = 126500, upload-time = "2026-10-07T14:09:03.863Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ea/cf257fc8a7f4b18f5677c22b3a9673a1b51d4b7161f25177ed389b76560e/orjson-3.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:6adcaa85d79977659a448b4123a88eb33511a11ed2db243535ad7ea88a6668e0", size = 121378, upload-time = "2026-10-07T14:09:05.375Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/9f4643f849e9918eab11983b83928af3aac14bedb04002e28e885ee1936f/orjson-3.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:83705c12b4afde10c62a5dd3fe6fdb21b7900bd0dcd5af1c85612ae94d0ee590", size = 126123, upload-time = "2026-10-07T14:09:07.085Z" }, + { url = "https://files.pythonhosted.org/packages/8c/15/d265f2b556c0c7c0b30ea830316d6e5af5b85dde08f234a1ebed60fab386/orjson-3.13.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5ef4d4157392a0439b74f7e49e5636b4ea43d9616bd0884effc0195fffcaa2d5", size = 223305, upload-time = "2026-10-07T14:09:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/0c/97/781be8b80a33b8171b3f5acea941af47182c8b4b5827c2b7c3fea706f21c/orjson-3.13.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:84d87e322e1674408f85adea63f11aa19201eba082755aec20ebc217f493bbd2", size = 123515, upload-time = "2026-10-07T14:09:10.792Z" }, + { url = "https://files.pythonhosted.org/packages/20/68/011bb98fa7da7b430b363db1bb7ef9160c438fc5c43e7468fb593c220037/orjson-3.13.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:8c2ac5c09b017c484df1b4c68b2cf250b4e8ba08204cb58e7cd6cbbc71a9c902", size = 129222, upload-time = "2026-10-07T14:09:12.542Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d96fa2aedaaec14c095ea9cd48d2158fdf33c0f4fd6e7a598d899d536b03/orjson-3.13.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:51d11525bc3ca736fa97ce4e4c7da9999cc00bf261522bede43b4e7531bd7965", size = 113152, upload-time = "2026-10-07T14:09:14.059Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2d/ee77aa685c54bd920a1f0e2936986b46269adb0d72bf5098c2c694dbeb36/orjson-3.13.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:ac81530647c3423107cf61c3481e91f57134e9ddfb6ef83f5150ccbdcbc3a3ee", size = 130749, upload-time = "2026-10-07T14:09:15.835Z" }, + { url = "https://files.pythonhosted.org/packages/48/eb/3411fbfdad61b3f3af22343b5af7ed5c8a1679e35f442e8f1b229b33040e/orjson-3.13.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:0526a3456db67b264c6d661b5f090077f326b6cd074d0ef53a72763595dec5d7", size = 130471, upload-time = "2026-10-07T14:09:17.463Z" }, + { url = "https://files.pythonhosted.org/packages/87/71/abdc2b8c70b8d85a6cb22f404da0f52d7d712f9d49cda039a0cb1adcb973/orjson-3.13.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dd61e64802d51d1e4f16531c64536354fc3bc67932dc0cff254044f72bf0f187", size = 134793, upload-time = "2026-10-07T14:09:19.084Z" }, + { url = "https://files.pythonhosted.org/packages/0a/2e/1c13552d8b0241083116de02b2f284ee38501ef06ebfb79893f741538168/orjson-3.13.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c5e3ccaac3106e8fa6e2f2f6962449d7c757d7b067e41b395a19d6f0d6cec892", size = 126711, upload-time = "2026-10-07T14:09:20.645Z" }, + { url = "https://files.pythonhosted.org/packages/85/f8/d4ece953a519d064cf690adaa68cd389d5b64fd261726334841b32978d6a/orjson-3.13.0-cp315-cp315-win_amd64.whl", hash = "sha256:7804dd1d6161da0e53b284c2aebf20f23e78eaac617300803e1467d1828d987f", size = 121496, upload-time = "2026-10-07T14:09:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/70/cf/f691388c4a9bc4af7dcc1648c4b40845869908b517d7c0009d005c7d1fa1/orjson-3.13.0-cp315-cp315-win_arm64.whl", hash = "sha256:f5c05a8fee59309f537590a1ff12d3c1009c485e96a50a9ac60dd085c09d0fc0", size = 126260, upload-time = "2026-10-07T14:09:23.928Z" }, ] [[package]] @@ -3688,7 +3852,7 @@ version = "3.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] @@ -3759,7 +3923,7 @@ version = "2.3.3.260113" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "types-pytz" }, ] sdist = { url = "https://files.pythonhosted.org/packages/92/5d/be23854a73fda69f1dbdda7bc10fbd6f930bd1fa87aaec389f00c901c1e8/pandas_stubs-2.3.3.260113.tar.gz", hash = "sha256:076e3724bcaa73de78932b012ec64b3010463d377fa63116f4e6850643d93800", size = 116131, upload-time = "2026-01-13T22:30:16.704Z" } @@ -3778,11 +3942,11 @@ wheels = [ [[package]] name = "pgvector" -version = "0.5.0" +version = "0.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/ec/6eb80aebc728200f95229219882994c1b0585b956ca47da5edb9d062627a/pgvector-0.5.0.tar.gz", hash = "sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74", size = 35170, upload-time = "2026-07-06T18:27:27.767Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/23/96aa38899fbf8e103766db608d6e42acac269a96e08f3003fe9da3396fed/pgvector-0.5.1.tar.gz", hash = "sha256:94998a54b801b1075d623b8fa677fcb8210a7977b88f8e2203ab115c155af2e4", size = 35714, upload-time = "2026-10-09T01:50:22.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/e4/a5573f2c579ca9ad133293bfb624148ba0893674ca4a6eeec85ced9a6a09/pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f", size = 30958, upload-time = "2026-07-06T18:27:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8d/a9c2a531da0ebb54b4a7174450e8534a39db112a141ae3a437de28420111/pgvector-0.5.1-py3-none-any.whl", hash = "sha256:ec5bcd5ffaefe6ecb2dcc9564ca921d284564b969183bc837a144604773af8ea", size = 31056, upload-time = "2026-10-09T01:50:21.614Z" }, ] [[package]] @@ -3796,30 +3960,30 @@ wheels = [ [[package]] name = "polars" -version = "1.44.2" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/15/e8541eefc22fbc7ca89bcb5112298a153729f73cfbc0cf6a668e509f975c/polars-1.44.2.tar.gz", hash = "sha256:86c8e26b6c2de8c8d344bb910b74dfc47b118ac3fe0f19b44909467990a0b281", size = 766272, upload-time = "2026-09-09T07:42:08.859Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/e9/001f371ec6a1bb54893f599ceebd56e6144fed4091f09f09fec0021a9276/polars-2.0.0.tar.gz", hash = "sha256:62da109e27a19a9d36657ee25dc035c9d3f87e7bd610526fe467dc37ea7dc115", size = 778215, upload-time = "2026-10-06T11:51:29.679Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6d/3014112c7f717d1253223faa13b6db3ac3a64ed00ab2a3bc1b942bc9cdd4/polars-1.44.2-py3-none-any.whl", hash = "sha256:1bb331f17a40d9d931101533dcd33637b66edc61eb377b07020dac16a0f0377b", size = 865768, upload-time = "2026-09-09T07:40:12.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/09/cc33bbd5463749c116b62c204d88bed6c02a6cb901eac7adab0d38651b07/polars-2.0.0-py3-none-any.whl", hash = "sha256:35d62f3541b7a6d4c360a2e2f07fccc0c2bcbd33b0ea51c83a25417a47a3f3ad", size = 876611, upload-time = "2026-10-06T11:44:04.327Z" }, ] [[package]] name = "polars-runtime-32" -version = "1.44.2" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/a1/a7eace6587b56f22cf2a21ab4d5e695db372dc23fd96accb68b1ec12660b/polars_runtime_32-1.44.2.tar.gz", hash = "sha256:b84842f7d621aaca7a52e165e19a24f89db45f8aa13744941430218419a14a67", size = 3172205, upload-time = "2026-09-09T07:42:10.851Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/ad/dbb6f6d7070867951532bcfe5e6a648d8777b416b18cddabc07030404e8c/polars_runtime_32-2.0.0.tar.gz", hash = "sha256:b5f9afcc742b4a67eabd2c680ff0f12eb02ede9b4bf807bffabd6dbb9a58d5c7", size = 3591339, upload-time = "2026-10-06T11:51:31.076Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/5b/a5215f82c3dd443dc5d6911b0d3e937f97056e0ef7753f7e123422481a18/polars_runtime_32-1.44.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1fd536720668ba203a16a20b08cd6b23057e407a0279cf36b2f35f879d6e3208", size = 48334014, upload-time = "2026-09-09T07:40:16.43Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e0/f3dc93fce4b4e99370db6a89001a1b8d3c606e3560d0d91dda809d6c6324/polars_runtime_32-1.44.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0fd43720c8222ae39919c8ff891636d53b352706087120e62f83544dd3ff782", size = 43331099, upload-time = "2026-09-09T07:40:21.029Z" }, - { url = "https://files.pythonhosted.org/packages/4e/4f/076626ce93ddd622203c4b27be2a96d034cf5b24110c52e96e6029f0ea33/polars_runtime_32-1.44.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbf9b45040291dc1c6c588c837019c33557bde25ec536562a9cca9e1f6dfcc45", size = 46590853, upload-time = "2026-09-09T07:40:24.934Z" }, - { url = "https://files.pythonhosted.org/packages/e9/24/ed9982657c446dd5491b089370eea196725673570cfc61f7225a9fdd7ef0/polars_runtime_32-1.44.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1bafb441e99199a62c63bf1bbdc0ea09ee9776dbac2bf31452b5000fb1df2f7", size = 49912258, upload-time = "2026-09-09T07:40:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/71/42/5490ab360aa2406119825ad82203a5e2ff27a3a5893ca8e0b93c053a59a3/polars_runtime_32-1.44.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:10c0c695a418407617b5159db7d9a21074a733e4c6d61275b6762f25cb31ca99", size = 46737310, upload-time = "2026-09-09T07:40:33.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/8f/d741afb1dcd1848161189e017d27972e7e78556d8dce66b94d4235093706/polars_runtime_32-1.44.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c4a09fb14aad711526346efc0cb2015c2fd0555ce4118b6524e5debbaea65ff5", size = 49887654, upload-time = "2026-09-09T07:40:37.455Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e7/c61c1c7eea37705920fe7c1302d1dd80d1165db2b928f0da3eae6d1ebb75/polars_runtime_32-1.44.2-cp310-abi3-win_amd64.whl", hash = "sha256:8598e7a20efba70bb74978c7df7af7c606ff4d79b9b48fdd808250b189bc9a13", size = 51297967, upload-time = "2026-09-09T07:40:41.993Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a0/d0dd0d2ec95fa328dd47055905fae53ba3cd79f11c8973326ebe75a49e4c/polars_runtime_32-1.44.2-cp310-abi3-win_arm64.whl", hash = "sha256:d51040d3ab40157f6db3c62be59cab5b80fb3c8d158924769c4982a1c8eef730", size = 44303345, upload-time = "2026-09-09T07:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/82/88/d35dec6c8928dfbaa1cccf9b626a1067da906e792c92d9f994ca825ab2b5/polars_runtime_32-2.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ffb7ac6cf4e8c4a652df1951e3c3840c7c23a033603d5a9efd422fa8dd699d82", size = 52494314, upload-time = "2026-10-06T11:44:07.768Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fd/2237bf53ffaff47cdf1edc6c10587a7a6444d4951150eeb08d84f3493ff8/polars_runtime_32-2.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7012d8a0201bd95638545ce8f256c0efe2c5cab0f806eb043021dddde5a9498b", size = 47930083, upload-time = "2026-10-06T11:44:11.592Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0d/85e3ed90417996fc09770be91b39979074fe2978fc15b431bf8a9459760d/polars_runtime_32-2.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b85bb42e6009acc9629afcc70a83473fd468694d6a30ffb0ab376c8dd1a0a17", size = 50417889, upload-time = "2026-10-06T11:50:20.774Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/e9fecfd49159da92f54ff2445883577a0f1bc195da53ecc9535c458d55dd/polars_runtime_32-2.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d6ac584ea2b38913784db943879412380d92e28ab9cb88e20a77ba71ba3f911", size = 54475036, upload-time = "2026-10-06T11:50:24.411Z" }, + { url = "https://files.pythonhosted.org/packages/48/ad/b2abf732697b21467aaaeaac0f3bf7eee0d89c59ce8125f1ed41b28a2d97/polars_runtime_32-2.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a6bf5e260e0a6f00d0f9181438fe9e45776df8c66cee9cba16e3675cc3888488", size = 50579474, upload-time = "2026-10-06T11:50:28.377Z" }, + { url = "https://files.pythonhosted.org/packages/7f/05/304deee59a95865e1b5e9ec7b066069b49093b81b768f473d9d3b165c686/polars_runtime_32-2.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55c26eef325b6840584d91aac232e9cf3ac19e1b904594b9b54131be1edeab4d", size = 54413293, upload-time = "2026-10-06T11:50:31.828Z" }, + { url = "https://files.pythonhosted.org/packages/61/59/8c9fd7199f7c4eb1b64e640306a946a2e4a46337b3bbb33b840972c7d84b/polars_runtime_32-2.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:7da1caf3c7b4f397fb213c984013a0c755557619a2d511899a1ff74392484078", size = 54229989, upload-time = "2026-10-06T11:50:35.206Z" }, + { url = "https://files.pythonhosted.org/packages/e2/93/43608026f38aa6ed4d22da8597706a61682ee403caef0021ce8e6dc73227/polars_runtime_32-2.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:c30ba698c8904048df4a9bc3d6c5033cc2d0a7cbb0e13f4fd2de5a1947b61994", size = 48730655, upload-time = "2026-10-06T11:50:38.756Z" }, ] [[package]] @@ -4219,45 +4383,59 @@ wheels = [ [[package]] name = "pyarrow" -version = "25.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/8b/0d23b47702fcfe8b3618d5292035099675c5a1c48258932350c08020f7b5/pyarrow-25.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:51093dd9e10325fbdb3c10a2ae7c4806e5c822d94e74ae4938b26524a3323fee", size = 35946180, upload-time = "2026-08-10T12:37:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/d8/17/707d17a5476c55a9541fde0db8213ac30979a792864d72415f176ba50c45/pyarrow-25.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:eb6203482ff3746a5632303a7279ae0b5a304c46985b49ed1378cb350ea6728d", size = 37644787, upload-time = "2026-08-10T12:37:25.795Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b2/cdc98ecf1a6408280bc3a6a07054cdd99a3f4670acc0545d383ce113e87d/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:880523be3d29efcf83d3998835d206118ccf35e3871dbd2fb60408cf6b007a80", size = 46834633, upload-time = "2026-08-10T12:37:33.604Z" }, - { url = "https://files.pythonhosted.org/packages/c8/6e/d3fafc41f378b2c65be43b827798c0fae42049a641c8526633ed3eb573e2/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:25f8720bf6387d5dc2ebd2622112de630760419e4b66134405dd24110d15f37e", size = 50065507, upload-time = "2026-08-10T12:37:40.565Z" }, - { url = "https://files.pythonhosted.org/packages/d5/12/8d0698954b8c3001844a898e0a6900bebe83d7ee40c11195174c5122f324/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4facd65742a024a4a366328a1d2292062d72d6e023c1b7dda8d4c37544933a25", size = 49955690, upload-time = "2026-08-10T12:37:46.644Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0b/1ecb936ac6409e90a34d58eea1c7cec09a9ae6d2141b9e49ad01a2b1ea47/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0559502e1cd6254d6814614085dd9c5a3dd0419362978a936a3f68a9e5c3df", size = 53128198, upload-time = "2026-08-10T12:37:52.531Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1c/5236033550633c9b7377b2a53660b2bbb06cb06dc09c4356332d67643ca1/pyarrow-25.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:62cd0d785b8aa6675ee355f9fc02252a340f4441257c42674937826fd7594325", size = 27857263, upload-time = "2026-08-10T12:37:56.943Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" }, - { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" }, - { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" }, - { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" }, - { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" }, - { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" }, - { url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" }, - { url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" }, - { url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" }, - { url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" }, - { url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" }, - { url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" }, - { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" }, - { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" }, - { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" }, - { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" }, - { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" }, - { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" }, - { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" }, - { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" }, - { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" }, - { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" }, - { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" }, - { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/34/17c34cb38e5d940e38f0f0d9fdfa0e8a506676409ea9b85aff7e3079f831/pyarrow-26.0.0.tar.gz", hash = "sha256:0cccd36e00ea3afeb52ded61f2721ce71f604853d70c45365c58324eb773d6ae", size = 1239433, upload-time = "2026-10-09T08:26:25.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/68/e0707097cee93be7f693e7e89495fabfeb8bf95ee30619063f8b30fffc29/pyarrow-26.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:fcdd1e04982637c6042337d3e24d472f938f01fdc502e2b994844b726d12c3f4", size = 36370896, upload-time = "2026-10-09T08:13:28.874Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/591211c00612aef83236daff1620412b24aeb07c646de08c18a8a6c95a39/pyarrow-26.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:f800e9e722c145ccd18012d82a864cb21bfee4ba4ceffde77100d25eced511a9", size = 38709806, upload-time = "2026-10-09T08:13:33.417Z" }, + { url = "https://files.pythonhosted.org/packages/50/ea/9b035a9d1556e06e64ea86169d9a985d0fc092d427ac5edbb3af7183289c/pyarrow-26.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7aa12ab8e236789b1ecd2d6ecaef036b4e63d675ddf1864a43c6799d18f2d028", size = 50885975, upload-time = "2026-10-09T08:13:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/e1/81/8e685683897a6d3d5887c3e2fd24f3c14bc5d6d6bb3a2387484e665c580e/pyarrow-26.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6e89dee53aaeb50505ed6152ea55bc7ddfd4f4df264f5427ea255288d8f0e580", size = 53904793, upload-time = "2026-10-09T08:13:42.984Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ad/d474a0b1b00110f3a879aa5df654f857c81929a32b2a4222869240de5220/pyarrow-26.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1c1b4263fd13abbc339a16f2bf19f3a5cbf2a620853d812b1256f03c5342cb8", size = 54458010, upload-time = "2026-10-09T08:13:47.778Z" }, + { url = "https://files.pythonhosted.org/packages/d4/86/2c2861e905810c59fed4d98c85b994c21e8613730c5c3b436781d89110f2/pyarrow-26.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ff1e816af7abff71f289242e109217036723ce36aca74ad6691e52d964a74afa", size = 57368406, upload-time = "2026-10-09T08:13:52.651Z" }, + { url = "https://files.pythonhosted.org/packages/0e/02/823e606633c15155bb965c7a0f3750c4f20dd47c4ab48213c7693df0e0ba/pyarrow-26.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b0972a3dc71b642050d1bc72664a3916e14f59c943d8c1368154d6e4b0c2d5", size = 28522657, upload-time = "2026-10-09T08:13:56.513Z" }, + { url = "https://files.pythonhosted.org/packages/b3/60/6793778f2617cce469383dac0ba08c4f2401cf342df0c7b9ca53939d9b46/pyarrow-26.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:90ddaf7c625307ad52f31a9b25c34fe5e4897c7529ee3481135822b2b6842ff1", size = 36333953, upload-time = "2026-10-09T08:14:00.387Z" }, + { url = "https://files.pythonhosted.org/packages/db/81/f944cc63ce8a753e5fbff25de6d1d475ebd7fffdf9cf98c65130294fc896/pyarrow-26.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:ee341973f78a0b46e073d065e88e75026a9c584051e97f98a0d05d96c6bac7dd", size = 38688456, upload-time = "2026-10-09T08:14:04.344Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7e5c722fa5d5d9f3b75e62fe11694b34217664d4f05ac88031197166b277/pyarrow-26.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:01c863a18bd9c8412453dd0d92de6d0ee7b2b3d6fb079d9734a4b2a3c8bd4453", size = 50867603, upload-time = "2026-10-09T08:14:09.115Z" }, + { url = "https://files.pythonhosted.org/packages/88/e4/9cd356d906e71bd79b0c3fc5c9a54e01a0020dcf14c152ccfbcb503c7298/pyarrow-26.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6a628922ba20705fa964ca73e4ef959c2fb2f14b9bbec5589a6a1e68e6257c85", size = 53931932, upload-time = "2026-10-09T08:14:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e4/5bae3133b7fe04c24907a20f3bc1fba388cbbde659199e7b76445982047a/pyarrow-26.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:954d971b363b16ee41f89389a4053315dc71265f2ce5c2468eb0a910b1166268", size = 54444720, upload-time = "2026-10-09T08:14:31.214Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/ee422493bb6dafdbef776cfe2c2a73106a1063a79bf4e78d1e5f51176885/pyarrow-26.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5d5768d03426abe6526d5274adefa00abf00a7f81118c46e98b5a46390f5549e", size = 57388949, upload-time = "2026-10-09T08:14:38.964Z" }, + { url = "https://files.pythonhosted.org/packages/54/3c/1783aab1dac28e175dcf26dfc7123725efc474caecaed91e8a34cb89cad0/pyarrow-26.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cc903e1069e9dd5e9dcf780324c0112e27e051e422ecfaff574fb33ed65d9160", size = 28567581, upload-time = "2026-10-09T08:14:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/4d/35/ca95493712af97c46a312945c8e9d16b21c5fe2f148be5466168d0290505/pyarrow-26.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a6ca849f90cf73fe361f08a5762c783ead9671e4548c1f558cc637b54c9103f2", size = 36336700, upload-time = "2026-10-09T08:14:51.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/ef/b1a675f79c9babfd4fcd99af62141d3c2d1a78a524e311b0c6b80110445a/pyarrow-26.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:c2ba350957076b1b3a22f549261dc3e9c67ca20816d8bd5f79d7b9c69be4c4c2", size = 38698502, upload-time = "2026-10-09T08:14:57.114Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7c/cea852a832a327a8de797b3a68e5c25ce0f5aa1d20503807671bd90ec642/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e3b190ba1d3d22a5a8758597f797111b77d433473744352a184a5ee0a42d672e", size = 50865064, upload-time = "2026-10-09T08:20:01.614Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/e95834b29360092376fe4da9956ba41bb7b021869efe6ee9d4172d05cb15/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:240bd18a7487f8767616a948a69dd4e740a8bc36a1c9da49e4dc9a32c5c2faed", size = 53926722, upload-time = "2026-10-09T08:23:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7f/98257444e2aea2e1fddceee3af3bd2077236d550428413f80393bd1f888d/pyarrow-26.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2b5fcd69c0e1107b79e55839877db5a6ed04651b73fd6fec581d09e230bed5e4", size = 54443093, upload-time = "2026-10-09T08:23:16.971Z" }, + { url = "https://files.pythonhosted.org/packages/88/ca/dac99cfb25cfa62bf7194600cc99abc14a6bd2af50d7fdb7f15eeaf6e202/pyarrow-26.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f7444ea6975c49a857c68f9bd8fa11acae96dede63d120ffb3bf0a603ea82516", size = 57381937, upload-time = "2026-10-09T08:23:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ed/138d29fddaf803b90f4527e124bb6aaddc18aaf4a6c50fd0a5f577c94989/pyarrow-26.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:3de30a7432b48b98b9decbd9e25a53bb9251d202c2e6c5a29a50869592ccb117", size = 28478571, upload-time = "2026-10-09T08:23:30.535Z" }, + { url = "https://files.pythonhosted.org/packages/8c/32/01858422a37f083911c2bb4d15cc32c5eeaa9d9b2bf5ddedee995a7146a6/pyarrow-26.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5780d487ff6c6ed7b42298609680d87fe0036e529a9dc2e1105364bce9697f50", size = 36378402, upload-time = "2026-10-09T08:23:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/00/85/f6b5976c2878b752d0804d371684e0495a71de296b6dc6559e6fbaa4311a/pyarrow-26.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:a0e4e92eeb088f1d7c2c04d6c7de8434c75abb4b4ccf0bbcd045aa7164c68d93", size = 38733074, upload-time = "2026-10-09T08:23:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/81/bc/c90fcbbcf893631e23dab1b0fb3fa29a508a8614326571b03c0894eda00b/pyarrow-26.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:eaf9e7cc7ab59f6c760232bbde18f64d559bbc50544841303bfb32be53533297", size = 50929201, upload-time = "2026-10-09T08:23:50.507Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c1/0c1ff38ab7df1b2cf54cf0ad9f19a516c4e416c6c9b4c966cc2c9d587f77/pyarrow-26.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ab6914db225d7f399652ae1f08588dfbc9efe617612715701e3d9d5cfa5ca19f", size = 53951865, upload-time = "2026-10-09T08:23:57.692Z" }, + { url = "https://files.pythonhosted.org/packages/9f/70/6a6b170496925472adad45a32528770fc8632db35fc60d4edd1e9ce1be0b/pyarrow-26.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41dd3661ef40790a78870052ad7a58ad827b27c67a4511f06962eb9e9b74d19b", size = 54496388, upload-time = "2026-10-09T08:24:05.23Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/033ef9dba80976820190e292a10a5a23e9406572b76bbeb4d685d90e5c8d/pyarrow-26.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6e949744dcfc2d379808f7013c5f9cafaf0f817656dff7d46c6931528dd1784b", size = 57411588, upload-time = "2026-10-09T08:24:12.043Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ff/a74892c50aaf1f9f744a84493e08a2f99221e77c39d2d4a926de21a99edf/pyarrow-26.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:4a5fa8dc70dd50808990ff36faf44088e357b353d86c7682dd92d4b78d4c97d5", size = 29237858, upload-time = "2026-10-09T08:24:58.106Z" }, + { url = "https://files.pythonhosted.org/packages/03/10/f0ee0976ef08a851a743c57608917ac9a47623f688b9ee0efe5429975ba1/pyarrow-26.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:e2a1856e9565fe2679863b372478c681806aebbf7d0a6e72f33e77f804e647d6", size = 36495870, upload-time = "2026-10-09T08:24:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/0bc431a509bf10b4472dbb94f4184752ecbbddeb7f467152dac0fdaed469/pyarrow-26.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:4bcba83299cb2b8f8e443d36c6ba6269a5034431879015fb0719495df8a14de2", size = 38819754, upload-time = "2026-10-09T08:24:20.875Z" }, + { url = "https://files.pythonhosted.org/packages/61/59/2be41d26af7a07fb71581fb753cae396403ba1a2978355fd553929d44a9a/pyarrow-26.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3a4d235876f14b4136b4d616ec42eb469ea0d6ead336cae631aa1dd29b21c962", size = 50933671, upload-time = "2026-10-09T08:24:27.199Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/b6d5048cf3178be9678f5c9c60040199894b2f69c3439c87ced91fd24da9/pyarrow-26.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:210cc9b83888b87cdc8f793eebb264f22b20d0dedbedefc73b9687a7047b4747", size = 53906419, upload-time = "2026-10-09T08:24:33.536Z" }, + { url = "https://files.pythonhosted.org/packages/09/2b/23e30fbd776c81d18d134d2592eb60daca13e8a57ab087d0fa042f9d9f3d/pyarrow-26.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca77c43ca55bfc9a4eeb1f0cd5f093f08731b77c24cdba0829035f084959b0bb", size = 54527960, upload-time = "2026-10-09T08:24:41.292Z" }, + { url = "https://files.pythonhosted.org/packages/e2/23/fce251cd6b0546dfc181b00d5c8ef1c95a8c4cae83266bc3dfd5f719c62c/pyarrow-26.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:290a74c48e9491b436fd5edacfadf357943f82aa45c81110bd83a69aab33d1cf", size = 57388010, upload-time = "2026-10-09T08:24:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/44/a5/0126fb0ef8d59bf257bdd68bb41623b72afc6e81790a0b4ac863a0f58861/pyarrow-26.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:515a10dae2a1d236bc9c9209d0317acb6746ea63cd4f98704904af7156d90ed1", size = 29406123, upload-time = "2026-10-09T08:24:53.387Z" }, + { url = "https://files.pythonhosted.org/packages/ed/66/8ada1b5165359d84b4b9b5384742304d1081da670f77d458fd9c9b8a2161/pyarrow-26.0.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:e890816e5ee89c74a0f8b9379fe8b5ba83f46132b2a0bbb9b1c21359ec30dfda", size = 36373215, upload-time = "2026-10-09T08:25:03.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/83/74f10c3d803a6834b2acab21847724d4bdbc74d246eb17321432844707f3/pyarrow-26.0.0-cp315-cp315-macosx_12_0_x86_64.whl", hash = "sha256:9db18a9dc0af52135c9eac549d80a7a882696efbe5406cf882b044525d4ecc2e", size = 38730866, upload-time = "2026-10-09T08:25:07.924Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5a/ea2fa2163b1bd8ff73efd39c4060be63fd6ddec03e7887a471acd1e042a4/pyarrow-26.0.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:734312d3d99088d9ec28c5b17bad40389bd8373a1afc10acb60b83fd217af087", size = 50924443, upload-time = "2026-10-09T08:25:13.864Z" }, + { url = "https://files.pythonhosted.org/packages/78/80/8c47b6cf8cfd42826df65193eff026c1cc81fa6cb213a3c3f5d203e6f67a/pyarrow-26.0.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:24f892fdf1ae1942d69d3f7742e2f49960ec95277cfb1a70b8a1d91f4a96d935", size = 53948540, upload-time = "2026-10-09T08:25:19.305Z" }, + { url = "https://files.pythonhosted.org/packages/69/1f/3a506a76d944ec5c5e4b7f01d8d0446b392a6fb384de627a12e503f616b4/pyarrow-26.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:879331ddea2a26479fa18fade71e6facf684a6cf19f67daec3775c871569e8e5", size = 54494863, upload-time = "2026-10-09T08:25:24.517Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/08c4bb04d651788d2eaca78065743f4f6ded974d4ef96ae3c473993e9d0c/pyarrow-26.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:5b827650e874f1f9f9392524ea3e9e3e8a245de5ba64acca1f81ab188090afb9", size = 57409877, upload-time = "2026-10-09T08:25:31.157Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f3/c64781fbd7b6d3c07993b698c14944d0d195f07e800fa931c486ae6ab36a/pyarrow-26.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:8e8e28c464552b5ca03e30d4504168c4425ce383884f8611b00e972f9fd933fc", size = 29236658, upload-time = "2026-10-09T08:26:22.607Z" }, + { url = "https://files.pythonhosted.org/packages/06/55/2ee3729daea999f19f061f03898d4895a242c4cd94f26e1324e5fdfbfe10/pyarrow-26.0.0-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:ce28748cbeb0f29c3ce9603782979c7117580fc76f16aa3ca448b38a22281adb", size = 36489011, upload-time = "2026-10-09T08:25:37.64Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7d/3eb17f601f2bf13eda5f2ed28956379ca628b4dda97619cbb1cb1721622d/pyarrow-26.0.0-cp315-cp315t-macosx_12_0_x86_64.whl", hash = "sha256:106bb9290fc6fd9a84138a9440038ef184bac86463543c5ff099229cb30d996c", size = 38808480, upload-time = "2026-10-09T08:25:43.579Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e3/f0047360b0f4bfc031b256dc0aec3837a61f245b2fb70f8363438e2db665/pyarrow-26.0.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2e4a413046eba9896e632925066c74095182200ba32e19ff0166bf64d2f936ac", size = 50923273, upload-time = "2026-10-09T08:25:51.445Z" }, + { url = "https://files.pythonhosted.org/packages/38/d9/56d9fb91210407df31cbeb9b91138601c88c7c8fb5f6bf773b20d65509bf/pyarrow-26.0.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:d58798c4d8d629700058e9afc1e16b9801023f3ce4dc1c92d945e79b5ffe4e98", size = 53900905, upload-time = "2026-10-09T08:25:59.554Z" }, + { url = "https://files.pythonhosted.org/packages/cf/40/8e8a7e9e027c731520c7eb179dd00a153b76ebf0bc11d213c6c8f8502851/pyarrow-26.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:645917e976671debabf854abab6e2b75c571ca4f82adc33a2d338697f7c27d93", size = 54518345, upload-time = "2026-10-09T08:26:07.125Z" }, + { url = "https://files.pythonhosted.org/packages/be/89/1e768a3fdb88d34e708ad2dc00dbf8e4e30290784eb84198d59308963bea/pyarrow-26.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7c3fda041e7078802589cf257750323ee3d0cd1e56e53a9b20ec845697fb3d28", size = 57379403, upload-time = "2026-10-09T08:26:13.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/be/7b81a44d6a8e70581dcc1d6f01541f9000a973b1e5d75394aec91e7b179a/pyarrow-26.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:68cd662e9e2b00876a131950cf32336ace2d0865e1f9418763e3d3be8481dfa4", size = 29389953, upload-time = "2026-10-09T08:26:18.277Z" }, ] [[package]] @@ -4295,16 +4473,16 @@ wheels = [ [[package]] name = "pycparser" -version = "3.0" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, ] [[package]] name = "pydantic" -version = "2.13.5" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -4312,111 +4490,142 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, + { url = "https://files.pythonhosted.org/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.5" +version = "2.50.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, - { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, - { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, - { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, - { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, - { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, - { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, - { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, - { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, - { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, - { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, - { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, - { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, - { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, - { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, - { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, - { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, - { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, - { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, - { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, - { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, - { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, - { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, - { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, - { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, - { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, - { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, - { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, - { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, - { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, - { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, - { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, - { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, - { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, - { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, - { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, - { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, - { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, - { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, - { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, - { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, - { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, - { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, - { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, - { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, - { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, - { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, - { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, - { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, - { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, - { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, - { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, - { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, - { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, - { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, - { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, - { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, - { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, - { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, - { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, - { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, - { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, - { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, - { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, - { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, - { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, - { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, - { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/99/114c2e71405da7610c884c64f8370f85b2fbb69bd427158a3f714515a803/pydantic_core-2.50.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:55684bc850059fc85ff8dc21a3e342b722d3178993c49ebdfdee8b698a432720", size = 2152583, upload-time = "2026-10-08T14:26:12.913Z" }, + { url = "https://files.pythonhosted.org/packages/75/a7/fabcfea81a5c3525507af4347fa3a50e51036d5db6611a63e47379776e56/pydantic_core-2.50.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3ae59461625518449f800acc4f874753ae96fcf993c47a27be07beb651371fd", size = 2045652, upload-time = "2026-10-08T14:26:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/226019008a041da423dc59d34f980b2b6e60a71971d07a8f52e10fc4747a/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7080928078ff56c07da393392f772054e93a39c8033dc9c04547bd949f9b614", size = 2050325, upload-time = "2026-10-08T14:26:16.081Z" }, + { url = "https://files.pythonhosted.org/packages/b9/29/11c98cd9c49338f64aeb3d6e166c99b65d8949f00f285c5b66de75ade316/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b1bae96f41806b14dd8bb1568d39b5e33b5af7fb27f7be36c78b8fa84708917", size = 2189027, upload-time = "2026-10-08T14:26:17.581Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b7/af7d9c4de715cc13e947d1135b73f0c181bce2f01e9f1d9e3c428f9ee31c/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8facaf0a16121ac82cc403a71399a061b74624c1ae2034eec9ed6169c5d16ecb", size = 2388815, upload-time = "2026-10-08T14:26:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/cd95dcd4d066180b2e22eff3dca01ac3af56607f9ecc43b7ba0748496f47/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:610e9f483ac53c5cc59c2d3687224029b54eb494feec2d40a2bcfdd187635192", size = 2431821, upload-time = "2026-10-08T14:26:20.958Z" }, + { url = "https://files.pythonhosted.org/packages/f0/14/7122b78e9915e7dd50f9dcec6041a4e32874907b41b9e0880db2f1c7ae1a/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac223ea031905319a64d0df116979ae438b42d6265862a3d63c9f9808c2905a7", size = 2157622, upload-time = "2026-10-08T14:26:22.947Z" }, + { url = "https://files.pythonhosted.org/packages/97/86/fcddead5bda54fc8efa0d80dbba5962e9a91193570e227404c7593707833/pydantic_core-2.50.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:75b451ec5e64a1d4b803317f34710131742ff2e42f4bf5403f597d0af857d7d8", size = 2254921, upload-time = "2026-10-08T14:26:24.607Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e8/041d1c3e3e1f203a656ce592f8c1548e1ef2b0939d19c5ce66432c30e4bd/pydantic_core-2.50.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7eee44c3f1f8acc220a743a5ed5588949a4f18b33df4cfbef2b1277470285401", size = 2268163, upload-time = "2026-10-08T14:26:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/f515994ef1351e883276e85af0a50d839ead9d0d22743fa2c6dfd552f7ce/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f44107b5fdfecc03438feb165431652c8006650471326e53dd86d4c19124f5c5", size = 2302005, upload-time = "2026-10-08T14:26:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/b970c4c87d92c4c974a5c5dc16bcf747f89b4e5729c5c45131267ef0421b/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:718d05d4e078c7828f40ad3e310870fe4b837d93a32e7e8497a080c1f1040490", size = 2465387, upload-time = "2026-10-08T14:26:29.529Z" }, + { url = "https://files.pythonhosted.org/packages/4a/84/c0a92f97f9de7f133dfe694d0e8ca033a1c90fdf692dc69dfee3bf8a2e7b/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:031867348c98ab49c6d3a16ad39738369ea48da59900d76d71c1e64de71fe79b", size = 2478673, upload-time = "2026-10-08T14:26:31.898Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/88e851761351cfef90382f175b07cede48edafc62a1509575dbc879934ef/pydantic_core-2.50.0-cp311-cp311-win32.whl", hash = "sha256:e41f9d1d9240e8e0d8a670ad3e66c0c00f0b1f7150a31bc6c445a4f87c1cb3ba", size = 2121424, upload-time = "2026-10-08T14:26:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1f/89184f13b9ebaf6d8710cd0d1cfb466d7dc829de23468ab471b543dd07eb/pydantic_core-2.50.0-cp311-cp311-win_amd64.whl", hash = "sha256:90874428bc6678b26434336c77931c9734ecebedf50132c179205e5a9908761d", size = 2176278, upload-time = "2026-10-08T14:26:35.388Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7a/5a630fff51218a860aa4e7699f781a7c9bf41c031987b613629c98f16618/pydantic_core-2.50.0-cp311-cp311-win_arm64.whl", hash = "sha256:b9be297ffe1015bfb2db4a23b6e1fec7e48361cf7c7b4f4f6bbdd008451b0a7b", size = 2183464, upload-time = "2026-10-08T14:26:37.485Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/e0fe374bc0082dfd337ca319505c9302ce383aeeeacc33d93383edd159e5/pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a", size = 2222860, upload-time = "2026-10-08T14:26:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7d/0a2f829e3e1d809393faab907e3d9307245cd6043ebf54fad439f72f0003/pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30", size = 2025752, upload-time = "2026-10-08T14:26:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/67/d4/e2808af12de4809dd6a4b532e3877baa5f1000df51bd8ebb8756450c6c92/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5", size = 2048432, upload-time = "2026-10-08T14:26:43.593Z" }, + { url = "https://files.pythonhosted.org/packages/18/58/46ad42a321051585d89f4e563b115015332caf79ba4e96f2785715c7f52d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f", size = 2173565, upload-time = "2026-10-08T14:26:45.299Z" }, + { url = "https://files.pythonhosted.org/packages/92/69/541206e657ecea865e5f6d49a4a0d8062586d05da482d9d7ed77fdcdfc86/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2", size = 2361974, upload-time = "2026-10-08T14:26:47.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c0/cf4850441d0d3d3736abe5e8b6fcb49a50166aa0651ed8444172f81f4b4f/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145", size = 2440817, upload-time = "2026-10-08T14:26:48.937Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e2/2f793fa2f1b338aa03124efd4362956c84d7bf1b331dc86050bc0151e67d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e", size = 2160358, upload-time = "2026-10-08T14:26:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/35e314c08e721ec1f070408a4e41926b90347ecd2863c08c78daad5f8a16/pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f", size = 2231416, upload-time = "2026-10-08T14:26:52.733Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3a/6f7c36afe35a9eca24e5f74f33ca641ac8cf308aae4ac7af4e585d464a40/pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c", size = 2242496, upload-time = "2026-10-08T14:26:54.485Z" }, + { url = "https://files.pythonhosted.org/packages/8d/af/77adf30285836c25f6a927e170df45f8dd4af713c89135b22abcbf3c6d67/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0", size = 2279176, upload-time = "2026-10-08T14:26:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/08/37/4e2a05247f82b59337bf45e9bb14aed85e2d83aecd21e49ad6cedf91882b/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4", size = 2450488, upload-time = "2026-10-08T14:26:58.218Z" }, + { url = "https://files.pythonhosted.org/packages/85/e6/75f25906212ecb94d69a0c8ed16f5c4ac312dc9961c2e93ff2a7fdf9a07a/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa", size = 2476664, upload-time = "2026-10-08T14:26:59.98Z" }, + { url = "https://files.pythonhosted.org/packages/75/72/ebb97b3becd0c0f2c722f698d34dec951ab1aa6086edf535f8769b545e3e/pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe", size = 2102409, upload-time = "2026-10-08T14:27:01.846Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/5ebf3e62f5d0f6e3503ffdfffd7d0c2f8d45690afd37fa0cfdcb1786d7d6/pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1", size = 2171536, upload-time = "2026-10-08T14:27:03.797Z" }, + { url = "https://files.pythonhosted.org/packages/58/1c/879ee9d5b63c60e5a077bab74ad93a4a48476090ca9c97534ab4263d0bae/pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7", size = 2162946, upload-time = "2026-10-08T14:27:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/81/25/f9a6958f73d92f66d620e3e1b091becf6b7a2ea89437118d397e2c6ca9ba/pydantic_core-2.50.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:049b0404792dcb942f1092bfdae5f819ef30445b0d174782e1909fbdd91bb48b", size = 2222904, upload-time = "2026-10-08T14:27:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/c5/41/7f299b2ecf0ddbec8c2a68057ed53d29458ea5b0850615570849c454dd89/pydantic_core-2.50.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f16bc5bb12f4c581b0f40facc28dbf286db32d1bf4e7e4f4c4e0f7f4e34ecf9", size = 2024863, upload-time = "2026-10-08T14:27:09.78Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/a9852fa8780acca5dd81a21bb66d4b2fb41c39dc5672cec66c32cc7f13e9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d084e92d0f4a096a5155b23d1ac603db8073ef98a3d1384badf1455e5ae742", size = 2047340, upload-time = "2026-10-08T14:27:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/82/cd174e776e71ebcb1d289a57cb3e89565123a84c5708865e12efa82313f9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0af2d2f745ec00a6616c4dfba4477f9156ccfb45690f6ffa5fd34f42e871ed4", size = 2173461, upload-time = "2026-10-08T14:27:13.948Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5c/9c4b2aea09ec7b7af966e79631b1970f1adeb6a8ed001bc8bb64a2d1c23d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f28a5a299d4cefd1066a6883d22600b9ae3606f881c3015d255784634647580", size = 2362629, upload-time = "2026-10-08T14:27:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/93afd007b61c50425c3c8f0402425779341c8d498bd224e5788eaf419c4c/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd70c2d9e73bca09fad54605dfeaae2b4e770b2800c65f5f5342901ed567b9f", size = 2440134, upload-time = "2026-10-08T14:27:17.716Z" }, + { url = "https://files.pythonhosted.org/packages/c9/93/a4ef199535547aaa7c5d2cbf009a1d6a2b252885b14c6e70404e1c661716/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58acd43ac8d3905659c1d5309318dd576243e723dd7c3b5dd4f555479b77d4b", size = 2158480, upload-time = "2026-10-08T14:27:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/f216e028d3bfbd9f8c67f7a2f0f8db04240926f630e2e66a5a8ac45fe00d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:5a07c644047f5abc268b4c39f8cb5a30e08a3c349228cb70142c7d3ed87587c0", size = 2231837, upload-time = "2026-10-08T14:27:21.742Z" }, + { url = "https://files.pythonhosted.org/packages/56/3f/57a6acf26e0acb82238ba4e25b35cbd6013b1d74eac99c4f358ff56bf6d6/pydantic_core-2.50.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ad8dad549cd1645be591a50b4573995ee7e018f6619ddc2bc6ea44b2ad9f694", size = 2242240, upload-time = "2026-10-08T14:27:23.564Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/f5f4014b40f91db4479e87dc038ac48a460a60baadea7ff9c942bc64dc62/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1773001198030e16f946a7ff7760fc3ebd45b12150f66fd0f433780043dc13d8", size = 2277852, upload-time = "2026-10-08T14:27:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fa/485e4db093e10f29676fc696abf9d88f3626ee520e99cc488fb091222db9/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8ab9e74878172948e0426e3d27fba333fd6a1c3f9456d8e75643e8e6868ac1a1", size = 2450132, upload-time = "2026-10-08T14:27:27.493Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/fd07063cfc65e498528cea59b27b979eb5c890a54dcf5bb19ab0a589516d/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e15bb1535f68a27f28e579ba3a8f74e7b05d350c45f5622b76a311d5a19ae48a", size = 2477011, upload-time = "2026-10-08T14:27:29.972Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/6f34c285dc87ed2fae5f1b53c51bb732eda21dc0c6c73d6a5d9dcc7033cd/pydantic_core-2.50.0-cp313-cp313-win32.whl", hash = "sha256:c21e6a6e4e6d32fb6acbc4f0fa69e8319cac0d65eeaa8298d757371cc2a9c687", size = 2102027, upload-time = "2026-10-08T14:27:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/dbdb3ba6699d494e59db2f145aeb97990e28d1503bc9f7bdd57eb4c15677/pydantic_core-2.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f96ccd9368ecbf6685d8f6981584ab72b4f0ffd20685e747737e7e340277b8d", size = 2169974, upload-time = "2026-10-08T14:27:34.692Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/cfe0e52a9b45b5ba12b3a839db928669543948760a059a72f13cf0346c29/pydantic_core-2.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2b88f9f9fa52e1c34938ff1a18ee7fbffe482df0bb43c087a9a60578d273d68", size = 2162504, upload-time = "2026-10-08T14:27:36.989Z" }, + { url = "https://files.pythonhosted.org/packages/59/d9/6dd838672e5ccddf01556bfd1b4a6767e0c75abf9aaa4092cb87a56dbc4a/pydantic_core-2.50.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:62a93a9d206a3580c975c3c1f65a869cd063844d016c004f8d786a9309b3c591", size = 2224496, upload-time = "2026-10-08T14:27:39.281Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/c57d2efcc63fb8fb6ae9ec2818baf30e2a68751e8d1dedefaf3dab2e45fd/pydantic_core-2.50.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a8403eef4a66743e339102fb3cdd8c8b9016b8bd67924685893d062c88896f9", size = 2023860, upload-time = "2026-10-08T14:27:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/f44ea3f1b00288f715e2320cbcd0f604104325370ef4bee42c5ed2ab076e/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8354fbbe2abf0fb724b9303bef43bfd9b7a2779332813fa6d6559983954e7d8", size = 2049144, upload-time = "2026-10-08T14:27:43.695Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/2f5cf84ceaf6d7c351737124aebc135f50ce48a9dd28d2b5283103face54/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00963fde61cf8880d9e7b5635a9830e0591edfa45168447fdc5b47635c0f6437", size = 2173456, upload-time = "2026-10-08T14:27:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/dc0bd815a328e62b72bb93b5f3c762939670d4119fd27405b454970833da/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bbd7da16d8b2912cc56c9d0b85c4998a6cbf39b220f81c0d8c397c64672ae0e", size = 2365204, upload-time = "2026-10-08T14:27:48.117Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/458c4a29f52fdecb16b192fb27f117359da9c9c58401ebfd956637822dca/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed557fa2744617eac3e34dd39b85037efbf23cd33f06851c00fdb8f18ad8f4c2", size = 2445007, upload-time = "2026-10-08T14:27:50.637Z" }, + { url = "https://files.pythonhosted.org/packages/98/14/0c0e72e0663be91a456167a2f7ed49c65195b388e8f1e8b2bb5083b27458/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f816dc04e99627a5a6352ae51dfb30e603f2cb0b7009c91dc640c533def014", size = 2159911, upload-time = "2026-10-08T14:27:52.881Z" }, + { url = "https://files.pythonhosted.org/packages/62/7f/64af6921e17ed04dda0e07ebbd394b29fb557d33618cb0f3f81ec59c0508/pydantic_core-2.50.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:779b6c74526596a86d38248dedaecfb7851bbaf319c234be042c57acddd2c8c4", size = 2235761, upload-time = "2026-10-08T14:27:55.194Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/b6ff9f7207af629094f4deac947d04be3812d5c95054010ad8f710a052f0/pydantic_core-2.50.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:21b62d45f327eb0802f842c132fda6d01a8376a8077922dc4dda69011c64d34a", size = 2243726, upload-time = "2026-10-08T14:27:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/6f7101f3c746a1686987a32875ae2f7c19f518d783979cee1a8a32e0a9ef/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f31af62efd1fd0257735b72e6716b32d4f207654adeabfe524d44baf1bb6bed", size = 2277158, upload-time = "2026-10-08T14:27:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/67fe208f3ec7f5d8c47bf7cba70019a84b1cffad947ebc8caef14271b54f/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:933f2d639eb81a3e1f145aec415453cc00983236629933f028d9507222583a2e", size = 2450059, upload-time = "2026-10-08T14:28:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/4a9c56a69f5841bdd94ffff5876a12ad2733209d5b89cadb67638d69e7c7/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c05d035e72530f6b00297941b0162218601542b76870c3bf2756bd87f16fc538", size = 2479142, upload-time = "2026-10-08T14:28:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/30d36746051e42bb67df986ee874f787526edb99205f6b1583c33dd81202/pydantic_core-2.50.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab3f95f737fc1b258b8210308fc02ce1442cf5950cf6d30b09ad89ab9e8afebd", size = 1483929, upload-time = "2026-10-08T14:28:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/04/2c/c0e8949be4f99a74f02ae02d603bdd518c8a9eba72982a34d675134a792a/pydantic_core-2.50.0-cp314-cp314-win32.whl", hash = "sha256:f12d9690634414fc04b1a7072fdc35c34a9242232c1851fe4518383578bb09d4", size = 2104255, upload-time = "2026-10-08T14:28:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6f/a5a5baf99509c998e0f2335d66b815880c7d6c63231c8e4017b237d6837d/pydantic_core-2.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:63263d64884688554fb025a3906c7b00573980cfee75f9afeb86239d577384bc", size = 2170939, upload-time = "2026-10-08T14:28:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/40/bc/c89b93b69d59cfe61c1e91b1cd3d87cc740e57327ecfef84ce34b7dcadac/pydantic_core-2.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:753863dd4317ec8cc9eb3e6d9d01a8ef1a1726a8003b4354658d68a1ae05f9db", size = 2157035, upload-time = "2026-10-08T14:28:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5e/d0ad47a406c42db95876900f4bfc520313c28d07a0dd5080da4972220c27/pydantic_core-2.50.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4abc425789f8540e86ca4cfdbc8dc650433cc2ac7c6136fee9bcb29d5665a02", size = 2234007, upload-time = "2026-10-08T14:28:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/69a7ec8400c1e34e5e11ab967f850ffd8ca71bb3aa0a8db93df5912bb6f2/pydantic_core-2.50.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf150693a51ca21e8288cd08a9de05e5ab331776dfcfd0537b14523338f0502a", size = 2000888, upload-time = "2026-10-08T14:28:17.983Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/860bb499f7cdf4bb453e8080af5bc182487f2f025d94baeee29c907bde6f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158749408ee19682b8a2a7e7135cced7f41d6f2f9de96088b1b1609858a6a158", size = 2022761, upload-time = "2026-10-08T14:28:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/89/fd/f571420436e79b9f7cc8f8ec534dee1759c240b4dc086737608f14d05c93/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcbd1fe5083315243447c13f7252ae9fe9d128b1ae2857e3a916c609235dd863", size = 2192910, upload-time = "2026-10-08T14:28:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/b47c4a01ea978a7a3d02551e3625f0344ae1352d67ea78011c2eca5e39e4/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fdeda6272d60b1f6fb63f6a3dade55e274af62e1514d24a6549a12150c385cc", size = 2390178, upload-time = "2026-10-08T14:28:24.861Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/d109f26b3ee4443cca220eaaa4f3fbcf78ff07aeb577becc37d9224db63f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b16e164205a90b1050d2f5469f8a7829db7698ad198c69e6aab6cbfb5648b87", size = 2464453, upload-time = "2026-10-08T14:28:27.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/a4/7be2f608f6f8e7655155057a82a7e2f135ad9b37fcbadd34fdf11b1ade24/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17a5ca9c197788a6424749a09a3dce824cb2c17b72f835f8a5e330935b973609", size = 2150682, upload-time = "2026-10-08T14:28:29.65Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/a8d867f6d3981c232d0f3c2254458cdee8471de7bff3f7edd252cc00c30c/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:01340f4fbb4f854b1f36a6fe9dcd2b26c8936aead4e4ad1205624ac025c875fc", size = 2245995, upload-time = "2026-10-08T14:28:31.934Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a6/7297a39c8814beab877be5ba4f594c2e166108cdd20c1f1c51fb71d5389e/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99e203d5c2a814facef78dcd993b0fb7a933124a3475a38979fc09cceae210b7", size = 2264090, upload-time = "2026-10-08T14:28:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/c3/65/634fc407eaf61abec0d015d6fd4fd8456c18c71358e984f60b0a05092d49/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2092ce156f92aff17e3345baba2b7c0c1701f32ba922c5de71bd6248fbe164e3", size = 2293708, upload-time = "2026-10-08T14:28:36.725Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/b87fcf8062b8907c77769385a4340a814fe359660593dbbe4d8f3f7e9e61/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:74cbb6cbd74445ca279668790e0c10eccac0428fd79fe061fce6c9e3982ad3fe", size = 2468464, upload-time = "2026-10-08T14:28:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/77/f4/d7aacec95f9e00dcbad84bee2fd0fc081ae292d380dc8a3268e4103de957/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:42fff617cb0b08505d8123d71e6e7a8e54210d9007498f564855bd843ce984b1", size = 2511897, upload-time = "2026-10-08T14:28:41.681Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d8/0a755b0069f9a0f4c57f98fd05b524473952556406244649d632d25f8646/pydantic_core-2.50.0-cp314-cp314t-win32.whl", hash = "sha256:36f9ed6ae1069913e4f6e86d8233e119e83e00a20c54f88faf9c81292f2fecc0", size = 2098906, upload-time = "2026-10-08T14:28:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/cd10a1fbd1ba1d730871e382ec2ac550487465c7c9be1a72b3f4f9181817/pydantic_core-2.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c81c2ec53ea9eb2227c14b3e6de35670b2de163b638f2a803e90a3bd5bbb0", size = 2148359, upload-time = "2026-10-08T14:28:46.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b1/a766eadfbc16b58d401f63dde3bf4e0943dcd2402011b6b8d9de2031ebe0/pydantic_core-2.50.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dec0dafc116ac29a84d143fdbc3b83fdb5d4ed339276be2251154537ab30e14d", size = 2175241, upload-time = "2026-10-08T14:28:48.934Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/43d0e765a80d6fc7d87b5f66a9023777aad6da7e7d64f486a4c2be8158b7/pydantic_core-2.50.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:af2b808a79bb04075e87c81a5b6179365b93f9a851f29dafd67abff72085d0c8", size = 2224750, upload-time = "2026-10-08T14:28:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/07e047c21ad90f184c7e8bfc6c9966b9ca51d4698e1a06460b65ca2ce105/pydantic_core-2.50.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2918547195ffb20118b829fdb8938e9dc92c9527fe6cbe58572594c96362880e", size = 2023936, upload-time = "2026-10-08T14:28:53.978Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a9/fb26fdd343ead65a699245c9d2150e84c47496a9b250859214a90cceecc9/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0393763d66f6f61715488d074a2cefac04aeb3ee281e36fb4925dd44deaf9e17", size = 2048872, upload-time = "2026-10-08T14:28:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4d/9fc4ea28a8ecc1b85fe77a616f8df9a51696476aaa93c5b256269d025044/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62ec6568896e0abf258cbcd22c406c1c8bf27d16224b21bec8f75c4ae88a8173", size = 2173752, upload-time = "2026-10-08T14:28:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/70/7e/74b55196339413d787b283c2568cca4679b0645e48229421228acfa8839f/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec786cb9d597dd75d993f8c1273e31bb2114c9bc22f67fba611e654e8347701b", size = 2365284, upload-time = "2026-10-08T14:29:01.434Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/4ebc7a851f0ef63fcaba205739c171e50dd183f8d79dcf0099c3b46aa60b/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fce74add1099da1ea09473268950270071fd56773e2968604efb3ab1d240e02", size = 2445279, upload-time = "2026-10-08T14:29:04.034Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/03d3524fc7d4960840e32fd88c8cf487b5516daa7b6845ffc74274e0247a/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:243088c95e23b12db9f2cd7661d584a3f00814e087489f40cde7f9feac56b694", size = 2160424, upload-time = "2026-10-08T14:29:06.628Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b9/7c530c84049089033dd746700bfbb51520ed9376bc90f3dbbcb319880acf/pydantic_core-2.50.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:42a56b0052ac11d9d0d87b1c94a1ce52e31914fd133f269585e0f63a4ed988f2", size = 2236109, upload-time = "2026-10-08T14:29:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/70e07379ebc2538c22f8c372a37c009b96a3a1bd7921aee1dad28ee41be0/pydantic_core-2.50.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:350001f5573451150d919722ee095aa28bec037d6e23b86d7f04581a910fe924", size = 2244011, upload-time = "2026-10-08T14:29:11.549Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/294810baacae4b088bfac60edab06036951e5ae6e0c20e8f6cfef7d70f66/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:21e38a011783d8afc8b9d79928e273d06f349b52ae84edf63ec18ad07f077484", size = 2278392, upload-time = "2026-10-08T14:29:13.998Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/6990b812f124cf3564b36492799df7a66cd3cf5678004ffe65dda85b136b/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:588d309ce5c85448379556d72011b191c0414ee2e80d7c3f1ebe2ceb2d9027b1", size = 2450012, upload-time = "2026-10-08T14:29:16.524Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c4/f8f1a763550a077c51d2c69b7a32db56d1b54e61c613bc19e6c7fd2e7cb2/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:a8ee3965e01f10e4ff92ba1727626328ab7ba93bcd5674ff2b78ea1048ee0cea", size = 2479233, upload-time = "2026-10-08T14:29:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/7a09baa1e0e4710be47b84aaf808e7b86247d7fa43251ac8934099113f8d/pydantic_core-2.50.0-cp315-cp315-win32.whl", hash = "sha256:c05b75ef3574c9ee4e05bbcf8513f7ccb155d426514be9efef5f6f53152d5f5c", size = 2104507, upload-time = "2026-10-08T14:29:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/f1cf61f747538834ea2c14c442264e4f29663c95294ad7933867633a1dea/pydantic_core-2.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:8447678b49412294801c9ea15ae31ea3bae7da65425268c92d40e032c38eac6f", size = 2171379, upload-time = "2026-10-08T14:29:24.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/9871261b760672cfa334498d94bc7dcbff2495345e639e806127f7087254/pydantic_core-2.50.0-cp315-cp315-win_arm64.whl", hash = "sha256:92016718bcf3e6f35a6bd986880191a8da7a35aa1f5b1e97544582ef938464cf", size = 2157182, upload-time = "2026-10-08T14:29:27.082Z" }, + { url = "https://files.pythonhosted.org/packages/de/3c/5107269aee5ee7855fa370ff837998792dbf124b8545244c370bec251a66/pydantic_core-2.50.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:71061800a3c225e730f7997f8a7fad6e0d0dcbe36609cdc7e60576a099a2832b", size = 2234440, upload-time = "2026-10-08T14:29:29.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ac/cb179b0c404337ef517515539f9899164a6d3d43d5c79c2556165da211c1/pydantic_core-2.50.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bc87bd34239835c8d73171acd039e591cea0a2ca8615e6188044ad170a40fca1", size = 2001074, upload-time = "2026-10-08T14:29:32.327Z" }, + { url = "https://files.pythonhosted.org/packages/b0/59/ef1714204f145e9497ab71e8bd82c5e40b5adf365d420be777d1e047cdaf/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c0e5b26d82bf31ab2b044781527b1b1a36e9ed400856b6aca5097eb1abb909", size = 2023016, upload-time = "2026-10-08T14:29:35.06Z" }, + { url = "https://files.pythonhosted.org/packages/87/e9/08a3ade34b4af09a83740b62a4442b7623127d388fc21d4938798dd85a26/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41b9f2821f0a105f88cd54ad03fe1392ec600618ffa8610f7c5e466ecd98c531", size = 2193165, upload-time = "2026-10-08T14:29:37.721Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/29aa3e540d3aca72139e5b7dc56395a940849d85da2b7a9ccfbff71d0928/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:364f62d8024997536e57865cb1c8b36effad3249bf392a29a2379ea69db28238", size = 2390198, upload-time = "2026-10-08T14:29:40.616Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a5cbdde0a2090c47002819d85317428e15b41d22e581cf38e9714b4903af/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:955d7878130dfc124d6a5343e1b87d2933244fe14a4a0a3e98787e8e660a8eb4", size = 2464777, upload-time = "2026-10-08T14:29:43.29Z" }, + { url = "https://files.pythonhosted.org/packages/c5/55/fff3a363b0dcf72fdd72311c676b1fa769f2c13342129c694b2eb9886580/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf81432281af66ba3285b26b09c2d478d84730dc50ff90926c1bdcef54048a33", size = 2150389, upload-time = "2026-10-08T14:29:46.271Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f3/0062361fd2377185d322dc84cd05ec4b96da352195c89939c7b509664721/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:34a4a0938eb30931baca56e55786c5a6871ac7aa891a38c8cabcdb7e49dab91b", size = 2246225, upload-time = "2026-10-08T14:29:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/662e3870143d5f1632a02ceb9ce2d5168e88caf3da80e9b42f495ceb1284/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f517a417cd02aa8fb05b603a0ac6d87b7b004c3ba4fcd279cad25cab7229043b", size = 2264404, upload-time = "2026-10-08T14:29:51.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/5e/8b14ebf111700362c6e519e40209d86f521da81ef171b2891af0bb5de311/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:d01029d54ff1f45c195b1f7e6fbf6e58fb7e7e12cda9a6e639570d9c581decb2", size = 2294152, upload-time = "2026-10-08T14:29:54.349Z" }, + { url = "https://files.pythonhosted.org/packages/55/29/85c486e0d25a8b523803032e97d7138edb7b7bc887fa8c6dd085e8824cdb/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d06dbbfe8da01a0574de27afc19915bb3e7dddfbd184bb97e958f94051d8531b", size = 2469160, upload-time = "2026-10-08T14:29:57.193Z" }, + { url = "https://files.pythonhosted.org/packages/12/bf/c451db7567e92d6601c4d2f6bdbb945cf60104182e2b241d8fe0b6f01414/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:5307a8bd49158b57a0ca4e10950d31085aa35d9007e934047043ccc6d28eea97", size = 2512204, upload-time = "2026-10-08T14:30:00.177Z" }, + { url = "https://files.pythonhosted.org/packages/43/1e/5b93a2513ced099acb0dc4dd6684742d6564ade60a59abcbabd161624e07/pydantic_core-2.50.0-cp315-cp315t-win32.whl", hash = "sha256:c2b246fa7cbdf9918488d1542a82bbb928cf71bcba66905c24131981e759ff0b", size = 2099026, upload-time = "2026-10-08T14:30:03.154Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/1b5967e31b025a23f2f627ffea1f5a874cff0adb72fa5f5af6bb36394ad9/pydantic_core-2.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:36c49d4e1769127461b609f110d91963790091ffcc2de401ac1d3b2f6a63bd54", size = 2148456, upload-time = "2026-10-08T14:30:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/11/ff/8d126ca04417cbc76a81648c135296ecc2f36d07adfb0b3376638efd1dd0/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:38b03c5439c6a7a952f56e3e6596dae44bc78c3fb6a69df46cabfb2f888e5f0c", size = 2162541, upload-time = "2026-10-08T14:30:12.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/e3f11c40950d0f37b3891c1ed015f04a4ef36c881d04ef1a5d1ac91d1c6f/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:4faa766450ef44d9eabed5b65a280f63f903d1e1ed6d2e278961eb22345ec49f", size = 2075402, upload-time = "2026-10-08T14:30:14.968Z" }, + { url = "https://files.pythonhosted.org/packages/42/f2/727d2b23ff6ca67c9c050c4ff4e5abd7b599d9ebcd819a74141d3b11c7c1/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50920d66aaab60dbc6023c3035d55fbabfa58b6d205d0b39d47d820c4a7a13a8", size = 2108552, upload-time = "2026-10-08T14:30:17.774Z" }, + { url = "https://files.pythonhosted.org/packages/4a/da/a4321058eea61f0504366668a09472a4c37514be30cce77967c35ab7ffaa/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:586699b43066ca6038f96bd71d0eff0b6c292929e3d66ff63d5dc79d0f06d589", size = 2267745, upload-time = "2026-10-08T14:30:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c8/45e2cbac6379b8cec8cffd7e6aed50754fce13d93f0cff28bbbfe18c1d1c/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b916ff828d604d4311a5639b7b3da51eaea3923833ec3e300a5ee35eade99691", size = 2217049, upload-time = "2026-10-08T14:30:23.532Z" }, + { url = "https://files.pythonhosted.org/packages/61/8c/b81c4139bff6305e7a98fa7845a99e05df1461d3c33d0fef417e02114ae7/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:732efbb50977ab7cf18f01d4c255834aeb14cb2425459f7bff681a1ba3a4ffa1", size = 2039121, upload-time = "2026-10-08T14:30:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/0f/49/679293741809cf835290bccf80216aebfcf5d94cb84a34709f18b21abfd5/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9185366893714cd4514ae5e1a098227704fa395cb41a7855d75968ecedc826", size = 2109280, upload-time = "2026-10-08T14:30:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/f5/49/76f167aec9b9d77a82e38131539ca6c6fcf69d720965f76eda20b87b454d/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0def1dc09802a790e4a1b3cbc4401f0b53c58f273ce3671df9867f7bdf1fbe20", size = 2269313, upload-time = "2026-10-08T14:30:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/50/a6bd3c397b609aade207ffb78ce46f4ad4e29bb42a39007c87ee00e778f3/pydantic_core-2.50.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:68421ba548f6d86c7dededd70bfe530b4faf67eb811c53c70a1be69438d3ce99", size = 2154371, upload-time = "2026-10-08T14:30:34.965Z" }, + { url = "https://files.pythonhosted.org/packages/11/3c/2d32a1f945b9809b65701d2b72315bdcd700f13c093df5e60baaa784bc6a/pydantic_core-2.50.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:23e0940b7d73f405d92e98b2a05b93b16db163be13b7478bb19e1db2e486ade6", size = 2087028, upload-time = "2026-10-08T14:30:37.814Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/c2aea271192d8c4b81afeea9d61de8f5dc8686a1fcd1ace80e70a482fbce/pydantic_core-2.50.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee9913fa2b5bfa6111c11158cf481bb13544f8bd5d10382fb6ee2612b16fcf28", size = 2259611, upload-time = "2026-10-08T14:30:40.581Z" }, + { url = "https://files.pythonhosted.org/packages/35/88/32128d5498b95722fe914a21a25f59edd7dc8917474f47924005682532a8/pydantic_core-2.50.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ad3532291deedfdfcad3cf5d351d0076d646c68a0c63869a1bebf87c22159600", size = 2268636, upload-time = "2026-10-08T14:30:43.511Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/7dae31279e07aaa63dd4b17756f4ebd5647aed6bee5581ee3b3e85d82a0c/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:9279f4be22bc3765dd9f61f5b4d38d9cb219d167bbd6f2e0ffa3368b1b71b4d1", size = 2305416, upload-time = "2026-10-08T14:30:46.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/61e47defbd490618fcde2641f80d5c077f2961de62e88500edacb49297f9/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3b2f3e44af4c6512dd73557621385a71b18094c8be81d60e5a8e73a8ce96b7f4", size = 2466713, upload-time = "2026-10-08T14:30:49.214Z" }, + { url = "https://files.pythonhosted.org/packages/61/57/81327752c007834d684b0b7d20ce00087206f512c7347a6236fd89ef0223/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:593dd4f24abeb3ed90222f98bc6eefcac68cd5aa96fc7745f93862a802952bce", size = 2481971, upload-time = "2026-10-08T14:30:52.088Z" }, + { url = "https://files.pythonhosted.org/packages/48/c3/e321d3a4b2737372cf775ffdd1a02428c7d2791ee5afc9f228793a0ee245/pydantic_core-2.50.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b123f9d8702106f39dc3af63a031ca8b5279862a3747ec6c03ca49dbe78b71b9", size = 2337958, upload-time = "2026-10-08T14:30:55.045Z" }, ] [[package]] @@ -4483,59 +4692,59 @@ crypto = [ [[package]] name = "pymssql" -version = "2.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/ae/44c335d6d380dc75da6fb47c15cb90ba2faa3016237817f7ec193ebc8824/pymssql-2.4.2.tar.gz", hash = "sha256:f0f7e2fffc25de6ec7f56551c91afa7981644ffc9c4d2d9c2a19ef4890797b67", size = 209508, upload-time = "2026-09-27T01:32:54.965Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/ec/8cfb9dc051e2698ca683099e56c3792d236b253032c919bfc2868d73c4e0/pymssql-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:17d802a8b4a56eac1f004bf6bdf24be5fa9d99bc7f2d53812da95df265082c64", size = 3208069, upload-time = "2026-09-27T01:31:31.738Z" }, - { url = "https://files.pythonhosted.org/packages/67/0a/f76a2844ffa8b54ec3bb00453ccc92965e47ccaf72e0e31f9ec99037ada3/pymssql-2.4.2-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:19dda2f9581c862e135e1597abd5bd5f435c576dd3cf7b7be68f25b799aaab2f", size = 3012266, upload-time = "2026-09-27T01:31:33.135Z" }, - { url = "https://files.pythonhosted.org/packages/bf/33/d8e69edadef56686b38457a6d4c26bce4615d925bc7b176dd9c3c03485e8/pymssql-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fa9da3cc867003785b9a5ccb8d14180ae09b4f1462ffa00d36503b18b1a07d4", size = 3068155, upload-time = "2026-09-27T01:31:34.386Z" }, - { url = "https://files.pythonhosted.org/packages/88/b8/d4f568252ab47bbff6ff1ffd54be338a72735700659d4e91080cb1748e34/pymssql-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7eca757831c92836b8e4d5e20cc57034bb06b8026e0fe84925328c15fa0cfac", size = 3246808, upload-time = "2026-09-27T01:31:35.813Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ea/78a1e7248fa7f77eb4ea5b98022b577aaadcc57f086acce067e69b21b728/pymssql-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4b1d5646bb39a38b7f47cb9015979b39afd91e79b743d736281db64c2afbf7a1", size = 3717493, upload-time = "2026-09-27T01:31:37.152Z" }, - { url = "https://files.pythonhosted.org/packages/1f/c7/1451fbeb67fe7e85cec221c4eb9e4d3bdd1df282ce050c19374807f71c47/pymssql-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bab53310babced1db80563f05841343023ea0af85013a50b59d7934c3191efd6", size = 3454959, upload-time = "2026-09-27T01:31:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/af/ce/1cd313a0fff46982f6ccb48de7028c07189596bb11a51a2048b7024e9129/pymssql-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:5080a65bd6430cc15edc775f82cb0428ee21e825c7236b06ac69b915c7627e42", size = 2350195, upload-time = "2026-09-27T01:31:40.302Z" }, - { url = "https://files.pythonhosted.org/packages/fe/71/78f488879b130474b912cb4bf3c1b2e44dd7c4d66c400d3be19e47e5045b/pymssql-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:766f5d000cd116893b044b14ea8c96d43096c2acf4e93a89703ba75d52712b57", size = 3190010, upload-time = "2026-09-27T01:31:41.766Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5e/6ff0d46c6f723f4f8278ca255b4a3c79ae6fd01af11100053c6f2810fec3/pymssql-2.4.2-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:2b974acfa7f7151a31bcfcc058219a515597bdbef52162d44aee8108c1e4de79", size = 2993346, upload-time = "2026-09-27T01:31:43.219Z" }, - { url = "https://files.pythonhosted.org/packages/2d/da/306af14a8c8f2d4aa3a4b6ad6e85608e5d9789059cd5bd500bc17a17a5c4/pymssql-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6bd05d4703ec7b0b889af621c9fd8704d22f32ae5ec7d7e4d685d9e0c02014", size = 3098493, upload-time = "2026-09-27T01:31:44.7Z" }, - { url = "https://files.pythonhosted.org/packages/2c/30/3349790aedcf90166aca43a069c1c7b6013511895533ae94d0f3f5d1e91b/pymssql-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac37a077666ca15f0ee839447ecef5109a6248ea1f329665142afea36c48ecd", size = 3275231, upload-time = "2026-09-27T01:31:46.274Z" }, - { url = "https://files.pythonhosted.org/packages/f9/58/1fbef3ffd3ed4d80542baac14319655f6ef9c34abe60789023cc21025205/pymssql-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb3f0762650eb420de443d64fab1bfe6234b09ad9769c94f426f465ad7412d9", size = 3746494, upload-time = "2026-09-27T01:31:48.01Z" }, - { url = "https://files.pythonhosted.org/packages/c0/bd/1a9ffa743cf528271b71186082f68792968b9cf625739235d218b928ed5a/pymssql-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6784c439424bef6735ea1a05b9daafcd2ff367457da7a248d2b3b1cdae29e997", size = 3482983, upload-time = "2026-09-27T01:31:49.472Z" }, - { url = "https://files.pythonhosted.org/packages/10/c0/39dbcc514c6b004254445e3ced97b436d74b4c4e44edef31bf08b1965cca/pymssql-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:4758def2f0acd9cc1dd3d0b878307b6a2629ef9b903d9001d039b02312e69843", size = 2336763, upload-time = "2026-09-27T01:31:50.753Z" }, - { url = "https://files.pythonhosted.org/packages/d7/60/5fb0de21ec8db8cede9771fc42d2bddb3dce6c5dea9b2c7e035b50380ae0/pymssql-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:2dad5b22ebac474dea760df16fc173a8be0ae6f7063414aeff9ca0ab83166ffd", size = 3190559, upload-time = "2026-09-27T01:31:52.192Z" }, - { url = "https://files.pythonhosted.org/packages/9b/4d/cdbf6c8397983953473025646c6c7e91e5e6ea5f6c3f8f47dd4af46633eb/pymssql-2.4.2-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1d0f0f4e86933b1ed63b9cca4223d92abcff394b161bfee7b2859fbf7e904dd8", size = 2998677, upload-time = "2026-09-27T01:31:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/f4/30/8a82987c7f36724fcbd85a4383d8786f8754381ce8c62f8773556b5986d6/pymssql-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:207d1d8378c882123455300ad1baf145c3fcde66800622402d28deb001ca69f8", size = 3098065, upload-time = "2026-09-27T01:31:54.59Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c8/0efb224dcf80ab3651ac0fafdee12e594801c51406b6ec8fb710d7b94721/pymssql-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95881385d6f69488a601565659b6d1444e374c05a5936334b8efe109149cc888", size = 3274614, upload-time = "2026-09-27T01:31:56.113Z" }, - { url = "https://files.pythonhosted.org/packages/9f/19/0863b6c650aca1c1d2e952a20f4eb39a2d4010311e872e3e4b3b3652599d/pymssql-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8fdaaf4b6c35c879e5bc540d47f8905ed310ff5f5c855b7f613bc8584900ac54", size = 3746059, upload-time = "2026-09-27T01:31:57.4Z" }, - { url = "https://files.pythonhosted.org/packages/c9/88/24954c7981ec171bb5af29e44c5121ba19de8028142e3e9498bc34ba49aa/pymssql-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fb59ccad66e75085fbbf64e186ce683193986a9a3e6db46ca91ebdf49b88c655", size = 3482476, upload-time = "2026-09-27T01:31:58.68Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/7f8474be42856301d71ec9117dac84405ca14dfa639ac46c673dc3c75021/pymssql-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:e38e2daa3242f7ec026060c0472bf8be1c7845e1fa7d05efc0110d1b2ffcce1f", size = 2336029, upload-time = "2026-09-27T01:32:01.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6f/3bbc27d204cdf671b38357d4e451499afdc96e65290020f0bdf4415d2e15/pymssql-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:438a7a37a245236ba5182bd792638fbcbfad9a97122a0cbf88d8ece14a7b4cde", size = 3193880, upload-time = "2026-09-27T01:32:02.561Z" }, - { url = "https://files.pythonhosted.org/packages/e0/3d/75437cf2aea3e1c3206d9acd7561e0f10362fe665de9ab498b1459bd3e30/pymssql-2.4.2-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:8ceef1088170dcfc08e0b3536cad2be03e6effa66b6881dbf8ab0ed1d9da8219", size = 2999878, upload-time = "2026-09-27T01:32:04.342Z" }, - { url = "https://files.pythonhosted.org/packages/38/58/e1f13fec6673c64d411b18600501e83e5a253555733914c62a497b6ed494/pymssql-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0cf9b05da9e3747296508ab7adb72601a7d56790000b7953c3fb170566215a", size = 3096237, upload-time = "2026-09-27T01:32:05.94Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a8/0bfaa2577de662cccc584fc10dfd1c7a132ce222bef957c676b465f4a807/pymssql-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d0f0589abe9a4d6c12cb002563ea6f317090b8171ae2ae2b0df29fde825385", size = 3273053, upload-time = "2026-09-27T01:32:07.343Z" }, - { url = "https://files.pythonhosted.org/packages/61/ec/60230a1754b68e0c28c655a6ad7db840627d112bd354490371dc828f9641/pymssql-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:98e0d028ebdece1770d878172d65d0ef098e366c276ed9992c7bde2cf1e38e30", size = 3744955, upload-time = "2026-09-27T01:32:09.066Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f9/75ac2c0d398d7fea2c6477f1d42bf98048d0ee61439fbcef573dfd90b3bd/pymssql-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d5fa2c62a195d2b4b96155270081903c2f5d0b4ba6a5a1a37bb361c85ed21311", size = 3481043, upload-time = "2026-09-27T01:32:10.521Z" }, - { url = "https://files.pythonhosted.org/packages/da/24/2987ce47d8f03eb7c087dffa190e6e6109edfddfdb08ae0e76c6bd6885a0/pymssql-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:cb3029d053577b66d7dbf20225ffe75de2fed4a641d398c6fa03376ecd44bad6", size = 2399016, upload-time = "2026-09-27T01:32:11.851Z" }, - { url = "https://files.pythonhosted.org/packages/cf/7a/1a1a20c4e19807f0173d9363ba928990636a8f17eacc0120da601523d16c/pymssql-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:40da129a58600070c1c51498c36188ecebfea2158217336169e13ad4d1a04e1d", size = 3221444, upload-time = "2026-09-27T01:32:13.131Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b6/05813934f24ed79033c073d70a11bdc3b72c4c9f0c886cd03f9056c4b1b5/pymssql-2.4.2-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:539d358ed944c10dd09bd8ebe2832494b976782de8fa314a0e6c902f5d32ef6c", size = 3014102, upload-time = "2026-09-27T01:32:14.467Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4e/0728bffafe8a6a1b8a4e8727064d1511757e9ef101f7ba1fe07ed263dfda/pymssql-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1126b337f1adc509208df60e19c968c19117e39116fb02b46b01de1e64ba2a76", size = 3089542, upload-time = "2026-09-27T01:32:16.196Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5c/911fe0f09f777cb1eea7567599cfdd3d19eb6d40d23161618c353eabb9e6/pymssql-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136b15c790148245b3395a9bcabbfaa7d16406fb116043a92bac470701521dd0", size = 3268996, upload-time = "2026-09-27T01:32:17.878Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/7fb80d4b6c6805b46727c36133470144d822311e060eed44ef1365651b6c/pymssql-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:65e0adad5899b7d2e97407b8e3b6794207affc783cf5b32e76561d6413e709df", size = 3739514, upload-time = "2026-09-27T01:32:19.269Z" }, - { url = "https://files.pythonhosted.org/packages/cc/14/8c5c68753a6f8308d4b1e44720479bfaa44f6c92b765a6b519392056774f/pymssql-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b25f82d7fea0217e8a2932216425d4d149db2e2bed5e8899e835709b54fe046", size = 3479023, upload-time = "2026-09-27T01:32:21.211Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/93a63918f59bcdecaaa1cd5f8011fc353b2f5e5e4afd748b25842dd7a69c/pymssql-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:83ed0ed455e644de52065e7c6e7d606b2d511166ea28f05a58642dde0d3ddafc", size = 2418410, upload-time = "2026-09-27T01:32:22.854Z" }, - { url = "https://files.pythonhosted.org/packages/b9/39/20e8d6a3290e88be22655bedc3fec3ffd3d7beb26f2521b36bff681ca2b1/pymssql-2.4.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:4d917f3f8aeeeae6f5ddf5a5f3cba91772781f58081598820da7b9a75a129677", size = 3192838, upload-time = "2026-09-27T01:32:24.187Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f3/539159d5fa09a3540ccb387157b6255ff1d487a5df3a53b2a9d11496d10e/pymssql-2.4.2-cp315-cp315-macosx_15_0_x86_64.whl", hash = "sha256:655d0d1215672604d2b8400a7a1af29c6d84a45a87fc8de68c9de74208983a7e", size = 2998694, upload-time = "2026-09-27T01:32:25.608Z" }, - { url = "https://files.pythonhosted.org/packages/49/8d/f3d99ec233a8c029919c12b1c0d6cc8e21eaab959ad16f4221b96773b11d/pymssql-2.4.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7a0016dd896a4c80e9dbcad9dae3197fd3cebed8bea43d35fc7c9f57740e33f", size = 3096511, upload-time = "2026-09-27T01:32:26.972Z" }, - { url = "https://files.pythonhosted.org/packages/29/31/5a8f1d2394669f1b4afb3f55701dc59563d7b07184f00216cf49757e3bd8/pymssql-2.4.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:afc14d099fcab4242922350f753acd35f1ee24ad64695ec8f1676b29ddcee63f", size = 3273234, upload-time = "2026-09-27T01:32:28.319Z" }, - { url = "https://files.pythonhosted.org/packages/66/9f/6f639086c6438e2613a6835cd99a852efd4cbedd36b18170eb368b62b1cc/pymssql-2.4.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:7d1418b9b13c1c78b5ae0b52174e48d49021f7923bbc6fca8f57f22093dc756a", size = 3745155, upload-time = "2026-09-27T01:32:30.267Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d9/e97641fd79c1e1e50f2eb4481623f53fc8b49aedc4f7b8ccc4e097b8d2de/pymssql-2.4.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:7ffedac5cd70d4d2767d30f4c90f210757f49eda9e8b87a254f5e79be19129d5", size = 3481225, upload-time = "2026-09-27T01:32:31.9Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ea/19793256603c96f63a666dcee804812c459c73692a79a04f6b60279fe2f4/pymssql-2.4.2-cp315-cp315-win_amd64.whl", hash = "sha256:f497b6f917983890e842180762a5925c1983036395fafee4ae7dcf4db270f53e", size = 2398139, upload-time = "2026-09-27T01:32:33.198Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8e/25c33f91108c037a8d893c6d0483478095d50a9dd690c3ca4ad2309acbdf/pymssql-2.4.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:1c4f473420a17764e61ace8498d2190df6cba1120e1fbcf2414819bd392dd5cb", size = 3220829, upload-time = "2026-09-27T01:32:34.626Z" }, - { url = "https://files.pythonhosted.org/packages/48/fb/48b47d2f65c024f000418baec8ad4eb39e201c8d146a44e18837a01883fa/pymssql-2.4.2-cp315-cp315t-macosx_15_0_x86_64.whl", hash = "sha256:9e1063f3e319baba3ffe6c6f0c64ebcdd3438347a787fa7b14326e6b00fa6a3c", size = 3013308, upload-time = "2026-09-27T01:32:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/83/09/e467880baf373f3f8fc2ef3c3e8123eaa2225f5a6e936f4e4c74345b073d/pymssql-2.4.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5ff8e0f4dcf317e462e1094fea1c62118594c5496bb12d621a641b062c5f8d2", size = 3088785, upload-time = "2026-09-27T01:32:37.379Z" }, - { url = "https://files.pythonhosted.org/packages/67/37/1be61d48ace07c4cdc2f19e81bcb814b78fa5d862b3ba3eb1bf7be0a610a/pymssql-2.4.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2698ce5ae14bfede29e8b025946f76ef0abe207f0fa726e74011b11bb00dd248", size = 3268196, upload-time = "2026-09-27T01:32:38.688Z" }, - { url = "https://files.pythonhosted.org/packages/70/f9/6e38131b7a618ccb7d96ccbd7d5a21ba60f4029524f98ba7019948a6c6ef/pymssql-2.4.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:4bfbb6bf5313e0278afb0ebd609f9a052ac5816ac170a7d06d14c867df18aff4", size = 3738905, upload-time = "2026-09-27T01:32:40.045Z" }, - { url = "https://files.pythonhosted.org/packages/10/6d/fd0b85b68fa587254b74c5dc67f395d6b6f0bf3c1199eb0115873e1da85e/pymssql-2.4.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:eaf2cc76ba16375f8df95ac16cb76e1d55696e0be4960953e2a6122741a2e8b2", size = 3478329, upload-time = "2026-09-27T01:32:41.507Z" }, - { url = "https://files.pythonhosted.org/packages/47/ce/c42fc1d9dc12116e9fdc7a1609809fd25059546791fe45408e9976eb0cd0/pymssql-2.4.2-cp315-cp315t-win_amd64.whl", hash = "sha256:cf960b137f6ff411ff8a2b289daec6f8d13a50820b75e6942ee81b2e28046c4d", size = 2416827, upload-time = "2026-09-27T01:32:43.656Z" }, +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/3f/5d66aa70242731bfc6b6c62b14e4c6e9db0cb2dca778f0d24411b32bf88a/pymssql-2.4.4.tar.gz", hash = "sha256:6cd20778ca0e01bb5783c6d65fd2d2f2f5ca520015a4f86930d2a58ac72a0e3e", size = 211055, upload-time = "2026-10-10T04:09:49.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/72/b7f6f1caf55a4b0d96fc13040a66c05b36b84e8f7aeb8e1de649b57d7064/pymssql-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:db24af7e4185d4219b424617c7d8f831423ed8b48929d1f1d43cccd907a87b75", size = 3210125, upload-time = "2026-10-10T04:08:18.794Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ce/5c290dd5eef7ad579e1a4b0dbf622fabbe2485505d96f69087dca24c0286/pymssql-2.4.4-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:d6ccd0e323ece21a14c1364065fe9bc3176dbd03870557bd974ad37e80961830", size = 3014314, upload-time = "2026-10-10T04:08:20.583Z" }, + { url = "https://files.pythonhosted.org/packages/37/34/5f99945ed455d829ce93fda685ddc8a54b2941664d588342db323689c5c1/pymssql-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98936558f5a588fd26b89e020f80b8e7c7f8d7df3a8e239405dcedf5de88dea", size = 3067447, upload-time = "2026-10-10T04:08:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/f193fd02c19f98b539180b7cddf460f960c93bda227e711e26f887e692a6/pymssql-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8035874c7d12b83c54931cc014c761229fe480c30fc0793c0b7966496c80f530", size = 3246650, upload-time = "2026-10-10T04:08:23.642Z" }, + { url = "https://files.pythonhosted.org/packages/a5/61/f3710033a34ee7fb56558f9454ad8b39d259496d3026dd19e9065a57ad74/pymssql-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cfa2504e280e54961508de51c7e7122ef309c74d609118e365821a444220df61", size = 4459721, upload-time = "2026-10-10T04:08:25.377Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/905dbfc6829e0521872016749c5f1998ad19db667f29d59477f0cf366577/pymssql-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e459565167cffb0f2a82aed3175cd7e50e6697457bf863f7b82dd00cc4993032", size = 4196191, upload-time = "2026-10-10T04:08:27.107Z" }, + { url = "https://files.pythonhosted.org/packages/61/04/d55524256a8a78631cff38f003494d04677a6dd736569403359ba4a8d023/pymssql-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6ae241b1f57a543111cde534f3e379c2df1ea3064ecb16e1fdd4200388dd9948", size = 2324698, upload-time = "2026-10-10T04:08:28.87Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f9/e5af55e9c2a673a746f283fb34539f78ee5e4f5b2b06de874811f781dd34/pymssql-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:860446859caba331dd3b2580f2f384f64f3c536a6b20c1b90261078b22868ab0", size = 3192145, upload-time = "2026-10-10T04:08:30.516Z" }, + { url = "https://files.pythonhosted.org/packages/14/c1/088c959221eaf4ac12a05f02f034b87b5cc4f190ca42cc3d54993f14b177/pymssql-2.4.4-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:50d74b1171da25b0d9bb09f0186995e28bda02a65f95ccf4a3fcf28b204943a1", size = 2995170, upload-time = "2026-10-10T04:08:32.279Z" }, + { url = "https://files.pythonhosted.org/packages/84/f5/b15e5ccdd6564a73820b536511b7a3862c25fba1a24744655a18b9b1104d/pymssql-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0090e3684cc6a1198c84cb59945f6182806c2fdaa24442e6a0b06154d5b3a31d", size = 3097963, upload-time = "2026-10-10T04:08:33.905Z" }, + { url = "https://files.pythonhosted.org/packages/2a/00/3e6e3fd8a8e8a3a68a1c21afc6447dc5551f30fa496c726b07952d893374/pymssql-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f887aed9155f0ba8ba49ef06cdf259a7c0ebce329c978930dcdcbbc901ca4d98", size = 3275346, upload-time = "2026-10-10T04:08:35.342Z" }, + { url = "https://files.pythonhosted.org/packages/81/4f/9d20fb679200ddbabf57e001019646080832bf2251c32d75460439860353/pymssql-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e8d5e01fd4ed17274e0005082ce47fae1993681a1b2b05beceb9ef77d130579d", size = 4488379, upload-time = "2026-10-10T04:08:36.821Z" }, + { url = "https://files.pythonhosted.org/packages/a4/2b/306a16194937d5ca36d2dfb3d13ef06c7085f2ecc0198ba83e101bf9da0f/pymssql-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd67b469836dc69979b13d977724377942611e8b8e4aa6896ea7a53139a15a99", size = 4223503, upload-time = "2026-10-10T04:08:38.328Z" }, + { url = "https://files.pythonhosted.org/packages/60/0f/2146384af096f9ff0c23f30f7b9db57b741cd52d1db6940835e52b2f9584/pymssql-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:af196a9f57e06745e4dc956dae801faab853240836b9047d9068c1d4020b6b69", size = 2313364, upload-time = "2026-10-10T04:08:39.979Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3e/a133fad9e5d0e40a5d3d8826ff073874f6335e89d234a70b110464f139c7/pymssql-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f255f76f543d90eb0b471e252b0b0e2858033138f20f5ce261a9195105e6ed7e", size = 3192233, upload-time = "2026-10-10T04:08:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ae/1fa071f438ed7f414861afd3732ecfa1268b3e6a0317053ba1bc76e3ff61/pymssql-2.4.4-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:4fdaf0c55c49be5488ba515788b2d36a27763b0b8b345aa483b9fd3bd9a95563", size = 3000180, upload-time = "2026-10-10T04:08:43.322Z" }, + { url = "https://files.pythonhosted.org/packages/3a/92/c9626cba839ae336a8a120a626d4cc533d53f52ab10b68734bdd55da7fa5/pymssql-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19a5af6797232e9c4eb396f86bc3779ff237b224e2eadc81486699762f36d62e", size = 3097494, upload-time = "2026-10-10T04:08:44.875Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/6070333af8ce801b884e7c9a0050b4d5278f2f563cfa405f68a94e711bf1/pymssql-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12150aae35d961986d1ffa6550cac70349798ffefea9d4403a9a9943e1b6d394", size = 3274897, upload-time = "2026-10-10T04:08:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/c71d3291e03b3e07dfe8fb104dd3a4520fc61fc2ad5f7b3385b0e0ca4be6/pymssql-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e6c0d774d3aad54df6f7c428dad346f718704eeca9ccc270f8e4499ecc2e9f39", size = 4487921, upload-time = "2026-10-10T04:08:48.389Z" }, + { url = "https://files.pythonhosted.org/packages/b7/1b/18c887fccef26fd89ec2c233fe5e6606047e6e5ade9480897208225011f6/pymssql-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:59e47602c4c9c5c221cba8bc9e44f1cbe0343ec23dbce63243065a4b7979e4dc", size = 4223072, upload-time = "2026-10-10T04:08:50.212Z" }, + { url = "https://files.pythonhosted.org/packages/f6/88/5b7b335153a0f151852e8633b672d7e6ae54c38dc1e7d13cafa929129d02/pymssql-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:405823931c3dc51ad5442f8a04a0e17cddefa0c215684605d9e5c137b6f758a9", size = 2312567, upload-time = "2026-10-10T04:08:51.642Z" }, + { url = "https://files.pythonhosted.org/packages/d2/56/cc79d0f1c41c4ec5f70213bb1be60da1deab85c40ceaf36d4b9a4f8c271d/pymssql-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a8a35ffddbe3d5320fd5b113e9d3cf6f4876be2ca155c3618a048850c6d420b4", size = 3195642, upload-time = "2026-10-10T04:08:53.097Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/f56629a6c873d2d7a9737502c2544c4df43ced7a18982f71b11895bb3851/pymssql-2.4.4-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:c070f5b97466daaae2dbd55643fe3de9951bb736e80aaaf82a06e0e6d55a19fc", size = 3002116, upload-time = "2026-10-10T04:08:54.534Z" }, + { url = "https://files.pythonhosted.org/packages/c1/36/185dca7f4bb691d77ff853258bd7c7d55cd44fb8f44a5b79ab4df09883ee/pymssql-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b7a5766d4b2898e791646196af4ad8618a601029396dd13bf1317280c525599", size = 3095511, upload-time = "2026-10-10T04:08:55.93Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1a/ea7c498f7736209d27eb99f63eaad6121d21b25b19ebfa5f559d27d37642/pymssql-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc0fa1e68694d15ac8fee064cf6674053eacd51892fb8538cf3a703cac0a561c", size = 3273070, upload-time = "2026-10-10T04:08:57.345Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9b/18d141d421bf28cd3ea39c24cac45610f26c448efbfcc1cb4723a4e4ceba/pymssql-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3f039045feec1b0c60c9266fb27e3d39b4bcf8e69eee7485d7c4ead7adfc56b3", size = 4487159, upload-time = "2026-10-10T04:08:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/42/9b/87106c146316036a5cc19b2b2bf8d33d3b207f45f30f9525b6650e2ab05e/pymssql-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f5731261eec9761ea76fbb517834c2ee5e3ae6f689e8bc517923264a4886a184", size = 4221646, upload-time = "2026-10-10T04:09:00.269Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/f954af0ffd773731135c87add639ef01248b7646ad670f4db08db4c444f9/pymssql-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e86cb1d1ae4c6fc27fd0c0c3c2409d15c3bb9ba2dede52c4633eb8209f2081c4", size = 2375068, upload-time = "2026-10-10T04:09:02.149Z" }, + { url = "https://files.pythonhosted.org/packages/3f/cc/523a6b9f7e6b26b86cf0ce9996f51c94082fc480a028ff9ed44e86b900f8/pymssql-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7e6b0105dc5c3648095c528ab08704f49f0cdd3f3f6cbca8a3c317f11aeab281", size = 3223425, upload-time = "2026-10-10T04:09:03.782Z" }, + { url = "https://files.pythonhosted.org/packages/ac/95/e45755d00a99299030ac2c17f09bb0cbad492b41c58f006a634246b90109/pymssql-2.4.4-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:8dc95ccb06863883015e8340cfb2c7e1ca889ab789c7109326de3231412149a4", size = 3015996, upload-time = "2026-10-10T04:09:05.584Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ae/36e9ee73e1550bde1ebd106bca02df266758e939f9a9d3b1ec5932b338cb/pymssql-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ef6906a637106141f316f3215b9772104da52409b3b1b860baecf086ef9365d", size = 3089543, upload-time = "2026-10-10T04:09:07.9Z" }, + { url = "https://files.pythonhosted.org/packages/95/39/62da8c248f65b5914174d6380f4f61ba398a82303c0de24ae11c79a7da1c/pymssql-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ae93ad536e7b0b912e0356354ffef7d0f2688251f37d28ec31906c59dae7e088", size = 3269139, upload-time = "2026-10-10T04:09:09.278Z" }, + { url = "https://files.pythonhosted.org/packages/ea/02/3c2ebfe8ac54ef2eeac7e31b91133731497abed7ab2ef21276be04d5358e/pymssql-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:484f02d2ed27c47d90d72a2dde50afe6d1274e6698f38538dffbd53bc4701ca6", size = 4481795, upload-time = "2026-10-10T04:09:10.89Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/cd6e9364baa39fb4826e1bf563671fa12d50f39455bf14d3dc220c258c17/pymssql-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8f9e423685beb39f8568c6bb5ef7b6945bc10289e0369e66e9498a85255924aa", size = 4219628, upload-time = "2026-10-10T04:09:12.495Z" }, + { url = "https://files.pythonhosted.org/packages/76/43/e20c9cbc72aefa7f5111daa632a38f24ee05ab7c09b9d8f27171e87ae1a2/pymssql-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4404ff0d9e7b0593ad7039b037a01d6a34d7b9797017187f197384ac24b981e0", size = 2393053, upload-time = "2026-10-10T04:09:13.99Z" }, + { url = "https://files.pythonhosted.org/packages/13/98/baefb5f3fc477652b57306d71abbaffaf0ca2974d6b95d16517b6987b622/pymssql-2.4.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:4d46d723be19f1a103979d2d42ab720b218be8c536a757cbe017a4a82141d274", size = 3194650, upload-time = "2026-10-10T04:09:15.74Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/7e5ae04bc955af9b19d5ee46890763ba8afc466027ee80c8e4ee67a576b8/pymssql-2.4.4-cp315-cp315-macosx_15_0_x86_64.whl", hash = "sha256:72b3f34b5239dab8d87e47ad025cde11fea80fc48dbd3951a9bc5fcbe03b1ec9", size = 3000838, upload-time = "2026-10-10T04:09:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/99/4c/203448fc30c13537bda345113bf3dae9e0546ee6c60d89e6ec2d1828a571/pymssql-2.4.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0155c4bd8751eb6ca89c07dd0cd6a2b1a7d063efb6048a01489527239e10c5f5", size = 3095778, upload-time = "2026-10-10T04:09:18.965Z" }, + { url = "https://files.pythonhosted.org/packages/69/a6/c36c1c33c2a97131438eebed6cb306627be49e7f5af0b2c6c83732f6ed08/pymssql-2.4.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3dbc9f285a938f8642c8a138bcbb4a2ff002c5412521cc4093a149b21366d611", size = 3273194, upload-time = "2026-10-10T04:09:20.558Z" }, + { url = "https://files.pythonhosted.org/packages/ee/db/55bf702c56498baf6ea7251dc662927c0dc68c690389cf7b5fc335cad3ce/pymssql-2.4.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:604681336a1aeb716f3decbee51edb7de0d9c81402f18560e4d575787df61921", size = 4487327, upload-time = "2026-10-10T04:09:22.047Z" }, + { url = "https://files.pythonhosted.org/packages/54/e4/9bab285d4dbf7f062ea28e5e2d9b25aba3d560601c48e65bf2d99c110872/pymssql-2.4.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:89b69fd2f77f51552760d42941d04bfff79ff51211a6313de6a25c8d5aa31685", size = 4221768, upload-time = "2026-10-10T04:09:23.92Z" }, + { url = "https://files.pythonhosted.org/packages/64/2d/13e0202acdc769fc8a8baddf564c4cf1ee0019ef31dc4455afbb8c614683/pymssql-2.4.4-cp315-cp315-win_amd64.whl", hash = "sha256:b44e4f781eac5119061eb4478e6025dc05da98db029df185522eb3c38a5cdf2f", size = 2374420, upload-time = "2026-10-10T04:09:25.333Z" }, + { url = "https://files.pythonhosted.org/packages/2f/19/bedbcbb98ac6624fd21f8dd59ef9f69de2a07402cb8132a28d362bdb78f4/pymssql-2.4.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:db8c0d04afa884b57e19d45b025b10767ee7aaa5e5ed9758e536062319822cb9", size = 3222833, upload-time = "2026-10-10T04:09:27.035Z" }, + { url = "https://files.pythonhosted.org/packages/56/44/1183858e50511d1088029ef1c0a2fe59efce40abe3d5e69f7eb0670dfe73/pymssql-2.4.4-cp315-cp315t-macosx_15_0_x86_64.whl", hash = "sha256:8541a520f0355e31d7b1ca30829dc383ce1ab4530dc6513d8fad97e791aba07f", size = 3015114, upload-time = "2026-10-10T04:09:28.56Z" }, + { url = "https://files.pythonhosted.org/packages/49/22/22b070d1d254b62ddfece4c789a713d7dfddf3885275c8bda38dc50d66ad/pymssql-2.4.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6606a68ba05f467d7d4ed6e979a962eca84fe57adb74957acb1ed62953387d1f", size = 3088943, upload-time = "2026-10-10T04:09:30.58Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/42a5fa47a8cf077b7755cb873dc8c52accc83901b4828dfa3838032ccb42/pymssql-2.4.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eafb731d8fdec7800fa738c4b88062eb712c742caa87acc953654e5a9ab7484f", size = 3268608, upload-time = "2026-10-10T04:09:32.072Z" }, + { url = "https://files.pythonhosted.org/packages/cc/39/f7c339842ad568cf7a12ba4ff3d317f402c1d31d9896a6e70f27a163fca7/pymssql-2.4.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6651063f5ec2ab7be80ccaff7442de17e32ad6d7cc9b6cbda66b9b0ba8b3bc90", size = 4481171, upload-time = "2026-10-10T04:09:33.749Z" }, + { url = "https://files.pythonhosted.org/packages/32/f3/ec46b9c5721d3292621c1b8f49b61a45da7fc032cffa5e97d022bcaef486/pymssql-2.4.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51bb4f577da737a06532d8e3292d578a5aaec942c1461e05735c2b57471c91c5", size = 4218909, upload-time = "2026-10-10T04:09:35.289Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/d566b6101d4a22bb953e61489f6e9476c2fafa828ae4d9c2afc9ecf7e9d9/pymssql-2.4.4-cp315-cp315t-win_amd64.whl", hash = "sha256:54bccdd09ff7714b691cb06c9b9d121424ad0338e20ab9ca027814765033174a", size = 2390318, upload-time = "2026-10-10T04:09:36.749Z" }, ] [[package]] @@ -4682,11 +4891,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.3" +version = "1.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, ] [[package]] @@ -4700,11 +4909,11 @@ wheels = [ [[package]] name = "pytz" -version = "2026.4" +version = "2026.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ed/fa23d28713004418bbf2407127f80f385bfb0bf4e677bef02c25c27b00ee/pytz-2026.4.tar.gz", hash = "sha256:464303645bafafd72418898368b2429458f709cf1eb6a15372fbcc396b64da63", size = 318444, upload-time = "2026-09-24T03:58:40.443Z" } +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/d5/11/e3df59614bebe0ca50c905442603eda64ae7ac19811d4e4f74aa2d51ca85/pytz-2026.4-py2.py3-none-any.whl", hash = "sha256:9d514388fbc89ca0833203464272ac485b8828568ab73532f5020f17e892a0ff", size = 506747, upload-time = "2026-09-24T03:58:38.629Z" }, + { 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]] @@ -4863,150 +5072,162 @@ wheels = [ [[package]] name = "rpds-py" -version = "2026.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, - { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, - { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, - { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, - { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, - { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, - { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, - { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, - { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, - { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, - { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, - { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, - { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, - { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, - { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, - { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, - { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, - { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, - { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, - { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, - { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, - { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, - { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, - { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, - { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, - { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, - { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, - { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, - { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, - { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, - { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, - { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, - { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, - { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, - { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, - { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, - { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, - { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, - { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, - { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, - { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, - { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, - { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, - { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, - { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, - { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, - { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, - { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, - { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, - { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, - { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, - { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, - { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, - { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, - { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, - { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, - { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, - { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, - { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, - { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, - { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, - { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, - { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, - { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, - { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, - { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +version = "2026.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/6d/ddb8dfcf41892ba9c672d27988592c038d30db61708230313831b6780a1b/rpds_py-2026.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2", size = 344947, upload-time = "2026-10-04T16:28:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b2/632c3d034961149eff606e3e357547620a9ab37b849ae5815f67838753fd/rpds_py-2026.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044", size = 346770, upload-time = "2026-10-04T16:28:56.884Z" }, + { url = "https://files.pythonhosted.org/packages/6d/45/4df346410bb873d03ec60c3b891d1fceb8cabde1971d29844bbb625cc296/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa", size = 377795, upload-time = "2026-10-04T16:28:58.193Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b8/a70f302207e4f29f873c01a986fcac5def49b9c8224328cef1ad7da6b7a9/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52", size = 383137, upload-time = "2026-10-04T16:28:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/13/e3/3aa28a48a01169d0e20728bf0e5ebbea3ae2774285b40853ae49b3ef332a/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f", size = 496789, upload-time = "2026-10-04T16:29:01.011Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a7/414e8d088602d66ad6f47c65bf07fe5090992a8574cdd4571c1a83b647ed/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7", size = 395929, upload-time = "2026-10-04T16:29:02.465Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/aac3b960fd55d37b49f77c0eebbe043eaf36762ebd48e7b46951e59c1c05/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff", size = 375426, upload-time = "2026-10-04T16:29:03.944Z" }, + { url = "https://files.pythonhosted.org/packages/d3/80/77987a41c40be7202a78d9aed4a650bfb05792237cfd3b9e22638d990fe5/rpds_py-2026.9.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e", size = 381497, upload-time = "2026-10-04T16:29:05.308Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/640b02eb1ecd71a913c1178b6235287b31006266bca9a26862e80e497b54/rpds_py-2026.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888", size = 405740, upload-time = "2026-10-04T16:29:06.578Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/d034195d45d215625a23f2ae85dbbdb86a49a93a51272061c97c80b9d316/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834", size = 555850, upload-time = "2026-10-04T16:29:08.115Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/532f1addfc86207bc3cf23749638bb0d457bce891595012ed8a08d0543b8/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391", size = 618661, upload-time = "2026-10-04T16:29:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/50/5b/b0f2c9833a36a5b1f6ac5cb0e635fabacf713266f46ceefba2102da0bf51/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a", size = 583530, upload-time = "2026-10-04T16:29:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/464ed4b98d3c8c6a334f3b5e24d3886de04b05658a95fc40a37e0e6e4775/rpds_py-2026.9.1-cp311-cp311-win32.whl", hash = "sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1", size = 208125, upload-time = "2026-10-04T16:29:12.408Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/384a9b62f8cd89c8e3e609b8c9052c140e8c01d807261e0517c904b8bf29/rpds_py-2026.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f", size = 226045, upload-time = "2026-10-04T16:29:13.751Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/0a691f0dcb2301fa9c736346da2d0d4991778373283301c5c12b8f952f3d/rpds_py-2026.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12", size = 222636, upload-time = "2026-10-04T16:29:14.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/34/a828586ea3329fbb50895b50e9cf98ca3d924a9f41a0b26d443bff1b3794/rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af", size = 346822, upload-time = "2026-10-04T16:29:16.258Z" }, + { url = "https://files.pythonhosted.org/packages/90/81/ac6a0d064982251856ce009c9e1dd51a34110b3c055aa7d1aad18b4899a3/rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b", size = 341380, upload-time = "2026-10-04T16:29:17.706Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/bf7d54f362748fd6a49277b9d6531c394074110fedf50ad791ec59133fbb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a", size = 373053, upload-time = "2026-10-04T16:29:19.063Z" }, + { url = "https://files.pythonhosted.org/packages/60/de/74b0ccdbbd28687b9b5fdb34c1cabed88352182facaced9d6f5486b8b9ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07", size = 377742, upload-time = "2026-10-04T16:29:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6d/b979775a3057b2a5c26d75ecbff60a20a24ef081db6dd76836fca3f20247/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69", size = 487289, upload-time = "2026-10-04T16:29:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5d/7c34734ce3ece943d9d6ee0122a74a21bbc75b47acb5b7053c83f6efb1ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d", size = 390710, upload-time = "2026-10-04T16:29:23.378Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6d/b26eb1e75395925b3a142ed351cbed2ec8212f5c9d937aee3df32c701baa/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab", size = 371261, upload-time = "2026-10-04T16:29:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/b2fdfe10301a9e27af21e634337fbba7baac0bc17fe44619a17c26bba2cb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e", size = 376075, upload-time = "2026-10-04T16:29:26.748Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/c4b8d954e49c3c69cf8063c0c9e0919c99f3d2cecc46311606a1cbe835dc/rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237", size = 399358, upload-time = "2026-10-04T16:29:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/59/9559a7293c97dff0cbb293efffbd36644103883ecc27741af8ef90c84ca8/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3", size = 550267, upload-time = "2026-10-04T16:29:29.851Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/6dc60e49511de4c8b00e74f9c1d43aed4d27f712cdb1ade92f2c199cbf64/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc", size = 614018, upload-time = "2026-10-04T16:29:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bc/92a4ecf27301b886232a413360f6b348a81d55ffef654f2ebda5970ebc3d/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6", size = 578509, upload-time = "2026-10-04T16:29:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/08/53/efb97f2e6589b7ab8394385a73870e0b2ab2a195281c117b21fd6d7d4855/rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3", size = 205401, upload-time = "2026-10-04T16:29:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/1c/84/1700cc748d0eaa747486e4e82882d2575224e6e9a3148df583058850c629/rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f", size = 222923, upload-time = "2026-10-04T16:29:35.709Z" }, + { url = "https://files.pythonhosted.org/packages/c3/89/0302215373c2f8b4408b0fdfee955368cc378a98ff59508d47a7e0dc2dbf/rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02", size = 218415, upload-time = "2026-10-04T16:29:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229, upload-time = "2026-10-04T16:29:38.82Z" }, + { url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514, upload-time = "2026-10-04T16:29:40.405Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611, upload-time = "2026-10-04T16:29:41.778Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712, upload-time = "2026-10-04T16:29:43.242Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106, upload-time = "2026-10-04T16:29:44.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795, upload-time = "2026-10-04T16:29:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794, upload-time = "2026-10-04T16:29:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673, upload-time = "2026-10-04T16:29:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758, upload-time = "2026-10-04T16:29:50.601Z" }, + { url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370, upload-time = "2026-10-04T16:29:52.002Z" }, + { url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106, upload-time = "2026-10-04T16:29:53.655Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235, upload-time = "2026-10-04T16:29:55.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293, upload-time = "2026-10-04T16:29:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409, upload-time = "2026-10-04T16:29:58.049Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681, upload-time = "2026-10-04T16:29:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178, upload-time = "2026-10-04T16:30:00.865Z" }, + { url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878, upload-time = "2026-10-04T16:30:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963, upload-time = "2026-10-04T16:30:04.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975, upload-time = "2026-10-04T16:30:05.555Z" }, + { url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347, upload-time = "2026-10-04T16:30:07.156Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962, upload-time = "2026-10-04T16:30:08.77Z" }, + { url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623, upload-time = "2026-10-04T16:30:10.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997, upload-time = "2026-10-04T16:30:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169, upload-time = "2026-10-04T16:30:13.804Z" }, + { url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615, upload-time = "2026-10-04T16:30:15.332Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655, upload-time = "2026-10-04T16:30:16.92Z" }, + { url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602, upload-time = "2026-10-04T16:30:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315, upload-time = "2026-10-04T16:30:20.05Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852, upload-time = "2026-10-04T16:30:21.604Z" }, + { url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709, upload-time = "2026-10-04T16:30:23.036Z" }, + { url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596, upload-time = "2026-10-04T16:30:24.558Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114, upload-time = "2026-10-04T16:30:26.048Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197, upload-time = "2026-10-04T16:30:27.699Z" }, + { url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025, upload-time = "2026-10-04T16:30:29.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969, upload-time = "2026-10-04T16:30:30.995Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905, upload-time = "2026-10-04T16:30:32.628Z" }, + { url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569, upload-time = "2026-10-04T16:30:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482, upload-time = "2026-10-04T16:30:35.997Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040, upload-time = "2026-10-04T16:30:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849, upload-time = "2026-10-04T16:30:39.443Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842, upload-time = "2026-10-04T16:30:41.231Z" }, + { url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606, upload-time = "2026-10-04T16:30:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341, upload-time = "2026-10-04T16:30:44.954Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843, upload-time = "2026-10-04T16:30:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/a662f367d4896dd0a10cef2fc91f10b7f08af1c10858e287e019563f338d/rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9", size = 347463, upload-time = "2026-10-04T16:30:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25", size = 342051, upload-time = "2026-10-04T16:30:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/3dcd68c13d4bcc59c1f7eb33ac8e80698f06f3eb0d8a1e06419836071c20/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871", size = 374143, upload-time = "2026-10-04T16:30:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0d/68c1f058a250fbd1380ebda9fc227cbf50117adf8ffe8161ef383ea79f68/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91", size = 378604, upload-time = "2026-10-04T16:30:53.206Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d6/2d4c59b85397cb4800594fadf692688ccf5ce556adc930e7a5bf21061a5e/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f", size = 490790, upload-time = "2026-10-04T16:30:54.925Z" }, + { url = "https://files.pythonhosted.org/packages/da/04/7e05dc3aebaf52f4e026766bd668fdd09a9d0e23f64a14686b36b3501892/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d", size = 393146, upload-time = "2026-10-04T16:30:56.625Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945", size = 372022, upload-time = "2026-10-04T16:30:58.295Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cb/8f8774df5134e23424372838bcc5c7ed4127d723e1ff52f7bebd4dcb2563/rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297", size = 377092, upload-time = "2026-10-04T16:30:59.984Z" }, + { url = "https://files.pythonhosted.org/packages/90/02/8d7095d73bf9114219be40230baa5df00611e0a82ed9517779ff9c19f82b/rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0", size = 400830, upload-time = "2026-10-04T16:31:01.721Z" }, + { url = "https://files.pythonhosted.org/packages/ec/02/8206856f8f363cd042a8315dc86b3912f5dcb6d3c66bbb24b69c2bfb0775/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9", size = 551478, upload-time = "2026-10-04T16:31:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/41/6b/36211f1bb1f0b0313f496d92f5905b74ea107f27cb16fa3355a82f04575e/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6", size = 615173, upload-time = "2026-10-04T16:31:05.281Z" }, + { url = "https://files.pythonhosted.org/packages/35/77/cda0c4a6f055446b692f0ed5692f73707cafd3dff82672ede31b1a9b59de/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7", size = 579653, upload-time = "2026-10-04T16:31:07.065Z" }, + { url = "https://files.pythonhosted.org/packages/74/ec/d8385f446240aed643b9e92a5055cff3015cc04a13c61f73b2883c478ed5/rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429", size = 206498, upload-time = "2026-10-04T16:31:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a5/71b5cd00e0521e3b6b81828baea368c62b6b700ebdd9554cd7d41ddf12fa/rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15", size = 223121, upload-time = "2026-10-04T16:31:10.84Z" }, + { url = "https://files.pythonhosted.org/packages/33/58/dba857c3bc8221b31b62eb170a3080f4f191de79f047200389b7ed1b06a7/rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009", size = 218741, upload-time = "2026-10-04T16:31:12.592Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d0/320ab28ccc1415eeb509d68682b0014fb74690cd49f1c2d29a232475af50/rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0", size = 349719, upload-time = "2026-10-04T16:31:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/56/88/f5b12f1358f443c08b7ce3cc8391d82d335f4872580e2e097847fd36087a/rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a", size = 343267, upload-time = "2026-10-04T16:31:16.827Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0c/c765b0059d532acb3b9c45d781ccc22f15a96dbe443d00903f643ba9df10/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d", size = 374555, upload-time = "2026-10-04T16:31:18.931Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8a/cafddfda77564a10cd21184640c3bffda6a2b8d20972fb5dedd5e0166328/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db", size = 378346, upload-time = "2026-10-04T16:31:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/b9/01/5e626016eff72c183bf6c96539240cace15d402468647a453ec08415b2fc/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250", size = 492373, upload-time = "2026-10-04T16:31:22.614Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9c/15a2469e9389242f46896b3f0a01d68caea8a5a35c011fcb05ef333aae73/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f", size = 394371, upload-time = "2026-10-04T16:31:24.768Z" }, + { url = "https://files.pythonhosted.org/packages/63/f5/c100ff77e1e6366e947c75969c258fdfc4b7bc5bbfe7351e62ffbf2a1228/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018", size = 375132, upload-time = "2026-10-04T16:31:26.588Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/fe0269c9de253e99c81cabc12b8971a5feaa083debdaff1221e06264d9e3/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d", size = 377642, upload-time = "2026-10-04T16:31:28.677Z" }, + { url = "https://files.pythonhosted.org/packages/05/65/b34a7b257baccff8f4a24a722933166d4941d5ebdc9c3f4bc4ffcd5ce4f4/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b", size = 400855, upload-time = "2026-10-04T16:31:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/98/32/844e54176b6071b90b38a564e6940bc6eb8f97b2890dc709c19db9dec0f4/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6", size = 552573, upload-time = "2026-10-04T16:31:32.709Z" }, + { url = "https://files.pythonhosted.org/packages/17/73/6041d20729dffbfdf155c02d65be58bc225a1c1fb548fd87c23ef306138f/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad", size = 615840, upload-time = "2026-10-04T16:31:34.565Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/418094adaee6b056ce199051b255448ed872829051e341b2294c80da0977/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903", size = 582764, upload-time = "2026-10-04T16:31:36.665Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9b/1698ebf6b840ddfe8b472198abbed6ace35c6e398faeecd6c47dae742a4e/rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b", size = 203795, upload-time = "2026-10-04T16:31:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/f0eba548e297987964ef8088403db875b333963bf8189f3f563b6936b20e/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30", size = 384112, upload-time = "2026-10-04T16:31:42.643Z" }, + { url = "https://files.pythonhosted.org/packages/33/89/891fa4d81f1eae7fe262ecd0aba2c51de94a116c7f3ccfb034fa75f4c537/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050", size = 389662, upload-time = "2026-10-04T16:31:44.642Z" }, + { url = "https://files.pythonhosted.org/packages/4f/46/912cf623b2263f9c9c2d04e8508fc38dbe89983faeba5fb36ca4558ae550/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb", size = 504637, upload-time = "2026-10-04T16:31:46.667Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5c/7f3cfa7c5f01765c9f47c8398210ed0c1eb686c0e99c223cb1c6776d3fb9/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df", size = 402607, upload-time = "2026-10-04T16:31:49.068Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/2e915d4115ee453c33e6af17bc1df7e22ddc52974e178eeeb92a78c09983/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a", size = 382762, upload-time = "2026-10-04T16:31:51.24Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e5/844382925d7b84a4074ad79fc2b9d05eb6231ea243b34f247e68e519b606/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608", size = 389563, upload-time = "2026-10-04T16:31:53.549Z" }, + { url = "https://files.pythonhosted.org/packages/81/82/d6e3951602dc2e24790ef9d299aa24bf5e2493df5043f2c6da00480cc447/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9", size = 416842, upload-time = "2026-10-04T16:31:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1c/8d248e4ea671d525ab7daa0703ad81cc2175076fa0ccd7ef088facbd2b01/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c", size = 562434, upload-time = "2026-10-04T16:31:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/e7ac0db898120d15dea25e3661b1242a40cb3b23d1f4db86c15a01fe0ba1/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f", size = 627909, upload-time = "2026-10-04T16:31:59.977Z" }, + { url = "https://files.pythonhosted.org/packages/03/c8/37e555514932cb1ee36c75349187aa882a98dee5d5654c761699ed8a0d27/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e", size = 593658, upload-time = "2026-10-04T16:32:02.14Z" }, + { url = "https://files.pythonhosted.org/packages/12/5c/5f69a91add94a8a33acea68a2b034a464946a654ff7296af9d957ef0d6a5/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_10_12_x86_64.whl", hash = "sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32", size = 351769, upload-time = "2026-10-04T16:32:04.675Z" }, + { url = "https://files.pythonhosted.org/packages/41/22/35ea8bf3b2682b90ab687c74e4828418ff12298a5b0b0a0c033f61c86a30/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31", size = 351719, upload-time = "2026-10-04T16:32:06.834Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/a9ba81408369d34c1aca73319c15adc91ed45c1d21e9e8ee4832ffb7fe0b/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4", size = 381109, upload-time = "2026-10-04T16:32:08.994Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/5192a0bed94cec86bd2a5e1cc5a1bb8f7eec3aec907eacd2deb13e87e326/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759", size = 388222, upload-time = "2026-10-04T16:32:11.321Z" }, + { url = "https://files.pythonhosted.org/packages/92/cd/5356549711448f18b52f7a11ffbe90f1228774996fbf3c6cd1b18d22abaf/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb", size = 500384, upload-time = "2026-10-04T16:32:13.478Z" }, + { url = "https://files.pythonhosted.org/packages/7a/88/ddda9d28adfe33119c75b2e733d4ba7e326f41d7e1b1093951771768ca48/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82", size = 401661, upload-time = "2026-10-04T16:32:15.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/c3/bb59ba16a6b4a57995d15b8c04c00f28df3b938c4dcdde48fbe0f73edb0c/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c", size = 380748, upload-time = "2026-10-04T16:32:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b8/0580faa1c5a32ddc160130dd7ae54d2d007272a89a40a6b5db3e50f9f585/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14", size = 385731, upload-time = "2026-10-04T16:32:19.855Z" }, + { url = "https://files.pythonhosted.org/packages/bf/70/f73564642bbe3322c7eeef2c2f258cf040b71c41a430b532e59d04a1b838/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c", size = 412131, upload-time = "2026-10-04T16:32:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5d/17fff2e1f8f68721a2afb5cb48f7e442c751bb6b4883157c5abaf618cd08/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68", size = 558193, upload-time = "2026-10-04T16:32:24.4Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d0/869ab6fb08531f97aae4a780b4d61b4190d8aef48fe054ee28bcf7114466/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da", size = 622448, upload-time = "2026-10-04T16:32:26.844Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a5/9672e532fe02cd3b92c78cfd8743abca939d96853006c63835009a1ecb6f/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448", size = 591126, upload-time = "2026-10-04T16:32:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/3a9f8e4279c7920af561deed4cb7ebebed2794a8f608cf404d5eef14a105/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0", size = 357108, upload-time = "2026-10-04T16:32:31.641Z" }, + { url = "https://files.pythonhosted.org/packages/b7/55/4b2fa381a583760aea5c92841e4e928a358e1c6511b129219e9c2826a226/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37", size = 346877, upload-time = "2026-10-04T16:32:34.061Z" }, ] [[package]] name = "ruff" -version = "0.16.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, - { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, - { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, - { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, - { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, - { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, - { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, - { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, - { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, - { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, - { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, - { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, - { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, ] [[package]] @@ -5169,12 +5390,9 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "alabaster" }, @@ -5237,7 +5455,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-typehints" -version = "3.13.8" +version = "3.13.9" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -5246,19 +5464,16 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" } }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/7a/7595b6b0a4b17469496a4c022cad82b518c49d982e7d1dff6489c7b265ba/sphinx_autodoc_typehints-3.13.8.tar.gz", hash = "sha256:13ec0aa539f952562c78f51e9e05ab19645ab9aaca8509f031874f384baea2ab", size = 97092, upload-time = "2026-09-29T15:38:05.837Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/21/8bb72a58f5e961af0d55831cbe298d1eebe3cc817a2eb20e914d7ca73f7b/sphinx_autodoc_typehints-3.13.9.tar.gz", hash = "sha256:f5db988060f68a231dbc230df97d81421e54a0860778a63216b9f71958a9afdd", size = 97745, upload-time = "2026-10-06T14:32:55.479Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/65/8d4bcb876423694e24beac3b35d508dd50b3de336b088efb1638ef4ca85c/sphinx_autodoc_typehints-3.13.8-py3-none-any.whl", hash = "sha256:9e0db0e16dcd552a44dc59fd47f4a5194ffecf6a3b80e365c681dab03a5a1dcf", size = 49305, upload-time = "2026-09-29T15:38:04.439Z" }, + { url = "https://files.pythonhosted.org/packages/18/46/1172651844fcc3c8ffa9acd6769e79fc18d0e4b799b0b535884ea44197da/sphinx_autodoc_typehints-3.13.9-py3-none-any.whl", hash = "sha256:f5bac0996e76a2007fa26bc5393bb065ceb6cb4d062283fb5c69830c791c9716", size = 49588, upload-time = "2026-10-06T14:32:54.265Z" }, ] [[package]] @@ -5410,50 +5625,84 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.54" +version = "2.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/9c/271aa905cf2964f841371a97f3e63ab692bf51b4423d0491e67bc7f64037/sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b", size = 9969559, upload-time = "2026-09-15T21:06:57.337Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/bf/7339b18ef05c03335a32f077e450c8040eab0c952c2964e32cd889eafc22/sqlalchemy-2.0.54-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d03084f3352dd92048cb19c71d90f116d076c9c7937e0ebc7752c4685de6d38", size = 2187776, upload-time = "2026-09-15T22:32:35.535Z" }, - { url = "https://files.pythonhosted.org/packages/9b/bb/3b6f5f3f51582d63af4c88b1e70da7888e936dee6b86a237b7633fb3f216/sqlalchemy-2.0.54-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92622fbbda1b1fe1632f3402a6e516a93c0e41d9158839c6b3dfb12117f26b72", size = 3428952, upload-time = "2026-09-15T22:40:14.441Z" }, - { url = "https://files.pythonhosted.org/packages/77/d5/ffd6b59795ebec81d582ad866f71ec7c73d356768e0442c6a07bba61c40c/sqlalchemy-2.0.54-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5800ddea045c2c860ef1d359a07a3066c7c0c426f45e3abc3874e116cb3c6937", size = 3428566, upload-time = "2026-09-15T22:35:33.739Z" }, - { url = "https://files.pythonhosted.org/packages/63/ff/1f0bffb0653b30711e2445a48968eff9412b4e5bf0870f1e72e8a69595ef/sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1019abef05a4b5eafc8eae6fb483167fa28a4dbe5f518d577b744f31a5276a37", size = 3377697, upload-time = "2026-09-15T22:40:16.494Z" }, - { url = "https://files.pythonhosted.org/packages/97/76/774906be0d41cc4a14ecfbc015112fb7d0a3f1c3393f2c179d10388f8f0b/sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b67749f7da3985a529cefbb1474783cb91ef44371cb9713630bade3de908760d", size = 3402966, upload-time = "2026-09-15T22:35:35.726Z" }, - { url = "https://files.pythonhosted.org/packages/cd/bb/1d7fe50ace612bf342d3440d8b09544746949120326c3dc487cfc2592fba/sqlalchemy-2.0.54-cp311-cp311-win32.whl", hash = "sha256:2f61a70b3b82e2ec7ad6a4f2301422b9ca93ff06917983e41317bcae878bddf6", size = 2142297, upload-time = "2026-09-15T21:25:19.917Z" }, - { url = "https://files.pythonhosted.org/packages/d6/49/18fe80f64b1f6bcd422e649e40a78031f8624b2fde22ca54e950dfa6e25a/sqlalchemy-2.0.54-cp311-cp311-win_amd64.whl", hash = "sha256:1d887fbd5d248e250807bd801e697fc73e3b44866ce5f093dbc90512e75bde25", size = 2169222, upload-time = "2026-09-15T21:25:21.114Z" }, - { url = "https://files.pythonhosted.org/packages/c6/30/75504fd1d70458000e85a3e772333dd0fbf80254b0dc4d41c99922e4d112/sqlalchemy-2.0.54-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffba7eb2d67c7505e82a0902aa854d8824b74c28a183820d6a8bd3cfd0f812c2", size = 2187596, upload-time = "2026-09-15T22:32:37.031Z" }, - { url = "https://files.pythonhosted.org/packages/a2/3d/5dfbb9528a391186a99986daecc5cbe003f34408dee91db8f5cdcf917040/sqlalchemy-2.0.54-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63cae7210fea9899e0bf35c1f1ae55d3ddd9c6d47cae8b6b43d945afa79dd65b", size = 3448745, upload-time = "2026-09-15T22:40:18.168Z" }, - { url = "https://files.pythonhosted.org/packages/d9/93/34fdc4a4faced77037a6b3ba1db1acd92e9bd12c21e229b327edd1d3e881/sqlalchemy-2.0.54-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68d994e9b0d0423a02a20039631fa6fcbb7fa829a992f7605025774940305d19", size = 3466644, upload-time = "2026-09-15T22:35:37.7Z" }, - { url = "https://files.pythonhosted.org/packages/66/68/4beab40ae60ac3d679dbbb46bc0d2bb277013264a0bde37ecaf4b6780e98/sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3de32cc6721eb42c3aad35bcfb244bb7a18f66c00f3582aae6281d6287a339b5", size = 3401519, upload-time = "2026-09-15T22:40:19.907Z" }, - { url = "https://files.pythonhosted.org/packages/e5/df/a24757e3249b1c7c1c5f0317666d16a8ab901ba239819ecef29eb4ed172e/sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d31a2bc06a854ee52dd86b455be4df7c750b28817e2d1b884e31fff126c4fd7b", size = 3432590, upload-time = "2026-09-15T22:35:39.434Z" }, - { url = "https://files.pythonhosted.org/packages/d9/0c/69559e3d90d200fbfe9c44aff39a9ff4e506b1a3e9557a203d69df721244/sqlalchemy-2.0.54-cp312-cp312-win32.whl", hash = "sha256:32de6deded25e8b9b11d07428d496ff24dfbc882b8e990c177266948cb5f3d9e", size = 2140825, upload-time = "2026-09-15T21:25:22.444Z" }, - { url = "https://files.pythonhosted.org/packages/d9/10/4a0f7113664c2877906db143709c52ddbd28a88e4d51f0b520bc30048ba7/sqlalchemy-2.0.54-cp312-cp312-win_amd64.whl", hash = "sha256:d65f8ca742ef1e1e14bc417ef59dc2ddf207a7b66b30cfdc6152447314e030cf", size = 2170005, upload-time = "2026-09-15T21:25:23.885Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1c/021ccd7a838425f52e67c02edc541e8d53be8a27a103e684e14b18aac17b/sqlalchemy-2.0.54-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b374e3bc91e246a942592a98ba6a23be76fff21358b00546ac8c0ebc0fd0e00b", size = 2184259, upload-time = "2026-09-15T22:28:59.153Z" }, - { url = "https://files.pythonhosted.org/packages/74/8f/f95de908a4af7ac3a6925cdcb837a93274f608925a0fbc786df38811bccf/sqlalchemy-2.0.54-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d5458672a6f72db2c087f4a5098b3c8503ea0254186ff29205d63afa9401a4", size = 3398792, upload-time = "2026-09-15T22:29:35.218Z" }, - { url = "https://files.pythonhosted.org/packages/84/26/bd327a1a6be223e438c98ac8faf299307d83797225a81f3b209607a9fd98/sqlalchemy-2.0.54-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cad78d04254967bdbcccbed5e631d88fe4868530946ab0929aa45e9032849518", size = 3417562, upload-time = "2026-09-15T22:40:32.637Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b1/f13a8fe8e8e167d8e65484b91b827897b8a5ca1efd7ba6e87a5f63412fed/sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:48611087a75d26d798003645c688c7d3cfc26b89dbe4a2c568d6b378d330deae", size = 3349138, upload-time = "2026-09-15T22:29:36.729Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/5fd58f03281a74c57f02f509433e1be58a79d9f5b0e14020e665e0dcb614/sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d6adf80277372a89910a0f3ccfe960b846d279dc55b366dd5c5ec07f41c84758", size = 3382943, upload-time = "2026-09-15T22:40:34.587Z" }, - { url = "https://files.pythonhosted.org/packages/4b/73/e29fa88dfc809857a55e6523911d345d00cab4adf87032c21ade8874223c/sqlalchemy-2.0.54-cp313-cp313-win32.whl", hash = "sha256:264460333ed0b177cbb1956355d0ee4e0cab83fb415c934ce12a25db2e7be39c", size = 2138931, upload-time = "2026-09-15T22:42:51.801Z" }, - { url = "https://files.pythonhosted.org/packages/89/9d/885e7491836f3dad368c74f0d5b93551d37b0ec66c9e8753240ee988308f/sqlalchemy-2.0.54-cp313-cp313-win_amd64.whl", hash = "sha256:cf89e92bf0d4204a6afcc17af27b9271ed9c7e34e17d6f80c085d431ea4a1747", size = 2166496, upload-time = "2026-09-15T22:42:53.603Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c0/4a6503c9d22d6d00a5631082ab1484222ecf7d573db791e0f53161bf7745/sqlalchemy-2.0.54-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abd6b21bc58e91c1932eb5d6d7f1bd44a551dfec7b6a7f517c3638ccd67233a0", size = 2185899, upload-time = "2026-09-15T22:29:00.581Z" }, - { url = "https://files.pythonhosted.org/packages/12/28/f4424f618bd1f373761a32a821d53ce2c257350e9894bae9b968cb03d8fd/sqlalchemy-2.0.54-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5417322b3c025dd82918725d3bf09ec105fac95efc195722b8b06e1d9c381139", size = 3394763, upload-time = "2026-09-15T22:29:38.131Z" }, - { url = "https://files.pythonhosted.org/packages/59/d2/7f0c77f8e042cb5f28275fea29c3080b4ac6fd4b3fdd7f59ff1ef3e28c11/sqlalchemy-2.0.54-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f84099e4b04a5c2d44500a2a8302eee5af4bc6fee63e8c6e9cf6786e747280e", size = 3402800, upload-time = "2026-09-15T22:40:36.509Z" }, - { url = "https://files.pythonhosted.org/packages/af/32/3eaa930bcf71d17a72587081d2706a5fb97ab3f11a7e0fb838f581f7cff1/sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0956dc754d3884da7fe60097110ec7a8a105d26afa2f0844468f4b1598c6912", size = 3341469, upload-time = "2026-09-15T22:29:39.682Z" }, - { url = "https://files.pythonhosted.org/packages/eb/cc/cddb6cbd4408e5c55b3bf722be26b9d3b54d42509f901b7ecc15debd3d1f/sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:87ba8834318b0d8dc94fc6f405d071b5c08be32a6c3fd68107fd6952ee949615", size = 3373232, upload-time = "2026-09-15T22:40:39.181Z" }, - { url = "https://files.pythonhosted.org/packages/34/2f/9c2aa5efc642b7f3b985d13565cd1a5e78856e079ef3022796fea5180498/sqlalchemy-2.0.54-cp314-cp314-win32.whl", hash = "sha256:842540e4382472f23c79589995752648d14696a8200d0807ed8c5c59c92ade44", size = 2142018, upload-time = "2026-09-15T22:42:55.118Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0b/3594f1f51769feb3022d686135dc5d8682a12345ed15ae61d0c0ca42cbee/sqlalchemy-2.0.54-cp314-cp314-win_amd64.whl", hash = "sha256:f4e8f955d13af83fb4e35c3472e5377ee22d3445eada1e5e48199588edb69835", size = 2169220, upload-time = "2026-09-15T22:42:56.727Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a5/c211a9a7af83222509519407e16a4db760c6df3d03be69ebc5414d465321/sqlalchemy-2.0.54-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca05f4e7852cf48083b0cf157e4f9504b7068780422a50fa82f45353b8c5e14a", size = 2208458, upload-time = "2026-09-15T22:30:05.718Z" }, - { url = "https://files.pythonhosted.org/packages/cb/2e/490ad7b3731116cb48ba170f7722eaa99a89707193e54389ca84b7ad55af/sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a8b6417cbb7b735cf91c2b59453c2a554cefa0a8d7bd15aa35740739410d77", size = 3660585, upload-time = "2026-09-15T22:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/eb/25/15dfe6814847eeda773bd58ab6cf42a94b0176e5cf25a578fc1165777160/sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e55a0b96a1577a1e108c91ccdeeb9cd92768f28ce206597311c3bf6d6423abd", size = 3624442, upload-time = "2026-09-15T22:36:38.377Z" }, - { url = "https://files.pythonhosted.org/packages/aa/19/724d0a6a2fb2a86ff2d6008e581c258f722d9b7d8e52adc7b79085febdd4/sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:69cab115c40fd02c5a22c68e4ee630fa6ef9a1650f1de944419aab1f7096fc4f", size = 3562972, upload-time = "2026-09-15T22:36:08.581Z" }, - { url = "https://files.pythonhosted.org/packages/49/bb/9df1bd81c2f2d000cf5e7a1b1a9b331468a3aab939ad983355e701fa42b2/sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e08397c6c42f53b2488acde9108b8bfefd52d7afd1bf2f03d2ffcab7a204aceb", size = 3576479, upload-time = "2026-09-15T22:36:40.272Z" }, - { url = "https://files.pythonhosted.org/packages/df/c0/b5775465d3b89061d7c46057c31c56ff8fb6c509550b2b0c6570ffc248b3/sqlalchemy-2.0.54-cp314-cp314t-win32.whl", hash = "sha256:b9086b8ad48280ef6a7ba68262d5e44f7db1c4cb1973e8cdae8a9f467ae66f51", size = 2174794, upload-time = "2026-09-15T22:31:45.925Z" }, - { url = "https://files.pythonhosted.org/packages/77/f8/296c2e46b4ccd3f29b00b954ef2f195dde32f98e352ed21de1d292cedc0d/sqlalchemy-2.0.54-cp314-cp314t-win_amd64.whl", hash = "sha256:b67c1744e453af833667fc1b84de07adb4a64f3536ef52a8ec5ac2b941d43970", size = 2211942, upload-time = "2026-09-15T22:31:47.368Z" }, - { url = "https://files.pythonhosted.org/packages/24/a1/bd5e3e99bc9c8863b51ac5b9b03008a7f2da8c6b59695992f5c654e1265b/sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2", size = 1958015, upload-time = "2026-09-15T22:24:22.95Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/44/311bac6b6ef81e4dfd0287d04900108b1f5c00c9761dd3c0a2b7b9d0f86b/sqlalchemy-2.1.4.tar.gz", hash = "sha256:7bd7ad604487daa7eab8716471c29a7185f17b5287ce73bb7bc79fea050d8cfd", size = 10544216, upload-time = "2026-10-07T17:33:59.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/cd/264493ea522b887ac71949d442ef6a49ca04504e1090b427e878a71d5bb2/sqlalchemy-2.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6d147c31e189541ae7cd990482c4f960f9e8abce186551225fa355856dbf1a5", size = 2463376, upload-time = "2026-10-07T18:17:21.503Z" }, + { url = "https://files.pythonhosted.org/packages/59/16/1dbc3674709e945d113cfe0f652431cfeda0aa5999c0737444e7e4a416f8/sqlalchemy-2.1.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55072780d1aae84dea443ce27edeb745f6cc4d19ad89416abbb6b49712080e7c", size = 4628615, upload-time = "2026-10-07T18:37:37.947Z" }, + { url = "https://files.pythonhosted.org/packages/ec/24/0640dfb48fde362b83eaa122691457cb9a13f51d50cd6064ddcfba667c71/sqlalchemy-2.1.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:343a0493a81278bfe30be1ec81214a55f2f44aaa4662d230be359ab2aa18cc2a", size = 4650872, upload-time = "2026-10-07T18:24:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e4/5aec21a9e6ffadc919854fef1cd92b6f699ee204811e78ae1b1f9733da7e/sqlalchemy-2.1.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8080022e101afb17565dc5a358a165ff4a20cd97b20b4db49ebed66315b3c733", size = 4380816, upload-time = "2026-10-07T18:59:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/7aaf2b01d4d9c7168a55e0c318ab494ab436b434ebdfd4977fee5fabddf9/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:948dff080b5ac00c8e63bf9e59fa70e386cca1476f55c672a72b6ec12e5cdb05", size = 4566541, upload-time = "2026-10-07T18:37:40.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/61/3e4df04dd09d1db05ea31a2d7dc015aed26e33fefaca610eecbfe9b8d26b/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:12642e105b4e0cb2ca8428037368c1cbcded7b9d0344174607174d82b700e1eb", size = 4377842, upload-time = "2026-10-07T18:59:42.612Z" }, + { url = "https://files.pythonhosted.org/packages/53/4f/c983249adefed608b0cdc13bffe43a47a032316548e81bfdb4b6282a5b56/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976bd3fecfcfa58d69eab67e76325f564ed775aa0c0accf138ae17324b461431", size = 4621091, upload-time = "2026-10-07T18:24:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/257469b63c8cfad892b796c54a1392b3dfef93ee9273af5a8f49065d77ce/sqlalchemy-2.1.4-cp311-cp311-win32.whl", hash = "sha256:e2ace725a430e5b303fc3c422196966328ce77fb4fd053ad85572b46ed5fb71a", size = 2379577, upload-time = "2026-10-07T18:24:56.929Z" }, + { url = "https://files.pythonhosted.org/packages/3d/53/eae7fc135ac36ebc6385e87975ed5672d6311f0350f0358f38906a877f2c/sqlalchemy-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:3c998d70e60fc95e93e5971395818c50f8a34396a6352075256fefac6b5cf81b", size = 2430336, upload-time = "2026-10-07T18:24:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/81/fb/73b7ad29f65d9a114a3b42fe10ddf654bc360855dd29455381d4c7c34f97/sqlalchemy-2.1.4-cp311-cp311-win_arm64.whl", hash = "sha256:d045e63095828d2f1fd84d499936e6791522c15c390373fc755f118e4040393a", size = 2394934, upload-time = "2026-10-07T18:22:34.762Z" }, + { url = "https://files.pythonhosted.org/packages/49/5e/cb5b078e007340661b010fa8bd31ce27468f88e09b35266544df4e0c52ca/sqlalchemy-2.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f953be9ba26039a24a5205c65d33518b608ce6f4f0f4e9b9c14eaf42a10dfc52", size = 2466469, upload-time = "2026-10-07T18:17:24.049Z" }, + { url = "https://files.pythonhosted.org/packages/b1/98/44e2fdc5bc053dae559bf4f4eb7967ceecbad162299ecfc8de2edc3fcbe7/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ac64fce94c5b389062d2e3806db5dc780447591e0dfd5ead218c884f0703f2e", size = 4668213, upload-time = "2026-10-07T18:37:42.294Z" }, + { url = "https://files.pythonhosted.org/packages/08/25/ed2262f964687b06f10c2c98b2dc9c9ed211f7cc11702879969a9ac217e4/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e5045fb6aadbb0f978ab9b9d8822f7b7a97d2281814e7d13d791155664eace3", size = 4720857, upload-time = "2026-10-07T18:24:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d4/fab64c61d5d22ddbb077afd1e6b29b498bdacdf6406a03f53566e7e01686/sqlalchemy-2.1.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3a026436c51f296aa1d01243909a3b76490950e927824b10899a083cc26e7c3", size = 4369351, upload-time = "2026-10-07T18:59:45.483Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e4/33413f0fafbcf3b332320aac2c1e40f3b4f17e56359a9474cb10de4bee8b/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71040390ef01c85e9d26e5c83cb0c5942dcc8725c49186430af160ce2f54234d", size = 4591496, upload-time = "2026-10-07T18:37:44.433Z" }, + { url = "https://files.pythonhosted.org/packages/bb/65/19821440cbd5c93da053d627b3e402eff11ff252bfae37700645b3c155a4/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:07c60abaffb980b7382f2c75be8a5279c2b5df2626a0f5d751dd942799bf3b5c", size = 4362379, upload-time = "2026-10-07T18:59:48.278Z" }, + { url = "https://files.pythonhosted.org/packages/01/e3/168a0f93efd6ec40f59645a7e45ab08918e0bc8ecf07656e4ca09acdcc30/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a577e2127e52b0fe2bc54c73abb375a20ffe6f59fbc5568ccafc233f5bfcf8ef", size = 4667997, upload-time = "2026-10-07T18:24:48.72Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/0a852ef65864acd8d577d7aa6f67146167382bd6faee7a7586b9e6e28275/sqlalchemy-2.1.4-cp312-cp312-win32.whl", hash = "sha256:6c79e0c824d51c586757ecd342160bbdede9010df04bb71b9bbfffd5c7b6ee29", size = 2376664, upload-time = "2026-10-07T18:25:00.637Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/a5934263bb1d712f743289ca224ab3b87e3570ac157802291e37ab85d365/sqlalchemy-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:dffa69d2f3ba1933c1c1882dbef8fb3231b33eb19263e8b8c5cea24995071f06", size = 2429357, upload-time = "2026-10-07T18:25:02.565Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fa/a2323d81384ff214aa189057b7455b63623e66f28208b982e86c3cb042f5/sqlalchemy-2.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:e30524ae24e31d83e1b5f734862882c442f4158e3566f2c5f5e9bd3c659bb517", size = 2388756, upload-time = "2026-10-07T18:22:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e4/23174288ed2c03d6dbd5dfacd69e28303ee95f49642a8ed0544932999fb6/sqlalchemy-2.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:70006e9e6157200b795beeee04bd5cb15bccb40a14de595eb9f5dcf5945ed244", size = 2460507, upload-time = "2026-10-07T18:04:40.044Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/254fadc98bfd600445b976e81c6d777b08a728a415c3b77a8c8d35b89a83/sqlalchemy-2.1.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3341ddc430733cd961bc064889f42712a0b4056733a21c83176842aad67d12a6", size = 4594505, upload-time = "2026-10-07T18:16:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/83/6f/ac7beddc57c9c87bd77bc1c158fcbcdc20822f1873bf33ea3480d04e865f/sqlalchemy-2.1.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98f7a4bfeaed3722804f737ae2bd4077b35e57d6f4531fe612bac8160cda5acd", size = 4647364, upload-time = "2026-10-07T18:34:51.721Z" }, + { url = "https://files.pythonhosted.org/packages/0a/82/fc3891f261c4738a8b90cfdd805fe292d1af3b77f680a63b7349304c74e5/sqlalchemy-2.1.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec5d079935f67febe0ab8a3a203ad591b99508adc34ae0027f696dcb20373537", size = 4311619, upload-time = "2026-10-07T18:38:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1a/160c1320ab20e764a29721dc3fe7c31af34e291c652dca875d1ca6022b9a/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3d675b0856b6703b29d023517a4c19fecfbb55214ff5c72cd813527e40aed9b4", size = 4518031, upload-time = "2026-10-07T18:17:05.615Z" }, + { url = "https://files.pythonhosted.org/packages/30/2c/15a204333896e5dc63cb089ea20ca3ebc3c892bedf9fa00cc1a65e20d7b5/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a0bb9ee6a38cb36240dc88da11888348f61506047be54de3f09496c3b0ead6f5", size = 4312818, upload-time = "2026-10-07T18:38:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/a6/55/5e78d288f198598f278b4b7baef42f18e039b14b1e1045e9df3cf571300d/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61a2c48771cf314b6613d327c795902bbc0eb6d6169deb23b35004ba6ad6cc0d", size = 4608584, upload-time = "2026-10-07T18:34:53.69Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f6/e83b93ecc6e6528623fd7aa2af27ff0660d22354b78fe6ccad03f9ecbd9f/sqlalchemy-2.1.4-cp313-cp313-win32.whl", hash = "sha256:3fd608a06bafa768ad5711df4e17eb058bdc490e9df7d39b12a90947471e8712", size = 2373880, upload-time = "2026-10-07T18:22:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/8f/46/afb02975023db6aa4b8608177c2fae17d0b435d9cbfcb5df4fa6e65a8078/sqlalchemy-2.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:b756d74527c56a7e4cfae297f7930c1d75bdf4b23f214c8c13779746d28060cb", size = 2424430, upload-time = "2026-10-07T18:22:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/21/e5/76dc82d59186b98b27589b33b01175c0d49512679276170271d9384418e2/sqlalchemy-2.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:a64d54015233f824f171009977bfbb6b08bd0347b700cf17cb047ffb94c4148f", size = 2385057, upload-time = "2026-10-07T18:11:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/43/b0/6675a01f4e6215e0a809d28a800953294ab31370fe8c4bb3eb9e28c0b5a6/sqlalchemy-2.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a2f6164c0527cd8fc4cea79a5c9d8369ffee417b8ba444a42342f36b91deb75", size = 2463645, upload-time = "2026-10-07T18:04:41.615Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/4630a4009ea08a0769d5ff6517c7fc978f6a63eba32e08c44b98c284d7e4/sqlalchemy-2.1.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6929a11ad26a91a4efd891c1252b373c2e88f056910b83ec6030ed3f2cbcb734", size = 4587911, upload-time = "2026-10-07T18:17:12.512Z" }, + { url = "https://files.pythonhosted.org/packages/0e/02/953686f44448b92cc628245687a242799b6eb11ef30ad2bc7adacd51986d/sqlalchemy-2.1.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14528d37d7d46a92f2a483f188f7fecd86cdd789254a0412b960c9fc5e9efd6d", size = 4617539, upload-time = "2026-10-07T18:34:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/13/23/a44288ab4fa12e51c9d390e7d798d70a45669ddcbddc9dd9b5948eb1aa3f/sqlalchemy-2.1.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2cb669c6bd1f19caf51db6e3c4fdd4cbb76f9db3ef81c3aeb5e288d9bae101b", size = 4310883, upload-time = "2026-10-07T18:38:50.265Z" }, + { url = "https://files.pythonhosted.org/packages/a3/39/1c441ac015767f619a9e6cc306905bb042f94b84f2a1e930e989e9c6e209/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:63dc25b21fd9a41dc09b7aada4b3b0d97cf4b6414f74bced6ac45326bc799ac9", size = 4516511, upload-time = "2026-10-07T18:17:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b9/f54ea5ccb27d9a712d90d1617050bee761df25dc1fb5e0b7d2aa867deb51/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:308f96d24e773d64609a2a0d1161a068f9f6e9165523bc4e07aa9c45f0c4213f", size = 4312314, upload-time = "2026-10-07T18:38:53.249Z" }, + { url = "https://files.pythonhosted.org/packages/df/9a/c1e39287ee988e4c2e25c619959b8fb15b297734be040653fe85b57517ee/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93b9416b9011a3b7689a933e04ac9f61d15686b6cb1948ebc1f41467153116c3", size = 4577833, upload-time = "2026-10-07T18:34:57.829Z" }, + { url = "https://files.pythonhosted.org/packages/41/78/5f1ae1911d2b20ccdb39ee522118533a4b5262b6e5e06bbcbb1ebd1f4617/sqlalchemy-2.1.4-cp314-cp314-win32.whl", hash = "sha256:89db94855287fdac98d74595cf13ea59fbffa608d6400ff972b0fd4c036d873f", size = 2380724, upload-time = "2026-10-07T18:22:25.374Z" }, + { url = "https://files.pythonhosted.org/packages/ca/93/4dfa4ce15d082011fb94e06e7c6b4c2957a3f0ddeb8fe9b89d007bc058d7/sqlalchemy-2.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:080f8d853aac5bb5620f0ae6f46527397cf18dce0ec2b478b478469ef3cae2c4", size = 2431689, upload-time = "2026-10-07T18:22:27.144Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c4/6f6c29eaf459c4c2d9b7d24e300bab32043f8f8a936df863f3b886b5564a/sqlalchemy-2.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:64d41be1dd88f184de1931f0173f4827122a1b49fd1150656641200c0bdf640c", size = 2396106, upload-time = "2026-10-07T18:11:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/48f851411665e394f60c669d1f9494d660f5f1fe46e275f9615cfc812a98/sqlalchemy-2.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84272f329c15081a1e09b4a7261118b4e8a547f43e00fca98e55bbdf19eff3be", size = 2503038, upload-time = "2026-10-07T18:19:41.094Z" }, + { url = "https://files.pythonhosted.org/packages/41/ed/bf83068bda4051d7fd719c14cefc15d8466ef1e3656b9f4401b0509b11e0/sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3f58bd26fc010ea28976d401845e4e6ce02e1b7c0288b3ea9c9a3c396f0bcc", size = 4919623, upload-time = "2026-10-07T18:16:45.399Z" }, + { url = "https://files.pythonhosted.org/packages/56/de/57eb70d56b70d22a9360d658b195834ecfdeff7a7bc5c2e3a7fa7a8f7823/sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82d728075d42bd457d09655cf22e99d772a648c6f67e86743a4f05b7d063ca18", size = 4830238, upload-time = "2026-10-07T18:37:04.468Z" }, + { url = "https://files.pythonhosted.org/packages/70/3d/c410e9e79a53fff4c04444da609fed6404868d250f11fe8bc53d827bfb0e/sqlalchemy-2.1.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0970394ec5d9e397aafc5bc5fa2b7f8b58cb191f2703006b19a96ef4bf00b8d9", size = 4508380, upload-time = "2026-10-07T18:38:44.277Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c3/01b93821ba35b5b162e79c613279d960a120767694f656da1c1374dd3ed3/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6005f2f5fcd67fdd721446128e6a2a1d18f77387a604fbd26b0006a086b33096", size = 4775311, upload-time = "2026-10-07T18:16:47.724Z" }, + { url = "https://files.pythonhosted.org/packages/c7/88/0b40754e4d851d33548792062c23467a3d8dc07f2eff90cb19e4c404fb4c/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0e01a3e199ae219381c4889993c5584b1b905fffe6830f639adb6770036a8913", size = 4508558, upload-time = "2026-10-07T18:38:47.857Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2f/3916954eca5596d9e93fccd2ec0e45fd8c65981debac0ec4617639ded6ba/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22129e7d00ac66b291840c4dc83a9c497456ab5bffa682dcbfdc2356f9e49e5a", size = 4767114, upload-time = "2026-10-07T18:37:06.792Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d6/6a29716aec6ae17cd77e27b5e0dedc68cf9068594f2b601806c1d146427a/sqlalchemy-2.1.4-cp314-cp314t-win32.whl", hash = "sha256:bc33d3e59d4e84b8866cc9ba13732585e37212dbe3542cb09f232682b36f47a5", size = 2439584, upload-time = "2026-10-07T18:22:44.434Z" }, + { url = "https://files.pythonhosted.org/packages/34/79/2f0b33647d2d26f098269096c1864c0b4e81095354cdedb95192647f47cd/sqlalchemy-2.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:346d144e8912ae087b10d3c2081657cb634728600693eee6dbb71d7eb4768101", size = 2507217, upload-time = "2026-10-07T18:22:46.176Z" }, + { url = "https://files.pythonhosted.org/packages/93/e5/869c1ac0a21e17e4617b6a7828b50320bedb7074b6d67aec59299be5cdba/sqlalchemy-2.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3e5de57c71b3460e2ca6137e82cd3cb8c9f711f301f50d5c77156fdb9c822999", size = 2425329, upload-time = "2026-10-07T18:12:20.595Z" }, + { url = "https://files.pythonhosted.org/packages/2b/8e/a082a165b473dae45d2f2f79be15f5c405ac579830c64253efbf04695177/sqlalchemy-2.1.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:418786f05387ddb66ee683a1d016c5a8d9bf7be921e6ee8f285c7b6ac961a731", size = 2463572, upload-time = "2026-10-07T18:11:12.053Z" }, + { url = "https://files.pythonhosted.org/packages/d1/35/74db254005ecb384533973b157ba1fc3fe5bc41a5bc6e0500ab8369c49e6/sqlalchemy-2.1.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:283914efed30e4d44301e36ac90ad048570538b8a70f072fe01578d9b205d09c", size = 4591740, upload-time = "2026-10-07T18:01:00.314Z" }, + { url = "https://files.pythonhosted.org/packages/70/81/5cadd72b0c26b6ee7c1e6950cb9f0cfc383246a842314a1b2a87f455db25/sqlalchemy-2.1.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d2eacdbeb990b80235763860923c60a8393745b66f7149a734980c65896da72", size = 4627040, upload-time = "2026-10-07T18:09:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/8e/78/aed93cc373f61b57625e1f9f84bbf12358e32e935e64fa098f3a446e1203/sqlalchemy-2.1.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e43fca5fdd5f34a3f8c54107a3648d3139de8bbf596a189f3f0de94bd84949bb", size = 4333103, upload-time = "2026-10-07T18:33:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/ecc6bbd365671cdc512a59d42afa7c34b2833a8d841754918ae3f62d36dd/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2e1b5343d315b10a4a71da481729f66f830a561595e02b61e8a5a65d658325ac", size = 4519962, upload-time = "2026-10-07T18:01:02.268Z" }, + { url = "https://files.pythonhosted.org/packages/58/58/9f8f6157c2252aefe73f4a0b3859413bb720d14321aa7f367c691949aaf8/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:42c37c06adcecf444e8c981f7e9237a41bdd445c83da0df9e08b4ad958becbbc", size = 4331935, upload-time = "2026-10-07T18:33:50.334Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/a4ae4b95d17607004f01e9a085fb221087c557bbad77a3d87d5d0a5fd8bc/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:bab7f51d38766d6a64da2b41976f1b3f9cc2ff37d3f2f63bdbac876199f3a48e", size = 4589275, upload-time = "2026-10-07T18:09:26.872Z" }, + { url = "https://files.pythonhosted.org/packages/65/27/56f69293a01279ac0e6077b8c358eb0f1c2afc6aa17428414a86c8871042/sqlalchemy-2.1.4-cp315-cp315-win32.whl", hash = "sha256:1541ba5bf0f232cd61f9ef3df78c93977c72ba6031506a0e6d057b2a3ddb76e9", size = 2380494, upload-time = "2026-10-07T18:04:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7c/ff7e29f95996ed49b950afd531b89e7c8d15addb41735643d07090550090/sqlalchemy-2.1.4-cp315-cp315-win_amd64.whl", hash = "sha256:596a95611c217cb19c21f02f43c637cb507cab71dcf0467c5c7d98fcdd703007", size = 2431760, upload-time = "2026-10-07T18:04:27.275Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/4eaa4978760cd632093ea272e7c4f88223619202f5481f897e67d4377409/sqlalchemy-2.1.4-cp315-cp315-win_arm64.whl", hash = "sha256:0d1ca95e42ce3c18818f170b741d30a33b292c6f6b9a202ffd717e28fc99b8c7", size = 2395807, upload-time = "2026-10-07T18:30:54.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/b806fbfc61ade37c4f3aecec0874c345fb297b56a3743116dcefa3e4700d/sqlalchemy-2.1.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0f672ed6972164fec94a8f0b21dcf8545080d0727866335fb8adf9f4764ce6ec", size = 2499195, upload-time = "2026-10-07T18:19:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/4f9fba8340222f09287e936d7b76e6911a4e507c7d6373ada770e8f697d5/sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72e3fa41d1fdab87d4e88bbdd69c9522e2795549fbe7b07bcf4ae9ec175f4b11", size = 4896914, upload-time = "2026-10-07T18:16:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/55/34/c4aeec7bee453badd8b0e02c2021a13bd70ef01038303d05326e99f595b6/sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb2cb98d056e63e353ed697750004e07c79b054d73059ba3184ca3bb07296bea", size = 4846593, upload-time = "2026-10-07T18:37:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/82/54/6dd8504364e5f5efd328e98fea963e5a2e978ff8dcba70d95231314f82a9/sqlalchemy-2.1.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d66fdcc5506e0f8bb8d3f4f95125220a7cd6c46e8b1762750f01e9639973dd8", size = 4494821, upload-time = "2026-10-07T18:38:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/df/42/dc584c098bce29578fd0611cd6f36830e06b4dd2505d3020a0b592f4cf08/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:81f802c96dbf96e59c6982fa1b87da7868920fb0c27b9b81e560a62f57c2ccfb", size = 4752545, upload-time = "2026-10-07T18:16:55.711Z" }, + { url = "https://files.pythonhosted.org/packages/8c/41/69a70c1419bea97e80f65ce09f4f626df464752b276f4f3d69ff6fbf2325/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:acf8982c70471a68aa90d1aba08b48860c55b3357ec84ccb0f09368ead2ce099", size = 4494093, upload-time = "2026-10-07T18:38:54.37Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bd/d296c2223e8417b350db215d94dcd344bc0dfe9deb7d810a21f7d8cd0b14/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:778094c83e36c430756a7e1a1ac66fc3cffb2c6a1067958fe6b920abcec7bc5a", size = 4771973, upload-time = "2026-10-07T18:37:10.93Z" }, + { url = "https://files.pythonhosted.org/packages/13/4c/c3a10d9da10e4e60808ffd1825547b383c0d7ca9e56d15cdae47c04e752e/sqlalchemy-2.1.4-cp315-cp315t-win32.whl", hash = "sha256:963348422b22f760e9462e56bc32bf4d95d224cc5b8c79a3c6e3b786d3d2a2b2", size = 2436383, upload-time = "2026-10-07T18:22:48.162Z" }, + { url = "https://files.pythonhosted.org/packages/51/de/8045d4ad1fd3a66c3b9bb576f3734c86015e19ae2f1617af92eb63cf9e58/sqlalchemy-2.1.4-cp315-cp315t-win_amd64.whl", hash = "sha256:fba3500e170d25f581e053009edeb0b158116084d91d465de218718d336b67c3", size = 2502237, upload-time = "2026-10-07T18:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4b/245e2315d331cc15765a2373e068445fbd28eb63beb23ea862828808c0bf/sqlalchemy-2.1.4-cp315-cp315t-win_arm64.whl", hash = "sha256:0a9a464bc360856b7ea9bf8aa26aab92ca115dd08149cb0e004063d5db13584b", size = 2420917, upload-time = "2026-10-07T18:12:21.876Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/dbf11a262f6fbb41390cab2d8e47a30ec0961018b68201607b599dd489f5/sqlalchemy-2.1.4-py3-none-any.whl", hash = "sha256:0b96edcc2cd60fe1e35f67a46f4eb076e57297841b9eae949ac5f196593f00a7", size = 2054935, upload-time = "2026-10-07T18:01:16.403Z" }, ] [package.optional-dependencies] @@ -5463,11 +5712,11 @@ asyncio = [ [[package]] name = "sqlglot" -version = "30.21.0" +version = "30.22.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/c0/cd761f018601bf954c9018420eb6707e3675292dfa514cfda44d06aff2ee/sqlglot-30.21.0.tar.gz", hash = "sha256:6787ac202a3adc0e25434f1512370a655e51cb3de8c792544de5f6ff9c618d25", size = 6064227, upload-time = "2026-09-30T17:35:09.731Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e0/db58fbf2527426758dc1e862ce538736978e100e4e78fc9657e9661826ee/sqlglot-30.22.0.tar.gz", hash = "sha256:ec4b83ca8236ea8867f574a382dc15ce35b071c977fecfcc66482d9a3f500661", size = 6088770, upload-time = "2026-10-09T16:09:01.04Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/93/acaa0862f841051e490326c1fc8c61d618d2df26f9e7aed26c331419ab15/sqlglot-30.21.0-py3-none-any.whl", hash = "sha256:816d1a4815b7562b3976efcc0b561c928a743f976efd0fb8ad6db7a5b96c069e", size = 763892, upload-time = "2026-09-30T17:35:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4c/b8474b02b572d9c7a2903e364335d566d52b6128b834b92a7cdfe5597823/sqlglot-30.22.0-py3-none-any.whl", hash = "sha256:90aa461490fcd95d14ec3842a97506ae20f6d3e9313307ad31be793d479cca65", size = 777816, upload-time = "2026-10-09T16:08:59.07Z" }, ] [package.optional-dependencies] @@ -5477,29 +5726,29 @@ c = [ [[package]] name = "sqlglotc" -version = "30.21.0" +version = "30.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sqlglot" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/ab/fd1e7bc4be367998624be1392334d61b3e69e2c2a9089ec80ef4c71b93fe/sqlglotc-30.21.0.tar.gz", hash = "sha256:7644276e946fa3985d361e9f12b097cb910c4c215ccf73d9caa09e289ef59432", size = 528711, upload-time = "2026-10-01T01:29:13.274Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/ff/8cba0819493de02c460e1584a98df088231fc970b141e04205a93ddbaf71/sqlglotc-30.22.0.tar.gz", hash = "sha256:c0df3d87f16436b8aaaa96e1f446d009e636b819411d5320b85fc315d85891c3", size = 531858, upload-time = "2026-10-09T16:08:10.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/23/262dd51f76e2c2a387b46152cf7447c927f82c961edd2d5ff3d4007f6491/sqlglotc-30.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:57e84ef998118435bfabc23087da198b1e07dd5de5e500671a60ec89683a9fb9", size = 32995491, upload-time = "2026-09-30T17:22:58.205Z" }, - { url = "https://files.pythonhosted.org/packages/4f/86/49988180d083bc058a4e162cb81983d07d70e5e05b4002d8a69ead39f50f/sqlglotc-30.21.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2571092e68ebda8a18a91e69a9c8568c9e4f6947f58909ae683a3126e11b0021", size = 25656024, upload-time = "2026-09-30T17:23:00.492Z" }, - { url = "https://files.pythonhosted.org/packages/8f/0e/440800dab52972bec060a6ada9fb0a02caf63a7916d2a1a95fb4854f929d/sqlglotc-30.21.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:319e7487cb7255bbb2bae372368e89504fb8b20c17f5b54dcba756e275c7f6b8", size = 26832002, upload-time = "2026-09-30T17:23:02.781Z" }, - { url = "https://files.pythonhosted.org/packages/88/17/042e2963755d89ec33a7bde9557fcf1a597898a6088ca4f1f7ba281b2ba1/sqlglotc-30.21.0-cp311-cp311-win_amd64.whl", hash = "sha256:06a0e5b9e64896e35c11b4ede4622e6320619924f9d8702a1d149c0aa00e6ea5", size = 11172800, upload-time = "2026-09-30T17:23:05.163Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e2/b903d898bbeea50bf2802449525b9ddee08517aa47778a0d36659b354a4c/sqlglotc-30.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1a47a487fbd6cb3f6719f48d559c8d364a1d5dfda1c9cbf76e1ca8f4211ca061", size = 33243670, upload-time = "2026-09-30T17:23:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9d/d8b3474751f4bc858ffaf90a6a7aefb227c0b0d5d643ea0ae8a7509a7895/sqlglotc-30.21.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8715acf06a2b8f2da10deb9366ec1e0e31632a10885527751d0fbfb50bbf184f", size = 26594522, upload-time = "2026-09-30T17:23:09.519Z" }, - { url = "https://files.pythonhosted.org/packages/3c/7b/a6fe2fc2bc00e37a22e4a8cc656573bfb4a53853f7ae96cd7597c65d085e/sqlglotc-30.21.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a357e02dd3227987942b87b6c2bf842b406afa5748c64f3f6a2ac39eecee144", size = 27899445, upload-time = "2026-09-30T17:23:11.66Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e0/04f317efc30953c428d2ace9337219e19be10af68054207445623aac55fb/sqlglotc-30.21.0-cp312-cp312-win_amd64.whl", hash = "sha256:c586ed7bfbeaba0bd8c0a493f9789887d2016e74a06f1b780bbf027122722f39", size = 11395574, upload-time = "2026-09-30T17:23:13.605Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ef/5ef47473988f8646eb230426de6278aec70a314e5163d6e8c774dd1495ac/sqlglotc-30.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:76bb5342590166516d87d1b9a543ac6c40c0b841d7e8118f54d5f6d09fae5743", size = 32778050, upload-time = "2026-09-30T17:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/06/24/657a783f8d7a663b9635d5dc730a03e5a7515688bcb7531adc79d577695d/sqlglotc-30.21.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a5548cc19e6847d810179b23cd0c05fd12329f7c2f9717704260a808961dbef", size = 26173118, upload-time = "2026-09-30T17:23:18.582Z" }, - { url = "https://files.pythonhosted.org/packages/35/d5/81657c0a4fd5fb2054de416f86c0e8378cd832cb61a2dccd85a6beb55cdd/sqlglotc-30.21.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9de58f46f701635dc9ad930ca5b1c6a4b6ee45b2539cb47b9dcd8a45d4a0603", size = 27486336, upload-time = "2026-09-30T17:23:21.287Z" }, - { url = "https://files.pythonhosted.org/packages/84/6e/2973b3c3cf4078741c2dd0524a6ed128d2a9a92934f2691d44426f295383/sqlglotc-30.21.0-cp313-cp313-win_amd64.whl", hash = "sha256:8c6f8b96175678b05ea7f06a286324975529d010a3d959a13354f4546cb166df", size = 11393918, upload-time = "2026-10-01T01:29:01.753Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1d/2e616071ec17925a3c5dcf56e3d814f4fafcf38ea29f1d13ff22b4006bc3/sqlglotc-30.21.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4444f40d9b71f8fade88f3347d9cac80915b2ac7c8b2fa74dcf68ea7ded52716", size = 32700689, upload-time = "2026-10-01T01:29:04.098Z" }, - { url = "https://files.pythonhosted.org/packages/1a/f1/fd493441c16486488756d513f5abdb2e01eceb9dd6b5d7c68fdda79f27e7/sqlglotc-30.21.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e761ad9eb25560bc944cce7a1f21a364652816615864ad0200e95e0a5709f0", size = 26160896, upload-time = "2026-10-01T01:29:06.907Z" }, - { url = "https://files.pythonhosted.org/packages/83/37/d5050e37d8be978f2d06d1917f8f6f473aaca1668daa4d83030e1c6a9f3a/sqlglotc-30.21.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fd777af74926a6d35846088958f33d36385430c12cfb714dea4707732b0a8e1", size = 27384629, upload-time = "2026-10-01T01:29:09.36Z" }, - { url = "https://files.pythonhosted.org/packages/6f/42/5eaeec1ba43d5f4738f1cf1ea1762b6a9a0fb4f98ea783fe8f6254008d12/sqlglotc-30.21.0-cp314-cp314-win_amd64.whl", hash = "sha256:92cfc4ee59b83eabee6646d88ea83030dcb9c41777a12fcad8805e0ec7486713", size = 11537628, upload-time = "2026-10-01T01:29:11.587Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d6/e207eb09c45e28f646cb8e8de55008aff6d0b04bafa6c08fa6e72b8783e7/sqlglotc-30.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b139d02ab65c32982279d29cd1fe75324f266685644e479fc82a8b6053b6501e", size = 33596269, upload-time = "2026-10-09T16:07:01.572Z" }, + { url = "https://files.pythonhosted.org/packages/22/5b/1d7038ef497c4dd3eca2f4d47510458c5859f114e35e81ffc11522906099/sqlglotc-30.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a569fac804b8d67930242ba343a214869137b55148c089f93c1aa01e54569119", size = 26191918, upload-time = "2026-10-09T16:07:05.666Z" }, + { url = "https://files.pythonhosted.org/packages/26/f3/30d453501809c331b071dda1f12cc5c0cf262a368923a717282e197c4b78/sqlglotc-30.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:204b3044eafd12d70e06fb7b5b5841b6d8f65b145ddae3342b2a5e78ceb0ca0c", size = 27416566, upload-time = "2026-10-09T16:07:09.746Z" }, + { url = "https://files.pythonhosted.org/packages/f7/1f/8348fc673417326d9db318cf44d635d8b97b43012addc18da3f6e4e9b513/sqlglotc-30.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:7effd8b9c66ea99885a0db79eeae34fda303a9bf550799d653ce816000396b31", size = 11269580, upload-time = "2026-10-09T16:07:12.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/a3/dd794406862f19f42662bb1894717a064fb6e09163efaf03cb28ed3bfd7c/sqlglotc-30.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:caeaa95022ca19917cc1b669081a68b8bc37abac50c755288a391d62ba398378", size = 33881545, upload-time = "2026-10-09T16:07:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cd/8baa1c9f2d9e9adc3eee10f773a93552f8fe91f9cc1e40833da79560eb6e/sqlglotc-30.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64e6a88704d0fe64fbf059a3a5b018c7261a43d63e98e91066d673346992c850", size = 27128905, upload-time = "2026-10-09T16:07:21.886Z" }, + { url = "https://files.pythonhosted.org/packages/89/e6/15aa582551ceae2cd05f92cf534c011e36cfe3c82ff6f1c30284372d0c5f/sqlglotc-30.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4471f4032c47a96deffdee82ba66ca7f660d86eb6286d75b63c63c4bc890b3d8", size = 28517219, upload-time = "2026-10-09T16:07:25.831Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9d/f9e7fdc21e73430de558db45eb04d1595062d2949a7390d53b9b0fb5ca87/sqlglotc-30.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:ceeb4f7ce418348d605a2583f7ea16164351414ae80fcbaad464db43f44ec669", size = 11488985, upload-time = "2026-10-09T16:07:28.642Z" }, + { url = "https://files.pythonhosted.org/packages/38/5a/87109718bbd0877dd0c4b5d3492c6139cc2265f5758eb5698de68777c485/sqlglotc-30.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:017fd917c757badfafa24d1da652f1e32f0dc93106601edfd8ed36b7d294de6d", size = 33321837, upload-time = "2026-10-09T16:07:39.083Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c6/fe89d8d2951a76a2965261e3569700824db0201aeae90c35fbc1deab9a0a/sqlglotc-30.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d48ba1ddf876cf2bb83917bf43395ae16a42cda315dd313002ecd2527e8bf558", size = 26763509, upload-time = "2026-10-09T16:07:43.752Z" }, + { url = "https://files.pythonhosted.org/packages/62/78/56cecc9c9cb797afb3491416fc1eba20a9256f4b90495cc9312ce425081e/sqlglotc-30.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7c3b073fbb9c8ebcee1649919e763c684390b3755303f656e49b1e879c307a1", size = 28139394, upload-time = "2026-10-09T16:07:48.118Z" }, + { url = "https://files.pythonhosted.org/packages/d5/84/8d07e1d6bbbf43d667843de069e1ae580818cdaab669aca0f2de61018233/sqlglotc-30.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c418472c2ef88a49770084d4965b969bda7703882fce16a50c227c1fcc35a2e", size = 11499470, upload-time = "2026-10-09T16:07:51.306Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/087cc31fe3780a20d76d3dbc6ad4c715c3510f77032e22402104d791a1cf/sqlglotc-30.22.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:660784ee95e9112e34b2550168560f4e2967be347f7ffa9ed8af2aea528ff62d", size = 33228598, upload-time = "2026-10-09T16:07:55.856Z" }, + { url = "https://files.pythonhosted.org/packages/d1/17/069299dd379cd50d9a24fef91594460c89dfdb6af3831f6930dff9a7816e/sqlglotc-30.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5038cde15a0e38421ac2aea0097c97127d6481c786cb87ad2b1aa0b22e87abb5", size = 26749396, upload-time = "2026-10-09T16:08:00.24Z" }, + { url = "https://files.pythonhosted.org/packages/13/a6/838edbf989ca18554cdb5092f40fdebb9d651d7891fc36b359bb4acc743e/sqlglotc-30.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e123ecae3bd50aee7a3342f295cd9e1740fd4af692553d7d2906562343d8b91", size = 28021005, upload-time = "2026-10-09T16:08:04.913Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0c/494ae410795134d85e337463b56f8f062ed87a6382f4d3d25a7cd5348195/sqlglotc-30.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:8a9363924596f276ced9d5ee093bca7936b2e76173033d334498480b58a6c45e", size = 11652409, upload-time = "2026-10-09T16:08:08.139Z" }, ] [[package]] @@ -5716,7 +5965,7 @@ dev = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "sphinx-autodoc-typehints", version = "3.13.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autodoc-typehints", version = "3.13.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-click" }, { name = "sphinx-copybutton" }, { name = "sphinx-design" }, @@ -5744,7 +5993,7 @@ doc = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "sphinx-autodoc-typehints", version = "3.13.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autodoc-typehints", version = "3.13.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-click" }, { name = "sphinx-copybutton" }, { name = "sphinx-design" }, @@ -6047,56 +6296,74 @@ wheels = [ [[package]] name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, ] [[package]] @@ -6161,15 +6428,15 @@ wheels = [ [[package]] name = "types-docker" -version = "7.2.0.20260923" +version = "7.2.0.20261006" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/23/f0b34fc4c81fd172811763e191b19295a3fc9500bb424d97028332414b7b/types_docker-7.2.0.20260923.tar.gz", hash = "sha256:b585cff7ff88720ed63dea8388469a2fd1785ed51d6f8043606513c52a94d121", size = 37322, upload-time = "2026-09-23T06:52:15.995Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/bd/7c46cbb3190622accb6c6aed717083712b0531c6e8ec5f753b7b95cdebe6/types_docker-7.2.0.20261006.tar.gz", hash = "sha256:bd2fc6c302bce7c0caeb06b6e60f7738085fc15f7f5be540b917f6dd33b36619", size = 37408, upload-time = "2026-10-06T08:16:13.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/34/6469781657881c4e1b388f5e9df3cb5f5a4a34eefc9ca7525afad7c4fe2e/types_docker-7.2.0.20260923-py3-none-any.whl", hash = "sha256:d966f9e65708d9242995fc95515e2fb070623f14c3d7df9109266e3d50af41ae", size = 51416, upload-time = "2026-09-23T06:52:14.908Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/2f16e88c38b6d873d0d417f81a12af119998ede025f99b1422fb157ad288/types_docker-7.2.0.20261006-py3-none-any.whl", hash = "sha256:866041971a378a226edd095a4a55d3924e51d9d15faea5beeeef256f86f2320d", size = 51408, upload-time = "2026-10-06T08:16:12.831Z" }, ] [[package]] @@ -6210,32 +6477,32 @@ wheels = [ [[package]] name = "types-pytz" -version = "2026.4.0.20260926" +version = "2026.5.0.20261006" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/42/7edb292ada68a506f85adfc22f1e16a8efa6ef736abb11b557a47c8bc149/types_pytz-2026.4.0.20260926.tar.gz", hash = "sha256:2ad5cde113cd6a58dd59c34f3c93f1bce2d126c769bfac15207ca7d7efd65390", size = 11064, upload-time = "2026-09-26T06:45:48.674Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/47/b493f47f2dd0971db06459439ebdeb114cf95029188268099b331bbc4727/types_pytz-2026.5.0.20261006.tar.gz", hash = "sha256:1a522c2ec03aad8d4baaf97105958019ad51704b1e471c882d1c6ccea3e5e64b", size = 11073, upload-time = "2026-10-06T08:15:12.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/db/e65769c124c55958b337069a02a0684259e7e0408cfd18d7ed2e2ea4092a/types_pytz-2026.4.0.20260926-py3-none-any.whl", hash = "sha256:0d0357ffa7680a4416811fb0e56e7a5c0d6efec3f299390d146ea2675b66c774", size = 10173, upload-time = "2026-09-26T06:45:47.615Z" }, + { url = "https://files.pythonhosted.org/packages/77/38/e375026fb4ff74fed6bdb84d7d336571d0603b6b9675751579cec84cfc9b/types_pytz-2026.5.0.20261006-py3-none-any.whl", hash = "sha256:9e4a893b362a8eed4e10a348c80603ade65bdb3819419e589364afafe2ea08b1", size = 10168, upload-time = "2026-10-06T08:15:11.985Z" }, ] [[package]] name = "types-requests" -version = "2.33.0.20260906" +version = "2.33.0.20261006" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/18/4c2c0290953f8b3b9612adfcb07b57f144ade3ad32a76764fca42b77c5f3/types_requests-2.33.0.20260906.tar.gz", hash = "sha256:76ab8a0fb736744a0c3deee7aa57b2927e301f078d9e61f5391b3e92002416b9", size = 25263, upload-time = "2026-09-06T06:35:47.707Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/15/9b7e2e2e7c87d01366185b198b3febc6bc0c973f2bf21a62da4ab7d3495e/types_requests-2.33.0.20261006.tar.gz", hash = "sha256:0652999e9306aea345f40732d58fa49a7f6cade6a0d74d92119c5c8d82eddaf0", size = 25316, upload-time = "2026-10-06T08:15:57.782Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/4c/51ec821d22a45b4162fa3f3e9e94ea4c0f8c49e82363b10955baa5438391/types_requests-2.33.0.20260906-py3-none-any.whl", hash = "sha256:9f53622652bd921ead7a54d665be1b8d518165c8b51cc9d68d944cd6b6bfa8fb", size = 21461, upload-time = "2026-09-06T06:35:45.856Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/b82789207b3d360ce9f5a4372c790c52cc3dd928aeb7d4faeec652b651b2/types_requests-2.33.0.20261006-py3-none-any.whl", hash = "sha256:26cc8146505cab33cda9737991929e4144c559bebe05078ccc6998f27c4ca2c1", size = 21445, upload-time = "2026-10-06T08:15:56.658Z" }, ] [[package]] name = "types-setuptools" -version = "84.0.0.20260812" +version = "84.0.0.20261006" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/cd/3b2a3362a526f91c33f785a291462b2ec448ae531101c62372fc30a21f53/types_setuptools-84.0.0.20260812.tar.gz", hash = "sha256:09bedc248ebbb7a232c9419dfcdca329706e61bf2aa5743e9424d027f1d956b4", size = 46545, upload-time = "2026-08-12T03:52:42.316Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/36/012e992d81f3c3575e6428d53c2bbe266858cf1b7df7335476ab99f17908/types_setuptools-84.0.0.20261006.tar.gz", hash = "sha256:0f123655f44390a15ec62c9fa30b57f6dafe53014524d28b62cab1edbc303059", size = 46604, upload-time = "2026-10-06T08:15:27.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/e5/3a41cab4066465593facd660b2683c017f3d88d3297cdd26db0677b8e479/types_setuptools-84.0.0.20260812-py3-none-any.whl", hash = "sha256:799c08e4bc6a288e8a0b538afb5f5ff320a08927ba8dbedb30c74ee3ba5d867b", size = 70320, upload-time = "2026-08-12T03:52:41.154Z" }, + { url = "https://files.pythonhosted.org/packages/de/24/add82014f533f2ebf62088b39db86d6a341bb0fd94af50b3a3c1e8886683/types_setuptools-84.0.0.20261006-py3-none-any.whl", hash = "sha256:f435ec88f8f2319316969e37b5a1ca49496b276889124de249b422b061b492fe", size = 70326, upload-time = "2026-10-06T08:15:26.289Z" }, ] [[package]] @@ -6261,11 +6528,11 @@ wheels = [ [[package]] name = "tzdata" -version = "2026.4" +version = "2026.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +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/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, + { 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]] @@ -6470,40 +6737,52 @@ wheels = [ [[package]] name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/b1/948067eab45d5307f04b34e50eb7bd1f7352aee866fa5f0706b061ddacf0/uvloop-0.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24c58ae4a83e93a04c504bcc678125e36a0bfc44af928ad69444880c60f187a5", size = 1415276, upload-time = "2026-10-01T03:15:32.634Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6f/ee3ee84c5d27f2f0a47ae8b67a6adeacf9841b193c0e07412a1403586ce2/uvloop-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0efdd55bddbd36bb2fcb842d64c0d5f6407c6958c68088cc25df8c09edc5b5fd", size = 779533, upload-time = "2026-10-01T03:15:34.062Z" }, + { url = "https://files.pythonhosted.org/packages/25/0d/b5f69dae3736d96a8753c6ecd32d676ecd212be7ba3252e9c379ad9cc05c/uvloop-0.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fcd721113260ffb5e38bf14a8725b17d431f34209f7d1c7005b667946e630b3", size = 3896377, upload-time = "2026-10-01T03:15:35.816Z" }, + { url = "https://files.pythonhosted.org/packages/16/fd/8cbf6124607863399008ae4b0d2bb50c22ed83526deec28dca08d635eb6d/uvloop-0.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab17b3a8aa754be0de0e397f7b95f13b14e56f077a4c6ae295e3d4afd199b325", size = 3956355, upload-time = "2026-10-01T03:15:37.688Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7a/b73007866e7198519067a1f1afc343b4973ae924d2b7afcea67c44320a98/uvloop-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:80cac5cb90ed7b9b72a217a1d6982b15b829cdbd0ee6bc19b93e3a9e47fb0ac9", size = 3755618, upload-time = "2026-10-01T03:15:39.27Z" }, + { url = "https://files.pythonhosted.org/packages/3c/28/e50816f1ce38b97b28d62bc4adf7c82c33b7c68fa902e41a39adc8a3d189/uvloop-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:93087a845cdfb35753e539354ac9551bdd2ff528c202a98df0ae46e852bcf021", size = 3863192, upload-time = "2026-10-01T03:15:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" }, + { url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" }, + { url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" }, + { url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" }, + { url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" }, + { url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" }, + { url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" }, + { url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" }, + { url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" }, + { url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" }, + { url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" }, + { url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" }, ] [[package]] @@ -6578,42 +6857,50 @@ wheels = [ [[package]] name = "wcmatch" -version = "11.0.1" +version = "11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bracex" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/43/30e407989e313677dbb9d5f045f966549a7254834571e342eaa4b55cc67b/wcmatch-11.0.1.tar.gz", hash = "sha256:1ea2b4fa678b8ca268253798d5963935df39132d47c3e241c0a0732224005e7d", size = 144662, upload-time = "2026-08-14T15:20:40.477Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/cd/0e028d8b7d906d51acdac537334fbe85f8284f2a8147d48bc9403af77a0d/wcmatch-11.1.tar.gz", hash = "sha256:f2b6f0fad6f5e2571498723fcb3c8facdd0fba0dcc139f31d32f592eb648153e", size = 129091, upload-time = "2026-10-10T13:10:54.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/77/7a02b0f05b3ffcdbef9719ce3ee0b508d6a29b58e95299f1580055671db3/wcmatch-11.0.1-py3-none-any.whl", hash = "sha256:fd149ecddb9f0a88ea780017d6dde17c994e494e7f7303d4e3c9d6251f978f4b", size = 43449, upload-time = "2026-08-14T15:20:39.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/22/c4adda5a3754b7fc9429a544a0673fa6922fdd7808eab09abe18616f6e38/wcmatch-11.1-py3-none-any.whl", hash = "sha256:2863afee97a7dd62e64f7feada864848c1a5e31cb331b3aa530fa1300d2d5d3c", size = 43417, upload-time = "2026-10-10T13:10:52.939Z" }, ] [[package]] name = "wcwidth" -version = "0.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/ac/3a943d2792c9bb368aaa8b50121c0f778460ba2d7fbdc0a0366201d9e761/wcwidth-0.9.1.tar.gz", hash = "sha256:5823209b0d43af322ce698c689380d7c15ca31fa8e6e3be8459f27031bef0af5", size = 952410, upload-time = "2026-09-23T20:23:49.127Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/7c/130de33c0a7f6efecee28fef5711fc1f8d55ac33885a6b85f4f6689004ba/wcwidth-0.9.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:10b00ba23482e352f874d2e8135e7ace9da838646c7dd800566246bbd46125ff", size = 604219, upload-time = "2026-09-23T20:23:22.071Z" }, - { url = "https://files.pythonhosted.org/packages/33/7c/c7bb03da54de7ac5a08753e186b963f91847b04ed8ae5be136a4d5f52b75/wcwidth-0.9.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:40d936d72c9bdc10df43f93a8be502bc5024b487259139f66a328722c07f34a9", size = 609038, upload-time = "2026-09-23T20:23:24.941Z" }, - { url = "https://files.pythonhosted.org/packages/0b/71/f57effdf895c2d6d49333f1d6833a9f5438725c55a04ed3124a1c95bb3aa/wcwidth-0.9.1-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:69bb970cf5652b88cfdb9d3fdd1764fc15e5f8ad531643e7bb3e894cd969740e", size = 761464, upload-time = "2026-09-23T20:23:32.425Z" }, - { url = "https://files.pythonhosted.org/packages/4f/16/15b03d8fb7a747d0d2cf4a33da7b1b3e092493c58780f6589d5b1e9e3b49/wcwidth-0.9.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5da43d6967668982e44a52fb551967d293f86d26cd86036ac95bbdd34394ed9", size = 770483, upload-time = "2026-09-23T20:23:28.128Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a0/834886b30e3b885a5a96e8358f1b3917bc0bac43ae4178d77f0c210d079b/wcwidth-0.9.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:747fb724223f417a17541a95a17c1dac3a8ef9a0cf41684950f0eab191a35f65", size = 768443, upload-time = "2026-09-23T20:23:33.875Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ae/1b7597e0132ebc2c920632f39d8c1c61881345c9b480c0a95eb91e7460fa/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6e1272b7986cefe79783737e38bdb9eaae0682b333c7bd132024441193dd5ce7", size = 781171, upload-time = "2026-09-23T20:23:29.46Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bb/32149e3b481953f4ef35f38f166e2532886d33ae54ccdd71393f821dfad9/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:fe021c4d8de9d36c31a0cb41d0d2546dadd3b0708a301f1a0c66ce200851831f", size = 781576, upload-time = "2026-09-23T20:23:35.504Z" }, - { url = "https://files.pythonhosted.org/packages/d6/72/1ee3dff67697bda93bc56ec9f7b3b89609cd64b9abb328512a2f22eb9e47/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:708158c082364af442f9983de7b6ec9ac0d2e1b825ada25f0911b1f138d55405", size = 783526, upload-time = "2026-09-23T20:23:36.937Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0a/e94f19a60f6127bcf3dfcf223ca05a37650d57b37b5ed623597c842ce7ca/wcwidth-0.9.1-cp310-abi3-win32.whl", hash = "sha256:356376852357b8fca71fe5415808ec421679e04b4a98eb7c9cb6a7984b911a05", size = 592136, upload-time = "2026-09-23T20:23:43.774Z" }, - { url = "https://files.pythonhosted.org/packages/3c/ff/d884d2ec7dcdc86cda742a86c7160bc726dbccae76d7ae1f9ac833c26680/wcwidth-0.9.1-cp310-abi3-win_amd64.whl", hash = "sha256:991d1c8834f548e9c1f16432075ee84638e122312556bbf1ed595ea8fffc4673", size = 596738, upload-time = "2026-09-23T20:23:44.989Z" }, - { url = "https://files.pythonhosted.org/packages/fa/93/16e30d617b937272a2310a6e3c1f9d8f94984e19ad4a96eb935628e3e6b6/wcwidth-0.9.1-cp310-abi3-win_arm64.whl", hash = "sha256:61bd7aef9cafb6cb77a37a169998d7928ce82a51522146d11f60db9e7d1cb43a", size = 594700, upload-time = "2026-09-23T20:23:41.013Z" }, - { url = "https://files.pythonhosted.org/packages/0b/fb/bf93868205814f03d63e5b91757cf5bee99e024ce951c2e91fdeaab49c59/wcwidth-0.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dc10e262c3ac0abbfd0a2a51e45a848b1b7f500b21ff512630277973ba25674d", size = 605391, upload-time = "2026-09-23T20:23:23.602Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/dd3ae682f8ab13b1bffd8d52320d2f7becc16e3200580015f52124636e3a/wcwidth-0.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bcb9ed4a367cc025bf1092ac679a156759605182d60b7ed3c28f7221a42ddf55", size = 609851, upload-time = "2026-09-23T20:23:26.583Z" }, - { url = "https://files.pythonhosted.org/packages/56/e7/4f3a86953ab1f59eac15585795f7753da99370798f75b134d3cbf252b243/wcwidth-0.9.1-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:0d68a30d504c68cfdff2a5f804675c1e7ab4c0bbe878024c8b680ec5579cde67", size = 774751, upload-time = "2026-09-23T20:23:38.473Z" }, - { url = "https://files.pythonhosted.org/packages/48/0c/a456332bb581ebf3eb1a7dedb1492747b15772ce82c0717cc05387a64a17/wcwidth-0.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9f1636c5075ffd5c2e835b4561874f6e5dd2bbeba3c6c2c99f067d0d16883af8", size = 785798, upload-time = "2026-09-23T20:23:30.998Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6e/a35de089370d6d872c5424d234e00a8580e62c1bff518ff583750e7b15f7/wcwidth-0.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c4cead196551112cb8f43cdd1f80c235ef2456e34b1a9955c424537ec99961b2", size = 782933, upload-time = "2026-09-23T20:23:39.672Z" }, - { url = "https://files.pythonhosted.org/packages/ed/25/e39b6140d05f3aa8e2a6cbb2d679cba5767de7ac0a4860d1da246b6c36b3/wcwidth-0.9.1-cp314-cp314t-win32.whl", hash = "sha256:0665ee822ea04e25801e6e82e5407528be0863e88a17b0e7ca038843a4a3ac0c", size = 599829, upload-time = "2026-09-23T20:23:46.221Z" }, - { url = "https://files.pythonhosted.org/packages/12/ad/307b55d8e0a9484c48e8f78f40e9b9a4a9af0c9e2e1c5209ad6d5a09c77c/wcwidth-0.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eab587e18e7cadf1a750b0098fc8bebfb62c125eb9306f2268f0443a282a3d78", size = 604398, upload-time = "2026-09-23T20:23:47.672Z" }, - { url = "https://files.pythonhosted.org/packages/36/f1/69566660f633e2a93df7dad7353761562e9f71bf8a5a7b1eb7f34793b45d/wcwidth-0.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:03cfca3dcbffa86564290fe3c9978a6191ba003e8ced7f7dbda315fcb3fbe725", size = 602291, upload-time = "2026-09-23T20:23:42.345Z" }, - { url = "https://files.pythonhosted.org/packages/96/a2/f06f2e0be4895e5943d358755b51968ace85774cf0a4cc42b4dafb793832/wcwidth-0.9.1-py3-none-any.whl", hash = "sha256:e0c3a1c45c5b9550c6919a4449e95f5b177f6e165786376981db8f1addae9b21", size = 300656, upload-time = "2026-09-23T20:23:20.674Z" }, +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz", hash = "sha256:ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b", size = 955039, upload-time = "2026-10-05T00:24:05.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/1e/4532a81fb9dfbf4114a816775e0a36c3a64ee1d1f4bba2094e2da50be5dc/wcwidth-0.9.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07", size = 605343, upload-time = "2026-10-05T00:23:22.649Z" }, + { url = "https://files.pythonhosted.org/packages/a0/07/cb6940e81134b7ed25fa312ee9ab536a63db0793b149f88a90e603ceace9/wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17", size = 609960, upload-time = "2026-10-05T00:23:27.049Z" }, + { url = "https://files.pythonhosted.org/packages/a4/80/15ad05d40bfa99155639fb9e13b3d77083aa0fab893c816db2543d29005c/wcwidth-0.9.2-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79", size = 762566, upload-time = "2026-10-05T00:23:38.322Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/b8ef7758003d66b60f093695831a86dcc726aac01ee6446ffcbda27b61e3/wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724", size = 771632, upload-time = "2026-10-05T00:23:32.448Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389", size = 769763, upload-time = "2026-10-05T00:23:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/285f862826f721964ec7c42f81dc53d23afbd723a0f4cd989651f8218e25/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7", size = 782134, upload-time = "2026-10-05T00:23:33.926Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2d/64aa54882a5d556d3654c1f926d9118b797461033e23a158409941a37c8f/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2", size = 782851, upload-time = "2026-10-05T00:23:41.974Z" }, + { url = "https://files.pythonhosted.org/packages/59/39/52389f6de7fe2e9c14ceb8253dd99034bd86e1c87847ea3c100a97dded9a/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04", size = 784621, upload-time = "2026-10-05T00:23:43.449Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/20225500a076ace27bbcc8a6fd7c55125133c57a618816c7b7b8b73070b1/wcwidth-0.9.2-cp310-abi3-win32.whl", hash = "sha256:ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4", size = 593123, upload-time = "2026-10-05T00:23:55.953Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/b0690f55ea0483530a18bac917fbadbf54f35122510446fc370f5f1c2453/wcwidth-0.9.2-cp310-abi3-win_amd64.whl", hash = "sha256:2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec", size = 597827, upload-time = "2026-10-05T00:23:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/e5/11/6ecf4e9e268ab1a4ec617ffcccc2ee4a71301625f5490912dbaba462fa9c/wcwidth-0.9.2-cp310-abi3-win_arm64.whl", hash = "sha256:b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa", size = 598415, upload-time = "2026-10-05T00:23:51.517Z" }, + { url = "https://files.pythonhosted.org/packages/4e/41/549eef1ab767032bdbdc1f0ab655d404b082b1e9a1dab1361dbba90f64ed/wcwidth-0.9.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eda88ffdc97c0fbf193d407114f2c7a54b379f67f6e52a7531ee3b9fe749eca7", size = 606555, upload-time = "2026-10-05T00:23:24.188Z" }, + { url = "https://files.pythonhosted.org/packages/9b/64/a875ed7ea71cacadc0ae11b5fd3fac3486efd58bb25e67a7344248dceadd/wcwidth-0.9.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf361c8705576760623b4724ae564666d73b016f9a778bcfd1c7345378ef4ec", size = 610829, upload-time = "2026-10-05T00:23:28.563Z" }, + { url = "https://files.pythonhosted.org/packages/c6/98/513095e484fe79b6f2613d6a72f855f5d56b65e15c215c2a6746fbc638f5/wcwidth-0.9.2-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:97b878d1e158da5ed9ac5aac53fa3a55e282103af6a09ec353865613d1a31a76", size = 775819, upload-time = "2026-10-05T00:23:45.116Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/c02f3eec57224731e78f84b68e272250f784b6205acc7e0dcef6a7c23a0e/wcwidth-0.9.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:59dab4049cbd982b478bca098528df2c79a9160636a3a163ffebffcbd7d1b892", size = 786915, upload-time = "2026-10-05T00:23:35.323Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/b0529a79bac3fe8d94f32b4237a13dbc3f955508753f6a6f06c73d679dc2/wcwidth-0.9.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb08ceb501d6aaf94066c3ee122dd825b152df40ff0bd0df4dc27126233b948e", size = 784186, upload-time = "2026-10-05T00:23:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/d5/bd/6357c84ca9a734bfc735b7c48dbe21336b3777fab8a4101d14976dfe49a7/wcwidth-0.9.2-cp314-cp314t-win32.whl", hash = "sha256:8b4e381590b9b7390e07e22b2c0c1bb96ce50e1d2243c866d9387600362d51ed", size = 600791, upload-time = "2026-10-05T00:23:59.398Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/037591ca18d897cc2179559dde72e6efc6ce0c90e9cd1e6bca4e87c38b4b/wcwidth-0.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2f7b3bba5a5d5f31fc350fd36ce5b84b693c83b7eb95ee630b720da5a5ce06f", size = 605463, upload-time = "2026-10-05T00:24:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/d0/07/c9d96e106d938d26f7ab639bc80b8199359a1645ba6e3498413313ab6f38/wcwidth-0.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:734aa9405b321d1042301aa19c943c4731ee9e3460e4f8feea3299c064c97a14", size = 605936, upload-time = "2026-10-05T00:23:52.765Z" }, + { url = "https://files.pythonhosted.org/packages/82/8a/a28d61d910005ac93dfe48be3a0ebaa49352d88cebd25323e69e6ff2f4a8/wcwidth-0.9.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724", size = 606575, upload-time = "2026-10-05T00:23:25.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/c2/a3c66bd32766c8f4d6dc47d572532ba014fe5be30489f2576aff7cada363/wcwidth-0.9.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2", size = 611045, upload-time = "2026-10-05T00:23:30.421Z" }, + { url = "https://files.pythonhosted.org/packages/ec/8a/d39964f8f8c019d7d439b9b501d3e7bb42fee69f00354040ba0b27b5824c/wcwidth-0.9.2-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c", size = 778143, upload-time = "2026-10-05T00:23:47.7Z" }, + { url = "https://files.pythonhosted.org/packages/2f/53/525da13e8f9ff7b5b4e74ec6f8d68bdee63905796972e086c6b1b96670d2/wcwidth-0.9.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d", size = 786630, upload-time = "2026-10-05T00:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/d6a0c6df354b9d93466548a65cbf4ffcb48c719bbd307504cf3e76740837/wcwidth-0.9.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270", size = 784839, upload-time = "2026-10-05T00:23:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d7/3021feed1ed7926021ec134943ad3b24a2f7ea742cc9976461171482ed77/wcwidth-0.9.2-cp315-cp315t-win32.whl", hash = "sha256:0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b", size = 600845, upload-time = "2026-10-05T00:24:02.497Z" }, + { url = "https://files.pythonhosted.org/packages/63/80/6a03356d8ee38261e3a78cf89ee03d8e7f12c572d969237be00869e2dc73/wcwidth-0.9.2-cp315-cp315t-win_amd64.whl", hash = "sha256:33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9", size = 605441, upload-time = "2026-10-05T00:24:04.052Z" }, + { url = "https://files.pythonhosted.org/packages/0c/48/1a308a86a833fd12ff7a08d0d2491ff4a72c8a92d12f5ead8317630f771e/wcwidth-0.9.2-cp315-cp315t-win_arm64.whl", hash = "sha256:48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8", size = 605885, upload-time = "2026-10-05T00:23:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl", hash = "sha256:89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e", size = 301667, upload-time = "2026-10-05T00:23:21.097Z" }, ] [[package]] From 7e34399d24bb398a5dd1bb0153868cc8e5053ad0 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Mon, 12 Oct 2026 21:08:36 +0000 Subject: [PATCH 2/2] fix(core,adapters): improve driver lifecycle, query compilation, and adapter consistency --- sqlspec/__init__.py | 6 +- sqlspec/_typing.py | 78 +- sqlspec/adapters/__init__.py | 3 + sqlspec/adapters/adbc/__init__.py | 2 + sqlspec/adapters/adbc/_typing.py | 18 +- sqlspec/adapters/adbc/adk/store.py | 300 +++- sqlspec/adapters/adbc/config.py | 146 +- sqlspec/adapters/adbc/core.py | 431 +++-- sqlspec/adapters/adbc/data_dictionary.py | 14 +- sqlspec/adapters/adbc/driver.py | 157 +- sqlspec/adapters/adbc/events/store.py | 75 +- sqlspec/adapters/adbc/litestar/store.py | 58 +- sqlspec/adapters/adbc/type_converter.py | 83 +- sqlspec/adapters/aiomysql/__init__.py | 2 + sqlspec/adapters/aiomysql/_typing.py | 38 +- sqlspec/adapters/aiomysql/adk/store.py | 243 ++- sqlspec/adapters/aiomysql/config.py | 159 +- sqlspec/adapters/aiomysql/core.py | 47 +- sqlspec/adapters/aiomysql/driver.py | 113 +- sqlspec/adapters/aiomysql/litestar/store.py | 92 +- sqlspec/adapters/aiosqlite/__init__.py | 2 + sqlspec/adapters/aiosqlite/_typing.py | 14 +- sqlspec/adapters/aiosqlite/adk/store.py | 316 ++-- sqlspec/adapters/aiosqlite/config.py | 42 +- sqlspec/adapters/aiosqlite/core.py | 241 ++- sqlspec/adapters/aiosqlite/driver.py | 127 +- sqlspec/adapters/aiosqlite/events/store.py | 10 +- sqlspec/adapters/aiosqlite/litestar/store.py | 108 +- sqlspec/adapters/aiosqlite/pool.py | 856 +++++---- sqlspec/adapters/aiosqlite/type_converter.py | 14 +- sqlspec/adapters/arrow_odbc/adk/store.py | 277 ++- sqlspec/adapters/arrow_odbc/config.py | 83 +- sqlspec/adapters/arrow_odbc/core.py | 365 ++-- .../adapters/arrow_odbc/data_dictionary.py | 71 +- sqlspec/adapters/arrow_odbc/driver.py | 192 +- sqlspec/adapters/arrow_odbc/events/store.py | 20 +- sqlspec/adapters/arrow_odbc/litestar/store.py | 141 +- sqlspec/adapters/arrow_odbc/type_converter.py | 13 +- sqlspec/adapters/asyncmy/__init__.py | 2 + sqlspec/adapters/asyncmy/_typing.py | 87 +- sqlspec/adapters/asyncmy/adk/store.py | 285 ++- sqlspec/adapters/asyncmy/config.py | 216 ++- sqlspec/adapters/asyncmy/core.py | 47 +- sqlspec/adapters/asyncmy/driver.py | 132 +- sqlspec/adapters/asyncmy/litestar/store.py | 112 +- sqlspec/adapters/asyncpg/_typing.py | 22 +- sqlspec/adapters/asyncpg/adk/store.py | 34 +- sqlspec/adapters/asyncpg/config.py | 308 ++-- sqlspec/adapters/asyncpg/core.py | 88 +- sqlspec/adapters/asyncpg/data_dictionary.py | 34 +- sqlspec/adapters/asyncpg/driver.py | 150 +- sqlspec/adapters/asyncpg/events/backend.py | 40 +- sqlspec/adapters/asyncpg/litestar/store.py | 135 +- sqlspec/adapters/bigquery/__init__.py | 2 + sqlspec/adapters/bigquery/_typing.py | 37 +- sqlspec/adapters/bigquery/adk/store.py | 98 +- sqlspec/adapters/bigquery/config.py | 328 ++-- sqlspec/adapters/bigquery/core.py | 336 +++- sqlspec/adapters/bigquery/data_dictionary.py | 46 +- sqlspec/adapters/bigquery/driver.py | 380 ++-- sqlspec/adapters/bigquery/events/store.py | 23 + sqlspec/adapters/bigquery/litestar/store.py | 25 +- .../adapters/cockroach_asyncpg/__init__.py | 2 + sqlspec/adapters/cockroach_asyncpg/_typing.py | 24 +- .../cockroach_asyncpg/adk/__init__.py | 2 + .../adapters/cockroach_asyncpg/adk/store.py | 102 +- sqlspec/adapters/cockroach_asyncpg/config.py | 142 +- sqlspec/adapters/cockroach_asyncpg/core.py | 75 +- .../cockroach_asyncpg/data_dictionary.py | 23 +- sqlspec/adapters/cockroach_asyncpg/driver.py | 86 +- .../cockroach_asyncpg/events/__init__.py | 2 + .../cockroach_asyncpg/litestar/__init__.py | 2 + .../cockroach_asyncpg/litestar/store.py | 6 +- .../adapters/cockroach_psycopg/__init__.py | 2 + sqlspec/adapters/cockroach_psycopg/_typing.py | 49 +- .../cockroach_psycopg/adk/__init__.py | 2 + .../adapters/cockroach_psycopg/adk/store.py | 85 +- sqlspec/adapters/cockroach_psycopg/config.py | 269 +-- sqlspec/adapters/cockroach_psycopg/core.py | 47 +- .../cockroach_psycopg/data_dictionary.py | 40 +- sqlspec/adapters/cockroach_psycopg/driver.py | 239 ++- .../cockroach_psycopg/events/__init__.py | 2 + .../cockroach_psycopg/litestar/__init__.py | 2 + .../cockroach_psycopg/litestar/store.py | 12 +- sqlspec/adapters/db2/_typing.py | 35 +- sqlspec/adapters/db2/adk/store.py | 152 +- sqlspec/adapters/db2/config.py | 181 +- sqlspec/adapters/db2/core.py | 403 +++-- sqlspec/adapters/db2/driver.py | 277 +-- sqlspec/adapters/db2/litestar/store.py | 52 +- sqlspec/adapters/db2/migrations.py | 5 +- sqlspec/adapters/db2/pool.py | 145 +- sqlspec/adapters/duckdb/adk/store.py | 159 +- sqlspec/adapters/duckdb/config.py | 72 +- sqlspec/adapters/duckdb/core.py | 25 +- sqlspec/adapters/duckdb/driver.py | 267 +-- sqlspec/adapters/duckdb/litestar/store.py | 12 +- sqlspec/adapters/duckdb/pool.py | 163 +- sqlspec/adapters/mssql_python/_typing.py | 14 +- sqlspec/adapters/mssql_python/adk/store.py | 266 +-- sqlspec/adapters/mssql_python/config.py | 37 +- sqlspec/adapters/mssql_python/core.py | 177 +- sqlspec/adapters/mssql_python/driver.py | 125 +- sqlspec/adapters/mssql_python/events/store.py | 28 +- .../adapters/mssql_python/litestar/store.py | 45 +- sqlspec/adapters/mssql_python/migrations.py | 60 +- sqlspec/adapters/mssql_python/pool.py | 25 +- .../adapters/mssql_python/type_converter.py | 14 +- sqlspec/adapters/mysql_common.py | 231 ++- sqlspec/adapters/mysqlconnector/__init__.py | 2 + sqlspec/adapters/mysqlconnector/_typing.py | 72 +- sqlspec/adapters/mysqlconnector/adk/store.py | 219 ++- sqlspec/adapters/mysqlconnector/config.py | 237 ++- sqlspec/adapters/mysqlconnector/core.py | 32 +- sqlspec/adapters/mysqlconnector/driver.py | 83 +- .../adapters/mysqlconnector/litestar/store.py | 228 +-- sqlspec/adapters/oracledb/__init__.py | 2 + sqlspec/adapters/oracledb/_json_handlers.py | 56 +- sqlspec/adapters/oracledb/_storage.py | 14 +- sqlspec/adapters/oracledb/_typing.py | 48 +- sqlspec/adapters/oracledb/_vector_handlers.py | 30 +- sqlspec/adapters/oracledb/adk/store.py | 335 +++- sqlspec/adapters/oracledb/config.py | 66 +- sqlspec/adapters/oracledb/core.py | 390 +++-- sqlspec/adapters/oracledb/data_dictionary.py | 31 +- sqlspec/adapters/oracledb/driver.py | 227 ++- sqlspec/adapters/oracledb/events/_hub.py | 31 +- sqlspec/adapters/oracledb/litestar/store.py | 12 +- sqlspec/adapters/oracledb/migrations.py | 188 +- sqlspec/adapters/oracledb/type_converter.py | 4 +- sqlspec/adapters/psqlpy/_typing.py | 34 +- sqlspec/adapters/psqlpy/adk/store.py | 342 +++- sqlspec/adapters/psqlpy/config.py | 119 +- sqlspec/adapters/psqlpy/core.py | 150 +- sqlspec/adapters/psqlpy/data_dictionary.py | 20 +- sqlspec/adapters/psqlpy/driver.py | 139 +- sqlspec/adapters/psqlpy/events/_hub.py | 14 +- sqlspec/adapters/psqlpy/events/backend.py | 75 +- sqlspec/adapters/psycopg/__init__.py | 2 + sqlspec/adapters/psycopg/_typing.py | 74 +- sqlspec/adapters/psycopg/adk/store.py | 196 ++- sqlspec/adapters/psycopg/config.py | 472 ++--- sqlspec/adapters/psycopg/core.py | 156 +- sqlspec/adapters/psycopg/data_dictionary.py | 48 +- sqlspec/adapters/psycopg/driver.py | 197 ++- sqlspec/adapters/psycopg/events/_hub.py | 68 +- sqlspec/adapters/psycopg/events/backend.py | 109 +- sqlspec/adapters/psycopg/litestar/store.py | 32 +- sqlspec/adapters/pymssql/_typing.py | 32 +- sqlspec/adapters/pymssql/adk/store.py | 268 +-- sqlspec/adapters/pymssql/config.py | 40 +- sqlspec/adapters/pymssql/core.py | 145 +- sqlspec/adapters/pymssql/driver.py | 173 +- sqlspec/adapters/pymssql/events/store.py | 28 +- sqlspec/adapters/pymssql/litestar/store.py | 45 +- sqlspec/adapters/pymssql/migrations.py | 60 +- sqlspec/adapters/pymssql/pool.py | 131 +- sqlspec/adapters/pymysql/__init__.py | 2 + sqlspec/adapters/pymysql/_typing.py | 30 +- sqlspec/adapters/pymysql/adk/store.py | 107 +- sqlspec/adapters/pymysql/config.py | 124 +- sqlspec/adapters/pymysql/core.py | 2 + sqlspec/adapters/pymysql/driver.py | 73 +- sqlspec/adapters/pymysql/litestar/store.py | 97 +- sqlspec/adapters/pymysql/pool.py | 147 +- sqlspec/adapters/spanner/_typing.py | 34 +- .../adapters/spanner/adk/artifact_store.py | 145 +- sqlspec/adapters/spanner/adk/store.py | 228 ++- sqlspec/adapters/spanner/config.py | 413 +++-- sqlspec/adapters/spanner/core.py | 85 + sqlspec/adapters/spanner/data_dictionary.py | 47 +- sqlspec/adapters/spanner/driver.py | 238 ++- sqlspec/adapters/spanner/events/store.py | 40 +- sqlspec/adapters/spanner/litestar/store.py | 122 +- sqlspec/adapters/spanner/type_converter.py | 212 ++- sqlspec/adapters/sqlite/_typing.py | 11 +- sqlspec/adapters/sqlite/adk/store.py | 380 ++-- sqlspec/adapters/sqlite/config.py | 46 +- sqlspec/adapters/sqlite/core.py | 177 +- sqlspec/adapters/sqlite/driver.py | 79 +- sqlspec/adapters/sqlite/events/store.py | 10 +- sqlspec/adapters/sqlite/litestar/store.py | 118 +- sqlspec/adapters/sqlite/pool.py | 197 ++- sqlspec/adapters/sqlite/type_converter.py | 14 +- sqlspec/base.py | 88 +- sqlspec/builder/_base.py | 1081 +++++++----- sqlspec/builder/_column.py | 175 +- sqlspec/builder/_ddl.py | 338 ++-- sqlspec/builder/_delete.py | 24 +- sqlspec/builder/_dml.py | 77 +- sqlspec/builder/_explain.py | 42 +- sqlspec/builder/_expression_wrappers.py | 10 +- sqlspec/builder/_factory.py | 315 ++-- sqlspec/builder/_insert.py | 126 +- sqlspec/builder/_join.py | 45 +- sqlspec/builder/_merge.py | 235 +-- sqlspec/builder/_parsing_utils.py | 128 +- sqlspec/builder/_select.py | 686 ++++---- sqlspec/builder/_temporal.py | 3 +- sqlspec/builder/_update.py | 22 +- sqlspec/builder/_values.py | 8 +- sqlspec/builder/_vector_distance.py | 34 +- sqlspec/cli.py | 47 +- sqlspec/config.py | 10 +- sqlspec/core/_cursor.py | 158 +- sqlspec/core/_operators.py | 2 + sqlspec/core/_pipeline.py | 13 +- sqlspec/core/cache.py | 187 +- sqlspec/core/compiler.py | 200 ++- sqlspec/core/correlation.py | 22 +- sqlspec/core/filters.py | 172 +- sqlspec/core/hashing.py | 4 +- sqlspec/core/parameters/_alignment.py | 2 - sqlspec/core/parameters/_converter.py | 95 +- sqlspec/core/parameters/_declared.py | 74 +- sqlspec/core/parameters/_processor.py | 310 ++-- sqlspec/core/parameters/_types.py | 14 +- sqlspec/core/parameters/_validator.py | 60 +- sqlspec/core/query_modifiers.py | 6 - sqlspec/core/result/_base.py | 402 +++-- sqlspec/core/splitter.py | 138 +- sqlspec/core/sqlcommenter.py | 2 - sqlspec/core/statement.py | 1555 +++++++++-------- sqlspec/data_dictionary/_dependencies.py | 172 +- sqlspec/data_dictionary/_loader.py | 270 +-- sqlspec/data_dictionary/_registry.py | 2 + sqlspec/data_dictionary/_types.py | 351 ++-- .../dialects/bigquery/config.py | 12 +- .../dialects/bigquery/sql/constraints.sql | 18 +- .../dialects/bigquery/sql/tables.sql | 36 +- .../dialects/cockroachdb/config.py | 11 +- .../dialects/cockroachdb/sql/constraints.sql | 22 +- .../dialects/cockroachdb/sql/foreign_keys.sql | 54 +- .../dialects/cockroachdb/sql/indexes.sql | 19 +- .../dialects/db2/sql/tables.sql | 2 + .../data_dictionary/dialects/duckdb/config.py | 10 + .../dialects/duckdb/sql/foreign_keys.sql | 4 + .../dialects/duckdb/sql/tables.sql | 38 +- .../dialects/mariadb/sql/columns.sql | 16 + .../dialects/mariadb/sql/foreign_keys.sql | 28 + .../dialects/mariadb/sql/indexes.sql | 14 + .../dialects/mariadb/sql/tables.sql | 85 +- .../dialects/mariadb/sql/version.sql | 7 + .../data_dictionary/dialects/mssql/config.py | 45 +- .../dialects/mssql/sql/foreign_keys.sql | 2 + .../dialects/mssql/sql/tables.sql | 4 +- .../data_dictionary/dialects/mysql/config.py | 9 + .../dialects/mysql/dictionary.py | 50 +- .../dialects/mysql/sql/tables.sql | 4 +- .../data_dictionary/dialects/oracle/config.py | 14 + .../dialects/postgres/config.py | 11 +- .../dialects/postgres/sql/foreign_keys.sql | 70 +- .../dialects/postgres/sql/tables.sql | 15 +- .../dialects/spanner/config.py | 10 + .../spanner/sql/googlesql/change_streams.sql | 26 +- .../spanner/sql/googlesql/constraints.sql | 48 +- .../spanner/sql/googlesql/database.sql | 8 +- .../spanner/sql/googlesql/objects.sql | 10 +- .../spanner/sql/googlesql/privileges.sql | 12 +- .../spanner/sql/googlesql/property_graphs.sql | 8 +- .../spanner/sql/googlesql/routines.sql | 36 +- .../spanner/sql/googlesql/sequences.sql | 14 +- .../dialects/spanner/sql/googlesql/system.sql | 23 +- .../dialects/spanner/sql/googlesql/views.sql | 12 +- .../spanner/sql/postgresql/system.sql | 1 + .../data_dictionary/dialects/sqlite/config.py | 12 +- .../dialects/sqlite/sql/constraints.sql | 26 + .../dialects/sqlite/sql/foreign_keys.sql | 4 +- .../dialects/sqlite/sql/tables.sql | 23 +- sqlspec/dialects/db2/__init__.py | 1 + sqlspec/dialects/db2/_generators.py | 76 +- sqlspec/dialects/db2/_parsers.py | 255 ++- sqlspec/dialects/db2/_transforms.py | 25 +- sqlspec/dialects/postgres/_generators.py | 33 +- sqlspec/dialects/postgres/_operators.py | 48 +- sqlspec/dialects/spanner/_expressions.py | 106 +- sqlspec/dialects/spanner/_generators.py | 223 ++- sqlspec/dialects/spanner/_parsers.py | 491 ++++-- sqlspec/driver/_async.py | 427 +++-- sqlspec/driver/_common.py | 395 +++-- sqlspec/driver/_exception_handler.py | 89 +- sqlspec/driver/_query_cache.py | 6 +- sqlspec/driver/_storage_helpers.py | 3 - sqlspec/driver/_stream.py | 113 +- sqlspec/driver/_sync.py | 489 +++--- sqlspec/exceptions.py | 39 +- sqlspec/extensions/__init__.py | 3 + sqlspec/extensions/adk/__init__.py | 110 +- sqlspec/extensions/adk/artifact/service.py | 108 +- sqlspec/extensions/adk/converters.py | 1 - sqlspec/extensions/adk/memory/converters.py | 2 + sqlspec/extensions/adk/memory/service.py | 73 +- sqlspec/extensions/adk/memory/store.py | 47 +- sqlspec/extensions/adk/migrations/__init__.py | 3 + sqlspec/extensions/adk/service.py | 34 +- sqlspec/extensions/adk/store.py | 2 + sqlspec/extensions/events/_channel.py | 42 +- sqlspec/extensions/events/_queue.py | 54 +- sqlspec/extensions/events/_store.py | 1 + sqlspec/extensions/fastapi/extension.py | 76 +- sqlspec/extensions/fastapi/providers.py | 11 +- sqlspec/extensions/flask/extension.py | 264 +-- sqlspec/extensions/litestar/__init__.py | 2 + sqlspec/extensions/litestar/_utils.py | 4 +- sqlspec/extensions/litestar/channels.py | 42 +- sqlspec/extensions/litestar/cli.py | 2 + sqlspec/extensions/litestar/handlers.py | 39 +- sqlspec/extensions/litestar/plugin.py | 281 ++- sqlspec/extensions/litestar/providers.py | 18 +- sqlspec/extensions/prometheus/_observer.py | 15 +- sqlspec/extensions/sanic/extension.py | 144 +- sqlspec/extensions/starlette/extension.py | 223 +-- sqlspec/extensions/starlette/middleware.py | 21 +- sqlspec/loader.py | 63 +- sqlspec/migrations/_backup.py | 2 +- sqlspec/migrations/base.py | 47 +- sqlspec/migrations/commands.py | 1169 ++++++++----- sqlspec/migrations/context.py | 44 +- sqlspec/migrations/fix.py | 22 +- sqlspec/migrations/loaders.py | 134 +- sqlspec/migrations/runner.py | 532 +++--- sqlspec/migrations/schema.py | 30 +- sqlspec/migrations/squash.py | 124 +- sqlspec/migrations/templates.py | 18 +- sqlspec/migrations/tracker.py | 96 +- sqlspec/migrations/utils.py | 4 + sqlspec/migrations/validation.py | 61 +- sqlspec/migrations/version.py | 46 +- sqlspec/observability/_common.py | 16 +- sqlspec/observability/_config.py | 97 +- sqlspec/observability/_diagnostics.py | 5 +- sqlspec/observability/_dispatcher.py | 10 +- sqlspec/observability/_formatters/_aws.py | 22 +- sqlspec/observability/_formatters/_azure.py | 22 +- sqlspec/observability/_formatters/_gcp.py | 22 +- sqlspec/observability/_observer.py | 139 +- sqlspec/observability/_runtime.py | 139 +- sqlspec/observability/_sampling.py | 71 +- sqlspec/observability/_spans.py | 45 +- sqlspec/protocols.py | 93 +- sqlspec/service/_base.py | 28 +- sqlspec/service/_core.py | 64 +- sqlspec/storage/__init__.py | 81 +- sqlspec/storage/_arrow_payload.py | 16 +- sqlspec/storage/_paths.py | 55 +- sqlspec/storage/_utils.py | 48 +- sqlspec/storage/backends/__init__.py | 42 + sqlspec/storage/backends/base.py | 138 +- sqlspec/storage/backends/fsspec.py | 137 +- sqlspec/storage/backends/local.py | 165 +- sqlspec/storage/backends/obstore.py | 728 ++++---- sqlspec/storage/errors.py | 5 +- sqlspec/storage/pipeline.py | 125 +- sqlspec/storage/registry.py | 124 +- sqlspec/utils/__init__.py | 2 + sqlspec/utils/arrow_helpers.py | 23 +- sqlspec/utils/config_tools.py | 27 +- sqlspec/utils/correlation.py | 9 +- sqlspec/utils/deprecation.py | 24 + sqlspec/utils/dispatch.py | 13 +- sqlspec/utils/fixtures.py | 83 +- sqlspec/utils/logging.py | 15 +- sqlspec/utils/module_loader.py | 2 +- sqlspec/utils/portal.py | 146 +- sqlspec/utils/schema.py | 207 ++- sqlspec/utils/serializers/_json.py | 290 +-- sqlspec/utils/serializers/_schema.py | 75 +- sqlspec/utils/sync_tools.py | 91 +- sqlspec/utils/text.py | 5 +- sqlspec/utils/type_converters.py | 125 +- sqlspec/utils/type_guards.py | 37 +- sqlspec/utils/uuids.py | 39 +- .../adapters/_shared/_driver_type_system.py | 145 +- .../test_adbc_config_normalization.py | 51 +- .../test_adbc/test_adbc_serialization.py | 10 + .../test_adbc/test_adk_dialect_support.py | 81 +- .../test_adbc/test_arrow_streaming.py | 101 +- tests/unit/adapters/test_adbc/test_core.py | 52 +- .../test_adbc/test_extension_detection.py | 10 +- .../adapters/test_aiomysql/test_driver.py | 29 + .../adapters/test_aiosqlite/test_driver.py | 24 + .../test_arrow_odbc/test_adk_store.py | 10 +- .../adapters/test_arrow_odbc/test_config.py | 145 +- .../test_arrow_odbc/test_db2_stores.py | 4 +- .../adapters/test_arrow_odbc/test_driver.py | 256 ++- .../test_arrow_odbc/test_tsql_stores.py | 112 +- tests/unit/adapters/test_async_adapters.py | 28 +- .../unit/adapters/test_asyncmy/test_driver.py | 69 + .../test_asyncpg/test_cloud_connectors.py | 42 + .../unit/adapters/test_asyncpg/test_config.py | 81 +- .../test_asyncpg/test_transaction_state.py | 154 ++ .../test_asyncpg/test_type_handlers.py | 24 +- .../unit/adapters/test_bigquery/test_core.py | 356 +++- .../test_cockroach_asyncpg/test_config.py | 161 +- .../test_cockroach_psycopg/test_config.py | 144 ++ tests/unit/adapters/test_db2/test_adk.py | 70 +- .../unit/adapters/test_db2/test_async_pool.py | 1 + tests/unit/adapters/test_db2/test_config.py | 60 +- tests/unit/adapters/test_db2/test_driver.py | 90 +- .../unit/adapters/test_db2/test_migrations.py | 11 +- .../adapters/test_db2/test_transactions.py | 3 +- .../adapters/test_duckdb/test_adk_store.py | 61 + .../test_duckdb/test_arrow_streaming.py | 25 + .../unit/adapters/test_duckdb/test_config.py | 33 + tests/unit/adapters/test_duckdb/test_pool.py | 41 +- .../test_duckdb/test_transaction_state.py | 34 +- .../test_mssql_python/test_adk_store.py | 138 +- .../adapters/test_mssql_python/test_config.py | 101 ++ .../adapters/test_mssql_python/test_core.py | 52 + .../adapters/test_mssql_python/test_driver.py | 57 +- .../test_mssql_python/test_events_store.py | 8 +- .../test_mssql_python/test_litestar_store.py | 26 +- .../test_migration_schema.py | 18 + .../test_mssql_python/test_migrations.py | 33 +- .../unit/adapters/test_mssql_store_parity.py | 30 +- .../test_mysqlconnector/test_config.py | 38 +- .../test_mysqlconnector/test_driver.py | 12 + .../test_oracledb/test_json_handlers.py | 38 +- .../test_oracledb/test_litestar_store.py | 12 + .../test_oracledb/test_oracle_adk_store.py | 101 +- .../test_oracledb/test_pipeline_helpers.py | 125 +- .../test_oracledb/test_uuid_handlers.py | 1 - .../test_oracledb/test_vector_handlers.py | 20 +- .../adapters/test_psqlpy/test_adk_store.py | 42 +- .../unit/adapters/test_psqlpy/test_config.py | 21 + tests/unit/adapters/test_psqlpy/test_core.py | 57 +- .../test_psycopg/test_cloud_connectors.py | 63 +- .../unit/adapters/test_psycopg/test_config.py | 106 +- tests/unit/adapters/test_psycopg/test_core.py | 45 +- .../unit/adapters/test_psycopg/test_driver.py | 57 +- .../adapters/test_pymssql/test_adk_store.py | 138 +- .../unit/adapters/test_pymssql/test_config.py | 28 +- tests/unit/adapters/test_pymssql/test_core.py | 67 + .../unit/adapters/test_pymssql/test_driver.py | 64 +- .../adapters/test_pymssql/test_extensions.py | 26 +- .../test_pymssql/test_migration_schema.py | 18 + .../adapters/test_pymssql/test_migrations.py | 33 +- tests/unit/adapters/test_pymssql/test_pool.py | 66 +- .../unit/adapters/test_pymysql/test_config.py | 40 +- .../unit/adapters/test_pymysql/test_driver.py | 15 + tests/unit/adapters/test_spanner/test_core.py | 364 ++++ .../test_spanner/test_litestar_store.py | 12 +- .../unit/adapters/test_sqlite/test_driver.py | 44 +- tests/unit/adapters/test_sync_adapters.py | 28 +- tests/unit/builder/test_ddl_builder.py | 59 + tests/unit/builder/test_sql_factory.py | 115 +- tests/unit/config/test_kwargs_validation.py | 66 +- tests/unit/core/test_compiler.py | 23 + tests/unit/core/test_hashing.py | 21 + tests/unit/core/test_parameters.py | 63 + tests/unit/core/test_result.py | 53 +- tests/unit/core/test_splitter.py | 8 + tests/unit/core/test_statement.py | 54 + .../test_bigquery_spanner_metadata.py | 40 + .../data_dictionary/test_ddl_dependencies.py | 39 + .../data_dictionary/test_mysql_metadata.py | 28 + .../test_postgres_cockroach_query_packs.py | 28 + .../test_sqlite_duckdb_metadata.py | 45 + tests/unit/dialects/test_db2.py | 47 + .../unit/dialects/test_db2_statement_tail.py | 9 + tests/unit/dialects/test_isolation.py | 30 + tests/unit/dialects/test_paradedb.py | 11 +- tests/unit/dialects/test_pgvector.py | 24 +- tests/unit/dialects/test_registration.py | 6 +- tests/unit/dialects/test_spangres.py | 39 + tests/unit/dialects/test_spangres_hints.py | 18 + tests/unit/dialects/test_spanner.py | 44 +- tests/unit/dialects/test_spanner_hints.py | 19 + tests/unit/driver/test_count_query.py | 41 + .../driver/test_declared_param_validation.py | 22 + tests/unit/driver/test_execute_script.py | 26 + tests/unit/driver/test_query_cache.py | 34 +- tests/unit/driver/test_select_stream.py | 24 + tests/unit/driver/test_stack_base.py | 34 + .../unit/exceptions/test_exception_handler.py | 68 +- tests/unit/exceptions/test_exceptions.py | 22 + .../extensions/test_adk/test_migrations.py | 9 +- .../test_events/test_listener_hubs.py | 5 +- .../extensions/test_fastapi/test_providers.py | 10 +- .../test_litestar/test_providers.py | 32 +- .../test_serialization_plugin.py | 8 +- .../unit/extensions/test_service_reexports.py | 444 +++++ tests/unit/loader/test_cache_integration.py | 10 +- tests/unit/loader/test_slots.py | 29 + .../migrations/test_migration_execution.py | 444 ++++- tests/unit/migrations/test_tracker_squash.py | 42 +- .../observability/test_observability_core.py | 255 ++- tests/unit/storage/test_storage_utils.py | 386 +++- .../serializers/test_default_type_encoders.py | 22 +- tests/unit/utils/test_arrow_helpers.py | 27 + tests/unit/utils/test_fixtures.py | 73 +- tests/unit/utils/test_portal.py | 43 +- tests/unit/utils/test_sync_tools.py | 24 +- tests/unit/utils/test_to_value_type.py | 137 +- tests/unit/utils/test_type_guards.py | 103 +- 495 files changed, 31601 insertions(+), 16148 deletions(-) create mode 100644 sqlspec/data_dictionary/dialects/mariadb/sql/foreign_keys.sql create mode 100644 sqlspec/data_dictionary/dialects/mariadb/sql/version.sql diff --git a/sqlspec/__init__.py b/sqlspec/__init__.py index f2ec20ba8..66bf53248 100644 --- a/sqlspec/__init__.py +++ b/sqlspec/__init__.py @@ -1,8 +1,6 @@ """SQLSpec: Type-safe SQL query mapper for Python.""" # ruff: noqa: E402 -# Suppress noisy Google library deprecation warnings about Python version support. -# These are informational and clutter CLI output unnecessarily. import warnings as _warnings _warnings.filterwarnings( @@ -166,6 +164,7 @@ "adapters", "base", "builder", + "config", "core", "create_event", "create_statement_observer", @@ -180,6 +179,7 @@ "matches_param_type", "migrations", "nanoid", + "observability", "register_param_type", "resolve_param_type", "sql", @@ -295,8 +295,6 @@ def __dir__() -> list[str]: return sorted(set(globals()) | _EXPORTS.keys()) -# Native cross-module initialization is not protected by Python import locks. -# Retain eager exports in compiled wheels until mypyc supports lazy concurrency. if _COMPILED: for _name in __all__: if _name not in globals(): diff --git a/sqlspec/_typing.py b/sqlspec/_typing.py index 8185015ec..257601710 100644 --- a/sqlspec/_typing.py +++ b/sqlspec/_typing.py @@ -140,8 +140,6 @@ class DataclassProtocol(Protocol): T = TypeVar("T") -# Always define stub types for type checking - class BaseModelStub: """Placeholder implementation.""" @@ -193,6 +191,8 @@ def model_dump_json( # noqa: PLR0913 class TypeAdapterStub: """Placeholder implementation.""" + __slots__ = ("_type",) + def __init__( self, type: Any, # noqa: A002 @@ -225,9 +225,6 @@ class FailFastStub: fail_fast: bool = True -# Always define stub types for msgspec - - @dataclass_transform() class StructStub: """Placeholder implementation.""" @@ -241,7 +238,7 @@ def __init__(self, **kwargs: Any) -> None: def convert_stub( # noqa: PLR0913 - obj: Any, # noqa: ARG001 + obj: Any, type: Any, # noqa: A002,ARG001 *, strict: bool = True, # noqa: ARG001 @@ -251,7 +248,7 @@ def convert_stub( # noqa: PLR0913 str_keys: bool = False, # noqa: ARG001 ) -> Any: """Placeholder implementation.""" - return {} + return obj def msgspec_fields_stub(type_: Any, /) -> "tuple[Any, ...]": # noqa: ARG001 @@ -265,7 +262,6 @@ class UnsetTypeStub(enum.Enum): UNSET_STUB = UnsetTypeStub.UNSET -# Try to import real implementations at runtime try: from msgspec import UNSET as _REAL_UNSET from msgspec import Struct as _RealStruct @@ -289,7 +285,6 @@ class UnsetTypeStub(enum.Enum): msgspec_fields = msgspec_fields_stub # type: ignore[assignment] -# Always define stub type for DTOData @runtime_checkable class DTODataStub(Protocol[T]): """Placeholder implementation.""" @@ -311,7 +306,6 @@ def as_builtins(self) -> Any: return {} -# Always define stub types for attrs @dataclass_transform() class AttrsInstanceStub: """Placeholder Implementation for attrs classes""" @@ -376,9 +370,6 @@ class EmptyEnum(Enum): class ArrowTableResult(Protocol): """This is a typed shim for pyarrow.Table.""" - def to_batches(self, batch_size: int) -> Any: - return None - @property def num_rows(self) -> int: return 0 @@ -387,6 +378,9 @@ def num_rows(self) -> int: def num_columns(self) -> int: return 0 + def to_batches(self, batch_size: int) -> Any: + return None + def to_pydict(self) -> dict[str, Any]: return {} @@ -441,24 +435,28 @@ def slice(self, offset: int = 0, length: "int | None" = None) -> Any: class ArrowSchemaProtocol(Protocol): """Typed shim for pyarrow.Schema.""" - def field(self, i: int) -> Any: - """Get field by index.""" - ... + def __len__(self) -> int: + """Get number of fields.""" + return 0 @property def names(self) -> "list[str]": """Get list of field names.""" ... - def __len__(self) -> int: - """Get number of fields.""" - return 0 + def field(self, i: int) -> Any: + """Get field by index.""" + ... @runtime_checkable class ArrowRecordBatchReaderProtocol(Protocol): """Typed shim for pyarrow.RecordBatchReader.""" + def __iter__(self) -> "Iterable[Any]": + """Iterate over batches.""" + ... + def read_all(self) -> Any: """Read all batches into a table.""" ... @@ -467,10 +465,6 @@ def read_next_batch(self) -> Any: """Read next batch.""" ... - def __iter__(self) -> "Iterable[Any]": - """Iterate over batches.""" - ... - _ARROW_TABLE_SHIM = ArrowTableResult _ARROW_RECORD_BATCH_SHIM = ArrowRecordBatchResult @@ -526,6 +520,14 @@ def tolist(self) -> "list[Any]": class _SpanShim: + __slots__ = () + + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: + return None + def set_attribute(self, key: str, value: Any) -> None: return None @@ -544,12 +546,6 @@ def set_status(self, status: Any, description: "str | None" = None) -> None: def end(self, end_time: "int | None" = None) -> None: return None - def __enter__(self) -> Self: - return self - - def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: - return None - _SPAN_SHIM = _SpanShim _SPAN_SHIM.__name__ = "Span" @@ -557,6 +553,8 @@ def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None: class _TracerShim: + __slots__ = () + def start_span( self, name: str, @@ -577,6 +575,8 @@ def start_span( class _TraceModule: + __slots__ = () + def get_tracer( self, instrumenting_module_name: str, @@ -589,9 +589,9 @@ def get_tracer( def get_tracer_provider(self) -> Any: # pragma: no cover return None - TracerProvider = type(None) # Shim for TracerProvider if needed elsewhere - StatusCode = type(None) # Shim for StatusCode - Status = type(None) # Shim for Status + TracerProvider = type(None) + StatusCode = type(None) + Status = type(None) _TRACE_SHIM = _TraceModule() @@ -599,7 +599,9 @@ def get_tracer_provider(self) -> Any: # pragma: no cover _STATUS_SHIM = type(None) -class _Metric: # Base shim for metrics +class _Metric: + __slots__ = () + def __init__( self, name: str, @@ -609,7 +611,7 @@ def __init__( subsystem: str = "", unit: str = "", registry: Any = None, - ejemplar_fn: Any = None, + exemplar_fn: Any = None, buckets: Any = None, **_: Any, ) -> None: @@ -620,6 +622,8 @@ def labels(self, *labelvalues: str, **labelkwargs: str) -> "_MetricInstance": class _MetricInstance: + __slots__ = () + def inc(self, amount: float = 1) -> None: return None @@ -634,6 +638,8 @@ def observe(self, amount: float) -> None: class _CounterShim(_Metric): + __slots__ = () + def labels(self, *labelvalues: str, **labelkwargs: str) -> "_MetricInstance": return _MetricInstance() # pragma: no cover @@ -644,6 +650,8 @@ def labels(self, *labelvalues: str, **labelkwargs: str) -> "_MetricInstance": class _GaugeShim(_Metric): + __slots__ = () + def labels(self, *labelvalues: str, **labelkwargs: str) -> "_MetricInstance": return _MetricInstance() # pragma: no cover @@ -654,6 +662,8 @@ def labels(self, *labelvalues: str, **labelkwargs: str) -> "_MetricInstance": class _HistogramShim(_Metric): + __slots__ = () + def labels(self, *labelvalues: str, **labelkwargs: str) -> "_MetricInstance": return _MetricInstance() # pragma: no cover diff --git a/sqlspec/adapters/__init__.py b/sqlspec/adapters/__init__.py index e69de29bb..1cba91164 100644 --- a/sqlspec/adapters/__init__.py +++ b/sqlspec/adapters/__init__.py @@ -0,0 +1,3 @@ +"""SQLSpec database adapters namespace package.""" + +__all__ = () diff --git a/sqlspec/adapters/adbc/__init__.py b/sqlspec/adapters/adbc/__init__.py index f2bfa5ed3..34c638acd 100644 --- a/sqlspec/adapters/adbc/__init__.py +++ b/sqlspec/adapters/adbc/__init__.py @@ -1,3 +1,5 @@ +"""ADBC adapter for SQLSpec.""" + from sqlspec.adapters.adbc._typing import AdbcConnection from sqlspec.adapters.adbc.config import AdbcConfig, AdbcConnectionParams, AdbcDriverFeatures from sqlspec.adapters.adbc.driver import AdbcCursor, AdbcDriver, AdbcExceptionHandler diff --git a/sqlspec/adapters/adbc/_typing.py b/sqlspec/adapters/adbc/_typing.py index ccdcc896d..ca995dfd2 100644 --- a/sqlspec/adapters/adbc/_typing.py +++ b/sqlspec/adapters/adbc/_typing.py @@ -1,4 +1,3 @@ -# pyright: reportCallIssue=false, reportAttributeAccessIssue=false, reportArgumentType=false """ADBC adapter type definitions. This module contains type aliases and classes that are excluded from mypyc @@ -6,7 +5,7 @@ """ import contextlib -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeAlias from adbc_driver_manager import Error as _AdbcNativeError from adbc_driver_manager import NotSupportedError as AdbcNotSupportedError @@ -14,27 +13,21 @@ from adbc_driver_manager.dbapi import Connection from adbc_driver_manager.dbapi import Cursor as _AdbcRawCursor +from sqlspec.adapters.adbc.core import resolve_dialect_name from sqlspec.typing import import_optional_attr -_AdbcConnection = Connection - if TYPE_CHECKING: from collections.abc import Callable from types import TracebackType - from typing import TypeAlias from adbc_driver_flightsql import DatabaseOptions as AdbcFlightSqlDatabaseOptions from sqlspec.adapters.adbc.driver import AdbcDriver from sqlspec.core import StatementConfig - AdbcConnection: TypeAlias = _AdbcConnection - AdbcRawCursor: TypeAlias = _AdbcRawCursor - AdbcNativeError: TypeAlias = _AdbcNativeError -else: - AdbcConnection = _AdbcConnection - AdbcRawCursor = _AdbcRawCursor - AdbcNativeError = _AdbcNativeError +AdbcConnection: TypeAlias = Connection +AdbcRawCursor: TypeAlias = _AdbcRawCursor +AdbcNativeError: TypeAlias = _AdbcNativeError __all__ = ( "AdbcConnection", @@ -105,7 +98,6 @@ def __init__( self._driver: AdbcDriver | None = None def __enter__(self) -> "AdbcDriver": - from sqlspec.adapters.adbc.core import resolve_dialect_name from sqlspec.adapters.adbc.driver import AdbcDriver self._connection = self._acquire_connection() diff --git a/sqlspec/adapters/adbc/adk/store.py b/sqlspec/adapters/adbc/adk/store.py index ccc4b0af3..6703c6d0c 100644 --- a/sqlspec/adapters/adbc/adk/store.py +++ b/sqlspec/adapters/adbc/adk/store.py @@ -7,6 +7,7 @@ from sqlspec.adapters.adbc.core import ( driver_kind_from_driver_name, + is_postgres_dialect, is_shared_object_driver, resolve_dialect_from_config, ) @@ -30,6 +31,7 @@ DIALECT_POSTGRESQL: Final = "postgresql" DIALECT_SQLITE: Final = "sqlite" DIALECT_DUCKDB: Final = "duckdb" +DIALECT_BIGQUERY: Final = "bigquery" DIALECT_SNOWFLAKE: Final = "snowflake" DIALECT_GENERIC: Final = "generic" @@ -42,21 +44,6 @@ ) -@lru_cache(maxsize=256) -def _postgresql_store_sql(sql: str) -> str: - """Cache positional SQL conversion without changing quoted or commented text.""" - segments: list[str] = [] - last_end = 0 - index = 0 - for match in PARAMETER_REGEX.finditer(sql): - if match.lastgroup == "qmark": - index += 1 - segments.extend((sql[last_end : match.start()], f"${index}")) - last_end = match.end() - segments.append(sql[last_end:]) - return "".join(segments) - - class AdbcADKStore(BaseSyncADKStore["AdbcConfig"]): """ADBC synchronous ADK store for Arrow Database Connectivity. @@ -67,8 +54,8 @@ class AdbcADKStore(BaseSyncADKStore["AdbcConfig"]): Events use the clean-break contract: id, session_id, invocation_id, timestamp, and event_data. The full ADK Event payload is stored as a single JSON blob in event_data using a dialect-appropriate column type - (JSONB for PostgreSQL, JSON for DuckDB, VARIANT for Snowflake, TEXT for - SQLite and generic fallback). + (JSONB for PostgreSQL, JSON for DuckDB and BigQuery, VARIANT for Snowflake, + TEXT for SQLite and generic fallback). Provides: - Session state management with JSON serialization @@ -228,12 +215,14 @@ def _detect_dialect(self) -> str: return DIALECT_GENERIC resolved = resolve_dialect_from_config(self._config.connection_config) - if resolved == "postgres": + if is_postgres_dialect(resolved): return DIALECT_POSTGRESQL if resolved == "sqlite": return DIALECT_SQLITE if resolved == "duckdb": return DIALECT_DUCKDB + if resolved == "bigquery": + return DIALECT_BIGQUERY if resolved == "snowflake": return DIALECT_SNOWFLAKE @@ -244,6 +233,22 @@ def _detect_dialect(self) -> str: ) return DIALECT_GENERIC + def _encode_timestamp(self, value: datetime) -> Any: + if self._dialect == DIALECT_SQLITE: + return value.timestamp() + return value + + def _decode_timestamp(self, value: Any) -> datetime: + if isinstance(value, datetime): + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + if isinstance(value, (int, float)): + return datetime.fromtimestamp(float(value), tz=UTC) + if isinstance(value, str): + parsed = datetime.fromisoformat(value) + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC) + parsed = datetime.fromisoformat(str(value)) + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC) + def _serialize_state(self, state: "dict[str, Any]") -> str: """Serialize state dictionary to JSON string. @@ -284,7 +289,7 @@ def _serialize_json_field(self, value: Any) -> "str | None": def _json_storage_type(self) -> str: if self._dialect == DIALECT_POSTGRESQL: return "JSONB" - if self._dialect == DIALECT_DUCKDB: + if self._dialect in {DIALECT_DUCKDB, DIALECT_BIGQUERY}: return "JSON" if self._dialect == DIALECT_SNOWFLAKE: return "VARIANT" @@ -295,6 +300,8 @@ def _timestamp_storage_type(self) -> str: return "TIMESTAMPTZ" if self._dialect == DIALECT_SNOWFLAKE: return "TIMESTAMP_TZ" + if self._dialect == DIALECT_SQLITE: + return "REAL" return "TIMESTAMP" def _deserialize_json_field(self, data: Any) -> "dict[str, Any] | None": @@ -322,6 +329,8 @@ def _sessions_table_ddl(self) -> str: return self._sessions_ddl_sqlite() if self._dialect == DIALECT_DUCKDB: return self._sessions_ddl_duckdb() + if self._dialect == DIALECT_BIGQUERY: + return self._sessions_ddl_bigquery() if self._dialect == DIALECT_SNOWFLAKE: return self._sessions_ddl_snowflake() return self._sessions_ddl_generic() @@ -380,6 +389,24 @@ def _sessions_ddl_duckdb(self) -> str: ) """ + def _sessions_ddl_bigquery(self) -> str: + """BigQuery DDL with JSON and TIMESTAMP. + + Returns: + SQL to create sessions table optimized for BigQuery. + """ + owner_id_ddl = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" + return f""" + CREATE TABLE IF NOT EXISTS {self._session_table} ( + id STRING NOT NULL, + app_name STRING NOT NULL, + user_id STRING NOT NULL{owner_id_ddl}, + state JSON NOT NULL, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(), + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() + ) + """ + def _sessions_ddl_snowflake(self) -> str: """Snowflake DDL with VARIANT type. @@ -428,6 +455,8 @@ def _events_table_ddl(self) -> str: return self._events_ddl_sqlite() if self._dialect == DIALECT_DUCKDB: return self._events_ddl_duckdb() + if self._dialect == DIALECT_BIGQUERY: + return self._events_ddl_bigquery() if self._dialect == DIALECT_SNOWFLAKE: return self._events_ddl_snowflake() return self._events_ddl_generic() @@ -489,6 +518,24 @@ def _events_ddl_duckdb(self) -> str: ) """ + def _events_ddl_bigquery(self) -> str: + """BigQuery DDL for events table. + + Returns: + SQL to create events table optimized for BigQuery. + """ + return f""" + CREATE TABLE IF NOT EXISTS {self._events_table} ( + id STRING NOT NULL, + app_name STRING NOT NULL, + user_id STRING NOT NULL, + session_id STRING NOT NULL, + invocation_id STRING, + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(), + event_data JSON NOT NULL + ) + """ + def _events_ddl_snowflake(self) -> str: """Snowflake DDL for events table. @@ -528,32 +575,66 @@ def _events_ddl_generic(self) -> str: """ def _app_states_table_ddl(self) -> str: + if self._dialect == DIALECT_BIGQUERY: + return f""" + CREATE TABLE IF NOT EXISTS {self._app_state_table} ( + app_name STRING NOT NULL, + state JSON NOT NULL, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() + ) + """ json_type = self._json_storage_type() timestamp_type = self._timestamp_storage_type() - default = "DEFAULT CURRENT_TIMESTAMP()" if self._dialect == DIALECT_SNOWFLAKE else "DEFAULT CURRENT_TIMESTAMP" + if self._dialect == DIALECT_SQLITE: + default = "" + elif self._dialect == DIALECT_SNOWFLAKE: + default = " DEFAULT CURRENT_TIMESTAMP()" + else: + default = " DEFAULT CURRENT_TIMESTAMP" return f""" CREATE TABLE IF NOT EXISTS {self._app_state_table} ( app_name VARCHAR(128) PRIMARY KEY, state {json_type} NOT NULL, - update_time {timestamp_type} NOT NULL {default} + update_time {timestamp_type} NOT NULL{default} ) """ def _user_states_table_ddl(self) -> str: + if self._dialect == DIALECT_BIGQUERY: + return f""" + CREATE TABLE IF NOT EXISTS {self._user_state_table} ( + app_name STRING NOT NULL, + user_id STRING NOT NULL, + state JSON NOT NULL, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() + ) + """ json_type = self._json_storage_type() timestamp_type = self._timestamp_storage_type() - default = "DEFAULT CURRENT_TIMESTAMP()" if self._dialect == DIALECT_SNOWFLAKE else "DEFAULT CURRENT_TIMESTAMP" + if self._dialect == DIALECT_SQLITE: + default = "" + elif self._dialect == DIALECT_SNOWFLAKE: + default = " DEFAULT CURRENT_TIMESTAMP()" + else: + default = " DEFAULT CURRENT_TIMESTAMP" return f""" CREATE TABLE IF NOT EXISTS {self._user_state_table} ( app_name VARCHAR(128) NOT NULL, user_id VARCHAR(128) NOT NULL, state {json_type} NOT NULL, - update_time {timestamp_type} NOT NULL {default}, + update_time {timestamp_type} NOT NULL{default}, PRIMARY KEY (app_name, user_id) ) """ def _metadata_table_ddl(self) -> str: + if self._dialect == DIALECT_BIGQUERY: + return f""" + CREATE TABLE IF NOT EXISTS {self._metadata_table} ( + key STRING NOT NULL, + value STRING NOT NULL + ) + """ return f""" CREATE TABLE IF NOT EXISTS {self._metadata_table} ( key VARCHAR(128) PRIMARY KEY, @@ -586,6 +667,7 @@ def _drop_tables_sql(self) -> "list[str]": def _create_tables(self) -> None: """Create both sessions and events tables if they don't exist.""" + skip_indexes = self._dialect in {DIALECT_BIGQUERY, DIALECT_SNOWFLAKE} with self._config.provide_connection() as conn: cursor = conn.cursor() try: @@ -594,20 +676,22 @@ def _create_tables(self) -> None: cursor.execute(self._sessions_table_ddl()) conn.commit() - sessions_idx_app_user = f"CREATE INDEX IF NOT EXISTS idx_{self._session_table}_app_user ON {self._session_table}(app_name, user_id)" - cursor.execute(sessions_idx_app_user) - conn.commit() + if not skip_indexes: + sessions_idx_app_user = f"CREATE INDEX IF NOT EXISTS idx_{self._session_table}_app_user ON {self._session_table}(app_name, user_id)" + cursor.execute(sessions_idx_app_user) + conn.commit() - sessions_idx_update = f"CREATE INDEX IF NOT EXISTS idx_{self._session_table}_update_time ON {self._session_table}(update_time DESC)" - cursor.execute(sessions_idx_update) - conn.commit() + sessions_idx_update = f"CREATE INDEX IF NOT EXISTS idx_{self._session_table}_update_time ON {self._session_table}(update_time DESC)" + cursor.execute(sessions_idx_update) + conn.commit() cursor.execute(self._events_table_ddl()) conn.commit() - events_idx = f"CREATE INDEX IF NOT EXISTS idx_{self._events_table}_session ON {self._events_table}(session_id, timestamp ASC)" - cursor.execute(events_idx) - conn.commit() + if not skip_indexes: + events_idx = f"CREATE INDEX IF NOT EXISTS idx_{self._events_table}_session ON {self._events_table}(session_id, timestamp ASC)" + cursor.execute(events_idx) + conn.commit() cursor.execute(self._app_states_table_ddl()) conn.commit() @@ -695,21 +779,22 @@ def _create_session( """ state_json = self._serialize_state(state) state_placeholder = self._json_placeholder() + now_val = self._encode_timestamp(datetime.now(UTC)) params: tuple[Any, ...] if self._owner_id_column_name: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, {self._owner_id_column_name}, state, create_time, update_time) - VALUES (?, ?, ?, ?, {state_placeholder}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, {state_placeholder}, ?, ?) """ - params = (session_id, app_name, user_id, owner_id, state_json) + params = (session_id, app_name, user_id, owner_id, state_json, now_val, now_val) else: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time) - VALUES (?, ?, ?, {state_placeholder}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, {state_placeholder}, ?, ?) """ - params = (session_id, app_name, user_id, state_json) + params = (session_id, app_name, user_id, state_json, now_val, now_val) with self._config.provide_connection() as conn: cursor = conn.cursor() @@ -740,12 +825,13 @@ def _get_session( Session record or None if not found. """ if renew_for is not None and self._calculate_expires_at(renew_for) is not None: + now_val = self._encode_timestamp(datetime.now(UTC)) sql = f""" UPDATE {self._session_table} - SET update_time = CURRENT_TIMESTAMP + SET update_time = ? WHERE app_name = ? AND user_id = ? AND id = ? """ - params: tuple[Any, ...] = (app_name, user_id, session_id) + params: tuple[Any, ...] = (now_val, app_name, user_id, session_id) select_after_update = True else: sql = f""" @@ -770,7 +856,7 @@ def _get_session( FROM {self._session_table} WHERE app_name = ? AND user_id = ? AND id = ? """, - params, + (app_name, user_id, session_id), ) row = cursor.fetchone() @@ -782,8 +868,8 @@ def _get_session( app_name=row[1], user_id=row[2], state=self._deserialize_state(row[3]), - create_time=row[4], - update_time=row[5], + create_time=self._decode_timestamp(row[4]), + update_time=self._decode_timestamp(row[5]), ) finally: cursor.close() @@ -803,16 +889,17 @@ def _update_session_state(self, app_name: str, user_id: str, session_id: str, st state: New state dictionary (replaces existing state). """ state_json = self._serialize_state(state) + now_val = self._encode_timestamp(datetime.now(UTC)) sql = f""" UPDATE {self._session_table} - SET state = {self._json_placeholder()}, update_time = CURRENT_TIMESTAMP + SET state = {self._json_placeholder()}, update_time = ? WHERE app_name = ? AND user_id = ? AND id = ? """ with self._config.provide_connection() as conn: cursor = conn.cursor() try: - self._execute(cursor, sql, (state_json, app_name, user_id, session_id)) + self._execute(cursor, sql, (state_json, now_val, app_name, user_id, session_id)) conn.commit() finally: cursor.close() @@ -882,8 +969,8 @@ def _list_sessions( app_name=row[1], user_id=row[2], state=self._deserialize_state(row[3]), - create_time=row[4], - update_time=row[5], + create_time=self._decode_timestamp(row[4]), + update_time=self._decode_timestamp(row[5]), ) for row in rows ] @@ -920,7 +1007,7 @@ def _insert_event(self, event_record: "StoredEvent") -> None: event_record["user_id"], event_record["session_id"], event_record["invocation_id"], - event_record["timestamp"], + self._encode_timestamp(event_record["timestamp"]), event_data, ), ) @@ -964,7 +1051,7 @@ def _append_event_and_update_state( """ update_sql = f""" UPDATE {self._session_table} - SET state = {self._json_placeholder()}, update_time = CURRENT_TIMESTAMP + SET state = {self._json_placeholder()}, update_time = ? WHERE app_name = ? AND user_id = ? AND id = ? """ select_sql = f""" @@ -984,11 +1071,12 @@ def _append_event_and_update_state( """ state_json = self._serialize_state(state) event_data = self._serialize_json_field(event_record["event_data"]) + now_val = self._encode_timestamp(datetime.now(UTC)) with self._config.provide_connection() as conn: cursor = conn.cursor() try: - self._execute(cursor, update_sql, (state_json, app_name, user_id, session_id)) + self._execute(cursor, update_sql, (state_json, now_val, app_name, user_id, session_id)) self._execute(cursor, select_sql, (app_name, user_id, session_id)) row = cursor.fetchone() if row is not None: @@ -1001,23 +1089,21 @@ def _append_event_and_update_state( event_record["user_id"], event_record["session_id"], event_record["invocation_id"], - event_record["timestamp"], + self._encode_timestamp(event_record["timestamp"]), event_data, ), ) if app_state is not None: self._execute(cursor, delete_app_state_sql, (app_name,)) self._execute( - cursor, - insert_app_state_sql, - (app_name, self._serialize_state(app_state), datetime.now(UTC)), + cursor, insert_app_state_sql, (app_name, self._serialize_state(app_state), now_val) ) if user_state is not None: self._execute(cursor, delete_user_state_sql, (app_name, user_id)) self._execute( cursor, insert_user_state_sql, - (app_name, user_id, self._serialize_state(user_state), datetime.now(UTC)), + (app_name, user_id, self._serialize_state(user_state), now_val), ) conn.commit() else: @@ -1039,8 +1125,8 @@ def _append_event_and_update_state( app_name=row[1], user_id=row[2], state=self._deserialize_state(row[3]), - create_time=row[4], - update_time=row[5], + create_time=self._decode_timestamp(row[4]), + update_time=self._decode_timestamp(row[5]), ) def _get_events( @@ -1071,10 +1157,13 @@ def _get_events( if after_timestamp is not None: where_clauses.append("e.timestamp > ?") - params.append(after_timestamp) + params.append(self._encode_timestamp(after_timestamp)) where_clause = " AND ".join(where_clauses) - limit_clause = f" LIMIT {limit}" if limit is not None else "" + limit_clause = "" + if limit is not None: + limit_clause = " LIMIT ?" + params.append(limit) sql = f""" SELECT e.id, e.session_id, e.invocation_id, e.timestamp, e.event_data, s.app_name, s.user_id FROM {self._events_table} e @@ -1095,7 +1184,7 @@ def _get_events( id=row[0], session_id=row[1], invocation_id=row[2] or "", - timestamp=row[3], + timestamp=self._decode_timestamp(row[3]), event_data=self._deserialize_json_field(row[4]) or {}, app_name=row[5], user_id=row[6], @@ -1111,11 +1200,12 @@ def _get_events( raise def _delete_expired_events(self, before: datetime, app_name: "str | None" = None) -> int: + encoded_before = self._encode_timestamp(before) where = "WHERE timestamp < ?" - params: tuple[Any, ...] = (before,) + params: tuple[Any, ...] = (encoded_before,) if app_name is not None: where += " AND app_name = ?" - params = (before, app_name) + params = (encoded_before, app_name) count_sql = f"SELECT COUNT(*) FROM {self._events_table} {where}" delete_sql = f"DELETE FROM {self._events_table} {where}" @@ -1138,11 +1228,12 @@ def _delete_expired_events(self, before: datetime, app_name: "str | None" = None raise def _delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" = None) -> int: + encoded_updated_before = self._encode_timestamp(updated_before) where = "WHERE update_time < ?" - params: tuple[Any, ...] = (updated_before,) + params: tuple[Any, ...] = (encoded_updated_before,) if app_name is not None: where += " AND app_name = ?" - params = (updated_before, app_name) + params = (encoded_updated_before, app_name) count_sql = f"SELECT COUNT(*) FROM {self._session_table} {where}" delete_events_sql = f""" DELETE FROM {self._events_table} @@ -1170,11 +1261,12 @@ def _delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" raise def _delete_idle_user_states(self, updated_before: datetime, app_name: "str | None" = None) -> int: + encoded_updated_before = self._encode_timestamp(updated_before) where = "WHERE update_time < ?" - params: tuple[Any, ...] = (updated_before,) + params: tuple[Any, ...] = (encoded_updated_before,) if app_name is not None: where += " AND app_name = ?" - params = (updated_before, app_name) + params = (encoded_updated_before, app_name) count_sql = f"SELECT COUNT(*) FROM {self._user_state_table} {where}" delete_sql = f"DELETE FROM {self._user_state_table} {where}" @@ -1233,11 +1325,12 @@ def _get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None def _upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: delete_sql = f"DELETE FROM {self._app_state_table} WHERE app_name = ?" insert_sql = f"INSERT INTO {self._app_state_table} (app_name, state, update_time) VALUES (?, {self._json_placeholder()}, ?)" + now_val = self._encode_timestamp(datetime.now(UTC)) with self._config.provide_connection() as conn: cursor = conn.cursor() try: self._execute(cursor, delete_sql, (app_name,)) - self._execute(cursor, insert_sql, (app_name, self._serialize_state(state), datetime.now(UTC))) + self._execute(cursor, insert_sql, (app_name, self._serialize_state(state), now_val)) conn.commit() finally: cursor.close() @@ -1248,11 +1341,12 @@ def _upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any] INSERT INTO {self._user_state_table} (app_name, user_id, state, update_time) VALUES (?, ?, {self._json_placeholder()}, ?) """ + now_val = self._encode_timestamp(datetime.now(UTC)) with self._config.provide_connection() as conn: cursor = conn.cursor() try: self._execute(cursor, delete_sql, (app_name, user_id)) - self._execute(cursor, insert_sql, (app_name, user_id, self._serialize_state(state), datetime.now(UTC))) + self._execute(cursor, insert_sql, (app_name, user_id, self._serialize_state(state), now_val)) conn.commit() finally: cursor.close() @@ -1351,12 +1445,14 @@ def _detect_dialect(self) -> str: return DIALECT_GENERIC resolved = resolve_dialect_from_config(self._config.connection_config) - if resolved == "postgres": + if is_postgres_dialect(resolved): return DIALECT_POSTGRESQL if resolved == "sqlite": return DIALECT_SQLITE if resolved == "duckdb": return DIALECT_DUCKDB + if resolved == "bigquery": + return DIALECT_BIGQUERY if resolved == "snowflake": return DIALECT_SNOWFLAKE logger.warning("Unknown ADBC driver: %s. Using generic SQL dialect.", driver_name) @@ -1389,12 +1485,14 @@ def _encode_timestamp(self, value: datetime) -> Any: def _decode_timestamp(self, value: Any) -> datetime: if isinstance(value, datetime): - return value + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) if isinstance(value, (int, float)): return datetime.fromtimestamp(float(value), tz=UTC) if isinstance(value, str): - return datetime.fromisoformat(value) - return datetime.fromisoformat(str(value)) + parsed = datetime.fromisoformat(value) + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC) + parsed = datetime.fromisoformat(str(value)) + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC) def _memory_table_ddl(self) -> str: if self._dialect == DIALECT_POSTGRESQL: @@ -1403,6 +1501,8 @@ def _memory_table_ddl(self) -> str: return self._memory_ddl_sqlite() if self._dialect == DIALECT_DUCKDB: return self._memory_ddl_duckdb() + if self._dialect == DIALECT_BIGQUERY: + return self._memory_ddl_bigquery() if self._dialect == DIALECT_SNOWFLAKE: return self._memory_ddl_snowflake() return self._memory_ddl_generic() @@ -1464,6 +1564,25 @@ def _memory_ddl_duckdb(self) -> str: ) """ + def _memory_ddl_bigquery(self) -> str: + owner_id_ddl = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" + return f""" + CREATE TABLE IF NOT EXISTS {self._memory_table} ( + id STRING NOT NULL, + session_id STRING NOT NULL, + app_name STRING NOT NULL, + user_id STRING NOT NULL, + scope STRING NOT NULL DEFAULT 'user', + event_id STRING NOT NULL, + author STRING{owner_id_ddl}, + timestamp TIMESTAMP NOT NULL, + content_json JSON NOT NULL, + content_text STRING NOT NULL, + metadata_json JSON, + inserted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() + ) + """ + def _memory_ddl_snowflake(self) -> str: owner_id_ddl = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" return f""" @@ -1509,25 +1628,25 @@ def _create_tables(self) -> None: if not self._enabled: return + skip_indexes = self._dialect in {DIALECT_BIGQUERY, DIALECT_SNOWFLAKE} with self._config.provide_connection() as conn: cursor = conn.cursor() try: cursor.execute(self._memory_table_ddl()) conn.commit() - idx_app_scope_user = f"CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_app_scope_user_time ON {self._memory_table}(app_name, scope, user_id, timestamp DESC)" - cursor.execute(idx_app_scope_user) - conn.commit() + if not skip_indexes: + idx_app_scope_user = f"CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_app_scope_user_time ON {self._memory_table}(app_name, scope, user_id, timestamp DESC)" + cursor.execute(idx_app_scope_user) + conn.commit() - idx_scope = f"CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_scope ON {self._memory_table}(app_name, scope)" - cursor.execute(idx_scope) - conn.commit() + idx_scope = f"CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_scope ON {self._memory_table}(app_name, scope)" + cursor.execute(idx_scope) + conn.commit() - idx_session = ( - f"CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_session ON {self._memory_table}(session_id)" - ) - cursor.execute(idx_session) - conn.commit() + idx_session = f"CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_session ON {self._memory_table}(session_id)" + cursor.execute(idx_session) + conn.commit() finally: cursor.close() @@ -1635,6 +1754,10 @@ def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "objec continue raise conn.commit() + except Exception: + with contextlib.suppress(Exception): + conn.rollback() + raise finally: cursor.close() @@ -1781,6 +1904,21 @@ def _rows_to_records(self, rows: "list[Any]") -> "list[StoredMemory]": return records +@lru_cache(maxsize=256) +def _postgresql_store_sql(sql: str) -> str: + """Cache positional SQL conversion without changing quoted or commented text.""" + segments: list[str] = [] + last_end = 0 + index = 0 + for match in PARAMETER_REGEX.finditer(sql): + if match.lastgroup == "qmark": + index += 1 + segments.extend((sql[last_end : match.start()], f"${index}")) + last_end = match.end() + segments.append(sql[last_end:]) + return "".join(segments) + + def _build_adbc_scope_where( app_name: str, user_id: str, scope_filter: Literal["all", "user", "app"] ) -> tuple[str, tuple[Any, ...]]: diff --git a/sqlspec/adapters/adbc/config.py b/sqlspec/adapters/adbc/config.py index f527dae77..343757240 100644 --- a/sqlspec/adapters/adbc/config.py +++ b/sqlspec/adapters/adbc/config.py @@ -9,6 +9,7 @@ detect_postgres_extensions, get_statement_config, is_postgres_dialect, + normalize_connection_aliases, resolve_dialect_from_config, resolve_dialect_name, resolve_driver_connect_func, @@ -164,8 +165,9 @@ def __enter__(self) -> "AdbcConnection": def __exit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> bool | None: - if self._connection: - self._connection.close() + if self._connection is not None: + if self._connection is not self._config.connection_instance: + self._connection.close() self._connection = None return None @@ -184,7 +186,8 @@ def acquire_connection(self) -> "AdbcConnection": def release_connection(self, _conn: "AdbcConnection", **kwargs: Any) -> None: if self._connection is None: return - self._connection.close() + if self._connection is not self._config.connection_instance: + self._connection.close() self._connection = None @@ -251,41 +254,7 @@ def __init__( observability_config: Adapter-level observability overrides for lifecycle hooks and observers **kwargs: Additional keyword arguments passed to the base configuration. """ - config_dict = normalize_connection_config(connection_config) - if "uri" not in config_dict: - uri = ( - config_dict.pop("url", None) - or config_dict.pop("dsn", None) - or config_dict.pop("connection_string", None) - ) - if uri is not None: - config_dict["uri"] = uri - else: - config_dict.pop("url", None) - config_dict.pop("dsn", None) - config_dict.pop("connection_string", None) - - driver_name = config_dict.get("driver_name") - if (driver_name in {"sqlite", "adbc_driver_sqlite"} or driver_name is None) and "uri" not in config_dict: - db_path = ( - config_dict.pop("database", None) - or config_dict.pop("db", None) - or config_dict.pop("path", None) - or config_dict.pop("file", None) - ) - if db_path is not None: - config_dict["uri"] = db_path - elif driver_name in {"duckdb", "adbc_driver_duckdb"} and "path" not in config_dict: - duck_path = ( - config_dict.pop("database", None) - or config_dict.pop("db", None) - or config_dict.pop("file", None) - or config_dict.pop("path", None) - ) - if duck_path is not None: - config_dict["path"] = duck_path - - self.connection_config = config_dict + self.connection_config = normalize_connection_aliases(normalize_connection_config(connection_config)) self._pgvector_available: bool | None = None self._paradedb_available: bool | None = None self._pg_textsearch_available: bool | None = None @@ -319,15 +288,22 @@ def supports_migration_schemas(self) -> bool: # type: ignore[override] """Migration schema support is only available for PostgreSQL-backed ADBC drivers.""" return is_postgres_dialect(self._resolved_dialect) + @property + def pg_textsearch_available(self) -> bool: + """Return True if the pg_textsearch extension is available.""" + return bool(self._pg_textsearch_available) + def create_connection(self) -> AdbcConnection: """Create and return a new connection using the specified driver. Returns: - A new connection instance. + A new connection instance, or ``connection_instance`` when configured. Raises: ImproperConfigurationError: If the connection could not be established. """ + if self.connection_instance is not None: + return self.connection_instance try: driver_name = cast("str | None", self.connection_config.get("driver_name")) @@ -343,51 +319,6 @@ def create_connection(self) -> AdbcConnection: msg = f"Could not configure connection using driver '{err_driver_name}'. Error: {e}" raise ImproperConfigurationError(msg) from e - @property - def pg_textsearch_available(self) -> bool: - """Return True if the pg_textsearch extension is available.""" - return bool(self._pg_textsearch_available) - - def _detect_extensions_if_needed(self) -> None: - """Detect postgres extensions on first call, caching results. - - Only queries ``pg_extension`` when the resolved dialect is PostgreSQL - and detection has not yet run (``_pgvector_available is None``). - """ - if self._pgvector_available is not None: - return - - dialect = resolve_dialect_name(self.statement_config.dialect) - if not is_postgres_dialect(dialect): - self._pgvector_available = False - self._paradedb_available = False - self._pg_textsearch_available = False - return - - connection = self.create_connection() - try: - probe_names = build_postgres_extension_probe_names(self.driver_features) - pgvector_available, paradedb_available, pg_textsearch_available = detect_postgres_extensions( - connection, - enable_pgvector="vector" in probe_names, - enable_paradedb="pg_search" in probe_names, - enable_pg_textsearch="pg_textsearch" in probe_names, - ) - finally: - connection.close() - - detected_extensions: set[str] = set() - if pgvector_available: - detected_extensions.add("vector") - if paradedb_available: - detected_extensions.add("pg_search") - if pg_textsearch_available: - detected_extensions.add("pg_textsearch") - self.statement_config, self._pgvector_available, self._paradedb_available = resolve_postgres_extension_state( - self.statement_config, self.driver_features, detected_extensions - ) - self._pg_textsearch_available = is_postgres_extension_active(self.driver_features, "pg_textsearch") - def provide_connection(self, *args: Any, **kwargs: Any) -> "AdbcConnectionContext": """Provide a connection context manager. @@ -453,3 +384,50 @@ def get_event_runtime_hints(self) -> "EventRuntimeHints": """Return polling defaults suitable for ADBC warehouses.""" return EventRuntimeHints(poll_interval=2.0, lease_seconds=60, retention_seconds=172_800) + + def _detect_extensions_if_needed(self) -> None: + """Detect postgres extensions on first call, caching results. + + Only queries ``pg_extension`` when the resolved dialect is PostgreSQL + and detection has not yet run (``_pgvector_available is None``). + """ + if self._pgvector_available is not None: + return + + dialect = resolve_dialect_name(self.statement_config.dialect) + if not is_postgres_dialect(dialect): + self._pgvector_available = False + self._paradedb_available = False + self._pg_textsearch_available = False + return + + probe_names = build_postgres_extension_probe_names(self.driver_features) + if not probe_names: + self._pgvector_available = False + self._paradedb_available = False + self._pg_textsearch_available = False + return + + connection = self.create_connection() + try: + pgvector_available, paradedb_available, pg_textsearch_available = detect_postgres_extensions( + connection, + enable_pgvector="vector" in probe_names, + enable_paradedb="pg_search" in probe_names, + enable_pg_textsearch="pg_textsearch" in probe_names, + ) + finally: + if connection is not self.connection_instance: + connection.close() + + detected_extensions: set[str] = set() + if pgvector_available: + detected_extensions.add("vector") + if paradedb_available: + detected_extensions.add("pg_search") + if pg_textsearch_available: + detected_extensions.add("pg_textsearch") + self.statement_config, self._pgvector_available, self._paradedb_available = resolve_postgres_extension_state( + self.statement_config, self.driver_features, detected_extensions + ) + self._pg_textsearch_available = is_postgres_extension_active(self.driver_features, "pg_textsearch") diff --git a/sqlspec/adapters/adbc/core.py b/sqlspec/adapters/adbc/core.py index 1094f8121..6e4cbd099 100644 --- a/sqlspec/adapters/adbc/core.py +++ b/sqlspec/adapters/adbc/core.py @@ -1,6 +1,6 @@ """ADBC adapter compiled helpers.""" -import datetime +import datetime as dt import decimal from collections.abc import Sized from functools import lru_cache, partial @@ -11,8 +11,7 @@ from sqlglot import exp from sqlglot.errors import ParseError -import sqlspec.adapters.adbc._typing as _adbc_typing -from sqlspec.adapters.adbc.type_converter import get_adbc_type_converter +from sqlspec.adapters.adbc.type_converter import ADBCOutputConverter, get_adbc_type_converter from sqlspec.core import ( DriverParameterProfile, ParameterStyle, @@ -20,12 +19,6 @@ build_null_pruning_transform, build_statement_config_from_profile, ) -from sqlspec.core.config_runtime import ( - build_postgres_extension_probe_names, - is_postgres_extension_active, - resolve_postgres_extension_state, - resolve_runtime_statement_config, -) from sqlspec.core.parameters import type_coercion_dispatcher from sqlspec.exceptions import ( CheckViolationError, @@ -46,7 +39,7 @@ _classify_timeout_or_cancellation, map_sqlstate_to_exception, ) -from sqlspec.typing import PGVECTOR_INSTALLED, Empty +from sqlspec.typing import PGVECTOR_INSTALLED, Empty, import_optional_attr from sqlspec.utils.module_loader import import_string from sqlspec.utils.serializers import to_json from sqlspec.utils.type_converters import build_uuid_coercions @@ -60,10 +53,8 @@ from sqlspec.utils.dispatch import TypeDispatcher __all__ = ( - "_prepare_batch_with_casts", "apply_driver_features", "build_connection_config", - "build_postgres_extension_probe_names", "build_profile", "collect_rows", "create_mapped_exception", @@ -73,14 +64,16 @@ "driver_kind_from_driver_name", "driver_kind_from_uri", "driver_profile", + "enforce_strict_type_coercion", "get_statement_config", "handle_postgres_rollback", "is_postgres_dialect", - "is_postgres_extension_active", "is_shared_object_driver", + "normalize_connection_aliases", "normalize_driver_path", "normalize_postgres_empty_parameters", "normalize_script_rowcount", + "prepare_batch_with_casts", "prepare_parameters_with_casts", "prepare_postgres_parameters", "prepare_postgres_uuid_bindings", @@ -93,14 +86,31 @@ "resolve_driver_name_from_config", "resolve_many_rowcount", "resolve_parameter_casts", - "resolve_parameter_styles", - "resolve_postgres_extension_state", "resolve_rowcount", - "resolve_runtime_statement_config", ) COLUMN_CACHE_MAX_SIZE: int = 256 _UUID_TYPES: Final[tuple[type[Any], ...]] = tuple(build_uuid_coercions()) +_STRICT_COERCION_ALLOWED_TYPES: Final[tuple[type[Any], ...]] = ( + type(None), + bool, + int, + float, + str, + bytes, + bytearray, + memoryview, + decimal.Decimal, + dt.date, + dt.time, + dt.datetime, + dt.timedelta, + UUID, + list, + tuple, + dict, + *_UUID_TYPES, +) DIALECT_PATTERNS: "dict[str, tuple[str, ...]]" = { "postgres": ("postgres", "postgresql"), @@ -117,7 +127,10 @@ "postgresql": (ParameterStyle.NUMERIC, [ParameterStyle.NUMERIC]), "bigquery": (ParameterStyle.NAMED_AT, [ParameterStyle.NAMED_AT]), "sqlite": (ParameterStyle.QMARK, [ParameterStyle.QMARK]), - "duckdb": (ParameterStyle.QMARK, [ParameterStyle.QMARK, ParameterStyle.NUMERIC, ParameterStyle.NAMED_DOLLAR]), + "duckdb": ( + ParameterStyle.QMARK, + [ParameterStyle.QMARK, ParameterStyle.NUMERIC, ParameterStyle.NAMED_DOLLAR, ParameterStyle.NAMED_COLON], + ), "mysql": (ParameterStyle.POSITIONAL_PYFORMAT, [ParameterStyle.POSITIONAL_PYFORMAT, ParameterStyle.NAMED_PYFORMAT]), "snowflake": (ParameterStyle.QMARK, [ParameterStyle.QMARK, ParameterStyle.NUMERIC]), } @@ -163,15 +176,6 @@ ("grpc", "sqlite"), ) -_PARAMETER_STYLES_BY_KEYWORD: "tuple[tuple[str, tuple[tuple[str, ...], str]], ...]" = ( - ("postgresql", (("numeric",), "numeric")), - ("sqlite", (("qmark", "named_colon"), "qmark")), - ("duckdb", (("qmark", "numeric"), "qmark")), - ("gizmosql", (("qmark", "numeric"), "qmark")), - ("bigquery", (("named_at",), "named_at")), - ("snowflake", (("qmark", "numeric"), "qmark")), -) - _BIGQUERY_DB_KWARGS_OPTIONS: "tuple[tuple[str, str], ...]" = ( ("project_id", "adbc.bigquery.sql.project_id"), ("dataset_id", "adbc.bigquery.sql.dataset_id"), @@ -321,8 +325,9 @@ def normalize_driver_path(driver_name: str) -> str: def driver_from_uri(uri: str) -> "str | None": """Resolve a default driver connect path from a URI.""" + lowered = uri.lower() for prefix, driver_path in _URI_PREFIX_DRIVER: - if uri.startswith(prefix): + if lowered.startswith(prefix): return driver_path return None @@ -343,10 +348,10 @@ def driver_kind_from_driver_name(driver_name: str) -> "str | None": def driver_kind_from_uri(uri: str) -> "str | None": """Return a canonical driver kind based on URI scheme.""" lowered = uri.lower() - if lowered.startswith(("gizmosql://", "gizmo://", "grpc+tls://")): + if lowered.startswith(("gizmosql://", "gizmo://")): return "gizmosql" for prefix, driver_path in _URI_PREFIX_DRIVER: - if uri.startswith(prefix): + if lowered.startswith(prefix): return driver_kind_from_driver_name(driver_path) return None @@ -440,40 +445,22 @@ def resolve_dialect_from_config(connection_config: "Mapping[str, Any]") -> str: ) if isinstance(uri, str): lowered_uri = uri.lower() - if lowered_uri.startswith(("gizmosql://", "gizmo://", "grpc+tls://")): + if lowered_uri.startswith(("gizmosql://", "gizmo://")): + return "duckdb" + if driver_name is None and lowered_uri.startswith("grpc+tls://"): return "duckdb" return resolve_dialect_from_driver_path(resolve_driver_name_from_config(connection_config)) -def resolve_parameter_styles(driver_path: str | None, logger: Any | None = None) -> "tuple[tuple[str, ...], str]": - """Resolve supported and default parameter styles from a driver path. - - Args: - driver_path: Import path for the ADBC driver connect function. - logger: Optional logger for diagnostics. - - Returns: - Tuple of (supported_parameter_styles, default_parameter_style) - """ - if isinstance(driver_path, str): - lowered = driver_path.lower() - for keyword, styles in _PARAMETER_STYLES_BY_KEYWORD: - if keyword in lowered: - return styles - if logger is not None: - logger.debug("Error resolving parameter styles, using defaults") - return (("qmark",), "qmark") - - -def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": - """Build a normalized connection configuration dictionary. +def normalize_connection_aliases(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": + """Normalize URI and file-path aliases in an ADBC connection config mapping. Args: connection_config: Raw connection configuration mapping. Returns: - Normalized connection configuration dictionary. + Dictionary with normalized ``uri`` / ``path`` keys. """ config = dict(connection_config) @@ -503,11 +490,7 @@ def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str ) if db_path is not None: config["uri"] = db_path - uri = db_path - if driver_kind is None: - driver_kind = "sqlite" - - if driver_kind == "duckdb" and "path" not in config: + elif driver_kind == "duckdb" and "path" not in config: duck_path = ( config.pop("database", None) or config.pop("db", None) @@ -517,9 +500,33 @@ def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str if duck_path is not None: config["path"] = duck_path - if isinstance(uri, str) and driver_kind == "sqlite" and uri.startswith("sqlite://"): + return config + + +def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": + """Build a normalized connection configuration dictionary. + + Args: + connection_config: Raw connection configuration mapping. + + Returns: + Normalized connection configuration dictionary. + """ + config = normalize_connection_aliases(connection_config) + + driver_name = config.get("driver_name") + uri = config.get("uri") + driver_kind: str | None = None + if isinstance(driver_name, str): + driver_kind = driver_kind_from_driver_name(driver_name) + if driver_kind is None and isinstance(uri, str): + driver_kind = driver_kind_from_uri(uri) + if driver_kind is None and "uri" in config: + driver_kind = "sqlite" + + if isinstance(uri, str) and driver_kind == "sqlite" and uri.lower().startswith("sqlite://"): config["uri"] = uri[9:] - if isinstance(uri, str) and driver_kind == "duckdb" and uri.startswith("duckdb://"): + if isinstance(uri, str) and driver_kind == "duckdb" and uri.lower().startswith("duckdb://"): config["path"] = uri[9:] config.pop("uri", None) @@ -599,32 +606,6 @@ def handle_postgres_rollback(dialect: str, cursor: Any, logger: Any | None = Non _log_rollback(logger) -def _try_rollback(rollback: "Callable[[], Any]") -> bool: - """Run a rollback callable and report whether it succeeded. - - Args: - rollback: Zero-argument callable performing the rollback. - - Returns: - True if the rollback completed without raising. - """ - try: - rollback() - except Exception: - return False - return True - - -def _log_rollback(logger: "Any | None") -> None: - """Record that a PostgreSQL rollback completed. - - Args: - logger: Optional logger for diagnostics. - """ - if logger is not None: - logger.debug("PostgreSQL rollback executed after transaction failure") - - def normalize_postgres_empty_parameters(dialect: str, parameters: Any) -> Any: """Normalize empty parameter payloads for PostgreSQL drivers. @@ -650,10 +631,12 @@ def prepare_postgres_parameters( ) -> Any: """Prepare Postgres parameters with cast-aware coercion.""" postgres_compatible = normalize_postgres_empty_parameters(dialect, parameters) - converter = get_adbc_type_converter(dialect) + converter = get_adbc_type_converter(dialect, json_serializer=json_serializer) if isinstance(postgres_compatible, (list, tuple)) and hasattr(converter, "convert_sequence"): seq = [ - converter.convert_sequence(item) if isinstance(item, (list, tuple)) else item + converter.convert_sequence(item, json_serializer=json_serializer) + if isinstance(item, (list, tuple)) + else item for item in postgres_compatible ] postgres_compatible = tuple(seq) if isinstance(postgres_compatible, tuple) else seq @@ -740,7 +723,7 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec if "deadlock" in error_msg: return _create_adbc_error(error, DeadlockError, "deadlock detected") if "serialization" in error_msg or "concurrent update" in error_msg: - return _create_adbc_error(error, DeadlockError, "serialization failure") + return _create_adbc_error(error, SerializationConflictError, "serialization failure") if message_class := _classify_timeout_or_cancellation(error_msg): return _create_adbc_error(error, message_class, "query terminated") @@ -757,35 +740,6 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec return _create_adbc_error(error, SQLSpecError, "database error") -def _identity(value: Any) -> Any: - return value - - -def _convert_array_for_postgres_adbc(value: Any) -> Any: - """Convert array values for PostgreSQL compatibility.""" - - if isinstance(value, tuple): - return list(value) - return value - - -_BASE_TYPE_COERCION_MAP: Final[dict[type, Any]] = { - datetime.datetime: _identity, - datetime.date: _identity, - datetime.time: _identity, - decimal.Decimal: float, - bool: _identity, - int: _identity, - float: _identity, - bytes: _identity, - tuple: _convert_array_for_postgres_adbc, - list: _convert_array_for_postgres_adbc, - dict: _identity, - UUID: _identity, - **build_uuid_coercions(native=True), -} - - def build_profile() -> "DriverParameterProfile": """Create the ADBC driver parameter profile.""" @@ -802,10 +756,10 @@ def build_profile() -> "DriverParameterProfile": preserve_original_params_for_many=False, json_serializer_strategy="helper", custom_type_coercions={ - datetime.datetime: _identity, - datetime.date: _identity, - datetime.time: _identity, - decimal.Decimal: float, + dt.datetime: _identity, + dt.date: _identity, + dt.time: _identity, + decimal.Decimal: _identity, bool: _identity, int: _identity, float: _identity, @@ -842,6 +796,7 @@ def get_statement_config(detected_dialect: str) -> StatementConfig: if detected_dialect == "duckdb": parameter_overrides["preserve_parameter_format"] = False parameter_overrides["supported_execution_parameter_styles"] = {ParameterStyle.QMARK, ParameterStyle.NUMERIC} + parameter_overrides["ast_transformer"] = _transform_duckdb_copy_parameters if is_postgres_dialect(detected_dialect): parameter_overrides["ast_transformer"] = build_null_pruning_transform(dialect=sqlglot_dialect) @@ -882,6 +837,25 @@ def apply_driver_features( return statement_config, processed_features +def enforce_strict_type_coercion(parameters: Any, *, is_many: bool = False) -> None: + """Validate parameter values when ``strict_type_coercion`` is enabled. + + Args: + parameters: Parameter payload to validate. + is_many: Whether the payload represents an execute-many batch. + + Raises: + SQLSpecError: If any parameter value is not a supported SQL/Arrow scalar or container type. + """ + if parameters is None: + return + if is_many and isinstance(parameters, (list, tuple)): + for row in parameters: + _validate_strict_parameter_payload(row) + return + _validate_strict_parameter_payload(parameters) + + def resolve_column_names(description: "list[Any] | None", cache: "dict[int, tuple[Any, list[str]]]") -> list[str]: """Resolve and cache ADBC cursor description column names.""" if not description: @@ -1005,6 +979,34 @@ def prepare_parameters_with_casts( return parameters +def prepare_batch_with_casts( + parameter_sets: Any, + parameter_casts: "dict[int, str]", + statement_config: "StatementConfig", + *, + dialect: str, + json_serializer: "Callable[[Any], str]", +) -> Any: + """Prepare an execute-many batch while sharing converter setup across rows.""" + if not isinstance(parameter_sets, (list, tuple)): + return normalize_postgres_empty_parameters(dialect, parameter_sets) + + postgres_compatible = [normalize_postgres_empty_parameters(dialect, row) for row in parameter_sets] + if not parameter_casts: + return postgres_compatible + + json_encoder = statement_config.parameter_config.json_serializer or json_serializer + converter = get_adbc_type_converter(dialect) + type_map = statement_config.parameter_config.type_coercion_map + dispatcher = type_coercion_dispatcher(tuple(type_map.items())) if type_map else None + return [ + _prepare_parameter_sequence_with_casts(row, parameter_casts, type_map, dispatcher, converter, json_encoder) + if isinstance(row, (list, tuple)) + else row + for row in postgres_compatible + ] + + def prepare_postgres_uuid_bindings( compiled_sql: str, prepared_parameters: Any, *, is_many: bool, dialect: str ) -> "tuple[str, object]": @@ -1047,6 +1049,61 @@ def prepare_postgres_uuid_bindings( return rewritten_sql, converted_parameters +def _try_rollback(rollback: "Callable[[], Any]") -> bool: + """Run a rollback callable and report whether it succeeded. + + Args: + rollback: Zero-argument callable performing the rollback. + + Returns: + True if the rollback completed without raising. + """ + try: + rollback() + except Exception: + return False + return True + + +def _log_rollback(logger: "Any | None") -> None: + """Record that a PostgreSQL rollback completed. + + Args: + logger: Optional logger for diagnostics. + """ + if logger is not None: + logger.debug("PostgreSQL rollback executed after transaction failure") + + +def _identity(value: Any) -> Any: + return value + + +def _convert_array_for_postgres_adbc(value: Any) -> Any: + """Convert array values for PostgreSQL compatibility.""" + + if isinstance(value, tuple): + return list(value) + return value + + +_BASE_TYPE_COERCION_MAP: Final[dict[type, Any]] = { + dt.datetime: _identity, + dt.date: _identity, + dt.time: _identity, + decimal.Decimal: _identity, + bool: _identity, + int: _identity, + float: _identity, + bytes: _identity, + tuple: _convert_array_for_postgres_adbc, + list: _convert_array_for_postgres_adbc, + dict: _identity, + UUID: _identity, + **build_uuid_coercions(native=True), +} + + def _parameter_ordinal(parameter: exp.Parameter) -> "int | None": value = parameter.this if not isinstance(value, exp.Literal) or value.is_string: @@ -1284,6 +1341,89 @@ def _normalize_adbc_driver_features(processed_features: "dict[str, Any]") -> "di return processed_features +def _validate_strict_parameter_value(value: Any) -> None: + if isinstance(value, (list, tuple)): + for item in value: + _validate_strict_parameter_value(item) + return + if isinstance(value, dict): + for item in value.values(): + _validate_strict_parameter_value(item) + return + if not isinstance(value, _STRICT_COERCION_ALLOWED_TYPES): + msg = f"Unsupported parameter type for strict_type_coercion: {type(value).__name__}" + raise SQLSpecError(msg) + + +def _validate_strict_parameter_payload(parameters: Any) -> None: + if isinstance(parameters, (list, tuple)): + for item in parameters: + _validate_strict_parameter_value(item) + elif isinstance(parameters, dict): + for item in parameters.values(): + _validate_strict_parameter_value(item) + else: + _validate_strict_parameter_value(parameters) + + +def _transform_duckdb_copy_parameters( + expression: Any, parameters: Any, _parameter_profile: Any = None, is_many: bool = False +) -> "tuple[Any, Any]": + """Inline bound file-target placeholders in DuckDB ``COPY ... TO/FROM`` statements.""" + if is_many or not isinstance(expression, exp.Copy) or not isinstance(parameters, (list, tuple)) or not parameters: + return expression, parameters + + files = expression.args.get("files") + if not isinstance(files, list) or not files: + return expression, parameters + + this_node = expression.this + prefix_count = ( + len(list(this_node.find_all(exp.Placeholder, exp.Parameter))) if isinstance(this_node, exp.Expression) else 0 + ) + + inlined_indices: set[int] = set() + new_files: list[Any] = list(files) + file_qmark_offset = 0 + for idx, file_expr in enumerate(new_files): + placeholder_node: exp.Expression | None = None + if isinstance(file_expr, (exp.Placeholder, exp.Parameter)): + placeholder_node = file_expr + elif isinstance(file_expr, exp.Table) and isinstance(file_expr.this, (exp.Placeholder, exp.Parameter)): + placeholder_node = file_expr.this + + if placeholder_node is None: + continue + + param_idx: int | None = None + if isinstance(placeholder_node, exp.Parameter): + ordinal = _parameter_ordinal(placeholder_node) + if ordinal is not None: + param_idx = ordinal - 1 + elif isinstance(placeholder_node, exp.Placeholder): + raw_val = placeholder_node.this + if raw_val is not None and str(raw_val).lstrip("$").isdigit(): + param_idx = int(str(raw_val).lstrip("$")) - 1 + + if param_idx is None: + param_idx = prefix_count + file_qmark_offset + file_qmark_offset += 1 + + if 0 <= param_idx < len(parameters): + new_files[idx] = exp.Literal.string(str(parameters[param_idx])) + inlined_indices.add(param_idx) + + if not inlined_indices: + return expression, parameters + + transformed_copy = expression.copy() + transformed_copy.set("files", new_files) + remaining = [val for idx, val in enumerate(parameters) if idx not in inlined_indices] + if not remaining: + return transformed_copy, [] + return transformed_copy, tuple(remaining) if isinstance(parameters, tuple) else remaining + + def _apply_adbc_json_serializer( statement_config: "StatementConfig", json_serializer: "Callable[[Any], str]" ) -> "StatementConfig": @@ -1328,7 +1468,10 @@ def _prepare_parameter_sequence_with_casts( else: result.append(param) elif isinstance(param, dict): - result.append(converter.convert_dict(param)) + if isinstance(converter, ADBCOutputConverter): + result.append(converter.convert_dict(param, json_serializer=json_encoder)) + else: + result.append(converter.convert_dict(param)) elif isinstance(param, (list, tuple)): if type_map and dispatcher is not None: exact_converter = type_map.get(type(param)) @@ -1339,9 +1482,15 @@ def _prepare_parameter_sequence_with_casts( if converter_func is not None: param = converter_func(param) elif hasattr(converter, "convert_sequence"): - param = converter.convert_sequence(param) + if isinstance(converter, ADBCOutputConverter): + param = converter.convert_sequence(param, json_serializer=json_encoder) + else: + param = converter.convert_sequence(param) elif hasattr(converter, "convert_sequence"): - param = converter.convert_sequence(param) + if isinstance(converter, ADBCOutputConverter): + param = converter.convert_sequence(param, json_serializer=json_encoder) + else: + param = converter.convert_sequence(param) result.append(param) else: if type_map and dispatcher is not None: @@ -1356,38 +1505,12 @@ def _prepare_parameter_sequence_with_casts( return tuple(result) if isinstance(parameters, tuple) else result -def _prepare_batch_with_casts( - parameter_sets: Any, - parameter_casts: "dict[int, str]", - statement_config: "StatementConfig", - *, - dialect: str, - json_serializer: "Callable[[Any], str]", -) -> Any: - """Prepare an execute-many batch while sharing converter setup across rows.""" - if not isinstance(parameter_sets, (list, tuple)): - return normalize_postgres_empty_parameters(dialect, parameter_sets) - - postgres_compatible = [normalize_postgres_empty_parameters(dialect, row) for row in parameter_sets] - if not parameter_casts: - return postgres_compatible - - json_encoder = statement_config.parameter_config.json_serializer or json_serializer - converter = get_adbc_type_converter(dialect) - type_map = statement_config.parameter_config.type_coercion_map - dispatcher = type_coercion_dispatcher(tuple(type_map.items())) if type_map else None - return [ - _prepare_parameter_sequence_with_casts(row, parameter_casts, type_map, dispatcher, converter, json_encoder) - if isinstance(row, (list, tuple)) - else row - for row in postgres_compatible - ] +_prepare_batch_with_casts = prepare_batch_with_casts def _resolve_flightsql_db_kwargs_keys() -> "tuple[str, str]": - try: - database_options = _adbc_typing.AdbcFlightSqlDatabaseOptions - except ImportError: + database_options = import_optional_attr("adbc_driver_flightsql", "DatabaseOptions") + if database_options is None: return _FLIGHTSQL_TLS_SKIP_VERIFY_KEY, _FLIGHTSQL_AUTHORIZATION_HEADER_KEY return database_options.TLS_SKIP_VERIFY.value, database_options.AUTHORIZATION_HEADER.value diff --git a/sqlspec/adapters/adbc/data_dictionary.py b/sqlspec/adapters/adbc/data_dictionary.py index 290c77e8f..2ac108181 100644 --- a/sqlspec/adapters/adbc/data_dictionary.py +++ b/sqlspec/adapters/adbc/data_dictionary.py @@ -45,6 +45,7 @@ from sqlspec.driver import SyncDataDictionaryBase from sqlspec.exceptions import OperationalError, SQLFileNotFoundError from sqlspec.utils.logging import get_logger +from sqlspec.utils.module_loader import ensure_pyarrow, import_optional from sqlspec.utils.text import normalize_identifier if TYPE_CHECKING: @@ -56,6 +57,7 @@ __all__ = ("AdbcDataDictionary",) logger = get_logger("sqlspec.adapters.adbc") +_pa: Any = import_optional("pyarrow") _NATIVE_TABLE_TYPES: Final = frozenset({"table", "base table"}) _NATIVE_FALLBACK_ERRORS: Final = (AdbcNotSupportedError, AdbcOperationalError) @@ -109,13 +111,16 @@ def __init__(self) -> None: super().__init__() def get_version(self, driver: "AdbcDriver") -> "VersionInfo | None": - """Get database version information based on detected dialect.""" + """Get database version information based on detected dialect. + + Inlines the version cache check to avoid cross-module method calls + across MyPyC compilation boundaries. + """ dialect = self._normalize_dialect(driver) if dialect == "bigquery": return None driver_id = id(driver) - # Inline cache check to avoid cross-module method call that causes mypyc segfault if driver_id in self._version_fetch_attempted: return self._version_cache.get(driver_id) @@ -958,9 +963,8 @@ def _normalize_native_constraints( def _arrow_type_to_sql(data_type: Any) -> str: - import pyarrow as pa - - types = pa.types + ensure_pyarrow() + types = _pa.types if types.is_boolean(data_type): return "BOOLEAN" if types.is_int8(data_type) or types.is_int16(data_type) or types.is_uint8(data_type) or types.is_uint16(data_type): diff --git a/sqlspec/adapters/adbc/driver.py b/sqlspec/adapters/adbc/driver.py index aa25f5e21..278b69e3e 100644 --- a/sqlspec/adapters/adbc/driver.py +++ b/sqlspec/adapters/adbc/driver.py @@ -9,16 +9,18 @@ from sqlspec.adapters.adbc._typing import AdbcCursor, AdbcNativeError, AdbcSessionContext from sqlspec.adapters.adbc.core import ( - _prepare_batch_with_casts, + apply_driver_features, collect_rows, create_mapped_exception, detect_dialect, driver_profile, + enforce_strict_type_coercion, get_statement_config, handle_postgres_rollback, is_postgres_dialect, normalize_postgres_empty_parameters, normalize_script_rowcount, + prepare_batch_with_casts, prepare_postgres_parameters, prepare_postgres_uuid_bindings, resolve_column_names, @@ -40,9 +42,9 @@ from sqlspec.exceptions import DatabaseConnectionError, SQLSpecError from sqlspec.utils.arrow_helpers import arrow_reader_with_deferred_close, arrow_table_to_pylist from sqlspec.utils.logging import get_logger -from sqlspec.utils.module_loader import ensure_pyarrow +from sqlspec.utils.module_loader import ensure_pyarrow, import_optional from sqlspec.utils.serializers import to_json -from sqlspec.utils.text import normalize_identifier, quote_identifier +from sqlspec.utils.text import normalize_identifier, quote_identifier, split_qualified_identifier if TYPE_CHECKING: from collections.abc import Callable @@ -57,9 +59,12 @@ __all__ = ("AdbcCursor", "AdbcDriver", "AdbcExceptionHandler", "AdbcSessionContext") logger = get_logger("sqlspec.adapters.adbc") +_pa: Any = import_optional("pyarrow") _DIALECTS_WITHOUT_SAVEPOINTS = frozenset({"bigquery", "duckdb", "snowflake"}) _MULTI_ROW_BIND_UNSUPPORTED = "Binding multiple rows at once is not supported" +_SCHEMA_QUALIFIED_PARTS = 2 +_CATALOG_QUALIFIED_PARTS = 3 @final @@ -99,6 +104,7 @@ def __init__(self, driver: "AdbcDriver", sql: str, parameters: Any) -> None: def start(self) -> None: cursor_manager = self._driver.with_cursor(self._driver.connection) + cursor: Any = None reader: Any = None try: cursor = cursor_manager.__enter__() @@ -109,13 +115,12 @@ def start(self) -> None: reader = _fetch_record_batch(cursor) self._driver._check_pending_exception(handler) except BaseException: + if cursor is not None: + handle_postgres_rollback(self._driver._dialect_name, cursor, logger) with contextlib.suppress(Exception): cursor_manager.__exit__(None, None, None) raise - if reader is None: - msg = "ADBC did not return a record batch reader." - raise SQLSpecError(msg) self._cursor_manager = cursor_manager self._reader = iter(reader) @@ -174,14 +179,25 @@ def __init__( *, dialect: "str | None" = None, ) -> None: - self._detected_dialect = detect_dialect(connection, logger, fallback_dialect=dialect) + configured_dialect = resolve_dialect_name(statement_config.dialect) if statement_config is not None else None + fallback_dialect = dialect or configured_dialect + self._detected_dialect = detect_dialect(connection, logger, fallback_dialect=fallback_dialect) self._is_flightsql = self._detect_flightsql_connection(connection) if statement_config is None: base_config = get_statement_config(self._detected_dialect) statement_config = base_config.replace(enable_caching=get_cache_config().compiled_cache_enabled) + elif ( + dialect is None + and configured_dialect + and self._detected_dialect != configured_dialect + and not (self._detected_dialect == "postgres" and is_postgres_dialect(configured_dialect)) + ): + base_config = get_statement_config(self._detected_dialect) + statement_config = base_config.replace(enable_caching=statement_config.enable_caching) - super().__init__(connection=connection, statement_config=statement_config, driver_features=driver_features) + statement_config, normalized_features = apply_driver_features(statement_config, driver_features) + super().__init__(connection=connection, statement_config=statement_config, driver_features=normalized_features) self.dialect = statement_config.dialect self._dialect_name = dialect or resolve_dialect_name(self.dialect) self._is_postgres = is_postgres_dialect(self._dialect_name) @@ -190,15 +206,16 @@ def __init__( self._column_name_cache: dict[int, tuple[Any, list[str]]] = {} self._transaction_active = False - def _compiled_sql( - self, statement: "SQL", statement_config: "StatementConfig", flatten_single_parameters: bool = False - ) -> "tuple[str, object]": - compiled_sql, prepared_parameters = super()._compiled_sql( - statement, statement_config, flatten_single_parameters=flatten_single_parameters - ) - return prepare_postgres_uuid_bindings( - compiled_sql, prepared_parameters, is_many=statement.is_many, dialect=self._dialect_name - ) + @property + def data_dictionary(self) -> "AdbcDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = AdbcDataDictionary() + return self._data_dictionary def dispatch_execute(self, cursor: "AdbcRawCursor", statement: SQL) -> "ExecutionResult": """Execute single SQL statement. @@ -309,7 +326,9 @@ def dispatch_execute_script(self, cursor: "AdbcRawCursor", statement: "SQL") -> last_rowcount = 0 try: for stmt in statements: - if prepared_parameters: + if prepared_parameters and ( + len(statements) == 1 or any(marker in stmt for marker in ("?", "$", "%", "@", ":")) + ): postgres_compatible_params = normalize_postgres_empty_parameters( self._dialect_name, prepared_parameters ) @@ -353,17 +372,16 @@ def commit(self) -> None: try: self.connection.commit() except AdbcNativeError as e: - msg = f"Failed to commit transaction: {e}" - raise SQLSpecError(msg) from e - self._transaction_active = False + raise create_mapped_exception(e) from e + finally: + self._transaction_active = False def rollback(self) -> None: """Rollback database transaction.""" try: self.connection.rollback() except AdbcNativeError as e: - msg = f"Failed to rollback transaction: {e}" - raise SQLSpecError(msg) from e + raise create_mapped_exception(e) from e finally: self._transaction_active = False @@ -516,19 +534,30 @@ def select_to_arrow( raise if exc_handler.pending_exception is not None: + if cursor is not None: + with contextlib.suppress(Exception): + cursor_manager.__exit__(None, None, None) raise exc_handler.pending_exception from None if arrow_result is not None: return arrow_result if cursor is None or reader is None: + if cursor is not None: + with contextlib.suppress(Exception): + cursor_manager.__exit__(None, None, None) msg = "ADBC did not return an Arrow reader." raise SQLSpecError(msg) - return build_arrow_result_from_reader( - prepared_statement, - arrow_reader_with_deferred_close(reader, cursor.close), - return_format=return_format, - batch_size=batch_size, - arrow_schema=arrow_schema, - ) + try: + return build_arrow_result_from_reader( + prepared_statement, + arrow_reader_with_deferred_close(reader, cursor.close), + return_format=return_format, + batch_size=batch_size, + arrow_schema=arrow_schema, + ) + except BaseException: + with contextlib.suppress(Exception): + cursor_manager.__exit__(None, None, None) + raise with self.with_cursor(self.connection) as cursor, exc_handler: if cursor is None: @@ -597,16 +626,21 @@ def load_from_arrow( self._require_capability("arrow_import_enabled") ensure_pyarrow() - import pyarrow as pa - source_data = source.get_data() if hasattr(source, "get_data") else source + source_data: Any = source.get_data() if hasattr(source, "get_data") else source ingest_mode: Literal["append", "create", "replace", "create_append"] ingest_mode = "replace" if overwrite else "create_append" - if isinstance(source_data, pa.RecordBatchReader): + target_table, ingest_kwargs = _parse_adbc_ingest_target(table, mode=ingest_mode) + + if isinstance(source_data, _pa.RecordBatchReader): row_count = 0 exc_handler = self.handle_database_exceptions() with self.with_cursor(self.connection) as cursor, exc_handler: - ingest_result = cursor.adbc_ingest(table, source_data, mode=ingest_mode) + try: + ingest_result = cursor.adbc_ingest(target_table, source_data, **ingest_kwargs) + except Exception: + handle_postgres_rollback(self._dialect_name, cursor, logger) + raise if isinstance(ingest_result, int) and ingest_result > 0: row_count = ingest_result @@ -624,7 +658,11 @@ def load_from_arrow( arrow_table = self._coerce_arrow_table(source_data) exc_handler = self.handle_database_exceptions() with self.with_cursor(self.connection) as cursor, exc_handler: - cursor.adbc_ingest(table, arrow_table, mode=ingest_mode) + try: + cursor.adbc_ingest(target_table, arrow_table, **ingest_kwargs) + except Exception: + handle_postgres_rollback(self._dialect_name, cursor, logger) + raise if exc_handler.pending_exception is not None: raise exc_handler.pending_exception from None @@ -647,17 +685,6 @@ def load_from_storage( arrow_table, inbound = self._read_storage_arrow(source, file_format=file_format) return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound) - @property - def data_dictionary(self) -> "AdbcDataDictionary": - """Get the data dictionary for this driver. - - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = AdbcDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: "AdbcRawCursor", fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect ADBC rows for the direct execution path.""" column_names = self._resolve_column_names(cursor.description) @@ -692,6 +719,9 @@ def prepare_driver_parameters( Returns: Parameters with cast-aware type coercion applied """ + if self.driver_features.get("enable_strict_type_coercion", False): + enforce_strict_type_coercion(parameters, is_many=is_many) + enable_cast_detection = self.driver_features.get("enable_cast_detection", True) if enable_cast_detection and prepared_statement and self._is_postgres: prepared_parameters = super().prepare_driver_parameters( @@ -701,7 +731,7 @@ def prepare_driver_parameters( if is_many: if not parameter_casts or not isinstance(prepared_parameters, (list, tuple)): return prepared_parameters - return _prepare_batch_with_casts( + return prepare_batch_with_casts( prepared_parameters, parameter_casts, statement_config, @@ -718,6 +748,16 @@ def prepare_driver_parameters( return super().prepare_driver_parameters(parameters, statement_config, is_many, prepared_statement) + def _compiled_sql( + self, statement: "SQL", statement_config: "StatementConfig", flatten_single_parameters: bool = False + ) -> "tuple[str, object]": + compiled_sql, prepared_parameters = super()._compiled_sql( + statement, statement_config, flatten_single_parameters=flatten_single_parameters + ) + return prepare_postgres_uuid_bindings( + compiled_sql, prepared_parameters, is_many=statement.is_many, dialect=self._dialect_name + ) + def _connection_in_transaction(self) -> bool: """Check if connection is in transaction. @@ -771,12 +811,33 @@ def _resolve_count_result_rowcount(cursor: "AdbcRawCursor", *, fallback: int) -> return fallback +def _parse_adbc_ingest_target( + table: str, *, mode: Literal["append", "create", "replace", "create_append"] +) -> "tuple[str, dict[str, Any]]": + kwargs: dict[str, Any] = {"mode": mode} + if "." not in table: + return table, kwargs + parts = split_qualified_identifier(table, quote_chars='"') + if len(parts) == _SCHEMA_QUALIFIED_PARTS: + kwargs["db_schema_name"] = parts[0] + return parts[1], kwargs + if len(parts) >= _CATALOG_QUALIFIED_PARTS: + kwargs["catalog_name"] = parts[0] + kwargs["db_schema_name"] = parts[1] + return parts[-1], kwargs + return table, kwargs + + def _fetch_record_batch(cursor: Any) -> Any: fetch_record_batch = getattr(cursor, "fetch_record_batch", None) if fetch_record_batch is None: msg = "ADBC cursor does not expose fetch_record_batch() for native row streaming." raise SQLSpecError(msg) - return fetch_record_batch() + reader = fetch_record_batch() + if reader is None: + msg = "ADBC did not return a record batch reader." + raise SQLSpecError(msg) + return reader register_driver_profile("adbc", driver_profile) diff --git a/sqlspec/adapters/adbc/events/store.py b/sqlspec/adapters/adbc/events/store.py index dee159618..14f701029 100644 --- a/sqlspec/adapters/adbc/events/store.py +++ b/sqlspec/adapters/adbc/events/store.py @@ -14,6 +14,7 @@ import logging from typing import TYPE_CHECKING, Final +from sqlspec.adapters.adbc.core import is_postgres_dialect, resolve_dialect_from_config from sqlspec.extensions.events import BaseEventQueueStore from sqlspec.utils.logging import get_logger, log_with_context @@ -72,6 +73,28 @@ def dialect(self) -> str: self._dialect = self._detect_dialect_from_config() return self._dialect + def create_statements(self) -> "list[str]": + """Return DDL statements for table creation. + + Returns separate statements for table and index to support + databases that require separate execution. + """ + statements = [self._table_ddl()] + index_sql = self._index_ddl() + if index_sql: + statements.append(index_sql) + return statements + + def drop_statements(self) -> "list[str]": + """Return drop statements in reverse dependency order.""" + dialect = self.dialect + + if dialect in {DIALECT_BIGQUERY, DIALECT_SNOWFLAKE}: + return [f"DROP TABLE IF EXISTS {self.table_name}"] + + index_name = self._index_name() + return [f"DROP INDEX IF EXISTS {index_name}", f"DROP TABLE IF EXISTS {self.table_name}"] + def _detect_dialect_from_config(self) -> str: """Detect ADBC driver dialect from connection config. @@ -85,29 +108,27 @@ def _detect_dialect_from_config(self) -> str: driver_lower = str(driver_name).lower() if driver_name else "" uri_lower = str(uri).lower() if uri else "" - if "postgres" in driver_lower or uri_lower.startswith(("postgres://", "postgresql://")): - return DIALECT_POSTGRESQL - if "duckdb" in driver_lower or uri_lower.startswith("duckdb://"): - return DIALECT_DUCKDB - if ( - "gizmosql" in driver_lower - or "gizmo" in driver_lower - or uri_lower.startswith(("gizmosql://", "gizmo://", "grpc+tls://")) - ): - return DIALECT_DUCKDB - if "bigquery" in driver_lower or uri_lower.startswith("bigquery://"): - return DIALECT_BIGQUERY - if "snowflake" in driver_lower or uri_lower.startswith("snowflake://"): - return DIALECT_SNOWFLAKE if "flightsql" in driver_lower or "grpc" in driver_lower or uri_lower.startswith("grpc://"): return DIALECT_FLIGHTSQL - if "sqlite" in driver_lower or uri_lower.startswith("sqlite://"): + + resolved = resolve_dialect_from_config(connection_config) + if is_postgres_dialect(resolved): + return DIALECT_POSTGRESQL + if resolved in {DIALECT_DUCKDB, DIALECT_BIGQUERY, DIALECT_SNOWFLAKE}: + return resolved + if resolved == DIALECT_SQLITE and ( + "sqlite" in driver_lower + or uri_lower.startswith("sqlite://") + or str(connection_config.get("gizmosql_backend", "")).lower() == DIALECT_SQLITE + ): return DIALECT_SQLITE statement_config = self._config.statement_config if statement_config and statement_config.dialect is not None: dialect_str = str(statement_config.dialect).lower() - if dialect_str in {DIALECT_POSTGRESQL, DIALECT_SQLITE, DIALECT_DUCKDB, DIALECT_BIGQUERY, DIALECT_SNOWFLAKE}: + if is_postgres_dialect(dialect_str): + return DIALECT_POSTGRESQL + if dialect_str in {DIALECT_SQLITE, DIALECT_DUCKDB, DIALECT_BIGQUERY, DIALECT_SNOWFLAKE, DIALECT_FLIGHTSQL}: return dialect_str log_with_context( @@ -184,25 +205,3 @@ def _wrap_create_statement(self, statement: str, object_type: str) -> str: def _wrap_drop_statement(self, statement: str) -> str: """Return statement unchanged since ADBC dialects support IF EXISTS.""" return statement - - def create_statements(self) -> "list[str]": - """Return DDL statements for table creation. - - Returns separate statements for table and index to support - databases that require separate execution. - """ - statements = [self._table_ddl()] - index_sql = self._index_ddl() - if index_sql: - statements.append(index_sql) - return statements - - def drop_statements(self) -> "list[str]": - """Return drop statements in reverse dependency order.""" - dialect = self.dialect - - if dialect in {DIALECT_BIGQUERY, DIALECT_SNOWFLAKE}: - return [f"DROP TABLE IF EXISTS {self.table_name}"] - - index_name = self._index_name() - return [f"DROP INDEX IF EXISTS {index_name}", f"DROP TABLE IF EXISTS {self.table_name}"] diff --git a/sqlspec/adapters/adbc/litestar/store.py b/sqlspec/adapters/adbc/litestar/store.py index a7ecf8316..516ee7a71 100644 --- a/sqlspec/adapters/adbc/litestar/store.py +++ b/sqlspec/adapters/adbc/litestar/store.py @@ -16,6 +16,7 @@ from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING +from sqlspec.adapters.adbc.core import is_postgres_dialect from sqlspec.extensions.litestar.store import BaseSQLSpecStore from sqlspec.utils.sync_tools import async_ @@ -155,7 +156,7 @@ def _table_ddl(self) -> str: """ dialect = self._get_dialect() - if dialect in {"postgres", "postgresql"}: + if is_postgres_dialect(dialect): return f""" CREATE TABLE IF NOT EXISTS {self._table_name} ( session_id TEXT PRIMARY KEY, @@ -205,9 +206,7 @@ def _table_ddl(self) -> str: session_id STRING NOT NULL, data BYTES NOT NULL, expires_at TIMESTAMP - ); - CREATE INDEX idx_{self._table_name}_expires_at - ON {self._table_name}(expires_at); + ) """ if dialect == "snowflake": @@ -216,9 +215,7 @@ def _table_ddl(self) -> str: session_id VARCHAR(255) PRIMARY KEY, data BINARY NOT NULL, expires_at TIMESTAMP WITH TIME ZONE - ); - CREATE INDEX IF NOT EXISTS idx_{self._table_name}_expires_at - ON {self._table_name}(expires_at); + ) """ return f""" @@ -242,7 +239,7 @@ def _get_param_placeholder(self, position: int) -> str: """ dialect = self._get_dialect() - if dialect in {"postgres", "postgresql"}: + if is_postgres_dialect(dialect): return f"${position}" if dialect in {"mysql", "mariadb"}: return "%s" @@ -258,7 +255,7 @@ def _get_current_timestamp_expr(self) -> str: """ dialect = self._get_dialect() - if dialect in {"postgres", "postgresql"}: + if is_postgres_dialect(dialect): return "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'" if dialect in {"mysql", "mariadb"}: return "UTC_TIMESTAMP()" @@ -284,6 +281,9 @@ def _drop_table_sql(self) -> "list[str]": """ dialect = self._get_dialect() + if dialect in {"bigquery", "snowflake"}: + return [f"DROP TABLE IF EXISTS {self._table_name}"] + if dialect in {"mysql", "mariadb"}: return [ f"DROP INDEX idx_{self._table_name}_expires_at ON {self._table_name}", @@ -327,7 +327,12 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | return bytes(data) def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None: - """Synchronous implementation of set using ADBC driver with dialect-specific UPSERT.""" + """Synchronous implementation of set using ADBC driver with dialect-specific UPSERT. + + Uses separate SQL statements for ``NULL`` vs non-``NULL`` ``expires_at`` + on PostgreSQL, DuckDB, BigQuery, and Snowflake so ADBC Arrow parameter + binding does not infer an untyped ``na`` Arrow array from ``None``. + """ data = self._value_to_bytes(value) expires_at = self._calculate_expires_at(expires_in) dialect = self._get_dialect() @@ -336,15 +341,13 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No p2 = self._get_param_placeholder(2) p3 = self._get_param_placeholder(3) - if dialect in {"postgres", "postgresql", "sqlite", "duckdb"}: + if is_postgres_dialect(dialect) or dialect in {"sqlite", "duckdb"}: if dialect == "sqlite": sql = f""" INSERT OR REPLACE INTO {self._table_name} (session_id, data, expires_at) VALUES ({p1}, {p2}, {p3}) """ - elif dialect in {"postgres", "postgresql"}: - # ADBC Arrow driver cannot infer type from Python None, causing 'na' type error. - # Use separate SQL statements for NULL vs non-NULL expires_at to avoid the issue. + elif is_postgres_dialect(dialect): if expires_at is None: sql = f""" INSERT INTO {self._table_name} (session_id, data, expires_at) @@ -363,7 +366,6 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No SET data = EXCLUDED.data, expires_at = EXCLUDED.expires_at """ else: - # DuckDB: Same issue with Arrow 'na' type for None values if expires_at is None: sql = f""" INSERT INTO {self._table_name} (session_id, data, expires_at) @@ -394,12 +396,26 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No exists = result and result.get("count", 0) > 0 if exists: + if expires_at is None: + sql = f""" + UPDATE {self._table_name} + SET data = {p1}, expires_at = NULL + WHERE session_id = {p2} + """ + driver.execute(sql, data, key) + else: + sql = f""" + UPDATE {self._table_name} + SET data = {p1}, expires_at = {p2} + WHERE session_id = {p3} + """ + driver.execute(sql, data, expires_at, key) + elif expires_at is None: sql = f""" - UPDATE {self._table_name} - SET data = {p1}, expires_at = {p2} - WHERE session_id = {p3} + INSERT INTO {self._table_name} (session_id, data, expires_at) + VALUES ({p1}, {p2}, NULL) """ - driver.execute(sql, data, expires_at, key) + driver.execute(sql, key, data) else: sql = f""" INSERT INTO {self._table_name} (session_id, data, expires_at) @@ -462,7 +478,7 @@ def _expires_in(self, key: str) -> "int | None": """ with self._config.provide_session() as driver: - result = driver.select_one(sql, key) + result = driver.select_one_or_none(sql, key) if result is None or result.get("expires_at") is None: return None @@ -488,7 +504,7 @@ def _delete_expired(self) -> int: current_ts = self._get_current_timestamp_expr() dialect = self._get_dialect() - if dialect in {"postgres", "postgresql"}: + if is_postgres_dialect(dialect): sql = f"DELETE FROM {self._table_name} WHERE expires_at <= {current_ts} RETURNING session_id" else: count_sql = f"SELECT COUNT(*) as count FROM {self._table_name} WHERE expires_at <= {current_ts}" diff --git a/sqlspec/adapters/adbc/type_converter.py b/sqlspec/adapters/adbc/type_converter.py index 3612b00da..8183897fc 100644 --- a/sqlspec/adapters/adbc/type_converter.py +++ b/sqlspec/adapters/adbc/type_converter.py @@ -5,72 +5,101 @@ MySQL, BigQuery, Snowflake). """ -from typing import Any +from typing import TYPE_CHECKING, Any, Final from sqlspec.utils.serializers import to_json +if TYPE_CHECKING: + from collections.abc import Callable + __all__ = ("ADBCOutputConverter", "get_adbc_type_converter") +_JSON_DICT_DIALECTS: Final[frozenset[str]] = frozenset({ + "postgres", + "postgresql", + "pgvector", + "paradedb", + "pg_textsearch", + "pgtextsearch", + "bigquery", +}) + class ADBCOutputConverter: """ADBC-specific parameter conversion with dialect awareness.""" - __slots__ = ("dialect",) + __slots__ = ("_json_serializer", "dialect") - def __init__(self, dialect: str) -> None: + def __init__(self, dialect: str, *, json_serializer: "Callable[[Any], str]" = to_json) -> None: """Initialize with dialect-specific configuration. Args: dialect: Target database dialect (postgres, sqlite, duckdb, etc.) + json_serializer: JSON serializer callable for dictionary parameters. """ self.dialect = dialect.lower() + self._json_serializer = json_serializer - def convert_dict(self, value: "dict[str, Any]") -> Any: + def convert_dict(self, value: "dict[str, Any]", *, json_serializer: "Callable[[Any], str] | None" = None) -> Any: """Convert dictionary values with dialect-specific handling. Args: value: Dictionary to convert. + json_serializer: Optional per-call JSON serializer override. Returns: Converted value appropriate for the dialect. """ - if self.dialect in { - "postgres", - "postgresql", - "pgvector", - "paradedb", - "pg_textsearch", - "pgtextsearch", - "bigquery", - }: - return to_json(value) - return value - - def convert_sequence(self, value: "list[Any] | tuple[Any, ...]") -> "list[Any]": + if self.dialect in _JSON_DICT_DIALECTS: + serializer = json_serializer or self._json_serializer + return serializer(value) + return { + key: self.convert_dict(val, json_serializer=json_serializer) + if isinstance(val, dict) + else self.convert_sequence(val, json_serializer=json_serializer) + if isinstance(val, (list, tuple)) + else val + for key, val in value.items() + } + + def convert_sequence( + self, value: "list[Any] | tuple[Any, ...]", *, json_serializer: "Callable[[Any], str] | None" = None + ) -> "list[Any]": """Convert sequence/array parameter values with dialect awareness. - Preserves None elements within sequence parameters instead of converting - them to empty strings for PostgreSQL-family dialects. + Recursively converts nested sequences and dictionary items while + preserving ``None`` elements. Args: value: Sequence to convert. + json_serializer: Optional per-call JSON serializer override. Returns: Converted list parameter appropriate for the dialect. """ - items = list(value) - if self.dialect in {"postgres", "postgresql", "pgvector", "paradedb", "pg_textsearch", "pgtextsearch"}: - return [item if item is not None else None for item in items] - return items - - -def get_adbc_type_converter(dialect: str) -> ADBCOutputConverter: + converted: list[Any] = [] + for item in value: + if isinstance(item, dict): + converted.append(self.convert_dict(item, json_serializer=json_serializer)) + elif isinstance(item, (list, tuple)): + converted.append(self.convert_sequence(item, json_serializer=json_serializer)) + else: + converted.append(item) + return converted + + +def get_adbc_type_converter( + dialect: str, cache_size: int = 5000, *, json_serializer: "Callable[[Any], str] | None" = None +) -> ADBCOutputConverter: """Factory function to create dialect-specific ADBC type converter. Args: dialect: Database dialect name. + cache_size: Optional converter cache size accepted for compatibility. + json_serializer: Optional custom JSON serializer callable. Returns: Configured ADBCOutputConverter instance. """ - return ADBCOutputConverter(dialect) + _ = cache_size + return ADBCOutputConverter(dialect, json_serializer=json_serializer or to_json) diff --git a/sqlspec/adapters/aiomysql/__init__.py b/sqlspec/adapters/aiomysql/__init__.py index eb9cbb9a4..48125bd38 100644 --- a/sqlspec/adapters/aiomysql/__init__.py +++ b/sqlspec/adapters/aiomysql/__init__.py @@ -1,3 +1,5 @@ +"""aiomysql database adapter for SQLSpec.""" + from sqlspec.adapters.aiomysql._typing import AiomysqlConnection, AiomysqlCursor from sqlspec.adapters.aiomysql.config import ( AiomysqlConfig, diff --git a/sqlspec/adapters/aiomysql/_typing.py b/sqlspec/adapters/aiomysql/_typing.py index 26fee69b3..c101c8f48 100644 --- a/sqlspec/adapters/aiomysql/_typing.py +++ b/sqlspec/adapters/aiomysql/_typing.py @@ -7,17 +7,17 @@ import contextlib from typing import TYPE_CHECKING, Any -import aiomysql as _aiomysql # pyright: ignore -from aiomysql import Connection # pyright: ignore -from aiomysql import Error as _AiomysqlError # pyright: ignore -from aiomysql import MySQLError as _AiomysqlMySQLError # pyright: ignore -from aiomysql import Pool as _AiomysqlPool # pyright: ignore -from aiomysql import ProgrammingError as AiomysqlProgrammingError # pyright: ignore +import aiomysql as _aiomysql +from aiomysql import Connection +from aiomysql import Error as _AiomysqlError +from aiomysql import MySQLError as _AiomysqlMySQLError +from aiomysql import Pool as _AiomysqlPool +from aiomysql import ProgrammingError as AiomysqlProgrammingError from aiomysql import SSCursor as AiomysqlSSCursor from aiomysql.cursors import RE_INSERT_VALUES as AIOMYSQL_INSERT_VALUES_PATTERN -from aiomysql.cursors import Cursor as _AiomysqlCursor # pyright: ignore -from aiomysql.cursors import DictCursor as _AiomysqlDictCursor # pyright: ignore -from pymysql.constants import FIELD_TYPE as _PYMYSQL_FIELD_TYPE # pyright: ignore +from aiomysql.cursors import Cursor as _AiomysqlCursor +from aiomysql.cursors import DictCursor as _AiomysqlDictCursor +from pymysql.constants import FIELD_TYPE as _PYMYSQL_FIELD_TYPE if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -33,12 +33,16 @@ class AiomysqlConnectionProtocol(Protocol): async def cursor(self, cursor: "type[AiomysqlRawCursor] | None" = None) -> "AiomysqlRawCursor": ... + async def begin(self) -> Any: ... + async def commit(self) -> object: ... async def rollback(self) -> object: ... def close(self) -> object: ... + def ensure_closed(self) -> Any: ... + def get_transaction_status(self) -> bool: ... class AiomysqlModuleProtocol(Protocol): @@ -84,6 +88,8 @@ class AiomysqlFieldTypeProtocol(Protocol): "AiomysqlSessionContext", ) +_resolved_driver_type: "type[AiomysqlDriver] | None" = None + class AiomysqlCursor: """Context manager for aiomysql cursor operations. @@ -155,10 +161,9 @@ def __init__( self._driver: AiomysqlDriver | None = None async def __aenter__(self) -> "AiomysqlDriver": - from sqlspec.adapters.aiomysql.driver import AiomysqlDriver - + driver_cls = _get_aiomysql_driver_type() self._connection = await self._acquire_connection() - self._driver = AiomysqlDriver( + self._driver = driver_cls( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -170,3 +175,12 @@ async def __aexit__( await self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) self._connection = None return None + + +def _get_aiomysql_driver_type() -> "type[AiomysqlDriver]": + global _resolved_driver_type + if _resolved_driver_type is None: + from sqlspec.adapters.aiomysql.driver import AiomysqlDriver as _Driver + + _resolved_driver_type = _Driver + return _resolved_driver_type diff --git a/sqlspec/adapters/aiomysql/adk/store.py b/sqlspec/adapters/aiomysql/adk/store.py index d4989264b..3a9016b6e 100644 --- a/sqlspec/adapters/aiomysql/adk/store.py +++ b/sqlspec/adapters/aiomysql/adk/store.py @@ -5,13 +5,14 @@ from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, cast from sqlspec.adapters.aiomysql._typing import AiomysqlCursor, AiomysqlProgrammingError, AiomysqlRawCursor +from sqlspec.adapters.aiomysql.core import resolve_rowcount from sqlspec.config import ADKConfig from sqlspec.extensions.adk import BaseAsyncADKStore, StoredEvent, StoredSession, normalize_session_list_options from sqlspec.extensions.adk.memory.store import BaseAsyncADKMemoryStore from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Sequence from datetime import datetime, timedelta from sqlspec.adapters.aiomysql.config import AiomysqlConfig @@ -54,11 +55,12 @@ class AiomysqlADKConfig(ADKConfig): class AiomysqlADKStore(BaseAsyncADKStore["AiomysqlConfig"]): """MySQL/MariaDB ADK store using aiomysql driver.""" - __slots__ = () + __slots__ = ("_json_deserializer", "_json_serializer") def __init__(self, config: "AiomysqlConfig") -> None: """Initialize aiomysql ADK store.""" super().__init__(config) + self._json_serializer, self._json_deserializer = _resolve_json_codecs(config) async def create_tables(self) -> None: """Create all ADK session tables if they don't exist.""" @@ -77,7 +79,7 @@ async def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: """Create a new session.""" - state_json = to_json(state) + state_json = self._json_serializer(state) params: tuple[Any, ...] if self._owner_id_column_name: @@ -110,37 +112,32 @@ async def get_session( self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None ) -> "StoredSession | None": """Get session by scoped identifiers.""" - if renew_for is not None and self._calculate_expires_at(renew_for) is not None: - sql = f""" - UPDATE {self._session_table} - SET update_time = UTC_TIMESTAMP(6) - WHERE app_name = %s AND user_id = %s AND id = %s - """ - async with ( - self._config.provide_connection() as conn, - AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, - ): - await cursor.execute(sql, (app_name, user_id, session_id)) - await conn.commit() - - sql = f""" - SELECT id, app_name, user_id, state, create_time, update_time - FROM {self._session_table} - WHERE app_name = %s AND user_id = %s AND id = %s - """ - try: async with ( self._config.provide_connection() as conn, AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, ): - await cursor.execute(sql, (app_name, user_id, session_id)) - row = await cursor.fetchone() - - if row is None: - return None + if renew_for is not None and self._calculate_expires_at(renew_for) is not None: + await cursor.execute( + f""" + UPDATE {self._session_table} + SET update_time = UTC_TIMESTAMP(6) + WHERE app_name = %s AND user_id = %s AND id = %s + """, + (app_name, user_id, session_id), + ) + await conn.commit() - return _session_record_from_row(row) + await cursor.execute( + f""" + SELECT id, app_name, user_id, state, create_time, update_time + FROM {self._session_table} + WHERE app_name = %s AND user_id = %s AND id = %s + """, + (app_name, user_id, session_id), + ) + row = await cursor.fetchone() + return _session_record_from_row(row, self._json_deserializer) if row is not None else None except AiomysqlProgrammingError as exc: if _is_mysql_table_missing(exc): return None @@ -158,7 +155,7 @@ async def update_session_state(self, app_name: str, user_id: str, session_id: st self._config.provide_connection() as conn, AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, ): - await cursor.execute(sql, (to_json(state), app_name, user_id, session_id)) + await cursor.execute(sql, (self._json_serializer(state), app_name, user_id, session_id)) await conn.commit() async def list_sessions( @@ -187,7 +184,7 @@ async def list_sessions( ): await cursor.execute(sql, params) rows = await cursor.fetchall() - return [_session_record_from_row(row) for row in rows] + return [_session_record_from_row(row, self._json_deserializer) for row in rows] except AiomysqlProgrammingError as exc: if _is_mysql_table_missing(exc): return [] @@ -216,7 +213,7 @@ async def append_event(self, event_record: StoredEvent) -> None: self._config.provide_connection() as conn, AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, ): - await cursor.execute(sql, _event_insert_params(event_record)) + await cursor.execute(sql, _event_insert_params(event_record, self._json_serializer)) await conn.commit() async def append_event_and_update_state( @@ -252,7 +249,7 @@ async def append_event_and_update_state( AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, ): try: - await cursor.execute(update_sql, (to_json(state), app_name, user_id, session_id)) + await cursor.execute(update_sql, (self._json_serializer(state), app_name, user_id, session_id)) await cursor.execute(select_sql, (app_name, user_id, session_id)) row = await cursor.fetchone() if row is None: @@ -266,23 +263,24 @@ async def append_event_and_update_state( session_id, event_record["invocation_id"], event_record["timestamp"], - _json_for_storage(event_record["event_data"]), + _json_for_storage(event_record["event_data"], self._json_serializer), ), ) if app_state is not None: await cursor.execute( - _mysql_upsert_app_state_sql(self._app_state_table), (app_name, to_json(app_state)) + _mysql_upsert_app_state_sql(self._app_state_table), (app_name, self._json_serializer(app_state)) ) if user_state is not None: await cursor.execute( - _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, to_json(user_state)) + _mysql_upsert_user_state_sql(self._user_state_table), + (app_name, user_id, self._json_serializer(user_state)), ) await conn.commit() except Exception: await conn.rollback() raise - return _session_record_from_row(row) + return _session_record_from_row(row, self._json_deserializer) async def get_events( self, @@ -320,7 +318,7 @@ async def get_events( ): await cursor.execute(sql, params) rows = await cursor.fetchall() - return [_event_record_from_row(row) for row in rows] + return [_event_record_from_row(row, self._json_deserializer) for row in rows] except AiomysqlProgrammingError as exc: if _is_mysql_table_missing(exc): return [] @@ -341,7 +339,7 @@ async def delete_expired_events(self, before: "datetime", app_name: "str | None" ): await cursor.execute(sql, tuple(params)) await conn.commit() - return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + return resolve_rowcount(cursor) except AiomysqlProgrammingError as exc: if _is_mysql_table_missing(exc): return 0 @@ -362,7 +360,7 @@ async def delete_idle_sessions(self, updated_before: "datetime", app_name: "str ): await cursor.execute(sql, tuple(params)) await conn.commit() - return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + return resolve_rowcount(cursor) except AiomysqlProgrammingError as exc: if _is_mysql_table_missing(exc): return 0 @@ -383,7 +381,7 @@ async def delete_idle_user_states(self, updated_before: "datetime", app_name: "s ): await cursor.execute(sql, tuple(params)) await conn.commit() - return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + return resolve_rowcount(cursor) except AiomysqlProgrammingError as exc: if _is_mysql_table_missing(exc): return 0 @@ -400,7 +398,7 @@ async def get_app_state(self, app_name: str) -> "dict[str, Any] | None": ): await cursor.execute(sql, (app_name,)) row = await cursor.fetchone() - return _json_dict(row[0]) if row is not None else None + return _json_dict(_row_first_value(row, "state"), self._json_deserializer) if row is not None else None except AiomysqlProgrammingError as exc: if _is_mysql_table_missing(exc): return None @@ -417,7 +415,7 @@ async def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | ): await cursor.execute(sql, (app_name, user_id)) row = await cursor.fetchone() - return _json_dict(row[0]) if row is not None else None + return _json_dict(_row_first_value(row, "state"), self._json_deserializer) if row is not None else None except AiomysqlProgrammingError as exc: if _is_mysql_table_missing(exc): return None @@ -429,7 +427,9 @@ async def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None self._config.provide_connection() as conn, AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, ): - await cursor.execute(_mysql_upsert_app_state_sql(self._app_state_table), (app_name, to_json(state))) + await cursor.execute( + _mysql_upsert_app_state_sql(self._app_state_table), (app_name, self._json_serializer(state)) + ) await conn.commit() async def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: @@ -439,7 +439,7 @@ async def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, ): await cursor.execute( - _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, to_json(state)) + _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, self._json_serializer(state)) ) await conn.commit() @@ -454,7 +454,7 @@ async def get_metadata(self, key: str) -> "str | None": ): await cursor.execute(sql, (key,)) row = await cursor.fetchone() - return str(row[0]) if row is not None else None + return str(_row_first_value(row, "value")) if row is not None else None except AiomysqlProgrammingError as exc: if _is_mysql_table_missing(exc): return None @@ -521,11 +521,12 @@ def _drop_tables_sql(self) -> "list[str]": class AiomysqlADKMemoryStore(BaseAsyncADKMemoryStore["AiomysqlConfig"]): """MySQL/MariaDB ADK memory store using aiomysql driver.""" - __slots__ = () + __slots__ = ("_json_deserializer", "_json_serializer") def __init__(self, config: "AiomysqlConfig") -> None: """Initialize aiomysql memory store.""" super().__init__(config) + self._json_serializer, self._json_deserializer = _resolve_json_codecs(config) async def create_tables(self) -> None: """Create the memory table and indexes if they don't exist.""" @@ -568,6 +569,9 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " async with self._config.provide_connection() as conn: async with AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor: for entry in entries: + content_json = self._json_serializer(entry["content_json"]) + meta = entry.get("metadata_json") + metadata_json = self._json_serializer(meta) if meta is not None else None params: tuple[Any, ...] if self._owner_id_column_name: params = ( @@ -580,9 +584,9 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " entry.get("author"), owner_id, entry["timestamp"], - to_json(entry["content_json"]), + content_json, entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + metadata_json, entry["inserted_at"], ) else: @@ -595,13 +599,13 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " entry["event_id"], entry.get("author"), entry["timestamp"], - to_json(entry["content_json"]), + content_json, entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + metadata_json, entry["inserted_at"], ) await cursor.execute(sql, params) - inserted_count += cursor.rowcount + inserted_count += resolve_rowcount(cursor) await conn.commit() return inserted_count @@ -642,20 +646,28 @@ async def search_entries( """ params = (*scope_params, f"%{query}%", limit_value) - async with ( - self._config.provide_connection() as conn, - AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, - ): - await cursor.execute(sql, params) - rows = await cursor.fetchall() - columns = [col[0] for col in cursor.description or []] + try: + async with ( + self._config.provide_connection() as conn, + AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, + ): + await cursor.execute(sql, params) + rows = await cursor.fetchall() + columns = [col[0] for col in cursor.description or []] + except AiomysqlProgrammingError as exc: + if _is_mysql_table_missing(exc): + return [] + raise records: list[StoredMemory] = [] for row in rows: - rec = cast("StoredMemory", dict(zip(columns, row, strict=False))) - rec["content_json"] = _json_dict(rec.get("content_json")) + row_dict = dict(row) if isinstance(row, Mapping) else dict(zip(columns, row, strict=False)) + rec = cast("StoredMemory", row_dict) + rec["content_json"] = _json_dict(rec.get("content_json"), self._json_deserializer) metadata_val = rec.get("metadata_json") - rec["metadata_json"] = _json_dict(metadata_val) if metadata_val is not None else None + rec["metadata_json"] = ( + _json_dict(metadata_val, self._json_deserializer) if metadata_val is not None else None + ) rec["embedding"] = None records.append(rec) return records @@ -667,13 +679,18 @@ async def delete_entries_by_session(self, session_id: str) -> int: raise RuntimeError(msg) sql = f"DELETE FROM {self._memory_table} WHERE session_id = %s" - async with ( - self._config.provide_connection() as conn, - AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, - ): - await cursor.execute(sql, (session_id,)) - await conn.commit() - return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + try: + async with ( + self._config.provide_connection() as conn, + AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, + ): + await cursor.execute(sql, (session_id,)) + await conn.commit() + return resolve_rowcount(cursor) + except AiomysqlProgrammingError as exc: + if _is_mysql_table_missing(exc): + return 0 + raise async def delete_entries_older_than( self, days: int, app_name: "str | None" = None, scope: "str | None" = None @@ -694,13 +711,18 @@ async def delete_entries_older_than( where_sql = " AND ".join(clauses) sql = f"DELETE FROM {self._memory_table} WHERE {where_sql}" - async with ( - self._config.provide_connection() as conn, - AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, - ): - await cursor.execute(sql, tuple(params)) - await conn.commit() - return cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + try: + async with ( + self._config.provide_connection() as conn, + AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, + ): + await cursor.execute(sql, tuple(params)) + await conn.commit() + return resolve_rowcount(cursor) + except AiomysqlProgrammingError as exc: + if _is_mysql_table_missing(exc): + return 0 + raise async def _memory_table_ddl(self) -> str: """Get MySQL CREATE TABLE SQL for memory entries.""" @@ -743,6 +765,24 @@ def _drop_memory_table_sql(self) -> "list[str]": return [f"DROP TABLE IF EXISTS {self._memory_table}"] +def _resolve_json_codecs(config: Any) -> "tuple[Callable[[Any], str], Callable[[Any], Any]]": + driver_features = getattr(config, "driver_features", None) + if isinstance(driver_features, Mapping): + serializer = driver_features.get("json_serializer", to_json) + deserializer = driver_features.get("json_deserializer", from_json) + return cast( + "tuple[Callable[[Any], str], Callable[[Any], Any]]", + (serializer if callable(serializer) else to_json, deserializer if callable(deserializer) else from_json), + ) + return to_json, from_json + + +def _row_first_value(row: Any, key: str) -> Any: + if isinstance(row, Mapping): + return row[key] + return row[0] + + def _mysql_owner_id_column_parts(column_ddl: str) -> "tuple[str, str]": """Parse owner ID column DDL for MySQL FOREIGN KEY syntax. @@ -763,8 +803,11 @@ def _mysql_owner_id_column_parts(column_ddl: str) -> "tuple[str, str]": return (col_def, fk_constraint) -def _adk_config(config: "AiomysqlConfig") -> AiomysqlADKConfig: - adk_config = config.extension_config.get("adk") if config.extension_config else None +def _adk_config(config: Any) -> AiomysqlADKConfig: + extension_config = getattr(config, "extension_config", {}) + if not isinstance(extension_config, dict): + return {} + adk_config = extension_config.get("adk", {}) if not isinstance(adk_config, dict): return {} return cast("AiomysqlADKConfig", adk_config) @@ -788,25 +831,49 @@ def _is_mysql_table_missing(exc: BaseException) -> bool: ) -def _json_for_storage(value: Any) -> str: - return value if isinstance(value, str) else to_json(value) +def _json_for_storage(value: Any, serializer: "Callable[[Any], str]" = to_json) -> str: + return value if isinstance(value, str) else serializer(value) -def _json_dict(value: Any) -> "dict[str, Any]": - if isinstance(value, bytearray): +def _json_dict(value: Any, deserializer: "Callable[[Any], Any]" = from_json) -> "dict[str, Any]": + if isinstance(value, (bytearray, memoryview)): value = bytes(value) if isinstance(value, (bytes, str)): - return cast("dict[str, Any]", from_json(value)) + return cast("dict[str, Any]", deserializer(value)) return cast("dict[str, Any]", value) -def _session_record_from_row(row: Any) -> StoredSession: +def _session_record_from_row(row: Any, deserializer: "Callable[[Any], Any]" = from_json) -> StoredSession: + if isinstance(row, Mapping): + return StoredSession( + id=row["id"], + app_name=row["app_name"], + user_id=row["user_id"], + state=_json_dict(row["state"], deserializer), + create_time=row["create_time"], + update_time=row["update_time"], + ) return StoredSession( - id=row[0], app_name=row[1], user_id=row[2], state=_json_dict(row[3]), create_time=row[4], update_time=row[5] + id=row[0], + app_name=row[1], + user_id=row[2], + state=_json_dict(row[3], deserializer), + create_time=row[4], + update_time=row[5], ) -def _event_record_from_row(row: Any) -> StoredEvent: +def _event_record_from_row(row: Any, deserializer: "Callable[[Any], Any]" = from_json) -> StoredEvent: + if isinstance(row, Mapping): + return StoredEvent( + id=row["id"], + app_name=row["app_name"], + user_id=row["user_id"], + session_id=row["session_id"], + invocation_id=row["invocation_id"], + timestamp=row["timestamp"], + event_data=_json_dict(row["event_data"], deserializer), + ) return StoredEvent( id=row[0], app_name=row[1], @@ -814,11 +881,11 @@ def _event_record_from_row(row: Any) -> StoredEvent: session_id=row[3], invocation_id=row[4], timestamp=row[5], - event_data=_json_dict(row[6]), + event_data=_json_dict(row[6], deserializer), ) -def _event_insert_params(event_record: StoredEvent) -> "tuple[Any, ...]": +def _event_insert_params(event_record: StoredEvent, serializer: "Callable[[Any], str]" = to_json) -> "tuple[Any, ...]": return ( event_record["id"], event_record["app_name"], @@ -826,7 +893,7 @@ def _event_insert_params(event_record: StoredEvent) -> "tuple[Any, ...]": event_record["session_id"], event_record["invocation_id"], event_record["timestamp"], - _json_for_storage(event_record["event_data"]), + _json_for_storage(event_record["event_data"], serializer), ) diff --git a/sqlspec/adapters/aiomysql/config.py b/sqlspec/adapters/aiomysql/config.py index b32bb6004..1f257eb5c 100644 --- a/sqlspec/adapters/aiomysql/config.py +++ b/sqlspec/adapters/aiomysql/config.py @@ -94,44 +94,15 @@ class AiomysqlPoolParams(AiomysqlConnectionParams): pool_recycle: NotRequired[int] -def _normalize_local_infile(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": - """Normalize aiomysql local-infile aliases to the native connection flag.""" - config = dict(connection_config) - - config.pop("enable_local_infile", None) - allow_local_infile = bool(config.pop("allow_local_infile", False)) - config["local_infile"] = bool(config.get("local_infile", False) or allow_local_infile) - return config - - -def _normalize_connection_kwargs(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": - """Build aiomysql.connect-compatible kwargs from SQLSpec connection config.""" - config = _normalize_local_infile(connection_config) - - for key in _POOL_ONLY_CONFIG_KEYS: - config.pop(key, None) - - if "cursor_class" in config and "cursorclass" not in config: - config["cursorclass"] = config["cursor_class"] - config.pop("cursor_class", None) - - if "database" in config and "db" not in config: - config["db"] = config["database"] - config.pop("database", None) - - if "passwd" in config and "password" not in config: - config["password"] = config["passwd"] - config.pop("passwd", None) - - return config - - class AiomysqlDriverFeatures(TypedDict): """aiomysql driver feature flags. MySQL/MariaDB handle JSON natively, but custom serializers can be provided for specialized use cases. + enable_local_infile_bulk_load: Use native LOCAL INFILE for eligible Arrow rows. + Defaults to the connection's local_infile or allow_local_infile opt-in. + Set False to force executemany on an opted-in connection. json_serializer: Custom JSON serializer function. Defaults to sqlspec.utils.serializers.to_json. Use for performance (orjson) or custom encoding. @@ -191,10 +162,7 @@ def __init__(self, config: "AiomysqlConfig") -> None: self._contexts: dict[int, Any] = {} async def acquire_connection(self) -> "AiomysqlConnection": - pool = self._config.connection_instance - if pool is None: - pool = await self._config.create_pool() - self._config.connection_instance = pool + pool = await self._config.provide_pool() ctx = pool.acquire() connection = cast("AiomysqlConnection", await ctx.__aenter__()) self._contexts[id(connection)] = ctx @@ -228,10 +196,7 @@ def __init__(self, config: "AiomysqlConfig") -> None: self._connection: AiomysqlConnection | None = None async def __aenter__(self) -> AiomysqlConnection: - pool = self._config.connection_instance - if pool is None: - pool = await self._config.create_pool() - self._config.connection_instance = pool + pool = await self._config.provide_pool() ctx = pool.acquire() self._ctx = ctx connection = cast("AiomysqlConnection", await ctx.__aenter__()) @@ -266,6 +231,8 @@ async def __aexit__( class AiomysqlConfig(AsyncDatabaseConfig[AiomysqlConnection, "AiomysqlPool", AiomysqlDriver]): """Configuration for aiomysql database connections.""" + __slots__ = ("_initialized_connections", "_user_connection_hook") + driver_type: ClassVar[type[AiomysqlDriver]] = AiomysqlDriver connection_type: "ClassVar[type[Any]]" = cast("type[Any]", AiomysqlConnection) supports_transactional_ddl: ClassVar[bool] = False @@ -335,45 +302,6 @@ def __init__( **kwargs, ) - async def _create_pool(self) -> "AiomysqlPool": - """Create the actual async connection pool. - - MySQL/MariaDB handle JSON types natively without requiring connection-level - type handlers. JSON serialization is handled via type_coercion_map in the - driver's statement_config (see driver.py). - """ - return cast("AiomysqlPool", await aiomysql.create_pool(**self._pool_kwargs())) - - def _connection_kwargs(self) -> "dict[str, Any]": - """Return aiomysql.connect-compatible kwargs without pool-only settings.""" - return _normalize_connection_kwargs(self.connection_config) - - def _pool_kwargs(self) -> "dict[str, Any]": - """Return aiomysql.create_pool kwargs with normalized connection settings.""" - pool_kwargs = self._connection_kwargs() - for key in _POOL_ONLY_CONFIG_KEYS: - if key in self.connection_config: - pool_kwargs[key] = self.connection_config[key] - return pool_kwargs - - async def _ensure_connection(self, connection: "AiomysqlConnection") -> None: - """Ensure connection callback has been called exactly once for this connection. - - Uses WeakSet tracking to ensure the callback runs once per physical connection. - """ - if self._user_connection_hook is None: - return - if connection not in self._initialized_connections: - await self._user_connection_hook(connection) - self._initialized_connections.add(connection) - - async def _close_pool(self) -> None: - """Close the actual async connection pool.""" - if self.connection_instance: - self.connection_instance.close() - await self.connection_instance.wait_closed() - self.connection_instance = None - async def create_connection(self) -> AiomysqlConnection: """Open a standalone connection owned by the caller. @@ -410,11 +338,13 @@ def get_signature_namespace(self) -> "dict[str, Any]": "AiomysqlConnection": AiomysqlConnection, "AiomysqlConnectionParams": AiomysqlConnectionParams, "AiomysqlCursor": AiomysqlCursor, + "AiomysqlDictCursor": AiomysqlDictCursor, "AiomysqlDriver": AiomysqlDriver, "AiomysqlDriverFeatures": AiomysqlDriverFeatures, "AiomysqlExceptionHandler": AiomysqlExceptionHandler, "AiomysqlPool": AiomysqlPool, "AiomysqlPoolParams": AiomysqlPoolParams, + "AiomysqlRawCursor": AiomysqlRawCursor, "AiomysqlSessionContext": AiomysqlSessionContext, }) return namespace @@ -423,3 +353,74 @@ def get_event_runtime_hints(self) -> "EventRuntimeHints": """Return queue polling defaults for aiomysql adapters.""" return EventRuntimeHints(poll_interval=0.25, lease_seconds=5, select_for_update=True, skip_locked=True) + + async def _create_pool(self) -> "AiomysqlPool": + """Create the actual async connection pool. + + MySQL/MariaDB handle JSON types natively without requiring connection-level + type handlers. JSON serialization is handled via type_coercion_map in the + driver's statement_config (see driver.py). + """ + return cast("AiomysqlPool", await aiomysql.create_pool(**self._pool_kwargs())) + + def _connection_kwargs(self) -> "dict[str, Any]": + """Return aiomysql.connect-compatible kwargs without pool-only settings.""" + return _normalize_connection_kwargs(self.connection_config) + + def _pool_kwargs(self) -> "dict[str, Any]": + """Return aiomysql.create_pool kwargs with normalized connection settings.""" + pool_kwargs = self._connection_kwargs() + for key in _POOL_ONLY_CONFIG_KEYS: + if key in self.connection_config: + pool_kwargs[key] = self.connection_config[key] + return pool_kwargs + + async def _ensure_connection(self, connection: "AiomysqlConnection") -> None: + """Ensure connection callback has been called exactly once for this connection. + + Uses WeakSet tracking to ensure the callback runs once per physical connection. + """ + if self._user_connection_hook is None: + return + if connection not in self._initialized_connections: + await self._user_connection_hook(connection) + self._initialized_connections.add(connection) + + async def _close_pool(self) -> None: + """Close the actual async connection pool.""" + if self.connection_instance: + self.connection_instance.close() + await self.connection_instance.wait_closed() + self.connection_instance = None + + +def _normalize_local_infile(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": + """Normalize aiomysql local-infile aliases to the native connection flag.""" + config = dict(connection_config) + + config.pop("enable_local_infile", None) + allow_local_infile = bool(config.pop("allow_local_infile", False)) + config["local_infile"] = bool(config.get("local_infile", False) or allow_local_infile) + return config + + +def _normalize_connection_kwargs(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": + """Build aiomysql.connect-compatible kwargs from SQLSpec connection config.""" + config = _normalize_local_infile(connection_config) + + for key in _POOL_ONLY_CONFIG_KEYS: + config.pop(key, None) + + if "cursor_class" in config and "cursorclass" not in config: + config["cursorclass"] = config["cursor_class"] + config.pop("cursor_class", None) + + if "database" in config and "db" not in config: + config["db"] = config["database"] + config.pop("database", None) + + if "passwd" in config and "password" not in config: + config["password"] = config["passwd"] + config.pop("passwd", None) + + return config diff --git a/sqlspec/adapters/aiomysql/core.py b/sqlspec/adapters/aiomysql/core.py index be8af2da1..063dca69e 100644 --- a/sqlspec/adapters/aiomysql/core.py +++ b/sqlspec/adapters/aiomysql/core.py @@ -9,6 +9,7 @@ bool_to_int, build_insert_statement, build_load_data_statement, + can_use_local_infile_records, collect_rows, collect_stream_rows, create_mapped_exception, @@ -26,6 +27,7 @@ resolve_rowcount, ) from sqlspec.core import DriverParameterProfile, ParameterStyle, StatementConfig, build_statement_config_from_profile +from sqlspec.utils.logging import get_logger from sqlspec.utils.serializers import from_json, to_json from sqlspec.utils.type_converters import build_uuid_coercions @@ -39,6 +41,7 @@ "build_load_data_statement", "build_profile", "build_statement_config", + "can_use_local_infile_records", "collect_rows", "collect_stream_rows", "create_mapped_exception", @@ -58,6 +61,8 @@ "resolve_rowcount", ) +logger = get_logger("sqlspec.adapters.aiomysql") + _MYSQL_ACCESS_ERROR_DISPATCH = mysql_common.MYSQL_ACCESS_ERROR_DISPATCH _MYSQL_CONNECTION_ERROR_DISPATCH = mysql_common.MYSQL_CONNECTION_ERROR_DISPATCH _MYSQL_CONSTRAINT_ERROR_DISPATCH = mysql_common.MYSQL_CONSTRAINT_ERROR_DISPATCH @@ -67,7 +72,16 @@ class AiomysqlStreamSource: """Compiled async chunk source streaming dict rows from an aiomysql unbuffered SSCursor.""" - __slots__ = ("_chunk_size", "_cursor", "_driver", "_json_type_codes", "_parameters", "_row_plan", "_sql") + __slots__ = ( + "_chunk_size", + "_cursor", + "_deserializer", + "_driver", + "_json_type_codes", + "_parameters", + "_row_plan", + "_sql", + ) def __init__(self, driver: Any, sql: str, parameters: Any, chunk_size: int, json_type_codes: "set[int]") -> None: self._driver = driver @@ -77,22 +91,16 @@ def __init__(self, driver: Any, sql: str, parameters: Any, chunk_size: int, json self._cursor: Any = None self._json_type_codes = json_type_codes self._row_plan: tuple[list[str], list[int] | None] | None = None + self._deserializer: Callable[[Any], Any] = cast( + "Callable[[Any], Any]", + getattr(driver, "_json_deserializer", None) or driver.driver_features.get("json_deserializer", from_json), + ) async def start(self) -> None: handler = self._driver.handle_database_exceptions() await self._driver._run_with_exception_handler(handler, self._start) self._driver._check_pending_exception(handler) - async def _start(self) -> None: - try: - cursor = await self._driver.connection.cursor(AiomysqlSSCursor) - self._cursor = cursor - await cursor.execute(self._sql, normalize_execute_parameters(self._parameters)) - self._row_plan = resolve_row_plan(self._cursor.description, self._json_type_codes) - except BaseException: - await self.close(error=True) - raise - async def fetch_chunk(self) -> "list[dict[str, Any]]": handler = self._driver.handle_database_exceptions() rows = await self._driver._run_with_exception_handler(handler, self._cursor.fetchmany, self._chunk_size) @@ -101,12 +109,7 @@ async def fetch_chunk(self) -> "list[dict[str, Any]]": return [] if self._row_plan is None: self._row_plan = resolve_row_plan(self._cursor.description, self._json_type_codes) - deserializer = cast( - "Callable[[Any], Any]", - getattr(self._driver, "_json_deserializer", None) - or self._driver.driver_features.get("json_deserializer", from_json), - ) - return collect_stream_rows(rows, self._row_plan, deserializer) + return collect_stream_rows(rows, self._row_plan, self._deserializer, logger=logger) async def close(self, error: bool = False) -> None: cursor = self._cursor @@ -115,6 +118,16 @@ async def close(self, error: bool = False) -> None: with contextlib.suppress(Exception): await cursor.close() + async def _start(self) -> None: + try: + cursor = await self._driver.connection.cursor(AiomysqlSSCursor) + self._cursor = cursor + await cursor.execute(self._sql, normalize_execute_parameters(self._parameters)) + self._row_plan = resolve_row_plan(self._cursor.description, self._json_type_codes) + except BaseException: + await self.close(error=True) + raise + def build_profile() -> "DriverParameterProfile": """Create the aiomysql driver parameter profile.""" diff --git a/sqlspec/adapters/aiomysql/driver.py b/sqlspec/adapters/aiomysql/driver.py index cec216b79..03463f59d 100644 --- a/sqlspec/adapters/aiomysql/driver.py +++ b/sqlspec/adapters/aiomysql/driver.py @@ -7,13 +7,13 @@ rather than a driver-specific error module. """ +import asyncio import os import tempfile from collections.abc import Sized +from pathlib import Path from typing import TYPE_CHECKING, Any, Final, cast -import anyio - from sqlspec.adapters.aiomysql._typing import ( AIOMYSQL_INSERT_VALUES_PATTERN, AiomysqlCursor, @@ -26,6 +26,7 @@ AiomysqlStreamSource, build_insert_statement, build_load_data_statement, + can_use_local_infile_records, collect_rows, create_mapped_exception, default_statement_config, @@ -58,7 +59,7 @@ __all__ = ("AiomysqlCursor", "AiomysqlDriver", "AiomysqlExceptionHandler", "AiomysqlSessionContext") -logger = get_logger(__name__) +logger = get_logger("sqlspec.adapters.aiomysql") json_type_value = ( AiomysqlFieldType.JSON if AiomysqlFieldType is not None and supports_json_type(AiomysqlFieldType) else None @@ -141,21 +142,16 @@ def __init__( "Callable[[Any], Any]", features.get("json_deserializer", from_json) ) - async def _execute_cache_hit( - self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" - ) -> "SQLResult": - if ( - "%" in cached.compiled_sql - and escape_literal_percent( - cached.compiled_sql, params or (None,), self.statement_config.parameter_validator - ) - != cached.compiled_sql - ): - statement = self._cached_statement( - sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql - ) - return await self._execute_cached_statement(statement) - return await super()._execute_cache_hit(sql, params, cached) + @property + def data_dictionary(self) -> "AiomysqlDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = AiomysqlDataDictionary() + return self._data_dictionary async def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": """Execute single SQL statement. @@ -171,9 +167,9 @@ async def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionRes sql = escape_literal_percent(sql, prepared_parameters, self.statement_config.parameter_validator) await cursor.execute(sql, normalize_execute_parameters(prepared_parameters)) - if statement.returns_rows(): + description = getattr(cursor, "description", None) or None + if statement.returns_rows() or description is not None: fetched_data = await cursor.fetchall() - description = cursor.description or None row_plan = resolve_row_plan(description, AIOMYSQL_JSON_TYPE_CODES) rows, column_names, row_format = collect_rows( fetched_data, row_plan, self._json_deserializer, logger=logger @@ -237,9 +233,15 @@ async def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "Execu successful_count = 0 last_cursor = cursor + normalized_params = normalize_execute_parameters(prepared_parameters) for stmt in statements: - await cursor.execute(stmt, normalize_execute_parameters(prepared_parameters)) + stmt_sql = ( + escape_literal_percent(stmt, prepared_parameters, self.statement_config.parameter_validator) + if normalized_params is not None + else stmt + ) + await cursor.execute(stmt_sql, normalized_params) successful_count += 1 return self.create_execution_result( @@ -255,6 +257,9 @@ async def begin(self) -> None: try: await self.connection.begin() except AiomysqlPymysqlMySQLError as e: + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, Exception): + raise mapped from e msg = f"Failed to begin MySQL transaction: {e}" raise SQLSpecError(msg) from e @@ -267,6 +272,9 @@ async def commit(self) -> None: try: await self.connection.commit() except AiomysqlPymysqlMySQLError as e: + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, Exception): + raise mapped from e msg = f"Failed to commit MySQL transaction: {e}" raise SQLSpecError(msg) from e @@ -279,6 +287,9 @@ async def rollback(self) -> None: try: await self.connection.rollback() except AiomysqlPymysqlMySQLError as e: + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, Exception): + raise mapped from e msg = f"Failed to rollback MySQL transaction: {e}" raise SQLSpecError(msg) from e @@ -350,15 +361,17 @@ async def load_from_arrow( if overwrite: statement = f"TRUNCATE TABLE {format_identifier(table)}" exc_handler = self.handle_database_exceptions() - async with exc_handler, self.with_cursor(self.connection) as cursor: - await cursor.execute(statement) - if exc_handler.pending_exception is not None: - raise exc_handler.pending_exception from None + await self._run_with_exception_handler(exc_handler, self._execute_cursor_statement, statement) + self._check_pending_exception(exc_handler) columns, records = self._arrow_table_to_rows(arrow_table) if records: needs_preparation = self._arrow_rows_need_preparation(arrow_table) - use_infile = bool(self.driver_features.get("enable_local_infile_bulk_load")) and not needs_preparation + use_infile = ( + bool(self.driver_features.get("enable_local_infile_bulk_load")) + and not needs_preparation + and can_use_local_infile_records(arrow_table, records) + ) if use_infile: payload = encode_records_for_local_infile(records) fd, tmp_name = tempfile.mkstemp(suffix=".tsv") @@ -367,12 +380,12 @@ async def load_from_arrow( tmp.write(payload) load_sql = build_load_data_statement(table, columns) exc_handler = self.handle_database_exceptions() - async with exc_handler, self.with_cursor(self.connection) as cursor: - await cursor.execute(load_sql, (tmp_name,)) - if exc_handler.pending_exception is not None: - raise exc_handler.pending_exception from None + await self._run_with_exception_handler( + exc_handler, self._execute_cursor_statement, load_sql, (tmp_name,) + ) + self._check_pending_exception(exc_handler) finally: - await anyio.Path(tmp_name).unlink(missing_ok=True) + await asyncio.to_thread(Path(tmp_name).unlink, True) else: insert_sql = build_insert_statement(table, columns) prepared_records = ( @@ -381,10 +394,10 @@ async def load_from_arrow( else records ) exc_handler = self.handle_database_exceptions() - async with exc_handler, self.with_cursor(self.connection) as cursor: - await cursor.executemany(insert_sql, prepared_records) - if exc_handler.pending_exception is not None: - raise exc_handler.pending_exception from None + await self._run_with_exception_handler( + exc_handler, self._executemany_cursor_statement, insert_sql, prepared_records + ) + self._check_pending_exception(exc_handler) telemetry_payload = self._ingest_telemetry(arrow_table) telemetry_payload["destination"] = table @@ -407,17 +420,6 @@ async def load_from_storage( table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound ) - @property - def data_dictionary(self) -> "AiomysqlDataDictionary": - """Get the data dictionary for this driver. - - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = AiomysqlDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect aiomysql rows for the direct execution path.""" description = cursor.description or None @@ -429,6 +431,25 @@ def resolve_rowcount(self, cursor: Any) -> int: """Resolve rowcount from aiomysql cursor for the direct execution path.""" return resolve_rowcount(cursor) + async def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + statement = self._cached_statement( + sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql + ) + return await self._execute_cached_statement(statement) + + async def _execute_cursor_statement(self, statement: str, parameters: Any = None) -> None: + async with self.with_cursor(self.connection) as cursor: + if parameters is None: + await cursor.execute(statement) + else: + await cursor.execute(statement, parameters) + + async def _executemany_cursor_statement(self, statement: str, parameters: Any) -> None: + async with self.with_cursor(self.connection) as cursor: + await cursor.executemany(statement, parameters) + def _connection_in_transaction(self) -> bool: """Check if connection is in transaction. diff --git a/sqlspec/adapters/aiomysql/litestar/store.py b/sqlspec/adapters/aiomysql/litestar/store.py index d7c56d6b7..8f8b449f7 100644 --- a/sqlspec/adapters/aiomysql/litestar/store.py +++ b/sqlspec/adapters/aiomysql/litestar/store.py @@ -1,9 +1,11 @@ """aiomysql session store for Litestar integration.""" +from collections.abc import Mapping from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, Final, NotRequired, cast from sqlspec.adapters.aiomysql._typing import AiomysqlCursor, AiomysqlProgrammingError, AiomysqlRawCursor +from sqlspec.adapters.aiomysql.core import resolve_rowcount from sqlspec.config import LitestarConfig from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.litestar.store import BaseSQLSpecStore @@ -100,7 +102,11 @@ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "by if row is None: return None - data_value, expires_at = row + if isinstance(row, Mapping): + data_value = row["data"] + expires_at = row["expires_at"] + else: + data_value, expires_at = row if renew_for is not None and expires_at is not None: new_expires_at = self._calculate_expires_at(renew_for) @@ -115,8 +121,8 @@ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "by await conn.commit() return bytes(data_value) - except AiomysqlProgrammingError as e: - if "doesn't exist" in str(e) or e.args[0] == MYSQL_TABLE_NOT_FOUND_ERROR: + except AiomysqlProgrammingError as exc: + if _is_mysql_table_missing(exc): return None raise @@ -175,8 +181,8 @@ async def delete_all(self) -> None: await cursor.execute(sql) await conn.commit() self._log_delete_all() - except AiomysqlProgrammingError as e: - if "doesn't exist" in str(e) or e.args[0] == MYSQL_TABLE_NOT_FOUND_ERROR: + except AiomysqlProgrammingError as exc: + if _is_mysql_table_missing(exc): logger.debug("Table %s does not exist, skipping delete_all", self._table_name) return raise @@ -204,8 +210,8 @@ async def exists(self, key: str) -> bool: await cursor.execute(sql, (key,)) result = await cursor.fetchone() return result is not None - except AiomysqlProgrammingError as e: - if "doesn't exist" in str(e) or e.args[0] == MYSQL_TABLE_NOT_FOUND_ERROR: + except AiomysqlProgrammingError as exc: + if _is_mysql_table_missing(exc): return False raise @@ -223,25 +229,32 @@ async def expires_in(self, key: str) -> "int | None": WHERE session_id = %s """ - async with ( - self._config.provide_connection() as conn, - AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, - ): - await cursor.execute(sql, (key,)) - row = await cursor.fetchone() + try: + async with ( + self._config.provide_connection() as conn, + AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, + ): + await cursor.execute(sql, (key,)) + row = await cursor.fetchone() - if row is None or row[0] is None: - return None + if row is None: + return None + expires_at_naive = row["expires_at"] if isinstance(row, Mapping) else row[0] + if expires_at_naive is None: + return None - expires_at_naive = row[0] - expires_at_utc = expires_at_naive.replace(tzinfo=UTC) - now = datetime.now(UTC) + expires_at_utc = expires_at_naive.replace(tzinfo=UTC) + now = datetime.now(UTC) - if expires_at_utc <= now: - return 0 + if expires_at_utc <= now: + return 0 - delta = expires_at_utc - now - return int(delta.total_seconds()) + delta = expires_at_utc - now + return int(delta.total_seconds()) + except AiomysqlProgrammingError as exc: + if _is_mysql_table_missing(exc): + return None + raise async def delete_expired(self) -> int: """Delete all expired sessions. @@ -251,16 +264,21 @@ async def delete_expired(self) -> int: """ sql = f"DELETE FROM {self._table_name} WHERE expires_at <= UTC_TIMESTAMP(6)" - async with ( - self._config.provide_connection() as conn, - AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, - ): - await cursor.execute(sql) - await conn.commit() - count: int = cursor.rowcount - if count > 0: - self._log_delete_expired(count) - return count + try: + async with ( + self._config.provide_connection() as conn, + AiomysqlCursor(conn, cursor_class=AiomysqlRawCursor) as cursor, + ): + await cursor.execute(sql) + await conn.commit() + count = resolve_rowcount(cursor) + if count > 0: + self._log_delete_expired(count) + return count + except AiomysqlProgrammingError as exc: + if _is_mysql_table_missing(exc): + return 0 + raise def _table_ddl(self) -> str: """Get MySQL CREATE TABLE SQL with optimized schema. @@ -291,6 +309,16 @@ def _drop_table_sql(self) -> "list[str]": ] +def _is_mysql_table_missing(exc: BaseException) -> bool: + args = getattr(exc, "args", ()) + errno = getattr(exc, "errno", None) + return ( + errno == MYSQL_TABLE_NOT_FOUND_ERROR + or "doesn't exist" in str(exc) + or bool(args and args[0] == MYSQL_TABLE_NOT_FOUND_ERROR) + ) + + def _mysql_table_options(litestar_config: "dict[str, Any]") -> str: """Format the litestar ``table_options`` config value for DDL interpolation. diff --git a/sqlspec/adapters/aiosqlite/__init__.py b/sqlspec/adapters/aiosqlite/__init__.py index daa39414d..53f574532 100644 --- a/sqlspec/adapters/aiosqlite/__init__.py +++ b/sqlspec/adapters/aiosqlite/__init__.py @@ -1,3 +1,5 @@ +"""AIOSQLite database adapter for SQLSpec.""" + from sqlspec.adapters.aiosqlite._typing import AiosqliteConnection, AiosqliteCursor, AiosqliteRawCursor from sqlspec.adapters.aiosqlite.config import ( AiosqliteAggregateConfig, diff --git a/sqlspec/adapters/aiosqlite/_typing.py b/sqlspec/adapters/aiosqlite/_typing.py index 8d0168ba9..80ca87cba 100644 --- a/sqlspec/adapters/aiosqlite/_typing.py +++ b/sqlspec/adapters/aiosqlite/_typing.py @@ -1,4 +1,3 @@ -# pyright: reportCallIssue=false, reportAttributeAccessIssue=false, reportArgumentType=false """AIOSQLite adapter type definitions. This module contains type aliases and classes that are excluded from mypyc @@ -6,15 +5,12 @@ """ import contextlib -import sqlite3 import sqlite3 as aiosqlite_sqlite_module from typing import TYPE_CHECKING, Any -import aiosqlite import aiosqlite as aiosqlite_module -from typing_extensions import TypeAliasType -_AiosqliteConnection = aiosqlite.Connection +_AiosqliteConnection = aiosqlite_module.Connection if TYPE_CHECKING: from collections.abc import Callable @@ -25,12 +21,12 @@ from sqlspec.core import StatementConfig AiosqliteConnection: TypeAlias = _AiosqliteConnection - AiosqliteConnectionFactory: TypeAlias = type[sqlite3.Connection] - AiosqliteRawCursor: TypeAlias = aiosqlite.Cursor + AiosqliteConnectionFactory: TypeAlias = type[aiosqlite_sqlite_module.Connection] + AiosqliteRawCursor: TypeAlias = aiosqlite_module.Cursor else: AiosqliteConnection = _AiosqliteConnection - AiosqliteConnectionFactory = TypeAliasType("AiosqliteConnectionFactory", type[sqlite3.Connection]) - AiosqliteRawCursor = aiosqlite.Cursor + AiosqliteConnectionFactory = type[aiosqlite_sqlite_module.Connection] + AiosqliteRawCursor = aiosqlite_module.Cursor __all__ = ( "AiosqliteConnection", diff --git a/sqlspec/adapters/aiosqlite/adk/store.py b/sqlspec/adapters/aiosqlite/adk/store.py index 9f19ba9ae..d37e75016 100644 --- a/sqlspec/adapters/aiosqlite/adk/store.py +++ b/sqlspec/adapters/aiosqlite/adk/store.py @@ -1,12 +1,21 @@ """Aiosqlite async ADK store for Google Agent Development Kit session/event storage.""" +import contextlib +import inspect import re from collections.abc import Mapping from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, cast from sqlspec.adapters.aiosqlite._typing import aiosqlite_sqlite_module as sqlite3 -from sqlspec.adapters.aiosqlite.core import end_transaction, render_pragmas +from sqlspec.adapters.aiosqlite.core import ( + JULIAN_EPOCH, + SECONDS_PER_DAY, + datetime_to_julian, + end_transaction, + julian_to_datetime, + render_pragmas, +) from sqlspec.config import ADKConfig from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.adk import BaseAsyncADKStore, StoredEvent, StoredSession, normalize_session_list_options @@ -21,11 +30,9 @@ from sqlspec.adapters.aiosqlite.config import AiosqliteConfig from sqlspec.extensions.adk import SessionOrderBy, StoredMemory -__all__ = ("AiosqliteADKConfig", "AiosqliteADKMemoryStore", "AiosqliteADKStore") +__all__ = ("JULIAN_EPOCH", "SECONDS_PER_DAY", "AiosqliteADKConfig", "AiosqliteADKMemoryStore", "AiosqliteADKStore") -SECONDS_PER_DAY = 86400.0 -JULIAN_EPOCH = 2440587.5 SQLITE_TABLE_NOT_FOUND_ERROR: Final = "no such table" _FTS_DETAIL_VALUES: Final = frozenset({"full", "column", "none"}) _FTS_TOKENIZE_PATTERN: Final = re.compile(r"^[A-Za-z0-9_ -]+$") @@ -67,7 +74,7 @@ class AiosqliteADKStore(BaseAsyncADKStore["AiosqliteConfig"]): config: AiosqliteConfig with extension_config["adk"] settings. """ - __slots__ = ("_pragma_overrides",) + __slots__ = ("_configured_connection_ids", "_pragma_overrides") def __init__(self, config: "AiosqliteConfig") -> None: """Initialize Aiosqlite ADK store. @@ -77,6 +84,7 @@ def __init__(self, config: "AiosqliteConfig") -> None: """ super().__init__(config) self._pragma_overrides = _pragma_overrides(config) + self._configured_connection_ids: set[int] = set() async def create_tables(self) -> None: """Create both sessions and events tables if they don't exist.""" @@ -128,8 +136,8 @@ async def create_session( params = (session_id, app_name, user_id, state_json, now_julian, now_julian) async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) - await conn.execute(sql, params) + await self._ensure_pragmas(conn) + await _close_cursor(await conn.execute(sql, params)) await end_transaction(conn, commit=True) return StoredSession( @@ -159,20 +167,26 @@ async def get_session( try: async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) + await self._ensure_pragmas(conn) if renew_for is not None and self._calculate_expires_at(renew_for) is not None: update_sql = f""" UPDATE {self._session_table} SET update_time = ? WHERE app_name = ? AND user_id = ? AND id = ? """ - await conn.execute(update_sql, (_datetime_to_julian(datetime.now(UTC)), *params)) + await _close_cursor( + await conn.execute(update_sql, (_datetime_to_julian(datetime.now(UTC)), *params)) + ) await end_transaction(conn, commit=True) cursor = await conn.execute(sql, params) - row = await cursor.fetchone() + try: + raw_row = await cursor.fetchone() + finally: + await _close_cursor(cursor) - if row is None: + if raw_row is None: return None + row = _row_values(raw_row) return StoredSession( id=row[0], @@ -206,8 +220,8 @@ async def update_session_state(self, app_name: str, user_id: str, session_id: st """ async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) - await conn.execute(sql, (state_json, now_julian, app_name, user_id, session_id)) + await self._ensure_pragmas(conn) + await _close_cursor(await conn.execute(sql, (state_json, now_julian, app_name, user_id, session_id))) await end_transaction(conn, commit=True) async def list_sessions( @@ -243,9 +257,12 @@ async def list_sessions( try: async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) + await self._ensure_pragmas(conn) cursor = await conn.execute(sql, params) - rows = await cursor.fetchall() + try: + rows = [_row_values(row) for row in await cursor.fetchall()] + finally: + await _close_cursor(cursor) return [ StoredSession( @@ -274,8 +291,8 @@ async def delete_session(self, app_name: str, user_id: str, session_id: str) -> sql = f"DELETE FROM {self._session_table} WHERE app_name = ? AND user_id = ? AND id = ?" async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) - await conn.execute(sql, (app_name, user_id, session_id)) + await self._ensure_pragmas(conn) + await _close_cursor(await conn.execute(sql, (app_name, user_id, session_id))) await end_transaction(conn, commit=True) async def append_event(self, event_record: StoredEvent) -> None: @@ -294,18 +311,20 @@ async def append_event(self, event_record: StoredEvent) -> None: """ async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) - await conn.execute( - sql, - ( - event_record["id"], - event_record["app_name"], - event_record["user_id"], - event_record["session_id"], - event_record["invocation_id"], - timestamp_julian, - event_data_json, - ), + await self._ensure_pragmas(conn) + await _close_cursor( + await conn.execute( + sql, + ( + event_record["id"], + event_record["app_name"], + event_record["user_id"], + event_record["session_id"], + event_record["invocation_id"], + timestamp_julian, + event_data_json, + ), + ) ) await end_transaction(conn, commit=True) @@ -371,27 +390,37 @@ async def append_event_and_update_state( """ async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) + await self._ensure_pragmas(conn) try: cursor = await conn.execute(update_sql, (state_json, now_julian, app_name, user_id, session_id)) - row = await cursor.fetchone() + try: + raw_row = await cursor.fetchone() + finally: + await _close_cursor(cursor) + row = _row_values(raw_row) if raw_row is not None else None if row is not None: - await conn.execute( - insert_sql, - ( - event_record["id"], - app_name, - user_id, - event_record["session_id"], - event_record["invocation_id"], - timestamp_julian, - event_data_json, - ), + await _close_cursor( + await conn.execute( + insert_sql, + ( + event_record["id"], + app_name, + user_id, + event_record["session_id"], + event_record["invocation_id"], + timestamp_julian, + event_data_json, + ), + ) ) if app_state is not None: - await conn.execute(app_upsert_sql, (app_name, to_json(app_state), now_julian)) + await _close_cursor( + await conn.execute(app_upsert_sql, (app_name, to_json(app_state), now_julian)) + ) if user_state is not None: - await conn.execute(user_upsert_sql, (app_name, user_id, to_json(user_state), now_julian)) + await _close_cursor( + await conn.execute(user_upsert_sql, (app_name, user_id, to_json(user_state), now_julian)) + ) except Exception: await end_transaction(conn, commit=False) raise @@ -456,9 +485,12 @@ async def get_events( try: async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) + await self._ensure_pragmas(conn) cursor = await conn.execute(sql, params) - rows = await cursor.fetchall() + try: + rows = [_row_values(row) for row in await cursor.fetchall()] + finally: + await _close_cursor(cursor) return [ StoredEvent( @@ -487,9 +519,12 @@ async def delete_expired_events(self, before: datetime, app_name: "str | None" = try: async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) + await self._ensure_pragmas(conn) cursor = await conn.execute(sql, tuple(params)) - deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + try: + deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + finally: + await _close_cursor(cursor) await end_transaction(conn, commit=True) return deleted_count except sqlite3.OperationalError as exc: @@ -507,9 +542,12 @@ async def delete_idle_sessions(self, updated_before: datetime, app_name: "str | try: async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) + await self._ensure_pragmas(conn) cursor = await conn.execute(sql, tuple(params)) - deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + try: + deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + finally: + await _close_cursor(cursor) await end_transaction(conn, commit=True) return deleted_count except sqlite3.OperationalError as exc: @@ -527,9 +565,12 @@ async def delete_idle_user_states(self, updated_before: datetime, app_name: "str try: async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) + await self._ensure_pragmas(conn) cursor = await conn.execute(sql, tuple(params)) - deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + try: + deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + finally: + await _close_cursor(cursor) await end_transaction(conn, commit=True) return deleted_count except sqlite3.OperationalError as exc: @@ -543,9 +584,13 @@ async def get_app_state(self, app_name: str) -> "dict[str, Any] | None": try: async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) + await self._ensure_pragmas(conn) cursor = await conn.execute(sql, (app_name,)) - row = await cursor.fetchone() + try: + raw_row = await cursor.fetchone() + finally: + await _close_cursor(cursor) + row = _row_values(raw_row) if raw_row is not None else None return from_json(row[0]) if row is not None and row[0] else None except sqlite3.OperationalError as exc: if SQLITE_TABLE_NOT_FOUND_ERROR in str(exc): @@ -562,9 +607,13 @@ async def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | try: async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) + await self._ensure_pragmas(conn) cursor = await conn.execute(sql, (app_name, user_id)) - row = await cursor.fetchone() + try: + raw_row = await cursor.fetchone() + finally: + await _close_cursor(cursor) + row = _row_values(raw_row) if raw_row is not None else None return from_json(row[0]) if row is not None and row[0] else None except sqlite3.OperationalError as exc: if SQLITE_TABLE_NOT_FOUND_ERROR in str(exc): @@ -582,8 +631,10 @@ async def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None """ async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) - await conn.execute(sql, (app_name, to_json(state), _datetime_to_julian(datetime.now(UTC)))) + await self._ensure_pragmas(conn) + await _close_cursor( + await conn.execute(sql, (app_name, to_json(state), _datetime_to_julian(datetime.now(UTC)))) + ) await end_transaction(conn, commit=True) async def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: @@ -597,8 +648,10 @@ async def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, """ async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) - await conn.execute(sql, (app_name, user_id, to_json(state), _datetime_to_julian(datetime.now(UTC)))) + await self._ensure_pragmas(conn) + await _close_cursor( + await conn.execute(sql, (app_name, user_id, to_json(state), _datetime_to_julian(datetime.now(UTC)))) + ) await end_transaction(conn, commit=True) async def get_metadata(self, key: str) -> "str | None": @@ -607,9 +660,13 @@ async def get_metadata(self, key: str) -> "str | None": try: async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) + await self._ensure_pragmas(conn) cursor = await conn.execute(sql, (key,)) - row = await cursor.fetchone() + try: + raw_row = await cursor.fetchone() + finally: + await _close_cursor(cursor) + row = _row_values(raw_row) if raw_row is not None else None return str(row[0]) if row is not None else None except sqlite3.OperationalError as exc: if SQLITE_TABLE_NOT_FOUND_ERROR in str(exc): @@ -625,22 +682,28 @@ async def set_metadata(self, key: str, value: str) -> None: """ async with self._config.provide_connection() as conn: - await self._apply_pragmas(conn) - await conn.execute(sql, (key, value)) + await self._ensure_pragmas(conn) + await _close_cursor(await conn.execute(sql, (key, value))) await end_transaction(conn, commit=True) + async def _ensure_pragmas(self, connection: Any) -> None: + """Apply PRAGMA optimization profile once per connection.""" + if id(connection) not in self._configured_connection_ids: + await self._apply_pragmas(connection) + async def _apply_pragmas(self, connection: Any) -> None: """Apply PRAGMA optimization profile for this connection. Args: connection: Aiosqlite connection. """ - await connection.execute("PRAGMA foreign_keys = ON") - await connection.execute("PRAGMA cache_size = -64000") - await connection.execute("PRAGMA mmap_size = 30000000") - await connection.execute("PRAGMA journal_size_limit = 67108864") + await _close_cursor(await connection.execute("PRAGMA foreign_keys = ON")) + await _close_cursor(await connection.execute("PRAGMA cache_size = -64000")) + await _close_cursor(await connection.execute("PRAGMA mmap_size = 30000000")) + await _close_cursor(await connection.execute("PRAGMA journal_size_limit = 67108864")) for pragma_name, pragma_value in self._pragma_overrides: - await connection.execute(f"PRAGMA {pragma_name} = {pragma_value}") + await _close_cursor(await connection.execute(f"PRAGMA {pragma_name} = {pragma_value}")) + self._configured_connection_ids.add(id(connection)) async def _sessions_table_ddl(self) -> str: """Get SQLite CREATE TABLE SQL for sessions. @@ -760,7 +823,7 @@ class AiosqliteADKMemoryStore(BaseAsyncADKMemoryStore["AiosqliteConfig"]): config: AiosqliteConfig with extension_config["adk"] settings. """ - __slots__ = ("_fts_options",) + __slots__ = ("_configured_connection_ids", "_fts_options", "_pragma_overrides") def __init__(self, config: "AiosqliteConfig") -> None: """Initialize Aiosqlite ADK memory store. @@ -770,6 +833,8 @@ def __init__(self, config: "AiosqliteConfig") -> None: """ super().__init__(config) self._fts_options = _fts_options(config) + self._pragma_overrides = _pragma_overrides(config) + self._configured_connection_ids: set[int] = set() async def create_tables(self) -> None: """Create the memory table and indexes if they don't exist. @@ -784,6 +849,7 @@ async def create_tables(self) -> None: return async with self._config.provide_session() as driver: + await self._enable_foreign_keys(driver.connection) await driver.execute_script(await self._memory_table_ddl()) await driver.commit() @@ -800,6 +866,7 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " return 0 async with self._config.provide_connection() as conn: + await self._ensure_foreign_keys(conn) params_list: list[tuple[Any, ...]] = [] if self._owner_id_column_name: sql = f""" @@ -855,7 +922,7 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " try: inserted_count = cursor.rowcount if cursor.rowcount >= 0 else len(params_list) finally: - await cursor.close() + await _close_cursor(cursor) await end_transaction(conn, commit=True) return inserted_count @@ -881,6 +948,7 @@ async def search_entries( columns: list[str] = [] async with self._config.provide_connection() as conn: + await self._ensure_foreign_keys(conn) if self._use_fts: where_scope, scope_params = _build_sqlite_scope_clause("m.", app_name, user_id, scope_filter) fts_sql = f""" @@ -897,7 +965,12 @@ async def search_entries( rows = list(await cursor.fetchall()) columns = [col[0] for col in cursor.description or []] finally: - await cursor.close() + await _close_cursor(cursor) + except sqlite3.OperationalError as exc: + error_text = str(exc) + if SQLITE_TABLE_NOT_FOUND_ERROR in error_text and f"{self._memory_table}_fts" not in error_text: + return [] + logger.warning("FTS search failed; falling back to simple search: %s", exc) except Exception as exc: logger.warning("FTS search failed; falling back to simple search: %s", exc) @@ -916,7 +989,7 @@ async def search_entries( rows = list(await cursor.fetchall()) columns = [col[0] for col in cursor.description or []] finally: - await cursor.close() + await _close_cursor(cursor) except sqlite3.OperationalError as exc: if SQLITE_TABLE_NOT_FOUND_ERROR in str(exc): return [] @@ -924,7 +997,7 @@ async def search_entries( records: list[StoredMemory] = [] for row in rows: - raw = dict(zip(columns, row, strict=False)) + raw = dict(row) if isinstance(row, dict) else dict(zip(columns, row, strict=False)) raw["timestamp"] = _julian_to_datetime(raw["timestamp"]) raw["inserted_at"] = _julian_to_datetime(raw["inserted_at"]) raw["content_json"] = from_json(raw["content_json"]) @@ -940,10 +1013,20 @@ async def delete_entries_by_session(self, session_id: str) -> int: raise RuntimeError(msg) sql = f"DELETE FROM {self._memory_table} WHERE session_id = ?" - async with self._config.provide_connection() as conn: - cursor = await conn.execute(sql, (session_id,)) - await end_transaction(conn, commit=True) - return cursor.rowcount + try: + async with self._config.provide_connection() as conn: + await self._ensure_foreign_keys(conn) + cursor = await conn.execute(sql, (session_id,)) + try: + deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + finally: + await _close_cursor(cursor) + await end_transaction(conn, commit=True) + return deleted_count + except sqlite3.OperationalError as exc: + if SQLITE_TABLE_NOT_FOUND_ERROR in str(exc): + return 0 + raise async def delete_entries_older_than( self, days: int, app_name: "str | None" = None, scope: "str | None" = None @@ -963,10 +1046,31 @@ async def delete_entries_older_than( sql += " AND scope = ?" params.append(scope) - async with self._config.provide_connection() as conn: - cursor = await conn.execute(sql, tuple(params)) - await end_transaction(conn, commit=True) - return cursor.rowcount + try: + async with self._config.provide_connection() as conn: + await self._ensure_foreign_keys(conn) + cursor = await conn.execute(sql, tuple(params)) + try: + deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + finally: + await _close_cursor(cursor) + await end_transaction(conn, commit=True) + return deleted_count + except sqlite3.OperationalError as exc: + if SQLITE_TABLE_NOT_FOUND_ERROR in str(exc): + return 0 + raise + + async def _ensure_foreign_keys(self, connection: Any) -> None: + if id(connection) not in self._configured_connection_ids: + await self._enable_foreign_keys(connection) + + async def _enable_foreign_keys(self, connection: Any) -> None: + """Enable foreign key constraints and apply PRAGMA overrides for this connection.""" + await _close_cursor(await connection.execute("PRAGMA foreign_keys = ON")) + for pragma_name, pragma_value in self._pragma_overrides: + await _close_cursor(await connection.execute(f"PRAGMA {pragma_name} = {pragma_value}")) + self._configured_connection_ids.add(id(connection)) async def _memory_table_ddl(self) -> str: """Get SQLite CREATE TABLE SQL for memory entries. @@ -1035,8 +1139,8 @@ def _drop_memory_table_sql(self) -> "list[str]": """Get SQLite DROP TABLE SQL statements.""" statements = [f"DROP TABLE IF EXISTS {self._memory_table}"] if self._use_fts: + statements.insert(0, f"DROP TABLE IF EXISTS {self._memory_table}_fts") statements.extend([ - f"DROP TABLE IF EXISTS {self._memory_table}_fts", f"DROP TRIGGER IF EXISTS {self._memory_table}_ai", f"DROP TRIGGER IF EXISTS {self._memory_table}_ad", f"DROP TRIGGER IF EXISTS {self._memory_table}_au", @@ -1044,6 +1148,20 @@ def _drop_memory_table_sql(self) -> "list[str]": return statements +async def _close_cursor(cursor: Any) -> None: + if cursor is not None: + with contextlib.suppress(Exception): + close_result = cursor.close() + if inspect.isawaitable(close_result): + await close_result + + +def _row_values(row: Any) -> Any: + if isinstance(row, dict): + return tuple(row.values()) + return row + + def _adk_config(config: "AiosqliteConfig") -> "dict[str, Any]": """Return the adapter-local ADK extension configuration.""" @@ -1099,33 +1217,17 @@ def _format_fts_options(options: "tuple[str, ...]") -> str: def _datetime_to_julian(dt: datetime) -> float: - """Convert datetime to Julian Day number for SQLite storage. - - Args: - dt: Datetime to convert (must be UTC-aware). - - Returns: - Julian Day number as REAL. - """ - if dt.tzinfo is None: - dt = dt.replace(tzinfo=UTC) - epoch = datetime(1970, 1, 1, tzinfo=UTC) - delta_days = (dt - epoch).total_seconds() / SECONDS_PER_DAY - return JULIAN_EPOCH + delta_days + """Convert datetime to Julian Day number for SQLite storage.""" + res = datetime_to_julian(dt) + assert res is not None + return res def _julian_to_datetime(julian: float) -> datetime: - """Convert Julian Day number back to datetime. - - Args: - julian: Julian Day number. - - Returns: - UTC-aware datetime. - """ - days_since_epoch = julian - JULIAN_EPOCH - timestamp = days_since_epoch * SECONDS_PER_DAY - return datetime.fromtimestamp(timestamp, tz=UTC) + """Convert Julian Day number back to datetime.""" + res = julian_to_datetime(julian) + assert res is not None + return res def _build_sqlite_scope_clause( diff --git a/sqlspec/adapters/aiosqlite/config.py b/sqlspec/adapters/aiosqlite/config.py index 8e341ccd0..faaba2368 100644 --- a/sqlspec/adapters/aiosqlite/config.py +++ b/sqlspec/adapters/aiosqlite/config.py @@ -1,5 +1,6 @@ """Aiosqlite database configuration.""" +from collections.abc import Mapping, Sequence from os import PathLike from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, TypedDict, cast @@ -16,6 +17,7 @@ build_connection_config, default_statement_config, render_pragmas, + require_python_version, ) from sqlspec.adapters.aiosqlite.driver import AiosqliteDriver, AiosqliteExceptionHandler from sqlspec.adapters.aiosqlite.pool import ( @@ -33,7 +35,7 @@ from sqlspec.utils.uuids import uuid4 if TYPE_CHECKING: - from collections.abc import Awaitable, Callable, Mapping, Sequence + from collections.abc import Awaitable, Callable from types import TracebackType from sqlspec.core import StatementConfig @@ -123,8 +125,8 @@ class AiosqliteDriverFeatures(TypedDict): Controls optional type handling and serialization features for SQLite connections. enable_custom_adapters: Enable custom type adapters for JSON/UUID/datetime conversion. - Defaults to True for enhanced Python type support. - Set to False only if you need pure SQLite behavior without type conversions. + Defaults to False. + Set to True if you want global sqlite3 module type adapter/converter registration. json_serializer: Custom JSON serializer function. Defaults to sqlspec.utils.serializers.to_json. json_deserializer: Custom JSON deserializer function. @@ -185,6 +187,7 @@ class AiosqliteDriverFeatures(TypedDict): _ROW_FACTORY_LITERALS = frozenset({"dict", "row", "tuple"}) +_TRANSACTION_MODE_LITERALS = frozenset({"DEFERRED", "IMMEDIATE", "EXCLUSIVE"}) _RUNTIME_FEATURE_KEYS = ( "authorizer_callback", "custom_aggregates", @@ -339,6 +342,8 @@ def __init__( observability_config=observability_config, **kwargs, ) + if self._user_connection_hook is not None: + self.driver_features["on_connection_create"] = self._user_connection_hook def get_signature_namespace(self) -> "dict[str, Any]": """Get the signature namespace for AiosqliteConfig types. @@ -451,7 +456,13 @@ async def _close_pool(self) -> None: def _validate_entries(entries: Any, required_keys: "tuple[str, ...]", feature_name: str) -> None: + if not isinstance(entries, Sequence) or isinstance(entries, (str, bytes)): + msg = f"driver_features['{feature_name}'] must be a sequence of mappings" + raise ImproperConfigurationError(msg) for entry in entries: + if not isinstance(entry, Mapping): + msg = f"driver_features['{feature_name}'] entry must be a mapping" + raise ImproperConfigurationError(msg) for required_key in required_keys: if required_key not in entry: msg = f"driver_features['{feature_name}'] entry is missing required key {required_key!r}" @@ -459,6 +470,13 @@ def _validate_entries(entries: Any, required_keys: "tuple[str, ...]", feature_na def _build_runtime_setup(features: "dict[str, Any]") -> "dict[str, Any] | None": + if "default_transaction_mode" in features and features["default_transaction_mode"] is not None: + mode = features["default_transaction_mode"] + if not isinstance(mode, str) or mode.upper() not in _TRANSACTION_MODE_LITERALS: + msg = f"driver_features['default_transaction_mode'] must be DEFERRED, IMMEDIATE, or EXCLUSIVE; got {mode!r}" + raise ImproperConfigurationError(msg) + features["default_transaction_mode"] = mode.upper() + runtime_setup: dict[str, Any] = {} for key in _RUNTIME_FEATURE_KEYS: if key in features: @@ -466,9 +484,18 @@ def _build_runtime_setup(features: "dict[str, Any]") -> "dict[str, Any] | None": if not runtime_setup: return None - if "pragmas" in runtime_setup: + if "pragmas" in runtime_setup and runtime_setup["pragmas"] is not None: + if not isinstance(runtime_setup["pragmas"], Mapping): + msg = "driver_features['pragmas'] must be a mapping of PRAGMA names to values" + raise ImproperConfigurationError(msg) runtime_setup["pragmas"] = render_pragmas(runtime_setup["pragmas"]) + if "extensions" in runtime_setup and runtime_setup["extensions"] is not None: + extensions = runtime_setup["extensions"] + if not isinstance(extensions, Sequence) or isinstance(extensions, (str, bytes)): + msg = "driver_features['extensions'] must be a sequence of extension paths" + raise ImproperConfigurationError(msg) + row_factory = runtime_setup.get("row_factory") if row_factory is not None and not isinstance(row_factory, str) and not callable(row_factory): msg = f"driver_features['row_factory'] must be 'row', 'dict', 'tuple', or a callable; got {row_factory!r}" @@ -482,9 +509,10 @@ def _build_runtime_setup(features: "dict[str, Any]") -> "dict[str, Any] | None": _validate_entries( runtime_setup.get("custom_aggregates", ()), ("name", "narg", "aggregate_class"), "custom_aggregates" ) - _validate_entries( - runtime_setup.get("custom_window_functions", ()), ("name", "narg", "window_class"), "custom_window_functions" - ) + window_functions = runtime_setup.get("custom_window_functions", ()) + _validate_entries(window_functions, ("name", "narg", "window_class"), "custom_window_functions") + if window_functions: + require_python_version("Custom SQLite window functions", (3, 11)) interval = runtime_setup.get("progress_handler_interval") if interval is not None and (not isinstance(interval, int) or isinstance(interval, bool) or interval < 1): diff --git a/sqlspec/adapters/aiosqlite/core.py b/sqlspec/adapters/aiosqlite/core.py index ecb8a7e75..a4604834e 100644 --- a/sqlspec/adapters/aiosqlite/core.py +++ b/sqlspec/adapters/aiosqlite/core.py @@ -4,7 +4,7 @@ import re import sys from collections.abc import Mapping -from datetime import date, datetime +from datetime import UTC, date, datetime, timedelta from decimal import Decimal from typing import TYPE_CHECKING, Any, TypeVar, cast @@ -43,6 +43,8 @@ _T = TypeVar("_T") __all__ = ( + "JULIAN_EPOCH", + "SECONDS_PER_DAY", "SQLITE_CONNECT_SUPPORTS_AUTOCOMMIT", "AiosqliteStreamSource", "apply_driver_features", @@ -53,6 +55,7 @@ "build_statement_config", "collect_rows", "create_mapped_exception", + "datetime_to_julian", "default_statement_config", "driver_profile", "end_transaction", @@ -63,6 +66,7 @@ "execute_many_on_worker_thread", "extension_pragma_statements", "format_identifier", + "julian_to_datetime", "normalize_execute_many_parameters", "normalize_execute_parameters", "normalize_lastrowid", @@ -85,14 +89,19 @@ "PRAGMA journal_size_limit = 67108864", ) +JULIAN_EPOCH = 2440587.5 +SECONDS_PER_DAY = 86400.0 SQLITE_CONSTRAINT_UNIQUE_CODE = 2067 SQLITE_CONSTRAINT_PRIMARYKEY_CODE = 1555 +SQLITE_CONSTRAINT_ROWID_CODE = 2579 SQLITE_CONSTRAINT_FOREIGNKEY_CODE = 787 SQLITE_CONSTRAINT_NOTNULL_CODE = 1811 SQLITE_CONSTRAINT_CHECK_CODE = 531 +SQLITE_CONSTRAINT_DATATYPE_CODE = 3091 SQLITE_CONSTRAINT_CODE = 19 SQLITE_CANTOPEN_CODE = 14 SQLITE_IOERR_CODE = 10 +SQLITE_CORRUPT_CODE = 11 SQLITE_MISMATCH_CODE = 20 SQLITE_BUSY_CODE = 5 SQLITE_LOCKED_CODE = 6 @@ -106,23 +115,24 @@ SQLITE_ROWID_ALIASES = ("rowid", "_rowid_", "oid") -def _end_transaction_on_worker(raw_conn: Any, *, commit: bool, supports_autocommit: bool) -> None: - """End an open transaction on a sqlite3 connection on the worker thread.""" - if supports_autocommit and getattr(raw_conn, "autocommit", False) is True: - if getattr(raw_conn, "in_transaction", False): - cursor = raw_conn.execute("COMMIT" if commit else "ROLLBACK") - with contextlib.suppress(Exception): - cursor.close() - return - if commit: - raw_conn.commit() - else: - raw_conn.rollback() +def datetime_to_julian(dt: "datetime | None") -> "float | None": + """Convert datetime to Julian Day number for SQLite storage.""" + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + epoch = datetime(1970, 1, 1, tzinfo=UTC) + delta_days = (dt - epoch).total_seconds() / SECONDS_PER_DAY + return JULIAN_EPOCH + delta_days -def _read_transaction_state_on_worker(raw_conn: Any) -> "tuple[bool, bool]": - """Read sqlite3 autocommit and in_transaction state on the worker thread.""" - return getattr(raw_conn, "autocommit", False) is True, bool(getattr(raw_conn, "in_transaction", False)) +def julian_to_datetime(julian: "float | None") -> "datetime | None": + """Convert Julian Day number from SQLite to UTC-aware datetime.""" + if julian is None: + return None + delta_days = julian - JULIAN_EPOCH + timestamp = delta_days * SECONDS_PER_DAY + return datetime(1970, 1, 1, tzinfo=UTC) + timedelta(seconds=timestamp) async def end_transaction( @@ -341,18 +351,14 @@ async def start(self) -> None: await self._driver._run_with_exception_handler(handler, self._start) self._driver._check_pending_exception(handler) - async def _start(self) -> None: - cursor = await self._driver.connection.cursor() - cursor.arraysize = self._chunk_size - self._cursor = cursor - await cursor.execute(self._sql, normalize_execute_parameters(self._parameters)) - async def fetch_chunk(self) -> "list[dict[str, Any]]": handler = self._driver.handle_database_exceptions() rows = await self._driver._run_with_exception_handler(handler, self._cursor.fetchmany, self._chunk_size) self._driver._check_pending_exception(handler) if not rows: return [] + if isinstance(rows[0], dict): + return [dict(row) for row in rows] if self._column_names is None: self._column_names = [description[0] for description in self._cursor.description] return rows_to_dicts(rows, self._column_names) @@ -364,6 +370,12 @@ async def close(self, error: bool = False) -> None: with contextlib.suppress(Exception): await cursor.close() + async def _start(self) -> None: + cursor = await self._driver.connection.cursor() + cursor.arraysize = self._chunk_size + self._cursor = cursor + await cursor.execute(self._sql, normalize_execute_parameters(self._parameters)) + def normalize_execute_many_parameters(parameters: Any) -> Any: """Normalize parameters for SQLite executemany calls. @@ -404,9 +416,17 @@ def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str "path", "file", } - if sys.version_info < (3, 12): - excluded_keys.add("autocommit") - connection_parameters = {key: value for key, value in connection_config.items() if key not in excluded_keys} + + def _filter_params(mapping: Mapping[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in mapping.items() + if key not in excluded_keys + and (value is not None or key == "isolation_level") + and (key != "autocommit" or SQLITE_CONNECT_SUPPORTS_AUTOCOMMIT) + } + + connection_parameters = _filter_params(connection_config) if "database" not in connection_parameters: database = ( connection_config.get("database") @@ -416,6 +436,9 @@ def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str ) if database is not None: connection_parameters["database"] = database + extra = connection_config.get("extra") + if isinstance(extra, Mapping): + connection_parameters.update(_filter_params(extra)) return connection_parameters @@ -441,61 +464,49 @@ def create_mapped_exception(error: BaseException, *, logger: Any | None = None) error_code = None error_name = None error_msg = str(error).lower() + primary_code = (error_code & 0xFF) if isinstance(error_code, int) else None - if error_code in (SQLITE_CONSTRAINT_UNIQUE_CODE, SQLITE_CONSTRAINT_PRIMARYKEY_CODE) or error_name in ( - "SQLITE_CONSTRAINT_UNIQUE", - "SQLITE_CONSTRAINT_PRIMARYKEY", - ): - return _create_aiosqlite_error(error, error_code, UniqueViolationError, "unique constraint violation") + constraint_error = _map_constraint_exception(error, error_code, error_name, primary_code, error_msg) + if constraint_error is not None: + return constraint_error - if error_code == SQLITE_BUSY_CODE or error_name == "SQLITE_BUSY": + if primary_code == SQLITE_BUSY_CODE or (isinstance(error_name, str) and error_name.startswith("SQLITE_BUSY")): return _create_aiosqlite_error(error, error_code, DeadlockError, "database busy") - if error_code == SQLITE_LOCKED_CODE or error_name == "SQLITE_LOCKED": + if primary_code == SQLITE_LOCKED_CODE or (isinstance(error_name, str) and error_name.startswith("SQLITE_LOCKED")): return _create_aiosqlite_error(error, error_code, DeadlockError, "database locked") if "locked" in error_msg or "busy" in error_msg: return _create_aiosqlite_error(error, error_code or 0, DeadlockError, "database locked") - if error_code == SQLITE_INTERRUPT_CODE or error_name == "SQLITE_INTERRUPT": + if primary_code == SQLITE_INTERRUPT_CODE or error_name == "SQLITE_INTERRUPT": return _create_aiosqlite_error(error, error_code, OperationCancelledError, "query interrupted") if "interrupt" in error_msg: return _create_aiosqlite_error(error, error_code or 0, OperationCancelledError, "query interrupted") - if error_code == SQLITE_PERM_CODE or error_name == "SQLITE_PERM": + if primary_code == SQLITE_PERM_CODE or error_name == "SQLITE_PERM": return _create_aiosqlite_error(error, error_code, PermissionDeniedError, "permission denied") - if error_code == SQLITE_READONLY_CODE or error_name == "SQLITE_READONLY": + if primary_code == SQLITE_READONLY_CODE or ( + isinstance(error_name, str) and error_name.startswith("SQLITE_READONLY") + ): return _create_aiosqlite_error(error, error_code, PermissionDeniedError, "database is read-only") if "permission denied" in error_msg or "readonly" in error_msg: return _create_aiosqlite_error(error, error_code or 0, PermissionDeniedError, "permission denied") - if not error_code: - if "unique constraint" in error_msg: - return _create_aiosqlite_error(error, 0, UniqueViolationError, "unique constraint violation") - if "foreign key constraint" in error_msg: - return _create_aiosqlite_error(error, 0, ForeignKeyViolationError, "foreign key constraint violation") - if "not null constraint" in error_msg: - return _create_aiosqlite_error(error, 0, NotNullViolationError, "not-null constraint violation") - if "check constraint" in error_msg: - return _create_aiosqlite_error(error, 0, CheckViolationError, "check constraint violation") - if "syntax" in error_msg: - return _create_aiosqlite_error(error, None, SQLParsingError, "SQL syntax error") - return _create_aiosqlite_error(error, None, SQLSpecError, "database error") - - if error_code == SQLITE_CONSTRAINT_FOREIGNKEY_CODE or error_name == "SQLITE_CONSTRAINT_FOREIGNKEY": - return _create_aiosqlite_error(error, error_code, ForeignKeyViolationError, "foreign key constraint violation") - if error_code == SQLITE_CONSTRAINT_NOTNULL_CODE or error_name == "SQLITE_CONSTRAINT_NOTNULL": - return _create_aiosqlite_error(error, error_code, NotNullViolationError, "not-null constraint violation") - if error_code == SQLITE_CONSTRAINT_CHECK_CODE or error_name == "SQLITE_CONSTRAINT_CHECK": - return _create_aiosqlite_error(error, error_code, CheckViolationError, "check constraint violation") - if error_code == SQLITE_CONSTRAINT_CODE or error_name == "SQLITE_CONSTRAINT": - return _create_aiosqlite_error(error, error_code, IntegrityError, "integrity constraint violation") - if error_code == SQLITE_CANTOPEN_CODE or error_name == "SQLITE_CANTOPEN": + if primary_code == SQLITE_CANTOPEN_CODE or ( + isinstance(error_name, str) and error_name.startswith("SQLITE_CANTOPEN") + ): return _create_aiosqlite_error(error, error_code, DatabaseConnectionError, "connection error") - if error_code == SQLITE_IOERR_CODE or error_name == "SQLITE_IOERR": + if primary_code in (SQLITE_IOERR_CODE, SQLITE_CORRUPT_CODE) or ( + isinstance(error_name, str) and error_name.startswith(("SQLITE_IOERR", "SQLITE_CORRUPT")) + ): return _create_aiosqlite_error(error, error_code, OperationalError, "operational error") - if error_code == SQLITE_MISMATCH_CODE or error_name == "SQLITE_MISMATCH": + if primary_code == SQLITE_MISMATCH_CODE or error_name == "SQLITE_MISMATCH": return _create_aiosqlite_error(error, error_code, DataError, "data error") - if error_code == 1 or "syntax" in error_msg: + if "syntax" in error_msg: return _create_aiosqlite_error(error, error_code, SQLParsingError, "SQL syntax error") + if primary_code == 1 or isinstance(error, sqlite3.OperationalError): + return _create_aiosqlite_error(error, error_code, OperationalError, "operational error") + if isinstance(error, sqlite3.DataError): + return _create_aiosqlite_error(error, error_code, DataError, "data error") return _create_aiosqlite_error(error, error_code, SQLSpecError, "database error") @@ -545,11 +556,13 @@ def apply_driver_features( ) -> "tuple[StatementConfig, dict[str, Any]]": """Apply AIOSQLite driver feature defaults to statement config.""" features: dict[str, Any] = dict(driver_features) if driver_features else {} + has_custom_serializer = "json_serializer" in features + has_custom_deserializer = "json_deserializer" in features features.setdefault("enable_custom_adapters", False) json_serializer = features.setdefault("json_serializer", to_json) json_deserializer = features.setdefault("json_deserializer", from_json) - if json_serializer is not None: + if json_serializer is not None and (has_custom_serializer or has_custom_deserializer): parameter_config = statement_config.parameter_config.with_json_serializers( json_serializer, deserializer=json_deserializer ) @@ -584,7 +597,10 @@ def extension_pragma_statements(config: Any, extension_name: str) -> "tuple[str, async def apply_extension_pragmas(connection: Any, statements: "tuple[str, ...]") -> None: for statement in statements: - await connection.execute(statement) + cursor = await connection.execute(statement) + if cursor is not None and callable(getattr(cursor, "close", None)): + with contextlib.suppress(Exception): + await cursor.close() def render_pragmas(pragmas: "Mapping[str, Any]") -> "list[tuple[str, str]]": @@ -606,6 +622,31 @@ def render_pragmas(pragmas: "Mapping[str, Any]") -> "list[tuple[str, str]]": return rendered +driver_profile = build_profile() + +default_statement_config = build_statement_config() + + +def _end_transaction_on_worker(raw_conn: Any, *, commit: bool, supports_autocommit: bool) -> None: + """End an open transaction on a sqlite3 connection on the worker thread.""" + if not getattr(raw_conn, "in_transaction", False): + return + if supports_autocommit and getattr(raw_conn, "autocommit", False) is True: + cursor = raw_conn.execute("COMMIT" if commit else "ROLLBACK") + with contextlib.suppress(Exception): + cursor.close() + return + if commit: + raw_conn.commit() + else: + raw_conn.rollback() + + +def _read_transaction_state_on_worker(raw_conn: Any) -> "tuple[bool, bool]": + """Read sqlite3 autocommit and in_transaction state on the worker thread.""" + return getattr(raw_conn, "autocommit", False) is True, bool(getattr(raw_conn, "in_transaction", False)) + + def _execute_fetchall_with_metadata( connection: "AiosqliteConnection", sql: str, @@ -620,8 +661,9 @@ def _execute_fetchall_with_metadata( try: fetched_data = cursor.fetchall() rowcount = cursor.rowcount if isinstance(cursor.rowcount, int) and cursor.rowcount > 0 else 0 + effective_rowcount = rowcount or (len(fetched_data) if operation_type == "INSERT" else 0) last_inserted_id = resolve_lastrowid( - raw_connection, cursor, operation_type, rowcount, expression, eligibility_cache + raw_connection, cursor, operation_type, effective_rowcount, expression, eligibility_cache ) return fetched_data, cursor.description, rowcount, last_inserted_id finally: @@ -667,10 +709,18 @@ def _execute_many_on_worker_thread(connection: "AiosqliteConnection", sql: str, execute_and_resolve_metadata = _execute_and_resolve_metadata + execute_fetchall_with_metadata = _execute_fetchall_with_metadata + execute_many_on_worker_thread = _execute_many_on_worker_thread +def _normalize_metadata_row(row: Any) -> Any: + if isinstance(row, dict): + return tuple(row.values()) + return row + + def _resolve_insert_target(expression: Any) -> "tuple[str | None, str] | None": if not isinstance(expression, exp.Insert): return None @@ -690,7 +740,7 @@ def _target_supports_rowid(connection: Any, target: "tuple[str | None, str]") -> try: table_cursor = connection.execute("PRAGMA table_list") try: - table_rows = table_cursor.fetchall() + table_rows = [_normalize_metadata_row(row) for row in table_cursor.fetchall()] finally: with contextlib.suppress(Exception): table_cursor.close() @@ -719,9 +769,10 @@ def _target_supports_rowid(connection: Any, target: "tuple[str | None, str]") -> try: database_cursor = connection.execute("PRAGMA database_list") try: + db_rows = [_normalize_metadata_row(row) for row in database_cursor.fetchall()] schema_order.extend( row[1] - for row in database_cursor.fetchall() + for row in db_rows if len(row) >= SQLITE_DATABASE_LIST_MIN_COLUMNS and isinstance(row[1], str) ) finally: @@ -744,9 +795,10 @@ def _target_supports_rowid_legacy(connection: Any, target: "tuple[str | None, st try: database_cursor = connection.execute("PRAGMA database_list") try: + db_rows = [_normalize_metadata_row(row) for row in database_cursor.fetchall()] schema_order.extend( row[1] - for row in database_cursor.fetchall() + for row in db_rows if len(row) >= SQLITE_DATABASE_LIST_MIN_COLUMNS and isinstance(row[1], str) and row[1] not in {"main", "temp"} @@ -767,7 +819,8 @@ def _target_supports_rowid_legacy(connection: Any, target: "tuple[str | None, st schema_cursor = connection.execute( f"SELECT type FROM {quoted_schema}.sqlite_master WHERE name = ? COLLATE NOCASE", (target_table,) ) - schema_row = schema_cursor.fetchone() + fetched_schema_row = schema_cursor.fetchone() + schema_row = _normalize_metadata_row(fetched_schema_row) if fetched_schema_row is not None else None except sqlite3.Error: pass finally: @@ -784,9 +837,10 @@ def _target_supports_rowid_legacy(connection: Any, target: "tuple[str | None, st table_info_cursor = connection.execute( f"PRAGMA {quoted_schema}.table_info({quote_identifier(target_table)})" ) + info_rows = [_normalize_metadata_row(row) for row in table_info_cursor.fetchall()] column_names = { row[1].casefold() - for row in table_info_cursor.fetchall() + for row in info_rows if len(row) >= SQLITE_TABLE_INFO_MIN_COLUMNS and isinstance(row[1], str) } except sqlite3.Error: @@ -811,6 +865,52 @@ def _target_supports_rowid_legacy(connection: Any, target: "tuple[str | None, st return False +def _map_constraint_exception( + error: Any, error_code: "int | None", error_name: "str | None", primary_code: "int | None", error_msg: str +) -> "SQLSpecError | None": + if error_code in ( + SQLITE_CONSTRAINT_UNIQUE_CODE, + SQLITE_CONSTRAINT_PRIMARYKEY_CODE, + SQLITE_CONSTRAINT_ROWID_CODE, + ) or error_name in ("SQLITE_CONSTRAINT_UNIQUE", "SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_ROWID"): + return _create_aiosqlite_error(error, error_code, UniqueViolationError, "unique constraint violation") + if error_code == SQLITE_CONSTRAINT_FOREIGNKEY_CODE or error_name == "SQLITE_CONSTRAINT_FOREIGNKEY": + return _create_aiosqlite_error(error, error_code, ForeignKeyViolationError, "foreign key constraint violation") + if error_code == SQLITE_CONSTRAINT_NOTNULL_CODE or error_name == "SQLITE_CONSTRAINT_NOTNULL": + return _create_aiosqlite_error(error, error_code, NotNullViolationError, "not-null constraint violation") + if error_code == SQLITE_CONSTRAINT_CHECK_CODE or error_name == "SQLITE_CONSTRAINT_CHECK": + return _create_aiosqlite_error(error, error_code, CheckViolationError, "check constraint violation") + if error_code == SQLITE_CONSTRAINT_DATATYPE_CODE or error_name == "SQLITE_CONSTRAINT_DATATYPE": + return _create_aiosqlite_error(error, error_code, DataError, "datatype constraint violation") + + if ( + not error_code + or primary_code == SQLITE_CONSTRAINT_CODE + or error_name == "SQLITE_CONSTRAINT" + or isinstance(error, sqlite3.IntegrityError) + ): + code_for_msg = error_code or 0 + if "unique constraint" in error_msg or "primary key" in error_msg: + return _create_aiosqlite_error(error, code_for_msg, UniqueViolationError, "unique constraint violation") + if "foreign key constraint" in error_msg: + return _create_aiosqlite_error( + error, code_for_msg, ForeignKeyViolationError, "foreign key constraint violation" + ) + if "not null constraint" in error_msg: + return _create_aiosqlite_error(error, code_for_msg, NotNullViolationError, "not-null constraint violation") + if "check constraint" in error_msg: + return _create_aiosqlite_error(error, code_for_msg, CheckViolationError, "check constraint violation") + if "cannot store" in error_msg: + return _create_aiosqlite_error(error, code_for_msg, DataError, "datatype constraint violation") + if ( + primary_code == SQLITE_CONSTRAINT_CODE + or error_name == "SQLITE_CONSTRAINT" + or isinstance(error, sqlite3.IntegrityError) + ): + return _create_aiosqlite_error(error, error_code, IntegrityError, "integrity constraint violation") + return None + + def _create_aiosqlite_error( error: Any, code: "int | None", error_class: type[SQLSpecError], description: str ) -> SQLSpecError: @@ -830,8 +930,3 @@ def _create_aiosqlite_error( exc = error_class(msg) exc.__cause__ = cast("BaseException", error) return exc - - -driver_profile = build_profile() - -default_statement_config = build_statement_config() diff --git a/sqlspec/adapters/aiosqlite/driver.py b/sqlspec/adapters/aiosqlite/driver.py index 7d8b03d17..d41d3963c 100644 --- a/sqlspec/adapters/aiosqlite/driver.py +++ b/sqlspec/adapters/aiosqlite/driver.py @@ -3,9 +3,11 @@ import asyncio import contextlib import inspect -import random +import secrets from typing import TYPE_CHECKING, Any, Literal, cast +from mypy_extensions import mypyc_attr + from sqlspec.adapters.aiosqlite._typing import AiosqliteCursor, AiosqliteRawCursor, AiosqliteSessionContext from sqlspec.adapters.aiosqlite._typing import aiosqlite_module as aiosqlite from sqlspec.adapters.aiosqlite._typing import aiosqlite_sqlite_module as sqlite3 @@ -35,7 +37,7 @@ parameter_value_needs_processing, type_coercion_fallbacks, ) -from sqlspec.exceptions import SQLSpecError +from sqlspec.exceptions import DeadlockError from sqlspec.utils.type_guards import resolve_row_format if TYPE_CHECKING: @@ -59,8 +61,10 @@ _execute_and_resolve_metadata = execute_and_resolve_metadata _execute_fetchall_with_metadata = execute_fetchall_with_metadata +random = secrets.SystemRandom() +@mypyc_attr(allow_interpreted_subclasses=True) class AiosqliteExceptionHandler(BaseAsyncExceptionHandler): """Async context manager for handling aiosqlite database exceptions. @@ -82,6 +86,7 @@ def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "Ba return False +@mypyc_attr(allow_interpreted_subclasses=True) class AiosqliteDriver(AsyncDriverAdapterBase): """AIOSQLite driver for async SQLite database operations.""" @@ -103,6 +108,17 @@ def __init__( self._data_dictionary: AiosqliteDataDictionary | None = None self._rowid_target_cache: dict[tuple[str | None, str], bool] = {} + @property + def data_dictionary(self) -> "AiosqliteDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = AiosqliteDataDictionary() + return self._data_dictionary + async def dispatch_execute(self, cursor: "AiosqliteRawCursor", statement: "SQL") -> "ExecutionResult": """Execute single SQL statement.""" sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) @@ -120,7 +136,6 @@ async def dispatch_execute(self, cursor: "AiosqliteRawCursor", statement: "SQL") statement.expression, self._rowid_target_cache, ) - self._invalidate_rowid_target_cache(statement.operation_type) data, column_names, row_count = collect_rows(fetched_data, description) row_format = resolve_row_format(data) @@ -144,21 +159,15 @@ async def dispatch_execute(self, cursor: "AiosqliteRawCursor", statement: "SQL") statement.expression, self._rowid_target_cache, ) - self._invalidate_rowid_target_cache(statement.operation_type) return self.create_execution_result(cursor, rowcount_override=affected_rows, last_inserted_id=last_inserted_id) async def dispatch_execute_many(self, cursor: "AiosqliteRawCursor", statement: "SQL") -> "ExecutionResult": """Execute SQL with multiple parameter sets.""" sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) self._invalidate_rowid_target_cache(statement.operation_type) - - try: - affected_rows = await run_on_worker_thread( - self.connection, execute_many_on_worker_thread, self.connection, sql, prepared_parameters - ) - finally: - self._invalidate_rowid_target_cache(statement.operation_type) - + affected_rows = await run_on_worker_thread( + self.connection, execute_many_on_worker_thread, self.connection, sql, prepared_parameters + ) return self.create_execution_result(cursor, rowcount_override=affected_rows, is_many_result=True) async def dispatch_execute_script(self, cursor: "AiosqliteRawCursor", statement: "SQL") -> "ExecutionResult": @@ -234,26 +243,31 @@ async def begin(self, mode: "Literal['DEFERRED', 'IMMEDIATE', 'EXCLUSIVE'] | Non msg = "Transaction mode must be DEFERRED, IMMEDIATE, or EXCLUSIVE" raise ValueError(msg) try: - if not self.connection.in_transaction: - await self.connection.execute(stmt) - except aiosqlite.Error as e: + if not await _check_in_transaction(self.connection): + cursor = await self.connection.execute(stmt) + if cursor is not None: + with contextlib.suppress(Exception): + close_result = cursor.close() + if inspect.isawaitable(close_result): + await close_result + except (aiosqlite.Error, sqlite3.Error) as e: await _retry_begin_with_backoff(self.connection, e, statement=stmt) async def commit(self) -> None: """Commit the current transaction.""" try: await end_transaction(self.connection, commit=True) - except aiosqlite.Error as e: - msg = f"Failed to commit transaction: {e}" - raise SQLSpecError(msg) from e + except (aiosqlite.Error, sqlite3.Error) as e: + raise create_mapped_exception(e) from e async def rollback(self) -> None: """Rollback the current transaction.""" try: await end_transaction(self.connection, commit=False) - except aiosqlite.Error as e: - msg = f"Failed to rollback transaction: {e}" - raise SQLSpecError(msg) from e + self._rowid_target_cache.clear() + except (aiosqlite.Error, sqlite3.Error) as e: + self._rowid_target_cache.clear() + raise create_mapped_exception(e) from e def with_cursor(self, connection: "AiosqliteConnection") -> "AiosqliteCursor": """Create async context manager for AIOSQLite cursor.""" @@ -313,7 +327,7 @@ async def load_from_arrow( insert_sql = build_insert_statement(table, columns) needs_prep = self._arrow_rows_need_preparation(arrow_table) - owns_transaction = not self.connection.in_transaction + owns_transaction = not await _check_in_transaction(self.connection) try: if owns_transaction: await self.begin() @@ -362,17 +376,6 @@ async def load_from_storage( table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound ) - @property - def data_dictionary(self) -> "AiosqliteDataDictionary": - """Get the data dictionary for this driver. - - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = AiosqliteDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect aiosqlite rows for the direct execution path.""" return collect_rows(fetched, cursor.description) @@ -385,8 +388,7 @@ async def _execute_cache_hit( self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: Any ) -> "SQLResult": """Execute cached queries through the async cursor fast path.""" - prepared_params = self.prepare_driver_parameters(params, self.statement_config, prepared_statement=cached) - normalized_parameters = normalize_execute_parameters(prepared_params) + normalized_parameters = normalize_execute_parameters(params) direct_statement: SQL | None = None exc_handler = self.handle_database_exceptions() result: SQLResult | None = None @@ -415,12 +417,7 @@ async def _execute_cache_hit( last_inserted_id=last_inserted_id, ) direct_statement = self._cached_statement( - sql, - params, - cached, - cast("tuple[Any, ...] | list[Any] | dict[str, Any]", prepared_params), - params_are_simple=True, - compiled_sql=cached.compiled_sql, + sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql ) result = self.build_statement_result(direct_statement, execution_result) else: @@ -440,7 +437,6 @@ async def _execute_cache_hit( assert result is not None return result finally: - self._invalidate_rowid_target_cache(cached.operation_type) if direct_statement is not None: self._release_pooled_statement(direct_statement) @@ -566,18 +562,33 @@ def _connection_in_transaction(self) -> bool: return bool(self.connection.in_transaction) +def _read_in_transaction_on_worker(raw_conn: Any) -> bool: + return bool(getattr(raw_conn, "in_transaction", False)) + + +async def _check_in_transaction(connection: "AiosqliteConnection") -> bool: + raw_conn = getattr(connection, "_conn", None) + if isinstance(raw_conn, sqlite3.Connection) and callable(getattr(connection, "_execute", None)): + return await run_on_worker_thread(connection, _read_in_transaction_on_worker, raw_conn) + return bool(getattr(connection, "in_transaction", False)) + + +def _is_busy_or_locked_error(error: BaseException) -> bool: + code = getattr(error, "sqlite_errorcode", None) + if isinstance(code, int) and code in {5, 6}: + return True + message = str(error).lower() + return "locked" in message or "busy" in message + + async def _retry_begin_with_backoff( connection: "AiosqliteConnection", - initial_error: aiosqlite.Error, + initial_error: "aiosqlite.Error | sqlite3.Error", max_retries: int = 3, *, statement: str = "BEGIN IMMEDIATE", ) -> None: - """Retry transaction start after SQLite reports a busy connection. - - Aiosqlite surfaces SQLite lock contention through ``aiosqlite.Error``. Preserve - the existing bounded exponential-backoff behavior for every native error and - report the original failure if all retries are exhausted. + """Retry transaction start after SQLite reports a busy or locked connection. Args: connection: Aiosqlite connection used to retry the transaction start. @@ -586,20 +597,30 @@ async def _retry_begin_with_backoff( statement: SQL statement used to start the transaction. Raises: - SQLSpecError: If every retry attempt fails. + SQLSpecError: If a non-transient error occurs or every retry attempt fails. """ + if not _is_busy_or_locked_error(initial_error): + raise create_mapped_exception(initial_error) from initial_error + for attempt in range(max_retries): - delay = 0.01 * (2**attempt) + random.uniform(0, 0.01) # noqa: S311 + delay = 0.01 * (2**attempt) + random.uniform(0, 0.01) await asyncio.sleep(delay) try: - await connection.execute(statement) - except aiosqlite.Error: + cursor = await connection.execute(statement) + if cursor is not None: + with contextlib.suppress(Exception): + close_result = cursor.close() + if inspect.isawaitable(close_result): + await close_result + except (aiosqlite.Error, sqlite3.Error) as exc: + if not _is_busy_or_locked_error(exc): + raise create_mapped_exception(exc) from exc if attempt == max_retries - 1: break else: return msg = f"Failed to begin transaction after retries: {initial_error}" - raise SQLSpecError(msg) from initial_error + raise DeadlockError(msg) from initial_error register_driver_profile("aiosqlite", driver_profile) diff --git a/sqlspec/adapters/aiosqlite/events/store.py b/sqlspec/adapters/aiosqlite/events/store.py index c4c9b2ba6..bbb5a1fe4 100644 --- a/sqlspec/adapters/aiosqlite/events/store.py +++ b/sqlspec/adapters/aiosqlite/events/store.py @@ -1,12 +1,15 @@ """AioSQLite event queue store.""" -from typing import Any, NotRequired +from typing import TYPE_CHECKING, Any, NotRequired from sqlspec.adapters.aiosqlite.config import AiosqliteConfig from sqlspec.adapters.aiosqlite.core import apply_extension_pragmas, extension_pragma_statements from sqlspec.config import EventsConfig from sqlspec.extensions.events import BaseEventQueueStore +if TYPE_CHECKING: + from sqlspec.migrations.schema import SchemaEnsureResult + __all__ = ("AiosqliteEventQueueStore", "AiosqliteEventsConfig") @@ -41,6 +44,11 @@ async def prepare_schema_async(self, driver: Any) -> None: """Apply configured SQLite PRAGMAs before queue DDL.""" await apply_extension_pragmas(driver.connection, self._pragma_statements) + async def reconcile_schema_async(self, driver: Any) -> "SchemaEnsureResult": + """Apply configured SQLite PRAGMAs and reconcile queue schema.""" + await self.prepare_schema_async(driver) + return await super().reconcile_schema_async(driver) + def _column_types(self) -> "tuple[str, str, str]": """Return SQLite-compatible column types for the event queue.""" return "TEXT", "TEXT", "TIMESTAMP" diff --git a/sqlspec/adapters/aiosqlite/litestar/store.py b/sqlspec/adapters/aiosqlite/litestar/store.py index 215414901..254461451 100644 --- a/sqlspec/adapters/aiosqlite/litestar/store.py +++ b/sqlspec/adapters/aiosqlite/litestar/store.py @@ -1,20 +1,26 @@ """AioSQLite session store for Litestar integration.""" +import contextlib +import inspect from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, NotRequired -from sqlspec.adapters.aiosqlite.core import apply_extension_pragmas, end_transaction, extension_pragma_statements +from sqlspec.adapters.aiosqlite.core import ( + JULIAN_EPOCH, + SECONDS_PER_DAY, + apply_extension_pragmas, + datetime_to_julian, + end_transaction, + extension_pragma_statements, + julian_to_datetime, +) from sqlspec.config import LitestarConfig from sqlspec.extensions.litestar.store import BaseSQLSpecStore if TYPE_CHECKING: from sqlspec.adapters.aiosqlite.config import AiosqliteConfig -__all__ = ("AiosqliteLitestarConfig", "AiosqliteStore") - - -SECONDS_PER_DAY = 86400.0 -JULIAN_EPOCH = 2440587.5 +__all__ = ("JULIAN_EPOCH", "SECONDS_PER_DAY", "AiosqliteLitestarConfig", "AiosqliteStore") class AiosqliteLitestarConfig(LitestarConfig): @@ -44,7 +50,7 @@ class AiosqliteStore(BaseSQLSpecStore["AiosqliteConfig"]): config: AiosqliteConfig instance. """ - __slots__ = ("_pragma_statements",) + __slots__ = ("_configured_connection_ids", "_pragma_statements") extension_config_options = BaseSQLSpecStore.extension_config_options | frozenset({ "pragma_overrides", "pragma_profile", @@ -58,6 +64,7 @@ def __init__(self, config: "AiosqliteConfig") -> None: """ super().__init__(config) self._pragma_statements = extension_pragma_statements(config, "litestar") + self._configured_connection_ids: set[int] = set() async def create_table(self) -> None: """Create the session table if it doesn't exist.""" @@ -67,13 +74,16 @@ async def create_table(self) -> None: sql = self._table_ddl() async with self._config.provide_session() as driver: await apply_extension_pragmas(driver.connection, self._pragma_statements) + self._configured_connection_ids.add(id(driver.connection)) await driver.execute_script(sql) + await driver.commit() self._log_table_created() await self.reconcile_schema(assume_existing=True) async def prepare_schema_async(self, driver: Any) -> None: """Apply configured SQLite PRAGMAs before migration DDL generation.""" await apply_extension_pragmas(driver.connection, self._pragma_statements) + self._configured_connection_ids.add(id(driver.connection)) async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": """Get a session value by key. @@ -92,13 +102,14 @@ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "by """ async with self._config.provide_connection() as conn: + await self._ensure_pragmas(conn) async with conn.execute(sql, (key,)) as cursor: - row = await cursor.fetchone() + raw_row = await cursor.fetchone() - if row is None: + if raw_row is None: return None - data, expires_at_julian = row + data, expires_at_julian = _row_values(raw_row) if renew_for is not None and expires_at_julian is not None: new_expires_at = self._calculate_expires_at(renew_for) @@ -109,7 +120,7 @@ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "by SET expires_at = ? WHERE session_id = ? """ - await conn.execute(update_sql, (new_expires_at_julian, key)) + await _close_cursor(await conn.execute(update_sql, (new_expires_at_julian, key))) await end_transaction(conn, commit=True) return bytes(data) @@ -132,7 +143,8 @@ async def set(self, key: str, value: "str | bytes", expires_in: "int | timedelta """ async with self._config.provide_connection() as conn: - await conn.execute(sql, (key, data, expires_at_julian)) + await self._ensure_pragmas(conn) + await _close_cursor(await conn.execute(sql, (key, data, expires_at_julian))) await end_transaction(conn, commit=True) async def delete(self, key: str) -> None: @@ -144,7 +156,8 @@ async def delete(self, key: str) -> None: sql = f"DELETE FROM {self._table_name} WHERE session_id = ?" async with self._config.provide_connection() as conn: - await conn.execute(sql, (key,)) + await self._ensure_pragmas(conn) + await _close_cursor(await conn.execute(sql, (key,))) await end_transaction(conn, commit=True) async def delete_all(self) -> None: @@ -152,7 +165,8 @@ async def delete_all(self) -> None: sql = f"DELETE FROM {self._table_name}" async with self._config.provide_connection() as conn: - await conn.execute(sql) + await self._ensure_pragmas(conn) + await _close_cursor(await conn.execute(sql)) await end_transaction(conn, commit=True) self._log_delete_all() @@ -171,9 +185,11 @@ async def exists(self, key: str) -> bool: AND (expires_at IS NULL OR expires_at > julianday('now')) """ - async with self._config.provide_connection() as conn, conn.execute(sql, (key,)) as cursor: - result = await cursor.fetchone() - return result is not None + async with self._config.provide_connection() as conn: + await self._ensure_pragmas(conn) + async with conn.execute(sql, (key,)) as cursor: + result = await cursor.fetchone() + return result is not None async def expires_in(self, key: str) -> "int | None": """Get the time in seconds until the session expires. @@ -190,10 +206,14 @@ async def expires_in(self, key: str) -> "int | None": """ async with self._config.provide_connection() as conn: + await self._ensure_pragmas(conn) async with conn.execute(sql, (key,)) as cursor: - row = await cursor.fetchone() + raw_row = await cursor.fetchone() - if row is None or row[0] is None: + if raw_row is None: + return None + row = _row_values(raw_row) + if row[0] is None: return None expires_at_julian = row[0] @@ -219,16 +239,22 @@ async def delete_expired(self) -> int: sql = f"DELETE FROM {self._table_name} WHERE expires_at IS NOT NULL AND expires_at <= julianday('now')" async with self._config.provide_connection() as conn: + await self._ensure_pragmas(conn) cursor = await conn.execute(sql) try: await end_transaction(conn, commit=True) - count = cursor.rowcount + count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 finally: - await cursor.close() + await _close_cursor(cursor) if count > 0: self._log_delete_expired(count) return count + async def _ensure_pragmas(self, connection: Any) -> None: + if self._pragma_statements and id(connection) not in self._configured_connection_ids: + await apply_extension_pragmas(connection, self._pragma_statements) + self._configured_connection_ids.add(id(connection)) + def _table_ddl(self) -> str: """Get SQLite CREATE TABLE SQL. @@ -254,33 +280,23 @@ def _drop_table_sql(self) -> "list[str]": return [f"DROP INDEX IF EXISTS idx_{self._table_name}_expires_at", f"DROP TABLE IF EXISTS {self._table_name}"] def _datetime_to_julian(self, dt: "datetime | None") -> "float | None": - """Convert datetime to Julian Day number for SQLite storage. - - Args: - dt: Datetime to convert (must be UTC-aware). - - Returns: - Julian Day number as REAL, or None if dt is None. - """ - if dt is None: - return None - - epoch = datetime(1970, 1, 1, tzinfo=UTC) - delta_days = (dt - epoch).total_seconds() / SECONDS_PER_DAY - return JULIAN_EPOCH + delta_days + """Convert datetime to Julian Day number for SQLite storage.""" + return datetime_to_julian(dt) def _julian_to_datetime(self, julian: "float | None") -> "datetime | None": - """Convert Julian Day number back to datetime. + """Convert Julian Day number back to datetime.""" + return julian_to_datetime(julian) - Args: - julian: Julian Day number. - Returns: - UTC-aware datetime, or None if julian is None. - """ - if julian is None: - return None +async def _close_cursor(cursor: Any) -> None: + if cursor is not None: + with contextlib.suppress(Exception): + close_result = cursor.close() + if inspect.isawaitable(close_result): + await close_result + - days_since_epoch = julian - JULIAN_EPOCH - timestamp = days_since_epoch * SECONDS_PER_DAY - return datetime.fromtimestamp(timestamp, tz=UTC) +def _row_values(row: Any) -> Any: + if isinstance(row, dict): + return tuple(row.values()) + return row diff --git a/sqlspec/adapters/aiosqlite/pool.py b/sqlspec/adapters/aiosqlite/pool.py index 0b8829df7..72dfb9f67 100644 --- a/sqlspec/adapters/aiosqlite/pool.py +++ b/sqlspec/adapters/aiosqlite/pool.py @@ -6,7 +6,7 @@ from contextlib import suppress from inspect import isawaitable from threading import Thread -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, cast from sqlspec.adapters.aiosqlite._typing import aiosqlite_module as aiosqlite from sqlspec.adapters.aiosqlite._typing import aiosqlite_sqlite_module as sqlite3 @@ -40,116 +40,6 @@ SQLITE_WAL_SWITCH_DELAY: Final = 0.01 -async def _attempt_wal_switch(connection: "AiosqliteConnection", attempt: int) -> bool: - """Attempt a single WAL mode switch, returning True on success.""" - try: - await connection.execute("PRAGMA journal_mode = WAL") - except sqlite3.OperationalError as exc: - if "locked" not in str(exc) or attempt == SQLITE_WAL_SWITCH_ATTEMPTS - 1: - raise - await asyncio.sleep(SQLITE_WAL_SWITCH_DELAY) - return False - return True - - -async def _enable_wal(connection: "AiosqliteConnection") -> None: - """Retry database and table locks briefly while switching to WAL mode.""" - for attempt in range(SQLITE_WAL_SWITCH_ATTEMPTS): - if await _attempt_wal_switch(connection, attempt): - return - - -def _dict_row_factory(cursor: Any, row: "tuple[Any, ...]") -> "dict[str, Any]": - return {description[0]: row[index] for index, description in enumerate(cursor.description)} - - -def _resolve_row_factory(row_factory: Any) -> Any: - if row_factory == "row": - return sqlite3.Row - if row_factory == "dict": - return _dict_row_factory - if row_factory == "tuple": - return None - return row_factory - - -def _has_active_transaction(connection: "AiosqliteConnection") -> bool: - return bool(getattr(connection, "in_transaction", False)) - - -def _register_runtime_objects( - connection: "AiosqliteConnection", - aggregates: "Sequence[dict[str, Any]]", - collations: "Sequence[dict[str, Any]]", - window_functions: "Sequence[dict[str, Any]]" = (), -) -> None: - """Register custom aggregates, collations, and window functions on the worker thread.""" - raw_connection = connection._conn - for aggregate_config in aggregates: - raw_connection.create_aggregate( - aggregate_config["name"], aggregate_config["narg"], aggregate_config["aggregate_class"] - ) - for collation_config in collations: - raw_connection.create_collation(collation_config["name"], collation_config["func"]) - create_window_fn = getattr(raw_connection, "create_window_function", None) - if window_functions and create_window_fn is None: - msg = "Custom SQLite window functions require Python 3.11 or later" - raise ImproperConfigurationError(msg) - if create_window_fn is not None: - for window_config in window_functions: - create_window_fn(window_config["name"], window_config["narg"], window_config["window_class"]) - - -async def _apply_runtime_setup(connection: "AiosqliteConnection", runtime_setup: "dict[str, Any]") -> None: - pragmas = runtime_setup.get("pragmas", ()) - if pragmas: - pragma_script = "\n".join(f"PRAGMA {pragma_name} = {pragma_value};" for pragma_name, pragma_value in pragmas) - await connection.executescript(pragma_script) - - extensions = runtime_setup.get("extensions") - if extensions: - await connection.enable_load_extension(True) - try: - for extension_path in extensions: - await connection.load_extension(extension_path) - finally: - await connection.enable_load_extension(False) - - for function_config in runtime_setup.get("custom_functions", ()): - await connection.create_function( - function_config["name"], - function_config["narg"], - function_config["func"], - deterministic=function_config.get("deterministic", False), - ) - - aggregates = runtime_setup.get("custom_aggregates", ()) - collations = runtime_setup.get("custom_collations", ()) - window_functions = runtime_setup.get("custom_window_functions", ()) - if aggregates or collations or window_functions: - await run_on_worker_thread( - connection, _register_runtime_objects, connection, aggregates, collations, window_functions - ) - - authorizer_callback = runtime_setup.get("authorizer_callback") - if authorizer_callback is not None: - await connection.set_authorizer(authorizer_callback) - - trace_callback = runtime_setup.get("trace_callback") - if trace_callback is not None: - await connection.set_trace_callback(trace_callback) - - progress_handler = runtime_setup.get("progress_handler") - if progress_handler is not None: - await connection.set_progress_handler(progress_handler, runtime_setup.get("progress_handler_interval", 1000)) - - if "row_factory" in runtime_setup: - connection.row_factory = _resolve_row_factory(runtime_setup["row_factory"]) - - if "text_factory" in runtime_setup: - connection.text_factory = runtime_setup["text_factory"] - - class AiosqlitePoolClosedError(SQLSpecError): """Pool has been closed and cannot accept new operations.""" @@ -226,7 +116,10 @@ async def is_alive(self) -> bool: self._healthy = False return False try: - await self.connection.execute("SELECT 1") + cursor = await self.connection.execute("SELECT 1") + if cursor is not None and callable(getattr(cursor, "close", None)): + with suppress(Exception): + await cursor.close() except Exception: self._healthy = False return False @@ -238,8 +131,6 @@ async def reset(self) -> None: """Reset connection to clean state.""" if self._closed: return - if not _has_active_transaction(self.connection): - return with suppress(Exception): await end_transaction(self.connection, commit=False) @@ -248,9 +139,8 @@ async def close(self) -> None: if self._closed: return try: - if _has_active_transaction(self.connection): - with suppress(Exception): - await end_transaction(self.connection, commit=False) + with suppress(Exception): + await end_transaction(self.connection, commit=False) await self.connection.close() except Exception: log_with_context( @@ -292,6 +182,7 @@ class AiosqliteConnectionPool: """Multi-connection pool for aiosqlite.""" __slots__ = ( + "_anchor_connection", "_closed_event", "_connect_timeout", "_connection_parameters", @@ -300,6 +191,7 @@ class AiosqliteConnectionPool: "_enable_optimizations", "_health_check_interval", "_idle_timeout", + "_in_flight_creations", "_lock", "_min_size", "_on_connection_create", @@ -352,7 +244,9 @@ def __init__( self._on_connection_create = on_connection_create self._runtime_setup = runtime_setup + self._anchor_connection: sqlite3.Connection | None = None self._connection_registry: dict[str, AiosqlitePoolConnection] = {} + self._in_flight_creations = 0 self._warmed = False self._pool_id = uuid4().hex[:8] @@ -375,93 +269,6 @@ def _database_name(self) -> str: db = self._connection_parameters.get("database", "unknown") return str(db).split("/")[-1] if db else "unknown" - def _set_connect_proxy_daemon(self, connect_proxy: Any) -> None: - """Set daemon mode on aiosqlite worker thread before await.""" - try: - if isinstance(connect_proxy, Thread): - connect_proxy.daemon = True - return - - worker_thread = getattr(connect_proxy, "_thread", None) - if isinstance(worker_thread, Thread): - worker_thread.daemon = True - except Exception: - log_with_context( - logger, - logging.DEBUG, - "pool.connection.daemon.configure.error", - adapter=_ADAPTER_NAME, - pool_id=self._pool_id, - database=self._database_name, - ) - - async def _force_stop_connection(self, connection: AiosqlitePoolConnection, *, reason: str) -> None: - """Force-stop aiosqlite worker thread when graceful close times out.""" - with suppress(Exception): - raw_conn = getattr(connection.connection, "_conn", None) - if raw_conn is not None: - raw_conn.interrupt() - try: - stop_method = getattr(connection.connection, "stop", None) - if stop_method is None: - log_with_context( - logger, - logging.DEBUG, - "pool.connection.force_stop.unavailable", - adapter=_ADAPTER_NAME, - pool_id=self._pool_id, - connection_id=connection.id, - reason=reason, - ) - return - except Exception: - log_with_context( - logger, - logging.DEBUG, - "pool.connection.force_stop.unavailable", - adapter=_ADAPTER_NAME, - pool_id=self._pool_id, - connection_id=connection.id, - reason=reason, - ) - return - - try: - stop_result = stop_method() - if isawaitable(stop_result): - await asyncio.wait_for(stop_result, timeout=self._operation_timeout) - log_with_context( - logger, - logging.DEBUG, - "pool.connection.force_stop.success", - adapter=_ADAPTER_NAME, - pool_id=self._pool_id, - connection_id=connection.id, - reason=reason, - ) - except TimeoutError: - log_with_context( - logger, - logging.WARNING, - "pool.connection.force_stop.timeout", - adapter=_ADAPTER_NAME, - pool_id=self._pool_id, - connection_id=connection.id, - timeout_seconds=self._operation_timeout, - reason=reason, - ) - except Exception as e: - log_with_context( - logger, - logging.WARNING, - "pool.connection.force_stop.error", - adapter=_ADAPTER_NAME, - pool_id=self._pool_id, - connection_id=connection.id, - reason=reason, - error=str(e), - ) - def size(self) -> int: """Get total number of connections in pool. @@ -478,26 +285,6 @@ def checked_out(self) -> int: """ return len(self._connection_registry) - self._queue.qsize() - async def _create_connection(self) -> AiosqlitePoolConnection: - """Create a new pooled connection and register it. - - Returns: - New pool connection instance - """ - connection = await self.new_connection() - try: - pool_connection = AiosqlitePoolConnection(connection) - pool_connection.mark_as_idle() - - async with self._lock: - self._connection_registry[pool_connection.id] = pool_connection - except BaseException: - with suppress(BaseException): - await connection.close() - raise - - return pool_connection - async def new_connection(self) -> "AiosqliteConnection": """Open a standalone connection configured exactly like a pooled one. @@ -508,7 +295,16 @@ async def new_connection(self) -> "AiosqliteConnection": """ connect_proxy = aiosqlite.connect(**self._connection_parameters) self._set_connect_proxy_daemon(connect_proxy) - connection = await connect_proxy + try: + connection = await connect_proxy + except BaseException: + with suppress(BaseException): + stop_fn = getattr(connect_proxy, "stop", None) + if callable(stop_fn): + stop_res = stop_fn() + if isawaitable(stop_res): + await stop_res + raise pragma_lines: list[str] = [] @@ -530,13 +326,12 @@ async def new_connection(self) -> "AiosqliteConnection": pragma_lines.append("PRAGMA synchronous = NORMAL") pragma_lines.append(f"PRAGMA busy_timeout = {SQLITE_BUSY_TIMEOUT}") + if is_shared_cache and is_memory_db: + pragma_lines.append("PRAGMA read_uncommitted = ON") if self._enable_foreign_keys: pragma_lines.append("PRAGMA foreign_keys = ON") - if is_shared_cache and is_memory_db: - pragma_lines.append("PRAGMA read_uncommitted = ON") - if pragma_lines: await connection.executescript(";\n".join(pragma_lines) + ";") await connection.commit() @@ -556,9 +351,14 @@ async def new_connection(self) -> "AiosqliteConnection": if pragma_statement == "PRAGMA foreign_keys = ON": continue with suppress(Exception): - await connection.execute(pragma_statement) + cursor = await connection.execute(pragma_statement) + if cursor is not None and callable(getattr(cursor, "close", None)): + await cursor.close() if self._enable_foreign_keys: - await connection.execute("PRAGMA foreign_keys = ON") + cursor = await connection.execute("PRAGMA foreign_keys = ON") + if cursor is not None and callable(getattr(cursor, "close", None)): + with suppress(Exception): + await cursor.close() await connection.commit() if self._runtime_setup is not None: @@ -573,36 +373,260 @@ async def new_connection(self) -> "AiosqliteConnection": return connection - async def _claim_if_healthy(self, connection: AiosqlitePoolConnection) -> bool: - """Check if connection is healthy and claim it. - - Uses passive health checks: connections idle less than health_check_interval - are assumed healthy based on their last known state. Active health checks - (SELECT 1) are only performed on long-idle connections. - - Args: - connection: Connection to check and claim + async def acquire(self) -> AiosqlitePoolConnection: + """Acquire a connection from the pool. Returns: - True if connection was claimed + Available connection + + Raises: + AiosqliteConnectTimeoutError: If acquisition times out """ - if connection.idle_time > self._idle_timeout: - await self._retire_connection(connection, reason="idle_timeout") - return False + try: + connection = await asyncio.wait_for(self._get_connection(), timeout=self._connect_timeout) + except AiosqlitePoolClosedError: + raise + except TimeoutError as e: + msg = f"Connection acquisition timed out after {self._connect_timeout}s" + raise AiosqliteConnectTimeoutError(msg) from e - if not connection.is_healthy: - await self._retire_connection(connection, reason="unhealthy") - return False + return connection - if connection.idle_time > self._health_check_interval: - try: - is_alive = await asyncio.wait_for(connection.is_alive(), timeout=self._operation_timeout) - if not is_alive: - await self._retire_connection(connection, reason="health_check_failed") - return False - except TimeoutError: - await self._retire_connection(connection, reason="health_check_timeout") - return False + async def release(self, connection: AiosqlitePoolConnection) -> None: + """Release a connection back to the pool. + + Args: + connection: Connection to release + """ + if self._closed_event.is_set(): + await self._retire_connection(connection) + return + + if connection.id not in self._connection_registry: + log_with_context( + logger, + logging.WARNING, + "pool.connection.release.unknown", + adapter=_ADAPTER_NAME, + pool_id=self._pool_id, + connection_id=connection.id, + ) + return + + try: + with suppress(Exception): + await end_transaction(connection.connection, commit=False) + connection.idle_since = time.time() + self._queue.put_nowait(connection) + except Exception as e: + log_with_context( + logger, + logging.WARNING, + "pool.connection.reset.error", + adapter=_ADAPTER_NAME, + pool_id=self._pool_id, + connection_id=connection.id, + error=str(e), + ) + connection.mark_unhealthy() + await self._retire_connection(connection) + + def get_connection(self) -> "AiosqlitePoolConnectionContext": + """Get a connection with automatic release.""" + return AiosqlitePoolConnectionContext(self) + + async def close(self) -> None: + """Close the connection pool.""" + if self.is_closed: + return + self._closed_event.set() + + while not self._queue.empty(): + self._queue.get_nowait() + + async with self._lock: + connections = list(self._connection_registry.values()) + self._connection_registry.clear() + anchor = self._anchor_connection + self._anchor_connection = None + + if connections: + for conn in connections: + with suppress(Exception): + raw_conn = getattr(conn.connection, "_conn", None) + if raw_conn is not None: + raw_conn.interrupt() + close_tasks = [asyncio.wait_for(conn.close(), timeout=self._operation_timeout) for conn in connections] + results = await asyncio.gather(*close_tasks, return_exceptions=True) + + for i, result in enumerate(results): + if isinstance(result, Exception): + if isinstance(result, asyncio.TimeoutError): + await self._force_stop_connection(connections[i], reason="pool_close_timeout") + log_with_context( + logger, + logging.WARNING, + "pool.close.connection.error", + adapter=_ADAPTER_NAME, + pool_id=self._pool_id, + connection_id=connections[i].id, + error=str(result), + ) + if anchor is not None: + with suppress(Exception): + anchor.close() + + def _set_connect_proxy_daemon(self, connect_proxy: Any) -> None: + """Set daemon mode on aiosqlite worker thread before await.""" + try: + if isinstance(connect_proxy, Thread): + connect_proxy.daemon = True + return + + worker_thread = getattr(connect_proxy, "_thread", None) + if isinstance(worker_thread, Thread): + worker_thread.daemon = True + except Exception: + log_with_context( + logger, + logging.DEBUG, + "pool.connection.daemon.configure.error", + adapter=_ADAPTER_NAME, + pool_id=self._pool_id, + database=self._database_name, + ) + + async def _force_stop_connection(self, connection: AiosqlitePoolConnection, *, reason: str) -> None: + """Force-stop aiosqlite worker thread when graceful close times out.""" + with suppress(Exception): + raw_conn = getattr(connection.connection, "_conn", None) + if raw_conn is not None: + raw_conn.interrupt() + try: + stop_method = getattr(connection.connection, "stop", None) + if stop_method is None: + log_with_context( + logger, + logging.DEBUG, + "pool.connection.force_stop.unavailable", + adapter=_ADAPTER_NAME, + pool_id=self._pool_id, + connection_id=connection.id, + reason=reason, + ) + return + except Exception: + log_with_context( + logger, + logging.DEBUG, + "pool.connection.force_stop.unavailable", + adapter=_ADAPTER_NAME, + pool_id=self._pool_id, + connection_id=connection.id, + reason=reason, + ) + return + + try: + stop_result = stop_method() + if isawaitable(stop_result): + await asyncio.wait_for(stop_result, timeout=self._operation_timeout) + log_with_context( + logger, + logging.DEBUG, + "pool.connection.force_stop.success", + adapter=_ADAPTER_NAME, + pool_id=self._pool_id, + connection_id=connection.id, + reason=reason, + ) + except TimeoutError: + log_with_context( + logger, + logging.WARNING, + "pool.connection.force_stop.timeout", + adapter=_ADAPTER_NAME, + pool_id=self._pool_id, + connection_id=connection.id, + timeout_seconds=self._operation_timeout, + reason=reason, + ) + except Exception as e: + log_with_context( + logger, + logging.WARNING, + "pool.connection.force_stop.error", + adapter=_ADAPTER_NAME, + pool_id=self._pool_id, + connection_id=connection.id, + reason=reason, + error=str(e), + ) + + async def _create_connection(self) -> AiosqlitePoolConnection: + """Create a new pooled connection and register it. + + Returns: + New pool connection instance + """ + connection = await self.new_connection() + try: + pool_connection = AiosqlitePoolConnection(connection) + pool_connection.mark_as_idle() + + database_path = str(self._connection_parameters.get("database", "")) + is_shared_memory = "mode=memory" in database_path and "cache=shared" in database_path + raw_conn = getattr(connection, "_conn", None) + + async with self._lock: + if ( + is_shared_memory + and self._anchor_connection is None + and not self.is_closed + and isinstance(raw_conn, sqlite3.Connection) + ): + with suppress(Exception): + sqlite_kwargs = {k: v for k, v in self._connection_parameters.items() if k != "iter_chunk_size"} + sqlite_kwargs.setdefault("check_same_thread", False) + self._anchor_connection = sqlite3.connect(**sqlite_kwargs) + self._connection_registry[pool_connection.id] = pool_connection + except BaseException: + with suppress(BaseException): + await connection.close() + raise + + return pool_connection + + async def _claim_if_healthy(self, connection: AiosqlitePoolConnection) -> bool: + """Check if connection is healthy and claim it. + + Uses passive health checks: connections idle less than health_check_interval + are assumed healthy based on their last known state. Active health checks + (SELECT 1) are only performed on long-idle connections. + + Args: + connection: Connection to check and claim + + Returns: + True if connection was claimed + """ + if connection.idle_time > self._idle_timeout: + await self._retire_connection(connection, reason="idle_timeout") + return False + + if not connection.is_healthy: + await self._retire_connection(connection, reason="unhealthy") + return False + + if connection.idle_time > self._health_check_interval: + try: + is_alive = await asyncio.wait_for(connection.is_alive(), timeout=self._operation_timeout) + if not is_alive: + await self._retire_connection(connection, reason="health_check_failed") + return False + except TimeoutError: + await self._retire_connection(connection, reason="health_check_timeout") + return False connection.mark_as_in_use() return True @@ -648,12 +672,13 @@ async def _try_provision_new_connection(self) -> "AiosqlitePoolConnection | None New connection if successful, None if at capacity """ async with self._lock: - if len(self._connection_registry) >= self._pool_size: + if len(self._connection_registry) + self._in_flight_creations >= self._pool_size: return None + self._in_flight_creations += 1 try: connection = await self._create_connection() - except Exception: + except BaseException: log_with_context( logger, logging.WARNING, @@ -668,6 +693,9 @@ async def _try_provision_new_connection(self) -> "AiosqlitePoolConnection | None else: connection.mark_as_in_use() return connection + finally: + async with self._lock: + self._in_flight_creations -= 1 async def _wait_for_healthy_connection(self) -> AiosqlitePoolConnection: """Wait for a healthy connection to become available. @@ -681,18 +709,21 @@ async def _wait_for_healthy_connection(self) -> AiosqlitePoolConnection: while True: get_connection_task = asyncio.create_task(self._queue.get()) pool_closed_task = asyncio.create_task(self._closed_event.wait()) - - done, pending = await asyncio.wait( - {get_connection_task, pool_closed_task}, return_when=asyncio.FIRST_COMPLETED - ) + pending: set[asyncio.Task[Any]] = {get_connection_task, pool_closed_task} + claimed = False try: + done, pending = await asyncio.wait( + {get_connection_task, pool_closed_task}, return_when=asyncio.FIRST_COMPLETED + ) + if pool_closed_task in done: msg = "Pool closed during connection acquisition" raise AiosqlitePoolClosedError(msg) connection = get_connection_task.result() if await self._claim_if_healthy(connection): + claimed = True return connection finally: @@ -700,6 +731,19 @@ async def _wait_for_healthy_connection(self) -> AiosqlitePoolConnection: task.cancel() with suppress(asyncio.CancelledError): await task + if ( + not claimed + and get_connection_task.done() + and not get_connection_task.cancelled() + and get_connection_task.exception() is None + ): + unclaimed = get_connection_task.result() + if not unclaimed.is_closed and unclaimed.id in self._connection_registry: + if self._closed_event.is_set(): + await self._retire_connection(unclaimed) + else: + with suppress(asyncio.QueueFull): + self._queue.put_nowait(unclaimed) async def _warm_pool(self) -> None: """Pre-create minimum connections for pool warming. @@ -710,37 +754,47 @@ async def _warm_pool(self) -> None: if self._warmed or self._min_size <= 0: return - self._warmed = True - connections_needed = self._min_size - len(self._connection_registry) - - if connections_needed <= 0: - return + async with self._lock: + if self._warmed: + return + self._warmed = True + available_capacity = self._pool_size - (len(self._connection_registry) + self._in_flight_creations) + connections_needed = min( + self._min_size - (len(self._connection_registry) + self._in_flight_creations), available_capacity + ) + if connections_needed <= 0: + return + self._in_flight_creations += connections_needed - log_with_context( - logger, - logging.DEBUG, - "pool.warmup.start", - adapter=_ADAPTER_NAME, - pool_id=self._pool_id, - database=self._database_name, - connections_needed=connections_needed, - min_size=self._min_size, - ) - tasks = [self._create_connection() for _ in range(connections_needed)] - results = await asyncio.gather(*tasks, return_exceptions=True) + try: + log_with_context( + logger, + logging.DEBUG, + "pool.warmup.start", + adapter=_ADAPTER_NAME, + pool_id=self._pool_id, + database=self._database_name, + connections_needed=connections_needed, + min_size=self._min_size, + ) + tasks = [self._create_connection() for _ in range(connections_needed)] + results = await asyncio.gather(*tasks, return_exceptions=True) - for result in results: - if isinstance(result, AiosqlitePoolConnection): - self._queue.put_nowait(result) - elif isinstance(result, Exception): - log_with_context( - logger, - logging.WARNING, - "pool.warmup.connection.error", - adapter=_ADAPTER_NAME, - pool_id=self._pool_id, - error=str(result), - ) + for result in results: + if isinstance(result, AiosqlitePoolConnection): + self._queue.put_nowait(result) + elif isinstance(result, Exception): + log_with_context( + logger, + logging.WARNING, + "pool.warmup.connection.error", + adapter=_ADAPTER_NAME, + pool_id=self._pool_id, + error=str(result), + ) + finally: + async with self._lock: + self._in_flight_creations -= connections_needed async def _get_connection(self) -> AiosqlitePoolConnection: """Run the three-phase connection acquisition cycle. @@ -765,111 +819,157 @@ async def _get_connection(self) -> AiosqlitePoolConnection: if idle_time <= self._health_check_interval and connection.is_healthy: connection.idle_since = None return connection - if await self._claim_if_healthy(connection): - return connection + try: + if await self._claim_if_healthy(connection): + return connection + except BaseException: + if not connection.is_closed and connection.id in self._connection_registry: + await self._retire_connection(connection) + raise - if len(self._connection_registry) < self._pool_size: + if len(self._connection_registry) + self._in_flight_creations < self._pool_size: new_connection = await self._try_provision_new_connection() if new_connection is not None: return new_connection return await self._wait_for_healthy_connection() - async def acquire(self) -> AiosqlitePoolConnection: - """Acquire a connection from the pool. - Returns: - Available connection - - Raises: - AiosqliteConnectTimeoutError: If acquisition times out - """ - try: - connection = await asyncio.wait_for(self._get_connection(), timeout=self._connect_timeout) - except AiosqlitePoolClosedError: +async def _attempt_wal_switch(connection: "AiosqliteConnection", attempt: int) -> bool: + """Attempt a single WAL mode switch, returning True on success.""" + try: + cursor = await connection.execute("PRAGMA journal_mode = WAL") + if cursor is not None and callable(getattr(cursor, "close", None)): + with suppress(Exception): + await cursor.close() + except sqlite3.OperationalError as exc: + if "locked" not in str(exc) or attempt == SQLITE_WAL_SWITCH_ATTEMPTS - 1: raise - except TimeoutError as e: - msg = f"Connection acquisition timed out after {self._connect_timeout}s" - raise AiosqliteConnectTimeoutError(msg) from e - - return connection + await asyncio.sleep(SQLITE_WAL_SWITCH_DELAY) + return False + return True - async def release(self, connection: AiosqlitePoolConnection) -> None: - """Release a connection back to the pool. - Args: - connection: Connection to release - """ - if self._closed_event.is_set(): - await self._retire_connection(connection) +async def _enable_wal(connection: "AiosqliteConnection") -> None: + """Retry database and table locks briefly while switching to WAL mode.""" + for attempt in range(SQLITE_WAL_SWITCH_ATTEMPTS): + if await _attempt_wal_switch(connection, attempt): return - if connection.id not in self._connection_registry: - log_with_context( - logger, - logging.WARNING, - "pool.connection.release.unknown", - adapter=_ADAPTER_NAME, - pool_id=self._pool_id, - connection_id=connection.id, - ) - return - try: - if _has_active_transaction(connection.connection): - with suppress(Exception): - await end_transaction(connection.connection, commit=False) - connection.idle_since = time.time() - self._queue.put_nowait(connection) - except Exception as e: - log_with_context( - logger, - logging.WARNING, - "pool.connection.reset.error", - adapter=_ADAPTER_NAME, - pool_id=self._pool_id, - connection_id=connection.id, - error=str(e), - ) - connection.mark_unhealthy() - await self._retire_connection(connection) +def _dict_row_factory(cursor: Any, row: "tuple[Any, ...]") -> "dict[str, Any]": + return {description[0]: row[index] for index, description in enumerate(cursor.description)} - def get_connection(self) -> "AiosqlitePoolConnectionContext": - """Get a connection with automatic release.""" - return AiosqlitePoolConnectionContext(self) - async def close(self) -> None: - """Close the connection pool.""" - if self.is_closed: - return - self._closed_event.set() +def _resolve_row_factory(row_factory: Any) -> Any: + if row_factory == "row": + return sqlite3.Row + if row_factory == "dict": + return _dict_row_factory + if row_factory == "tuple": + return None + return row_factory - while not self._queue.empty(): - self._queue.get_nowait() - async with self._lock: - connections = list(self._connection_registry.values()) - self._connection_registry.clear() +def _register_runtime_objects( + connection: "AiosqliteConnection", + aggregates: "Sequence[dict[str, Any]]", + collations: "Sequence[dict[str, Any]]", + window_functions: "Sequence[dict[str, Any]]" = (), + *, + has_row_factory: bool = False, + row_factory: Any = None, + has_text_factory: bool = False, + text_factory: Any = None, +) -> None: + """Register custom aggregates, collations, window functions, and factories on the worker thread.""" + raw_connection = connection._conn + for aggregate_config in aggregates: + raw_connection.create_aggregate( + aggregate_config["name"], aggregate_config["narg"], aggregate_config["aggregate_class"] + ) + for collation_config in collations: + raw_connection.create_collation(collation_config["name"], collation_config["func"]) + create_window_fn = getattr(raw_connection, "create_window_function", None) + if window_functions and create_window_fn is None: + msg = "Custom SQLite window functions require Python 3.11 or later" + raise ImproperConfigurationError(msg) + if create_window_fn is not None: + for window_config in window_functions: + create_window_fn(window_config["name"], window_config["narg"], window_config["window_class"]) + if has_row_factory: + raw_connection.row_factory = row_factory + if has_text_factory: + raw_connection.text_factory = text_factory - if connections: - for conn in connections: - with suppress(Exception): - raw_conn = getattr(conn.connection, "_conn", None) - if raw_conn is not None: - raw_conn.interrupt() - close_tasks = [asyncio.wait_for(conn.close(), timeout=self._operation_timeout) for conn in connections] - results = await asyncio.gather(*close_tasks, return_exceptions=True) - for i, result in enumerate(results): - if isinstance(result, Exception): - if isinstance(result, asyncio.TimeoutError): - await self._force_stop_connection(connections[i], reason="pool_close_timeout") - log_with_context( - logger, - logging.WARNING, - "pool.close.connection.error", - adapter=_ADAPTER_NAME, - pool_id=self._pool_id, - connection_id=connections[i].id, - error=str(result), - ) +async def _apply_runtime_setup(connection: "AiosqliteConnection", runtime_setup: "dict[str, Any]") -> None: + pragmas = runtime_setup.get("pragmas", ()) + if pragmas: + pragma_script = "\n".join(f"PRAGMA {pragma_name} = {pragma_value};" for pragma_name, pragma_value in pragmas) + await connection.executescript(pragma_script) + + extensions = runtime_setup.get("extensions") + if extensions: + await connection.enable_load_extension(True) + try: + for extension_path in extensions: + await connection.load_extension(extension_path) + finally: + await connection.enable_load_extension(False) + + for function_config in runtime_setup.get("custom_functions", ()): + await connection.create_function( + function_config["name"], + function_config["narg"], + function_config["func"], + deterministic=function_config.get("deterministic", False), + ) + + aggregates = runtime_setup.get("custom_aggregates", ()) + collations = runtime_setup.get("custom_collations", ()) + window_functions = runtime_setup.get("custom_window_functions", ()) + has_row_factory = "row_factory" in runtime_setup + resolved_row_factory = _resolve_row_factory(runtime_setup["row_factory"]) if has_row_factory else None + has_text_factory = "text_factory" in runtime_setup + resolved_text_factory = runtime_setup["text_factory"] if has_text_factory else None + + if ( + aggregates + or collations + or window_functions + or ( + (has_row_factory or has_text_factory) + and isinstance(getattr(connection, "_conn", None), sqlite3.Connection) + and callable(getattr(connection, "_execute", None)) + ) + ): + await run_on_worker_thread( + connection, + _register_runtime_objects, + connection, + aggregates, + collations, + window_functions, + has_row_factory=has_row_factory, + row_factory=resolved_row_factory, + has_text_factory=has_text_factory, + text_factory=resolved_text_factory, + ) + else: + if has_row_factory: + connection.row_factory = resolved_row_factory + if has_text_factory: + connection.text_factory = cast("Any", resolved_text_factory) + + authorizer_callback = runtime_setup.get("authorizer_callback") + if authorizer_callback is not None: + await connection.set_authorizer(authorizer_callback) + + trace_callback = runtime_setup.get("trace_callback") + if trace_callback is not None: + await connection.set_trace_callback(trace_callback) + + progress_handler = runtime_setup.get("progress_handler") + if progress_handler is not None: + await connection.set_progress_handler(progress_handler, runtime_setup.get("progress_handler_interval", 1000)) diff --git a/sqlspec/adapters/aiosqlite/type_converter.py b/sqlspec/adapters/aiosqlite/type_converter.py index d4fa27f7b..23fb2d079 100644 --- a/sqlspec/adapters/aiosqlite/type_converter.py +++ b/sqlspec/adapters/aiosqlite/type_converter.py @@ -1,4 +1,3 @@ -# Keep in sync with sqlspec/adapters/sqlite/type_converter.py """SQLite custom type handlers for optional JSON and type conversion support. Provides registration functions for SQLite's adapter/converter system to enable @@ -78,11 +77,14 @@ def register_type_handlers( def unregister_type_handlers() -> None: - """Unregister custom type handlers from sqlite3 module. - - Note: sqlite3 module does not provide an official unregister API, so this - function is a no-op placeholder for API consistency with other adapters. - """ + """Unregister custom type handlers from sqlite3 module.""" + adapters = getattr(sqlite3, "adapters", None) + if isinstance(adapters, dict): + adapters.pop((dict, sqlite3.PrepareProtocol), None) + adapters.pop((list, sqlite3.PrepareProtocol), None) + converters = getattr(sqlite3, "converters", None) + if isinstance(converters, dict): + converters.pop(DEFAULT_JSON_TYPE, None) def _make_json_adapter(serializer: "Callable[[Any], str] | None") -> "Callable[[Any], str]": diff --git a/sqlspec/adapters/arrow_odbc/adk/store.py b/sqlspec/adapters/arrow_odbc/adk/store.py index 51d904ecc..ced882b1b 100644 --- a/sqlspec/adapters/arrow_odbc/adk/store.py +++ b/sqlspec/adapters/arrow_odbc/adk/store.py @@ -1,6 +1,7 @@ """arrow-odbc ADK stores for Google Agent Development Kit session storage.""" -from datetime import UTC, datetime +import contextlib +from datetime import UTC, date, datetime from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NotRequired, cast from sqlspec.adapters.arrow_odbc.core import ( @@ -15,9 +16,10 @@ from sqlspec.extensions.adk import BaseSyncADKStore, StoredEvent, StoredSession, normalize_session_list_options from sqlspec.extensions.adk.memory import BaseSyncADKMemoryStore, StoredMemory from sqlspec.utils.serializers import from_json, to_json +from sqlspec.utils.text import split_qualified_identifier if TYPE_CHECKING: - from collections.abc import Callable, Sequence + from collections.abc import Callable, Iterator, Sequence from datetime import timedelta from sqlspec.adapters.arrow_odbc.config import ArrowOdbcConfig @@ -32,6 +34,7 @@ JSON_COLUMN_TYPE: Final[str] = "NVARCHAR(MAX)" DB2_JSON_COLUMN_TYPE: Final[str] = "CLOB(1M)" DB2_UNDEFINED_OBJECT_ERROR: Final[int] = -204 +_QUALIFIED_PARTS_MIN: Final[int] = 2 class ArrowOdbcADKConfig(ADKConfig): @@ -88,7 +91,7 @@ def create_session( else: params = (session_id, app_name, user_id, to_json(state)) params = (*params, *sql_dialect.now_params(), *sql_dialect.now_params()) - with self._config.provide_session() as driver: + with self._config.provide_session() as driver, _atomic_write(driver): driver.execute( f""" INSERT INTO {sql_dialect.table_ref(self._session_table)} ( @@ -101,10 +104,9 @@ def create_session( row = driver.select_one_or_none( sql_dialect.session_select_sql(self._session_table), (app_name, user_id, session_id) ) - driver.commit() - if row is None: - msg = "Failed to fetch created session" - raise RuntimeError(msg) + if row is None: + msg = "Failed to fetch created session" + raise RuntimeError(msg) return _session_record_from_row(row) def get_session( @@ -200,7 +202,7 @@ def append_event_and_update_state( ) -> StoredSession: """Atomically append an event and update durable session/scoped state.""" sql_dialect = self._sql - with self._config.provide_session() as driver: + with self._config.provide_session() as driver, _atomic_write(driver): driver.execute( f""" UPDATE {sql_dialect.table_ref(self._session_table)} @@ -224,7 +226,6 @@ def append_event_and_update_state( driver.execute( self._upsert_user_state_sql(), (app_name, user_id, to_json(user_state), *sql_dialect.now_params()) ) - driver.commit() return _session_record_from_row(row) def get_events( @@ -256,15 +257,7 @@ def delete_expired_events(self, before: datetime, app_name: "str | None" = None) count_sql += " AND app_name = ?" delete_sql += " AND app_name = ?" params.append(app_name) - try: - count = self._select_count(count_sql, tuple(params)) - self._execute(delete_sql, tuple(params), commit=True) - except SQLSpecError as exc: - if self._sql.is_table_missing(exc): - return 0 - raise - else: - return count + return self._count_and_delete(count_sql, delete_sql, tuple(params)) def delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" = None) -> int: """Delete sessions whose update_time is older than ``updated_before``.""" @@ -277,15 +270,7 @@ def delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" count_sql += " AND app_name = ?" delete_sql += " AND app_name = ?" params.append(app_name) - try: - count = self._select_count(count_sql, tuple(params)) - self._execute(delete_sql, tuple(params), commit=True) - except SQLSpecError as exc: - if self._sql.is_table_missing(exc): - return 0 - raise - else: - return count + return self._count_and_delete(count_sql, delete_sql, tuple(params)) def delete_idle_user_states(self, updated_before: datetime, app_name: "str | None" = None) -> int: """Delete user state rows whose update_time is older than ``updated_before``.""" @@ -298,15 +283,7 @@ def delete_idle_user_states(self, updated_before: datetime, app_name: "str | Non count_sql += " AND app_name = ?" delete_sql += " AND app_name = ?" params.append(app_name) - try: - count = self._select_count(count_sql, tuple(params)) - self._execute(delete_sql, tuple(params), commit=True) - except SQLSpecError as exc: - if self._sql.is_table_missing(exc): - return 0 - raise - else: - return count + return self._count_and_delete(count_sql, delete_sql, tuple(params)) def get_app_state(self, app_name: str) -> "dict[str, Any] | None": """Return app-scoped state.""" @@ -421,9 +398,11 @@ def _execute_fetchall(self, sql: str, params: "tuple[Any, ...]" = ()) -> "list[d def _execute(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = False) -> int: with self._config.provide_session() as driver: - result = driver.execute(sql, params) if commit: - driver.commit() + with _atomic_write(driver): + result = driver.execute(sql, params) + else: + result = driver.execute(sql, params) return int(result.rows_affected) def _select_count(self, sql: str, params: "tuple[Any, ...]" = ()) -> int: @@ -431,6 +410,18 @@ def _select_count(self, sql: str, params: "tuple[Any, ...]" = ()) -> int: value = driver.select_value(sql, params) return int(value or 0) + def _count_and_delete(self, count_sql: str, delete_sql: str, params: "tuple[Any, ...]") -> int: + try: + with self._config.provide_session() as driver, _atomic_write(driver): + value = driver.select_value(count_sql, params) + count = int(value or 0) + driver.execute(delete_sql, params) + except SQLSpecError as exc: + if self._sql.is_table_missing(exc): + return 0 + raise + return count + class ArrowOdbcADKMemoryStore(BaseSyncADKMemoryStore["ArrowOdbcConfig"]): """ADK memory store using arrow-odbc. @@ -470,7 +461,7 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object inserted_count = 0 sql_dialect = self._sql - with self._config.provide_session() as driver: + with self._config.provide_session() as driver, _atomic_write(driver): for entry in entries: exists = driver.select_one_or_none( sql_dialect.memory_duplicate_sql(self._memory_table), (entry["event_id"],) @@ -499,7 +490,6 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object params, ) inserted_count += 1 - driver.commit() return inserted_count def search_entries( @@ -511,7 +501,7 @@ def search_entries( scope_filter: Literal["all", "user", "app"] = "all", embedding: "Sequence[float] | None" = None, ) -> "list[StoredMemory]": - """Search memory entries with SQL Server LIKE matching.""" + """Search memory entries with LIKE matching.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) @@ -521,10 +511,13 @@ def search_entries( if effective_limit == 0: return [] where_scope, scope_params = _build_arrow_odbc_scope_where(app_name, user_id, scope_filter) + escaped_query = self._sql.escape_like_query(query) try: rows = self._execute_fetchall( - self._sql.memory_search_sql(self._memory_table, where_scope, effective_limit), - (*scope_params, f"%{query}%"), + self._sql.memory_search_sql( + self._memory_table, where_scope, effective_limit, has_escaped_wildcards=escaped_query != query + ), + (*scope_params, f"%{escaped_query}%"), ) except SQLSpecError as exc: if self._sql.is_table_missing(exc): @@ -538,9 +531,11 @@ def delete_entries_by_session(self, session_id: str) -> int: msg = "Memory store is disabled" raise RuntimeError(msg) table_ref = self._sql.table_ref(self._memory_table) - count = self._select_count(f"SELECT COUNT(*) AS row_count FROM {table_ref} WHERE session_id = ?", (session_id,)) - self._execute(f"DELETE FROM {table_ref} WHERE session_id = ?", (session_id,), commit=True) - return count + return self._count_and_delete( + f"SELECT COUNT(*) AS row_count FROM {table_ref} WHERE session_id = ?", + f"DELETE FROM {table_ref} WHERE session_id = ?", + (session_id,), + ) def delete_entries_older_than(self, days: int, app_name: "str | None" = None, scope: "str | None" = None) -> int: """Delete memory entries older than ``days`` days.""" @@ -559,9 +554,11 @@ def delete_entries_older_than(self, days: int, app_name: "str | None" = None, sc params.append(scope) where_sql = " AND ".join(clauses) table_ref = self._sql.table_ref(self._memory_table) - count = self._select_count(f"SELECT COUNT(*) AS row_count FROM {table_ref} WHERE {where_sql}", tuple(params)) - self._execute(f"DELETE FROM {table_ref} WHERE {where_sql}", tuple(params), commit=True) - return count + return self._count_and_delete( + f"SELECT COUNT(*) AS row_count FROM {table_ref} WHERE {where_sql}", + f"DELETE FROM {table_ref} WHERE {where_sql}", + tuple(params), + ) def _memory_table_ddl(self) -> str: return self._sql.memory_table_ddl(self._memory_table, self._owner_id_column_ddl) @@ -579,9 +576,11 @@ def _execute_fetchall(self, sql: str, params: "tuple[Any, ...]" = ()) -> "list[d def _execute(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = False) -> int: with self._config.provide_session() as driver: - result = driver.execute(sql, params) if commit: - driver.commit() + with _atomic_write(driver): + result = driver.execute(sql, params) + else: + result = driver.execute(sql, params) return int(result.rows_affected) def _select_count(self, sql: str, params: "tuple[Any, ...]" = ()) -> int: @@ -589,6 +588,18 @@ def _select_count(self, sql: str, params: "tuple[Any, ...]" = ()) -> int: value = driver.select_value(sql, params) return int(value or 0) + def _count_and_delete(self, count_sql: str, delete_sql: str, params: "tuple[Any, ...]") -> int: + try: + with self._config.provide_session() as driver, _atomic_write(driver): + value = driver.select_value(count_sql, params) + count = int(value or 0) + driver.execute(delete_sql, params) + except SQLSpecError as exc: + if self._sql.is_table_missing(exc): + return 0 + raise + return count + def _tsql_memory_table_ddl(table: str, owner_id_column_ddl: "str | None") -> str: owner_line = f",\n {owner_id_column_ddl}" if owner_id_column_ddl else "" @@ -613,10 +624,11 @@ def _tsql_memory_table_ddl(table: str, owner_id_column_ddl: "str | None") -> str def _tsql_memory_index_specs(table: str) -> "list[tuple[str, str, str]]": + _, bare_table = _split_tsql_table(table) return [ - (f"idx_{table}_app_scope_user_time", table, "app_name, scope, user_id, timestamp DESC"), - (f"idx_{table}_scope", table, "app_name, scope"), - (f"idx_{table}_session", table, "session_id"), + (f"idx_{bare_table}_app_scope_user_time", table, "app_name, scope, user_id, timestamp DESC"), + (f"idx_{bare_table}_scope", table, "app_name, scope"), + (f"idx_{bare_table}_session", table, "session_id"), ] @@ -646,9 +658,10 @@ def _sessions_table_ddl(table: str, owner_id_column_ddl: "str | None") -> str: def _sessions_index_specs(table: str) -> "list[tuple[str, str, str]]": + _, bare_table = _split_tsql_table(table) return [ - (f"idx_{table}_app_user", table, "app_name, user_id"), - (f"idx_{table}_update_time", table, "update_time DESC"), + (f"idx_{bare_table}_app_user", table, "app_name, user_id"), + (f"idx_{bare_table}_update_time", table, "update_time DESC"), ] @@ -672,12 +685,13 @@ def _events_table_ddl(table: str, session_table: str) -> str: def _events_index_specs(table: str) -> "list[tuple[str, str, str]]": + _, bare_table = _split_tsql_table(table) return [ - (f"idx_{table}_scope", table, "app_name, user_id, session_id, timestamp ASC"), - (f"idx_{table}_session", table, "session_id, timestamp ASC"), - (f"idx_{table}_invocation", table, "invocation_id"), - (f"idx_{table}_timestamp", table, "timestamp ASC"), - (f"idx_{table}_app_timestamp", table, "app_name, timestamp ASC"), + (f"idx_{bare_table}_scope", table, "app_name, user_id, session_id, timestamp ASC"), + (f"idx_{bare_table}_session", table, "session_id, timestamp ASC"), + (f"idx_{bare_table}_invocation", table, "invocation_id"), + (f"idx_{bare_table}_timestamp", table, "timestamp ASC"), + (f"idx_{bare_table}_app_timestamp", table, "app_name, timestamp ASC"), ] @@ -720,12 +734,12 @@ def _create_index_sql(table: str, index_name: str, columns: str) -> str: def _casefold_names(rows: "list[Any]", key: str) -> "set[str]": """Collapse data-dictionary rows into a case-folded, schema-stripped name set.""" - return {str(row.get(key, "")).rsplit(".", 1)[-1].casefold() for row in rows} + return {_split_tsql_table(str(row.get(key, "")))[1].casefold() for row in rows} def _bare_name(name: str) -> str: """Return the case-folded, schema-stripped object name for membership checks.""" - return name.rsplit(".", 1)[-1].casefold() + return _split_tsql_table(name)[1].casefold() def _insert_event_sql(table_ref: str) -> str: @@ -903,6 +917,8 @@ def _datetime_value(value: Any) -> datetime: if value.tzinfo is None: return value.replace(tzinfo=UTC) return value.astimezone(UTC) + if isinstance(value, date): + return datetime(value.year, value.month, value.day, tzinfo=UTC) if isinstance(value, bytearray): value = bytes(value) if isinstance(value, bytes): @@ -913,7 +929,8 @@ def _datetime_value(value: Any) -> datetime: if parsed.tzinfo is None: return parsed.replace(tzinfo=UTC) return parsed.astimezone(UTC) - return datetime.now(UTC) + msg = f"Unsupported datetime value: {value!r}" + raise TypeError(msg) def _format_datetime(value: "datetime | None") -> "str | None": @@ -932,12 +949,38 @@ def _quote_identifier(identifier: str) -> str: return f"[{identifier.replace(']', ']]')}]" +def _strip_tsql_quotes(part: str) -> str: + stripped = part.strip() + if len(stripped) >= _QUALIFIED_PARTS_MIN and stripped[0] == "[" and stripped[-1] == "]": + return stripped[1:-1].replace("]]", "]") + if len(stripped) >= _QUALIFIED_PARTS_MIN and stripped[0] == '"' and stripped[-1] == '"': + return stripped[1:-1].replace('""', '"') + return stripped + + +def _split_tsql_table(table: str) -> "tuple[str, str]": + parts = split_qualified_identifier(table.strip()) + if len(parts) >= _QUALIFIED_PARTS_MIN: + schema = _strip_tsql_quotes(parts[-2]) or MSSQL_SCHEMA + return schema, _strip_tsql_quotes(parts[-1]) + bare = _strip_tsql_quotes(parts[0]) if parts else _strip_tsql_quotes(table) + return MSSQL_SCHEMA, bare + + def _table_ref(table: str) -> str: - return f"{_quote_identifier(MSSQL_SCHEMA)}.{_quote_identifier(table)}" + schema, bare_table = _split_tsql_table(table) + return f"{_quote_identifier(schema)}.{_quote_identifier(bare_table)}" def _constraint_ref(prefix: str, table: str, suffix: str) -> str: - return _quote_identifier(f"{prefix}_{table}_{suffix}") + _, bare_table = _split_tsql_table(table) + return _quote_identifier(f"{prefix}_{bare_table}_{suffix}") + + +def _bare_db2_table(table: str) -> str: + parts = split_qualified_identifier(table.strip()) + raw = parts[-1] if parts else table + return _strip_tsql_quotes(raw) def _raise_session_not_found(session_id: str) -> None: @@ -973,8 +1016,7 @@ def _session_list_query( page_clause = "" if limit is not None: - params.extend((offset, limit)) - page_clause = "\n OFFSET ? ROWS FETCH NEXT ? ROWS ONLY" + page_clause = f"\n OFFSET {int(offset)} ROWS FETCH NEXT {int(limit)} ROWS ONLY" sql = f""" SELECT id, app_name, user_id, state, create_time, update_time @@ -985,6 +1027,29 @@ def _session_list_query( return sql, tuple(params) +@contextlib.contextmanager +def _atomic_write(driver: Any) -> "Iterator[None]": + """Wrap multi-statement store mutations in an explicit transaction when supported. + + Yields: + None while the atomic write block executes. + """ + in_tx = getattr(driver, "_connection_in_transaction", None) + if callable(in_tx) and in_tx() is True: + yield + return + if getattr(driver, "_connection_autocommit", True) is False: + driver.begin() + try: + yield + except BaseException: + with contextlib.suppress(Exception): + driver.rollback() + raise + else: + driver.commit() + + class _TsqlAdkSql: """SQL Server statements for the ADK stores.""" @@ -1011,10 +1076,17 @@ def is_table_missing(self, exc: BaseException) -> bool: def create_missing_objects( self, driver: Any, table_ddls: "Sequence[tuple[str, str]]", index_specs: "Sequence[tuple[str, str, str]]" ) -> None: - """Create the tables and indexes the data dictionary does not list in ``dbo``.""" + """Create the tables and indexes the data dictionary does not list in the target schemas.""" dd = driver.data_dictionary - existing_tables = _casefold_names(dd.get_tables(driver, schema=MSSQL_SCHEMA), "table_name") - existing_indexes = _casefold_names(dd.get_indexes(driver, schema=MSSQL_SCHEMA), "index_name") + schemas = ( + {_split_tsql_table(table)[0] for table, _ in table_ddls} + | {_split_tsql_table(index_table)[0] for _, index_table, _ in index_specs} + ) or {MSSQL_SCHEMA} + existing_tables: set[str] = set() + existing_indexes: set[str] = set() + for schema in schemas: + existing_tables.update(_casefold_names(dd.get_tables(driver, schema=schema), "table_name")) + existing_indexes.update(_casefold_names(dd.get_indexes(driver, schema=schema), "index_name")) for table, ddl in table_ddls: if _bare_name(table) not in existing_tables: driver.execute(ddl) @@ -1085,7 +1157,13 @@ def memory_index_specs(self, table: str) -> "list[tuple[str, str, str]]": def memory_duplicate_sql(self, table: str) -> str: return f"SELECT TOP 1 id FROM {_table_ref(table)} WHERE event_id = ?" - def memory_search_sql(self, table: str, where_scope: str, limit: int) -> str: + def escape_like_query(self, query: str) -> str: + return query.replace("[", "[[]").replace("%", "[%]").replace("_", "[_]") + + def memory_search_sql( + self, table: str, where_scope: str, limit: int, *, has_escaped_wildcards: bool = False + ) -> str: + _ = has_escaped_wildcards return f""" SELECT id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at @@ -1197,53 +1275,60 @@ def events_query( return sql, tuple(params) def session_index_specs(self, session_table: str, events_table: str) -> "list[tuple[str, str, str]]": + bare_session = _bare_db2_table(session_table) + bare_events = _bare_db2_table(events_table) return [ - (f"idx_{session_table}_app_user", session_table, "app_name, user_id"), - (f"idx_{session_table}_update_time", session_table, "update_time"), - (f"idx_{events_table}_scope", events_table, "app_name, user_id, session_id, timestamp"), - (f"idx_{events_table}_session", events_table, "session_id, timestamp"), - (f"idx_{events_table}_invocation", events_table, "invocation_id"), - (f"idx_{events_table}_timestamp", events_table, "timestamp"), - (f"idx_{events_table}_app_timestamp", events_table, "app_name, timestamp"), + (f"idx_{bare_session}_app_user", session_table, "app_name, user_id"), + (f"idx_{bare_session}_update_time", session_table, "update_time"), + (f"idx_{bare_events}_scope", events_table, "app_name, user_id, session_id, timestamp"), + (f"idx_{bare_events}_session", events_table, "session_id, timestamp"), + (f"idx_{bare_events}_invocation", events_table, "invocation_id"), + (f"idx_{bare_events}_timestamp", events_table, "timestamp"), + (f"idx_{bare_events}_app_timestamp", events_table, "app_name, timestamp"), ] def sessions_table_ddl(self, table: str, owner_id_column_ddl: "str | None") -> str: owner_line = f", {owner_id_column_ddl}" if owner_id_column_ddl else "" + bare_table = _bare_db2_table(table) return ( f"CREATE TABLE {table} (id VARCHAR(128) NOT NULL, app_name VARCHAR(128) NOT NULL, " f"user_id VARCHAR(128) NOT NULL{owner_line}, state {DB2_JSON_COLUMN_TYPE} NOT NULL, " "create_time TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, " "update_time TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, " - f"CONSTRAINT pk_{table}_id PRIMARY KEY (id))" + f"CONSTRAINT pk_{bare_table}_id PRIMARY KEY (id))" ) def events_table_ddl(self, table: str, session_table: str) -> str: + bare_table = _bare_db2_table(table) return ( f"CREATE TABLE {table} (id VARCHAR(128) NOT NULL, app_name VARCHAR(128) NOT NULL, " "user_id VARCHAR(128) NOT NULL, session_id VARCHAR(128) NOT NULL, " "invocation_id VARCHAR(256) NOT NULL, timestamp TIMESTAMP NOT NULL, " - f"event_data {DB2_JSON_COLUMN_TYPE} NOT NULL, CONSTRAINT pk_{table}_id PRIMARY KEY (id), " - f"CONSTRAINT fk_{table}_session FOREIGN KEY (session_id) REFERENCES {session_table}(id) ON DELETE CASCADE)" + f"event_data {DB2_JSON_COLUMN_TYPE} NOT NULL, CONSTRAINT pk_{bare_table}_id PRIMARY KEY (id), " + f"CONSTRAINT fk_{bare_table}_session FOREIGN KEY (session_id) REFERENCES {session_table}(id) ON DELETE CASCADE)" ) def app_states_table_ddl(self, table: str) -> str: + bare_table = _bare_db2_table(table) return ( f"CREATE TABLE {table} (app_name VARCHAR(128) NOT NULL, state {DB2_JSON_COLUMN_TYPE} NOT NULL, " "update_time TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, " - f"CONSTRAINT pk_{table}_app_name PRIMARY KEY (app_name))" + f"CONSTRAINT pk_{bare_table}_app_name PRIMARY KEY (app_name))" ) def user_states_table_ddl(self, table: str) -> str: + bare_table = _bare_db2_table(table) return ( f"CREATE TABLE {table} (app_name VARCHAR(128) NOT NULL, user_id VARCHAR(128) NOT NULL, " f"state {DB2_JSON_COLUMN_TYPE} NOT NULL, update_time TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, " - f"CONSTRAINT pk_{table}_app_user PRIMARY KEY (app_name, user_id))" + f"CONSTRAINT pk_{bare_table}_app_user PRIMARY KEY (app_name, user_id))" ) def metadata_table_ddl(self, table: str) -> str: + bare_table = _bare_db2_table(table) return ( f'CREATE TABLE {table} ("KEY" VARCHAR(128) NOT NULL, value VARCHAR(512) NOT NULL, ' - f'CONSTRAINT pk_{table}_key PRIMARY KEY ("KEY"))' + f'CONSTRAINT pk_{bare_table}_key PRIMARY KEY ("KEY"))' ) def drop_table_sql(self, table: str) -> str: @@ -1251,30 +1336,38 @@ def drop_table_sql(self, table: str) -> str: def memory_table_ddl(self, table: str, owner_id_column_ddl: "str | None") -> str: owner_line = f", {owner_id_column_ddl}" if owner_id_column_ddl else "" + bare_table = _bare_db2_table(table) return ( f"CREATE TABLE {table} (id VARCHAR(128) NOT NULL, session_id VARCHAR(128) NOT NULL, " "app_name VARCHAR(128) NOT NULL, user_id VARCHAR(128) NOT NULL, " "scope VARCHAR(16) NOT NULL DEFAULT 'user', event_id VARCHAR(128) NOT NULL, author VARCHAR(256), " f"timestamp TIMESTAMP NOT NULL, content_json {DB2_JSON_COLUMN_TYPE} NOT NULL, " f"content_text {DB2_JSON_COLUMN_TYPE} NOT NULL, metadata_json {DB2_JSON_COLUMN_TYPE}, " - f"inserted_at TIMESTAMP NOT NULL{owner_line}, CONSTRAINT pk_{table}_id PRIMARY KEY (id), " - f"CONSTRAINT uq_{table}_event UNIQUE (event_id))" + f"inserted_at TIMESTAMP NOT NULL{owner_line}, CONSTRAINT pk_{bare_table}_id PRIMARY KEY (id), " + f"CONSTRAINT uq_{bare_table}_event UNIQUE (event_id))" ) def memory_index_specs(self, table: str) -> "list[tuple[str, str, str]]": + bare_table = _bare_db2_table(table) return [ - (f"idx_{table}_scope", table, "app_name, user_id, scope, timestamp"), - (f"idx_{table}_session", table, "session_id, timestamp"), - (f"idx_{table}_inserted", table, "inserted_at"), + (f"idx_{bare_table}_scope", table, "app_name, user_id, scope, timestamp"), + (f"idx_{bare_table}_session", table, "session_id, timestamp"), + (f"idx_{bare_table}_inserted", table, "inserted_at"), ] def memory_duplicate_sql(self, table: str) -> str: return f"SELECT id FROM {table} WHERE event_id = ? FETCH FIRST 1 ROWS ONLY" - def memory_search_sql(self, table: str, where_scope: str, limit: int) -> str: + def escape_like_query(self, query: str) -> str: + return query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + def memory_search_sql( + self, table: str, where_scope: str, limit: int, *, has_escaped_wildcards: bool = False + ) -> str: + escape_clause = " ESCAPE '\\'" if has_escaped_wildcards else "" return ( "SELECT id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, " - f"content_text, metadata_json, inserted_at FROM {table} WHERE {where_scope} AND content_text LIKE ? " + f"content_text, metadata_json, inserted_at FROM {table} WHERE {where_scope} AND content_text LIKE ?{escape_clause} " f"ORDER BY timestamp DESC FETCH FIRST {int(limit)} ROWS ONLY" ) diff --git a/sqlspec/adapters/arrow_odbc/config.py b/sqlspec/adapters/arrow_odbc/config.py index 34d226d80..d8ddff9d9 100644 --- a/sqlspec/adapters/arrow_odbc/config.py +++ b/sqlspec/adapters/arrow_odbc/config.py @@ -28,7 +28,7 @@ from sqlspec.utils.type_guards import supports_close if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Mapping from types import TracebackType from sqlspec.core import StatementConfig @@ -41,27 +41,6 @@ _DRIVER_POOLING_LOCK = threading.Lock() -def _apply_driver_pooling(requested: bool) -> None: - """Apply the ODBC driver manager's process-global connection pooling switch. - - The upstream switch can only be turned on once, before the first connection - in the process. Declining it is therefore not a decision that can be latched: - only an opt-in is recorded, and a later configuration that does not ask for - pooling simply inherits it. - - Args: - requested: Whether this configuration asks for driver-level pooling. - """ - global _DRIVER_POOLING_ENABLED - if not requested: - return - with _DRIVER_POOLING_LOCK: - if _DRIVER_POOLING_ENABLED: - return - enable_odbc_connection_pooling() - _DRIVER_POOLING_ENABLED = True - - class ArrowOdbcConnectionParams(TypedDict): """arrow-odbc connection parameters.""" @@ -192,16 +171,24 @@ def __init__( ) -> None: """Initialize arrow-odbc configuration.""" normalized = normalize_connection_config(connection_config) + effective_config = dict(normalized) + extra = effective_config.get("extra") + if isinstance(extra, dict): + effective_config.update(extra) provided_statement_config = statement_config statement_config, features = apply_driver_features( statement_config or default_statement_config, driver_features ) - connection_string = normalized.get("connection_string") + connection_string = effective_config.get("connection_string") if connection_string is not None: features.setdefault("connection_string", str(connection_string)) - elif normalized.get("driver") is not None: - features.setdefault("dbms_name", str(normalized["driver"])) - features["connection_autocommit"] = bool(normalized.get("autocommit", True)) + if effective_config.get("driver") is not None: + features.setdefault("dbms_name", str(effective_config["driver"])) + elif effective_config.get("dsn") is not None and ( + connection_string is None or resolve_dialect_from_dbms_name(str(connection_string)) == "sqlite" + ): + features.setdefault("dbms_name", str(effective_config["dsn"])) + features["connection_autocommit"] = bool(effective_config.get("autocommit", True)) if provided_statement_config is None: statement_config = _resolve_statement_config(features) self._user_connection_hook = cast( @@ -224,16 +211,16 @@ def create_connection(self) -> "ArrowOdbcConnection": """Create and return a new arrow-odbc connection.""" if self.connection_instance is not None: return cast("ArrowOdbcConnection", self.connection_instance) - _apply_driver_pooling(bool(self.driver_features.get("enable_driver_pooling", False))) - dialect = resolve_dialect_from_dbms_name( - self.driver_features.get("dbms_name") or self.driver_features.get("connection_string") - ) - connection_string, connect_kwargs = build_connection_config(self.connection_config, dialect=dialect) try: + _apply_driver_pooling(bool(self.driver_features.get("enable_driver_pooling", False))) + dialect = _resolve_connection_dialect_hint(self.driver_features) + connection_string, connect_kwargs = build_connection_config(self.connection_config, dialect=dialect) connection = cast("ArrowOdbcConnection", arrow_odbc_connect(connection_string, **connect_kwargs)) if self._user_connection_hook is not None: self._user_connection_hook(connection) return cast("ArrowOdbcConnection", connection) + except ImproperConfigurationError: + raise except Exception as exc: msg = f"Could not configure arrow-odbc connection. Error: {exc}" raise ImproperConfigurationError(msg) from exc @@ -274,6 +261,27 @@ def get_event_runtime_hints(self) -> "EventRuntimeHints": return EventRuntimeHints(poll_interval=2.0, lease_seconds=60, retention_seconds=172_800) +def _apply_driver_pooling(requested: bool) -> None: + """Apply the ODBC driver manager's process-global connection pooling switch. + + The upstream switch can only be turned on once, before the first connection + in the process. Declining it is therefore not a decision that can be latched: + only an opt-in is recorded, and a later configuration that does not ask for + pooling simply inherits it. + + Args: + requested: Whether this configuration asks for driver-level pooling. + """ + global _DRIVER_POOLING_ENABLED + if not requested: + return + with _DRIVER_POOLING_LOCK: + if _DRIVER_POOLING_ENABLED: + return + enable_odbc_connection_pooling() + _DRIVER_POOLING_ENABLED = True + + def _apply_json_serializer_override(statement_config: "StatementConfig", features: dict[str, Any]) -> "StatementConfig": serializer = cast("Callable[[Any], str] | None", features.get("json_serializer")) deserializer = cast("Callable[[str], Any] | None", features.get("json_deserializer")) @@ -292,8 +300,19 @@ def _close_arrow_odbc_connection(connection: "ArrowOdbcConnection") -> None: connection.close() +def _resolve_connection_dialect_hint(features: "Mapping[str, Any]") -> str | None: + for key in ("dbms_name", "connection_string"): + value = features.get(key) + if not value: + continue + resolved = resolve_dialect_from_dbms_name(str(value)) + if resolved != "sqlite" or "sqlite" in str(value).lower(): + return resolved + return None + + def _resolve_statement_config(features: dict[str, Any]) -> "StatementConfig": - dialect = resolve_dialect_from_dbms_name(str(features.get("dbms_name") or features.get("connection_string") or "")) + dialect = _resolve_connection_dialect_hint(features) or "sqlite" if dialect == "sqlite": return _apply_json_serializer_override(default_statement_config, features) if dialect == "mssql": diff --git a/sqlspec/adapters/arrow_odbc/core.py b/sqlspec/adapters/arrow_odbc/core.py index c50d709e6..8ade8209d 100644 --- a/sqlspec/adapters/arrow_odbc/core.py +++ b/sqlspec/adapters/arrow_odbc/core.py @@ -64,6 +64,7 @@ ("driver", "Driver"), ("server", "Server"), ("host", "Server"), + ("port", "Port"), ("database", "Database"), ("uid", "UID"), ("pwd", "PWD"), @@ -95,10 +96,10 @@ "encrypt", }) _DIALECT_PATTERNS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = ( - ("mssql", ("sql server", "sqlserver", "microsoft sql", "msodbcsql")), + ("mssql", ("sql server", "sqlserver", "microsoft sql", "msodbcsql", "freetds", "tdsodbc")), ("oracle", ("oracle",)), ("mysql", ("mysql", "mariadb")), - ("postgres", ("postgres", "postgresql")), + ("postgres", ("postgres", "postgresql", "psqlodbc")), ("sqlite", ("sqlite",)), ("duckdb", ("duckdb",)), ("snowflake", ("snowflake",)), @@ -107,11 +108,14 @@ _ARROW_ODBC_ERROR_NUMBER_PATTERN: Final[re.Pattern[str]] = re.compile(r"Native error:\s*(-?\d+)") _ODBC_PUNCTUATION: Final[str] = "[]{}(),;?*=!@" _SQL_SERVER_DIAGNOSTIC_MARKERS: Final[tuple[str, ...]] = ("sql server", "msodbcsql") +_MSSQL_CONSTRAINT_ERROR_CODE: Final[int] = 547 _ERROR_CODE_MAPPING: Final[dict[int, tuple[type[SQLSpecError], str]]] = { 2601: (UniqueViolationError, "unique constraint violation"), 2627: (UniqueViolationError, "unique constraint violation"), - 547: (ForeignKeyViolationError, "foreign key or check constraint violation"), + _MSSQL_CONSTRAINT_ERROR_CODE: (ForeignKeyViolationError, "foreign key or check constraint violation"), 515: (NotNullViolationError, "not-null constraint violation"), + 229: (PermissionDeniedError, "permission denied"), + 230: (PermissionDeniedError, "permission denied"), 18456: (PermissionDeniedError, "permission denied"), 4060: (DatabaseConnectionError, "database connection error"), 53: (DatabaseConnectionError, "database connection error"), @@ -128,9 +132,13 @@ "23503": (ForeignKeyViolationError, "foreign key constraint violation"), "23502": (NotNullViolationError, "not-null constraint violation"), "23513": (CheckViolationError, "check constraint violation"), + "23514": (CheckViolationError, "check constraint violation"), "40001": (DeadlockError, "deadlock detected"), + "42501": (PermissionDeniedError, "insufficient privilege"), "57033": (DeadlockError, "statement rolled back due to deadlock or timeout"), "57014": (QueryTimeoutError, "query timeout or cancellation"), + "HYT00": (QueryTimeoutError, "timeout expired"), + "HYT01": (DatabaseConnectionError, "connection timeout expired"), } _LOCK_TIMEOUT_SQLSTATES: Final[frozenset[str]] = frozenset({"40001", "57033"}) _DB2_REASON_CODE_PATTERN: Final[re.Pattern[str]] = re.compile(r'Reason code\s*"?(\d+)"?', re.IGNORECASE) @@ -162,35 +170,21 @@ def resolve_dialect_from_dbms_name(dbms_name: str | None) -> str: Returns: The matched dialect name, or ``"sqlite"`` when nothing identifies the driver. """ - if not dbms_name: - return "sqlite" - driver_name = _connection_string_driver_name(dbms_name) if "=" in dbms_name else dbms_name - if not driver_name: - return "sqlite" - lowered = driver_name.lower() - for dialect, patterns in _DIALECT_PATTERNS: - if any(pattern in lowered for pattern in patterns): - return dialect - return "sqlite" - - -def _connection_string_driver_name(connection_string: str) -> "str | None": - """Return the unbraced Driver value of a connection string, falling back to its DSN value.""" - options = parse_odbc_connection_string(connection_string) - for wanted in ("driver", "dsn"): - for key, value in options: - if key.lower() == wanted: - return value.strip().removeprefix("{").removesuffix("}").strip() - return None + matched = _match_dialect_from_dbms_name(dbms_name) + return matched if matched is not None else "sqlite" -def create_mapped_exception(error: Exception, *, logger: Any | None = None) -> SQLSpecError: +def create_mapped_exception(error: BaseException, *, logger: Any | None = None) -> SQLSpecError: """Map an arrow-odbc exception to SQLSpec's exception hierarchy.""" del logger message = str(error) if _is_sql_server_diagnostic(message): error_number = extract_native_error_number(error) if error_number is not None: + if error_number == _MSSQL_CONSTRAINT_ERROR_CODE and "check constraint" in message.lower(): + return CheckViolationError( + f"ODBC SQL Server error {error_number}: check constraint violation. Original error: {error}" + ) mapping = _ERROR_CODE_MAPPING.get(error_number) if mapping is not None: error_class, description = mapping @@ -229,6 +223,14 @@ def apply_driver_features( "json_deserializer": from_json, } defaults.update(driver_features or {}) + serializer = defaults.get("json_serializer") + deserializer = defaults.get("json_deserializer") + if serializer is not to_json or deserializer is not from_json: + statement_config = statement_config.replace( + parameter_config=statement_config.parameter_config.with_json_serializers( + serializer or to_json, deserializer=deserializer + ) + ) return statement_config, defaults @@ -239,6 +241,7 @@ def apply_driver_features( "host": "Server", "address": "Server", "addr": "Server", + "port": "Port", "database": "Database", "db": "Database", "uid": "UID", @@ -257,20 +260,7 @@ def apply_driver_features( } -def _append_port(server: str, port: Any) -> str: - """Append a port number to a server hostname for ODBC.""" - if port is None: - return server - port_str = str(port).strip() - if not port_str: - return server - if "," in server: - host = server.split(",", 1)[0] - return f"{host},{port_str}" - return f"{server},{port_str}" - - -def build_connection_config(params: dict[str, Any], *, dialect: str = "mssql") -> tuple[str, dict[str, Any]]: +def build_connection_config(params: dict[str, Any], *, dialect: str | None = None) -> tuple[str, dict[str, Any]]: """Build arrow-odbc connection arguments with explicit-field precedence and key deduplication. When both ``connection_string`` and discrete connection fields are provided, @@ -280,8 +270,9 @@ def build_connection_config(params: dict[str, Any], *, dialect: str = "mssql") - For the ``db2`` dialect, host and port render as the IBM CLI ``Hostname`` and ``Port`` keywords, ``Protocol=TCPIP`` is added when a host is present and no - protocol is configured, and SQL Server-only options are refused. Every other - dialect uses the SQL Server keyword set with ``Server=host,port``. + protocol is configured, and SQL Server-only options are refused. SQL Server + combines host and port as ``Server=host,port``; other dialects emit separate + ``Server`` and ``Port`` keywords. Args: params: Raw connection configuration. @@ -309,50 +300,14 @@ def build_connection_config(params: dict[str, Any], *, dialect: str = "mssql") - if connection_string is not None and not config: return str(connection_string), connect_kwargs - if dialect == "db2": + resolved_dialect = _resolve_connection_dialect(config, connection_string, dialect) + if resolved_dialect == "db2": return _build_db2_connection_string(config, connection_string), connect_kwargs if connection_string is not None: - options: dict[str, tuple[str, str]] = {} - for raw_key, raw_value in parse_odbc_connection_string(str(connection_string)): - canonical_key = _CANONICAL_KEY_LOOKUP.get(raw_key.lower(), raw_key) - options[canonical_key.lower()] = (canonical_key, raw_value) - - server = config.pop("server", None) - if server is None: - server = config.pop("host", None) - port = config.pop("port", None) - if server is not None: - options["server"] = ("Server", _format_connection_value("Server", _append_port(str(server), port))) - elif port is not None and "server" in options: - disp, val = options["server"] - options["server"] = (disp, _format_connection_value(disp, _append_port(val, port))) - - consumed: set[str] = set() - for key, option_name in _CONNECTION_STRING_KEYS: - if key in ("server", "host"): - continue - value = config.get(key) - if value is not None: - canonical_name = _CANONICAL_KEY_LOOKUP.get(option_name.lower(), option_name) - options[canonical_name.lower()] = (canonical_name, _format_connection_value(canonical_name, value)) - consumed.add(key) - - for remaining_key, remaining_value in config.items(): - if remaining_key in consumed or remaining_value is None: - continue - canonical_key = _CANONICAL_KEY_LOOKUP.get(remaining_key.lower(), remaining_key) - options[canonical_key.lower()] = (canonical_key, _format_connection_value(canonical_key, remaining_value)) - - merged_parts = [f"{name}={val}" for name, val in options.values()] - return ";".join(merged_parts) + ";", connect_kwargs + return _merge_connection_string(config, str(connection_string), resolved_dialect), connect_kwargs - server = config.pop("server", None) - if server is None: - server = config.pop("host", None) - port = config.pop("port", None) - if server is not None: - config["server"] = _append_port(str(server), port) + _apply_standalone_host_port(config, resolved_dialect) parts: list[str] = [] standalone_consumed: set[str] = set() @@ -375,47 +330,6 @@ def build_connection_config(params: dict[str, Any], *, dialect: str = "mssql") - return ";".join(parts) + ";", connect_kwargs -def _build_db2_connection_string(config: "dict[str, Any]", connection_string: Any) -> str: - """Render an IBM Db2 CLI connection string from a base string and discrete fields. - - Args: - config: Discrete connection fields, which override matching options. - connection_string: Optional base connection string. - - Returns: - The connection string with CLI keywords in canonical order, followed by - any other options in the order they were given. - - Raises: - ImproperConfigurationError: If a SQL Server-only option is configured, or - no connection option is present. - """ - for key, value in config.items(): - if value is not None and key.lower() in _SQL_SERVER_ONLY_OPTIONS: - msg = f"{key!r} is a SQL Server option and is not supported for Db2." - raise ImproperConfigurationError(msg) - - options: dict[str, tuple[str, str]] = {} - if connection_string is not None: - for raw_key, raw_value in parse_odbc_connection_string(str(connection_string)): - name = _DB2_CANONICAL_KEY_LOOKUP.get(raw_key.lower(), raw_key) - options[name.lower()] = (name, raw_value) - for key, value in config.items(): - if value is None: - continue - name = _DB2_CANONICAL_KEY_LOOKUP.get(key.lower(), key) - options[name.lower()] = (name, _format_connection_value(name, value)) - if "hostname" in options and "protocol" not in options: - options["protocol"] = ("Protocol", "TCPIP") - if not options: - msg = "arrow-odbc connection_config requires 'connection_string' or ODBC connection fields." - raise ImproperConfigurationError(msg) - - ordered = [options[key] for key in _DB2_KEYWORD_ORDER if key in options] - ordered.extend(option for key, option in options.items() if key not in _DB2_KEYWORD_ORDER) - return ";".join(f"{name}={value}" for name, value in ordered) + ";" - - def build_profile() -> "DriverParameterProfile": """Create the arrow-odbc driver parameter profile.""" return DriverParameterProfile( @@ -442,30 +356,6 @@ def build_statement_config(*, dialect: str = "sqlite", json_serializer: "Any" = ) -def _identity(value: Any) -> Any: - return value - - -_CUSTOM_TYPE_COERCIONS: Final["dict[type, Callable[[Any], Any]]"] = { - bool: _identity, - int: _identity, - float: _identity, - str: _identity, - bytes: _identity, - **build_uuid_coercions(native=False), -} - - -def _custom_type_coercions() -> "dict[type, Callable[[Any], Any]]": - """Return custom type coercions for arrow-odbc.""" - return dict(_CUSTOM_TYPE_COERCIONS) - - -def _extract_sqlstate(message: str) -> "str | None": - match = _ARROW_ODBC_SQLSTATE_PATTERN.search(message) - return match.group(1) if match is not None else None - - def extract_native_error_number(error: BaseException) -> "int | None": """Return the ``Native error`` number an arrow-odbc diagnostic carries. @@ -484,11 +374,6 @@ def extract_native_error_number(error: BaseException) -> "int | None": return None -def _is_db2_lock_timeout(message: str) -> bool: - match = _DB2_REASON_CODE_PATTERN.search(message) - return match is not None and match.group(1) == _DB2_LOCK_TIMEOUT_REASON_CODE - - def normalize_column_names(column_names: "list[str]", lowercase: bool) -> "list[str]": """Lowercase column names a database folded to uppercase. @@ -547,6 +432,185 @@ def db2_timestamp_text(value: "datetime | None") -> "str | None": return value.isoformat(sep=" ", timespec="microseconds") +def _match_dialect_from_dbms_name(dbms_name: str | None) -> str | None: + if not dbms_name: + return None + driver_name = _connection_string_driver_name(dbms_name) if "=" in dbms_name else dbms_name + if not driver_name: + return None + lowered = driver_name.lower() + for dialect, patterns in _DIALECT_PATTERNS: + if any(pattern in lowered for pattern in patterns): + return dialect + return None + + +def _resolve_connection_dialect(config: "dict[str, Any]", connection_string: Any, dialect: str | None) -> str: + if dialect is not None: + return dialect + for candidate in (config.get("driver"), config.get("dsn"), connection_string): + if candidate is None: + continue + matched = _match_dialect_from_dbms_name(str(candidate)) + if matched is not None: + return matched + return "mssql" + + +def _apply_standalone_host_port(config: "dict[str, Any]", resolved_dialect: str) -> None: + server = config.pop("server", None) + if server is None: + server = config.pop("host", None) + port = config.pop("port", None) + if resolved_dialect == "mssql": + if server is not None: + config["server"] = _append_port(str(server), port) + elif port is not None: + config["port"] = port + return + if server is not None: + config["server"] = str(server) + if port is not None: + config["port"] = port + + +def _merge_connection_string(config: "dict[str, Any]", connection_string: str, resolved_dialect: str) -> str: + options: dict[str, tuple[str, str]] = {} + for raw_key, raw_value in parse_odbc_connection_string(connection_string): + canonical_key = _CANONICAL_KEY_LOOKUP.get(raw_key.lower(), raw_key) + options[canonical_key.lower()] = (canonical_key, raw_value) + + server = config.pop("server", None) + if server is None: + server = config.pop("host", None) + port = config.pop("port", None) + if resolved_dialect == "mssql": + if server is not None: + options["server"] = ("Server", _format_connection_value("Server", _append_port(str(server), port))) + elif port is not None and "server" in options: + disp, val = options["server"] + options["server"] = (disp, _format_connection_value(disp, _append_port(val, port))) + elif port is not None: + options["port"] = ("Port", _format_connection_value("Port", port)) + else: + if server is not None: + options["server"] = ("Server", _format_connection_value("Server", server)) + if port is not None: + options["port"] = ("Port", _format_connection_value("Port", port)) + + consumed: set[str] = {"port"} + for key, option_name in _CONNECTION_STRING_KEYS: + if key in ("server", "host", "port"): + continue + value = config.get(key) + if value is not None: + canonical_name = _CANONICAL_KEY_LOOKUP.get(option_name.lower(), option_name) + options[canonical_name.lower()] = (canonical_name, _format_connection_value(canonical_name, value)) + consumed.add(key) + + for remaining_key, remaining_value in config.items(): + if remaining_key in consumed or remaining_value is None: + continue + canonical_key = _CANONICAL_KEY_LOOKUP.get(remaining_key.lower(), remaining_key) + options[canonical_key.lower()] = (canonical_key, _format_connection_value(canonical_key, remaining_value)) + + merged_parts = [f"{name}={val}" for name, val in options.values()] + return ";".join(merged_parts) + ";" + + +def _connection_string_driver_name(connection_string: str) -> "str | None": + """Return the unbraced Driver value of a connection string, falling back to its DSN value.""" + options = parse_odbc_connection_string(connection_string) + for wanted in ("driver", "dsn"): + for key, value in options: + if key.lower() == wanted: + return value.strip().removeprefix("{").removesuffix("}").strip() + return None + + +def _append_port(server: str, port: Any) -> str: + """Append a port number to a server hostname for ODBC.""" + if port is None: + return server + port_str = str(port).strip() + if not port_str: + return server + if "," in server: + host = server.split(",", 1)[0] + return f"{host},{port_str}" + return f"{server},{port_str}" + + +def _build_db2_connection_string(config: "dict[str, Any]", connection_string: Any) -> str: + """Render an IBM Db2 CLI connection string from a base string and discrete fields. + + Args: + config: Discrete connection fields, which override matching options. + connection_string: Optional base connection string. + + Returns: + The connection string with CLI keywords in canonical order, followed by + any other options in the order they were given. + + Raises: + ImproperConfigurationError: If a SQL Server-only option is configured, or + no connection option is present. + """ + for key, value in config.items(): + if value is not None and key.lower() in _SQL_SERVER_ONLY_OPTIONS: + msg = f"{key!r} is a SQL Server option and is not supported for Db2." + raise ImproperConfigurationError(msg) + + options: dict[str, tuple[str, str]] = {} + if connection_string is not None: + for raw_key, raw_value in parse_odbc_connection_string(str(connection_string)): + name = _DB2_CANONICAL_KEY_LOOKUP.get(raw_key.lower(), raw_key) + options[name.lower()] = (name, raw_value) + for key, value in config.items(): + if value is None: + continue + name = _DB2_CANONICAL_KEY_LOOKUP.get(key.lower(), key) + options[name.lower()] = (name, _format_connection_value(name, value)) + if "hostname" in options and "protocol" not in options: + options["protocol"] = ("Protocol", "TCPIP") + if not options: + msg = "arrow-odbc connection_config requires 'connection_string' or ODBC connection fields." + raise ImproperConfigurationError(msg) + + ordered = [options[key] for key in _DB2_KEYWORD_ORDER if key in options] + ordered.extend(option for key, option in options.items() if key not in _DB2_KEYWORD_ORDER) + return ";".join(f"{name}={value}" for name, value in ordered) + ";" + + +def _identity(value: Any) -> Any: + return value + + +_CUSTOM_TYPE_COERCIONS: Final["dict[type, Callable[[Any], Any]]"] = { + bool: _identity, + int: _identity, + float: _identity, + str: _identity, + bytes: _identity, + **build_uuid_coercions(native=False), +} + + +def _custom_type_coercions() -> "dict[type, Callable[[Any], Any]]": + """Return custom type coercions for arrow-odbc.""" + return dict(_CUSTOM_TYPE_COERCIONS) + + +def _extract_sqlstate(message: str) -> "str | None": + match = _ARROW_ODBC_SQLSTATE_PATTERN.search(message) + return match.group(1) if match is not None else None + + +def _is_db2_lock_timeout(message: str) -> bool: + match = _DB2_REASON_CODE_PATTERN.search(message) + return match is not None and match.group(1) == _DB2_LOCK_TIMEOUT_REASON_CODE + + def _is_sql_server_diagnostic(message: str) -> bool: lowered = message.lower() return any(marker in lowered for marker in _SQL_SERVER_DIAGNOSTIC_MARKERS) @@ -592,5 +656,4 @@ def _format_connection_value(option_name: str, value: Any) -> str: driver_profile = build_profile() - default_statement_config = build_statement_config() diff --git a/sqlspec/adapters/arrow_odbc/data_dictionary.py b/sqlspec/adapters/arrow_odbc/data_dictionary.py index e47862721..21c2f9513 100644 --- a/sqlspec/adapters/arrow_odbc/data_dictionary.py +++ b/sqlspec/adapters/arrow_odbc/data_dictionary.py @@ -4,6 +4,7 @@ from mypy_extensions import mypyc_attr +from sqlspec.adapters.arrow_odbc.core import normalize_column_names from sqlspec.data_dictionary import ( ColumnMetadata, DDLResult, @@ -22,14 +23,20 @@ ) from sqlspec.driver import SyncDataDictionaryBase from sqlspec.exceptions import SQLFileNotFoundError +from sqlspec.typing import import_optional +from sqlspec.utils.module_loader import ensure_pyarrow from sqlspec.utils.text import normalize_identifier, quote_identifier if TYPE_CHECKING: from collections.abc import Sequence + import pyarrow as pa + from sqlspec.adapters.arrow_odbc.driver import ArrowOdbcDriver from sqlspec.core import SQL from sqlspec.data_dictionary._types import DialectConfig +else: + pa = import_optional("pyarrow") __all__ = ("ArrowOdbcDataDictionary",) @@ -68,6 +75,7 @@ class ArrowOdbcDataDictionary(SyncDataDictionaryBase): def __init__(self, dialect: str = "sqlite") -> None: super().__init__() self._dialect = dialect + object.__setattr__(self, "dialect", dialect) def get_dialect_config(self) -> "DialectConfig": """Return the runtime dialect configuration for this data dictionary.""" @@ -108,29 +116,6 @@ def resolve_identifier(self, identifier: str) -> str: """Return a runtime-dialect-normalized identifier.""" return normalize_identifier(identifier, self.get_dialect_config().name) - def _probe_columns(self, driver: "ArrowOdbcDriver", table: str, schema: "str | None") -> "list[ColumnMetadata]": - qualified = ( - quote_identifier(table) if schema is None else f"{quote_identifier(schema)}.{quote_identifier(table)}" - ) - probe_sql = f"SELECT * FROM {qualified} WHERE 1=0" - try: - reader = driver._read_arrow_batches(probe_sql, None, 1) # pyright: ignore[reportPrivateUsage] - except Exception: - return [] - columns: list[ColumnMetadata] = [] - for position, field in enumerate(reader.schema, start=1): - entry: ColumnMetadata = { - "table_name": table, - "column_name": field.name, - "data_type": _arrow_type_to_sql(field.type), - "is_nullable": bool(field.nullable), - "ordinal_position": position, - } - if schema is not None: - entry["schema_name"] = schema - columns.append(entry) - return columns - def get_version(self, driver: "ArrowOdbcDriver") -> VersionInfo | None: """Get database version information when the runtime dialect provides a query.""" driver_id = id(driver) @@ -254,6 +239,36 @@ def get_ddl( identity, source=MetadataSource.DRIVER_METADATA, warnings=(_ODBC_DDL_UNSUPPORTED_WARNING,) ) + def _probe_columns(self, driver: "ArrowOdbcDriver", table: str, schema: "str | None") -> "list[ColumnMetadata]": + qualified = ( + quote_identifier(table) if schema is None else f"{quote_identifier(schema)}.{quote_identifier(table)}" + ) + probe_sql = f"SELECT * FROM {qualified} WHERE 1=0" + try: + reader = driver._read_arrow_batches( # pyright: ignore[reportPrivateUsage] + probe_sql, None, 1, fetch_concurrently=False + ) + except Exception: + return [] + columns: list[ColumnMetadata] = [] + raw_names = [field.name for field in reader.schema] + normalized_names = normalize_column_names( + raw_names, + driver._lowercase_column_names, # pyright: ignore[reportPrivateUsage] + ) + for position, (field, column_name) in enumerate(zip(reader.schema, normalized_names, strict=False), start=1): + entry: ColumnMetadata = { + "table_name": table, + "column_name": column_name, + "data_type": _arrow_type_to_sql(field.type), + "is_nullable": bool(field.nullable), + "ordinal_position": position, + } + if schema is not None: + entry["schema_name"] = schema + columns.append(entry) + return columns + def _capability_for_domain(self, domain: str) -> MetadataCapability: if domain == "odbc_catalog": return MetadataCapability( @@ -272,6 +287,13 @@ def _capability_for_domain(self, domain: str) -> MetadataCapability: warnings=(_ODBC_DDL_UNSUPPORTED_WARNING,), ) if domain == "columns": + if self._has_query("columns", "by_schema"): + return MetadataCapability( + domain=domain, + support=MetadataSupport.SUPPORTED, + fidelity=MetadataFidelity.NATIVE, + source=MetadataSource.CATALOG, + ) return MetadataCapability( domain=domain, support=MetadataSupport.SUPPORTED, @@ -303,8 +325,7 @@ def _has_query(self, domain: str, operation: str) -> bool: def _arrow_type_to_sql(data_type: Any) -> str: - import pyarrow as pa - + ensure_pyarrow() types = pa.types if types.is_boolean(data_type): return "BOOLEAN" diff --git a/sqlspec/adapters/arrow_odbc/driver.py b/sqlspec/adapters/arrow_odbc/driver.py index fd465dffa..e7e3f323b 100644 --- a/sqlspec/adapters/arrow_odbc/driver.py +++ b/sqlspec/adapters/arrow_odbc/driver.py @@ -27,7 +27,7 @@ ) from sqlspec.core.parameters._validator import ParameterValidator from sqlspec.driver import BaseSyncExceptionHandler, SyncDriverAdapterBase, SyncRowStream, validate_savepoint_name -from sqlspec.exceptions import ImproperConfigurationError, SQLSpecError +from sqlspec.exceptions import DataError, ImproperConfigurationError, SQLSpecError from sqlspec.typing import import_optional from sqlspec.utils.module_loader import ensure_pyarrow from sqlspec.utils.text import quote_identifier, split_qualified_identifier @@ -54,7 +54,7 @@ class ArrowOdbcExceptionHandler(BaseSyncExceptionHandler): def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "BaseException") -> bool: if exc_type is None: return False - if isinstance(exc_val, ArrowOdbcError): + if isinstance(exc_val, ArrowOdbcError) or (pa is not None and isinstance(exc_val, pa.ArrowException)): self.pending_exception = create_mapped_exception(exc_val) return True return False @@ -63,7 +63,7 @@ def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "Ba class ArrowOdbcStreamSource: """Native Arrow ODBC chunk source backed by Arrow record batches.""" - __slots__ = ("_chunk_size", "_driver", "_parameters", "_reader", "_sql") + __slots__ = ("_chunk_size", "_driver", "_parameters", "_raw_reader", "_reader", "_sql") def __init__( self, driver: "ArrowOdbcDriver", sql: str, parameters: "list[str | None] | None", chunk_size: int @@ -72,13 +72,15 @@ def __init__( self._sql = sql self._parameters = parameters self._chunk_size = chunk_size + self._raw_reader: Any = None self._reader: Any = None def start(self) -> None: handler = self._driver.handle_database_exceptions() with handler: - reader = self._driver._read_arrow_batches(self._sql, self._parameters, self._chunk_size) - self._reader = iter(self._driver._normalize_reader(_to_pyarrow_reader(reader))) + raw_reader = self._driver._read_arrow_batches(self._sql, self._parameters, self._chunk_size) + self._raw_reader = raw_reader + self._reader = iter(self._driver._normalize_reader(_to_pyarrow_reader(raw_reader))) self._driver._check_pending_exception(handler) def fetch_chunk(self) -> "list[dict[str, Any]]": @@ -86,21 +88,30 @@ def fetch_chunk(self) -> "list[dict[str, Any]]": if reader is None: return [] while True: + batch: Any = None + handler = self._driver.handle_database_exceptions() try: - batch = next(reader) + with handler: + batch = next(reader) except StopIteration: return [] + self._driver._check_pending_exception(handler) + if batch is None: + return [] rows = cast("list[dict[str, Any]]", batch.to_pylist()) if rows: return rows def close(self, error: bool = False) -> None: reader = self._reader + raw_reader = self._raw_reader self._reader = None - close = getattr(reader, "close", None) - if callable(close): - with contextlib.suppress(Exception): - close() + self._raw_reader = None + for target in (reader, raw_reader): + close = getattr(target, "close", None) + if callable(close): + with contextlib.suppress(Exception): + close() class ArrowOdbcDriver(SyncDriverAdapterBase): @@ -130,6 +141,16 @@ def __init__( driver_features: "dict[str, Any] | None" = None, ) -> None: features = dict(driver_features or {}) + features.setdefault( + "storage_capabilities", + { + "arrow_export_enabled": True, + "arrow_import_enabled": True, + "parquet_export_enabled": False, + "parquet_import_enabled": False, + "partition_strategies": ["rows_per_chunk"], + }, + ) self._dbms_name = self._resolve_dbms_name(features) self._dialect = resolve_dialect_from_dbms_name(self._dbms_name) statement_dialect = _statement_dialect_for(self._dialect) @@ -142,9 +163,15 @@ def __init__( super().__init__(connection=connection, statement_config=statement_config, driver_features=features) self._chunk_size_val: int = int(features.get("chunk_size") or 65_536) - self._max_batch_bytes: int | None = features.get("max_bytes_per_batch") - self._max_binary_size_val: int | None = features.get("max_binary_size") - self._max_text_size_val: int | None = features.get("max_text_size") + self._max_batch_bytes: int = int( + features["max_bytes_per_batch"] if features.get("max_bytes_per_batch") is not None else 512 * 1024 * 1024 + ) + self._max_binary_size_val: int = int( + features["max_binary_size"] if features.get("max_binary_size") is not None else 1024 * 1024 + ) + self._max_text_size_val: int = int( + features["max_text_size"] if features.get("max_text_size") is not None else 1024 * 1024 + ) self._query_timeout_sec_val: int | None = features.get("query_timeout_sec") self._payload_text_encoding: Any = features.get("payload_text_encoding") self._use_concurrent_fetch: bool = bool(features.get("fetch_concurrently", True)) @@ -183,7 +210,7 @@ def dispatch_execute(self, cursor: "ArrowOdbcRawCursor", statement: "SQL") -> "E row_format="dict", ) - cursor.execute(query=sql, parameters=parameters) + self._execute_statement(cursor, sql, parameters) return self.create_execution_result(cursor, rowcount_override=0) def dispatch_execute_many(self, cursor: "ArrowOdbcRawCursor", statement: "SQL") -> "ExecutionResult": @@ -195,8 +222,8 @@ def dispatch_execute_many(self, cursor: "ArrowOdbcRawCursor", statement: "SQL") sql, parameter_set = _inline_mssql_pagination_parameters( sql, parameter_set, compiled_statement.parameter_profile, statement.statement_config ) - cursor.execute( - query=sql, parameters=_odbc_parameters(parameter_set, naive_utc_datetimes=self._dialect == "db2") + self._execute_statement( + cursor, sql, _odbc_parameters(parameter_set, naive_utc_datetimes=self._dialect == "db2") ) executed = True return self.create_execution_result(cursor, rowcount_override=-1 if executed else 0, is_many_result=True) @@ -204,15 +231,19 @@ def dispatch_execute_many(self, cursor: "ArrowOdbcRawCursor", statement: "SQL") def dispatch_execute_script(self, cursor: "ArrowOdbcRawCursor", statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) if self._dialect == "mssql": - cursor.execute(query=sql, parameters=_odbc_parameters(prepared_parameters)) + self._execute_statement(cursor, sql, _odbc_parameters(prepared_parameters)) return self.create_execution_result( cursor, rowcount_override=-1, statement_count=1, successful_statements=1, is_script_result=True ) statements = self.split_script_statements(sql, statement.statement_config, strip_trailing_semicolon=True) - parameters = _odbc_parameters(prepared_parameters, naive_utc_datetimes=self._dialect == "db2") + parameters = ( + _odbc_parameters(prepared_parameters, naive_utc_datetimes=self._dialect == "db2") + if len(statements) == 1 + else None + ) successful_count = 0 for stmt in statements: - cursor.execute(query=stmt, parameters=parameters) + self._execute_statement(cursor, stmt, parameters) successful_count += 1 return self.create_execution_result( cursor, statement_count=len(statements), successful_statements=successful_count, is_script_result=True @@ -243,23 +274,32 @@ def resolve_rowcount(self, cursor: "ArrowOdbcRawCursor") -> int: def begin(self) -> None: """Begin an explicit transaction. - SQL Server starts one with ``BEGIN TRANSACTION``. Db2 has no begin - statement: a connection opened with autocommit off is always inside a - unit of work, so only the boundary is recorded, and an autocommit - connection is refused because each statement would commit on its own. + Connections opened with ``autocommit=False`` are already in an ODBC unit + of work, so ``begin()`` records the active boundary without emitting SQL. + Under ``autocommit=True``, Db2 and Oracle connections are rejected because + neither supports a SQL ``BEGIN`` statement in autocommit mode, while SQL + Server issues ``BEGIN TRANSACTION`` and other dialects issue ``BEGIN``. Raises: - ImproperConfigurationError: If the Db2 connection was opened with autocommit on. + ImproperConfigurationError: If a Db2 or Oracle connection was opened + with autocommit on. SQLSpecError: If the begin statement fails. """ - if self._dialect == "db2": - if self._connection_autocommit: - msg = "Db2 transactions through arrow-odbc require connection_config={'autocommit': False}" - raise ImproperConfigurationError(msg) + if not self._connection_autocommit: self._transaction_active = True return + if self._dialect == "db2": + msg = "Db2 transactions through arrow-odbc require connection_config={'autocommit': False}" + raise ImproperConfigurationError(msg) + if self._dialect == "oracle": + msg = "Oracle transactions through arrow-odbc require connection_config={'autocommit': False}" + raise ImproperConfigurationError(msg) try: - self.connection.execute("BEGIN TRANSACTION" if self._dialect == "mssql" else "BEGIN") + self._execute_statement(self.connection, "BEGIN TRANSACTION" if self._dialect == "mssql" else "BEGIN", None) + except ArrowOdbcError as exc: + mapped = create_mapped_exception(exc) + msg = f"Failed to begin transaction: {mapped}" + raise type(mapped)(msg) from exc except Exception as exc: msg = f"Failed to begin transaction: {exc}" raise SQLSpecError(msg) from exc @@ -267,24 +307,30 @@ def begin(self) -> None: def commit(self) -> None: try: - if self._dialect == "mssql" and self._transaction_active: - self.connection.execute("COMMIT TRANSACTION") + if self._connection_autocommit and self._transaction_active: + self._execute_statement( + self.connection, "COMMIT TRANSACTION" if self._dialect == "mssql" else "COMMIT", None + ) else: self.connection.commit() except ArrowOdbcError as exc: - msg = f"Failed to commit transaction: {exc}" - raise SQLSpecError(msg) from exc + mapped = create_mapped_exception(exc) + msg = f"Failed to commit transaction: {mapped}" + raise type(mapped)(msg) from exc self._transaction_active = False def rollback(self) -> None: try: - if self._dialect == "mssql" and self._transaction_active: - self.connection.execute("ROLLBACK TRANSACTION") + if self._connection_autocommit and self._transaction_active: + self._execute_statement( + self.connection, "ROLLBACK TRANSACTION" if self._dialect == "mssql" else "ROLLBACK", None + ) else: self.connection.rollback() except ArrowOdbcError as exc: - msg = f"Failed to rollback transaction: {exc}" - raise SQLSpecError(msg) from exc + mapped = create_mapped_exception(exc) + msg = f"Failed to rollback transaction: {mapped}" + raise type(mapped)(msg) from exc finally: self._transaction_active = False @@ -376,9 +422,33 @@ def select_to_arrow( arrow_schema=arrow_schema, ) + def select_to_storage( + self, + statement: "Statement | QueryBuilder", + destination: "StorageDestination", + /, + *parameters: "StatementParameters | StatementFilter", + statement_config: "StatementConfig | None" = None, + partitioner: "dict[str, object] | None" = None, + format_hint: "StorageFormat | None" = None, + telemetry: "StorageTelemetry | None" = None, + **kwargs: Any, + ) -> "StorageBridgeJob": + """Execute a query and stream Arrow-compatible results to storage.""" + self._require_capability("arrow_export_enabled") + arrow_result = self.select_to_arrow(statement, *parameters, statement_config=statement_config, **kwargs) + sync_pipeline = self._storage_pipeline() + telemetry_payload = self._write_storage_result( + arrow_result, destination, format_hint=format_hint, pipeline=sync_pipeline + ) + self._attach_partition_telemetry(telemetry_payload, partitioner) + return self._storage_job(telemetry_payload, telemetry) + def bulk_insert_arrow(self, target_table: str, source: Any, *, chunk_size: int | None = None) -> None: """Insert an Arrow table or reader into a database table.""" ensure_pyarrow() + if hasattr(source, "get_data") and callable(source.get_data): + source = source.get_data() resolved_chunk_size = chunk_size or self._chunk_size() exc_handler = self.handle_database_exceptions() @@ -410,13 +480,24 @@ def load_from_arrow( ) -> "StorageBridgeJob": """Load Arrow data into a table via arrow-odbc bulk insert.""" self._require_capability("arrow_import_enabled") - arrow_table = self._coerce_arrow_table(source) + ensure_pyarrow() + raw_source = source.get_data() if hasattr(source, "get_data") and callable(source.get_data) else source if overwrite: target = _db2_table_reference(table) if self._dialect == "db2" else _quote_odbc_table(table) self.execute(f"DELETE FROM {target}") - self.bulk_insert_arrow(table, arrow_table) - telemetry_payload = self._ingest_telemetry(arrow_table) - telemetry_payload["destination"] = table + if isinstance(raw_source, pa.RecordBatchReader) and hasattr(self.connection, "insert_into_table"): + self.bulk_insert_arrow(table, raw_source) + telemetry_payload: StorageTelemetry = { + "rows_processed": 0, + "bytes_processed": 0, + "format": "arrow", + "destination": table, + } + else: + arrow_table = self._coerce_arrow_table(raw_source) + self.bulk_insert_arrow(table, arrow_table) + telemetry_payload = self._ingest_telemetry(arrow_table) + telemetry_payload["destination"] = table self._attach_partition_telemetry(telemetry_payload, partitioner) return self._storage_job(telemetry_payload, telemetry) @@ -442,7 +523,20 @@ def _connection_in_transaction(self) -> bool: """ return self._transaction_active - def _read_arrow_batches(self, sql: str, parameters: "list[str | None] | None", batch_size: int) -> Any: + def _execute_statement(self, target: Any, sql: str, parameters: "list[str | None] | None") -> None: + if self._query_timeout_sec_val is not None: + target.execute(query=sql, parameters=parameters, query_timeout_sec=self._query_timeout_sec_val) + return + target.execute(query=sql, parameters=parameters) + + def _read_arrow_batches( + self, + sql: str, + parameters: "list[str | None] | None", + batch_size: int, + *, + fetch_concurrently: bool | None = None, + ) -> Any: kwargs: dict[str, Any] = { "query": sql, "batch_size": batch_size, @@ -450,7 +544,7 @@ def _read_arrow_batches(self, sql: str, parameters: "list[str | None] | None", b "max_bytes_per_batch": self._max_batch_bytes, "max_text_size": self._max_text_size_val, "max_binary_size": self._max_binary_size_val, - "fetch_concurrently": self._use_concurrent_fetch, + "fetch_concurrently": self._use_concurrent_fetch if fetch_concurrently is None else fetch_concurrently, } if self._query_timeout_sec_val is not None: kwargs["query_timeout_sec"] = self._query_timeout_sec_val @@ -484,11 +578,13 @@ def _normalize_reader(self, reader: "ArrowRecordBatchReader") -> "ArrowRecordBat @staticmethod def _resolve_dbms_name(features: "dict[str, Any]") -> str | None: dbms_name = features.get("dbms_name") - if dbms_name: - return str(dbms_name) connection_string = features.get("connection_string") + if dbms_name and (not connection_string or resolve_dialect_from_dbms_name(str(dbms_name)) != "sqlite"): + return str(dbms_name) if connection_string: return str(connection_string) + if dbms_name: + return str(dbms_name) return None @@ -592,6 +688,9 @@ def _unwrap_parameter(value: Any, naive_utc_datetimes: bool = False) -> Any: wrapped = getattr(value, "value", value) if wrapped is None: return None + if isinstance(wrapped, (bytes, bytearray, memoryview)): + msg = "Binary parameter binding is not supported by arrow-odbc text parameter API; encode as hex or base64" + raise DataError(msg) if naive_utc_datetimes and isinstance(wrapped, datetime) and wrapped.tzinfo is not None: wrapped = wrapped.astimezone(UTC).replace(tzinfo=None) return str(wrapped) @@ -628,8 +727,11 @@ def _reader_to_table(reader: Any) -> Any: into_reader = getattr(reader, "into_pyarrow_record_batch_reader", None) if callable(into_reader): return cast("Any", into_reader()).read_all() + schema = getattr(reader, "schema", None) batches = list(reader) if not batches: + if isinstance(schema, pa.Schema): + return pa.Table.from_batches([], schema=schema) return pa.table({}) return pa.Table.from_batches(batches) diff --git a/sqlspec/adapters/arrow_odbc/events/store.py b/sqlspec/adapters/arrow_odbc/events/store.py index 56d828936..dfbd4597c 100644 --- a/sqlspec/adapters/arrow_odbc/events/store.py +++ b/sqlspec/adapters/arrow_odbc/events/store.py @@ -54,6 +54,15 @@ def _table_ddl(self) -> str: return _DB2_EVENT_TABLE_DDL.format(table=self.table_name) return super()._table_ddl() + def _index_name(self) -> str: + if self._db2: + return super()._index_name() + parts = [ + _strip_identifier_quotes(part) for part in split_qualified_identifier(self.table_name, quote_chars='"') + ] + flat = "_".join(part for part in parts if part) + return f"idx_{flat}_channel_status" + def _index_existence_target(self) -> "tuple[str | None, str] | None": """Return the upper-folded catalog schema and table checked for the Db2 queue index. @@ -78,8 +87,17 @@ def _wrap_drop_statement(self, statement: str) -> str: return f"IF OBJECT_ID(N'{_object_name(self.table_name)}', N'U') IS NOT NULL {statement};" +def _strip_identifier_quotes(part: str) -> str: + stripped = part.strip() + if len(stripped) >= _QUALIFIED_IDENTIFIER_MIN_PARTS and stripped.startswith("[") and stripped.endswith("]"): + return stripped[1:-1].replace("]]", "]") + if len(stripped) >= _QUALIFIED_IDENTIFIER_MIN_PARTS and stripped.startswith('"') and stripped.endswith('"'): + return stripped[1:-1].replace('""', '"') + return stripped + + def _split_table_name(table_name: str) -> tuple[str, str]: - parts = split_qualified_identifier(table_name, quote_chars='"') + parts = [_strip_identifier_quotes(part) for part in split_qualified_identifier(table_name, quote_chars='"')] if len(parts) < _QUALIFIED_IDENTIFIER_MIN_PARTS: return "dbo", parts[0] if parts else table_name schema_name = ".".join(parts[:-1]) diff --git a/sqlspec/adapters/arrow_odbc/litestar/store.py b/sqlspec/adapters/arrow_odbc/litestar/store.py index a735dc8c1..f1ecdb753 100644 --- a/sqlspec/adapters/arrow_odbc/litestar/store.py +++ b/sqlspec/adapters/arrow_odbc/litestar/store.py @@ -1,6 +1,7 @@ """arrow-odbc Litestar Store implementation.""" import base64 +import contextlib from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any @@ -12,13 +13,17 @@ ) from sqlspec.extensions.litestar.store import BaseSQLSpecStore from sqlspec.utils.sync_tools import async_ +from sqlspec.utils.text import split_qualified_identifier if TYPE_CHECKING: + from collections.abc import Iterator + from sqlspec.adapters.arrow_odbc.config import ArrowOdbcConfig __all__ = ("ArrowOdbcStore",) _MAX_INLINE_DATA_LENGTH = 3500 +_QUALIFIED_PARTS_MIN = 2 class ArrowOdbcStore(BaseSQLSpecStore["ArrowOdbcConfig"]): @@ -37,6 +42,10 @@ def __init__(self, config: "ArrowOdbcConfig") -> None: _Db2SessionSql(self._table_name) if dialect == "db2" else _TsqlSessionSql(self._table_name) ) + @property + def _chunk_table_name(self) -> str: + return f"{self._table_name}_chunks" + async def create_table(self) -> None: """Create the session table if it doesn't exist.""" if not self.create_schema_enabled: @@ -89,7 +98,8 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | expires_at = _normalize_utc(_row_value(row, "expires_at", 1)) if expires_at is not None and expires_at < datetime.now(UTC): - self._delete(key) + driver.execute(f"DELETE FROM {self._table_name} WHERE session_id = ?", (key,)) + driver.commit() return None if renew_for is not None and expires_at is not None: @@ -106,7 +116,7 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None: data = _encode_bytes(self._value_to_bytes(value)) expires_at = self._calculate_expires_at(expires_in) - with self._config.provide_session() as driver: + with self._config.provide_session() as driver, _atomic_write(driver): inline_data = data if len(data) <= _MAX_INLINE_DATA_LENGTH else None existing = driver.select_one_or_none( f"SELECT session_id FROM {self._table_name} WHERE session_id = ?", (key,) @@ -125,7 +135,6 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No """, (key, index, chunk), ) - driver.commit() def _delete(self, key: str) -> None: with self._config.provide_session() as driver: @@ -155,20 +164,15 @@ def _expires_in(self, key: str) -> "int | None": return max(0, int(remaining.total_seconds())) def _delete_expired(self) -> int: - with self._config.provide_session() as driver: + with self._config.provide_session() as driver, _atomic_write(driver): count_sql, count_parameters = self._sql.count_expired() count = int(driver.select_value(count_sql, *count_parameters) or 0) delete_sql, delete_parameters = self._sql.delete_expired() driver.execute(delete_sql, *delete_parameters) - driver.commit() if count > 0: self._log_delete_expired(count) return count - @property - def _chunk_table_name(self) -> str: - return f"{self._table_name}_chunks" - def _get_chunked_data(self, driver: Any, key: str) -> str: rows = driver.select( f""" @@ -193,11 +197,13 @@ def _drop_table_sql(self) -> "list[str]": class _TsqlSessionSql: """SQL Server statements for the session store.""" - __slots__ = ("_chunk_table_name", "_table_name") + __slots__ = ("_bare_chunk_table", "_bare_table", "_chunk_table_name", "_schema", "_table_name") def __init__(self, table_name: str) -> None: self._table_name = table_name self._chunk_table_name = f"{table_name}_chunks" + self._schema, self._bare_table = _split_tsql_table_name(table_name) + self._bare_chunk_table = f"{self._bare_table}_chunks" def create_table(self, driver: Any) -> None: """Create the session tables with the guarded T-SQL batch.""" @@ -276,8 +282,8 @@ def table_ddl(self) -> str: IF NOT EXISTS ( SELECT 1 FROM sys.tables - WHERE name = N'{self._table_name}' - AND schema_id = SCHEMA_ID(N'dbo') + WHERE name = N'{self._bare_table}' + AND schema_id = SCHEMA_ID(N'{self._schema}') ) BEGIN CREATE TABLE {self._table_name} ( @@ -288,7 +294,7 @@ def table_ddl(self) -> str: updated_at DATETIME2(6) NOT NULL DEFAULT SYSUTCDATETIME() ); - CREATE INDEX IX_{self._table_name}_expires_at + CREATE INDEX IX_{self._bare_table}_expires_at ON {self._table_name}(expires_at) WHERE expires_at IS NOT NULL; END; @@ -296,16 +302,16 @@ def table_ddl(self) -> str: IF NOT EXISTS ( SELECT 1 FROM sys.tables - WHERE name = N'{self._chunk_table_name}' - AND schema_id = SCHEMA_ID(N'dbo') + WHERE name = N'{self._bare_chunk_table}' + AND schema_id = SCHEMA_ID(N'{self._schema}') ) BEGIN CREATE TABLE {self._chunk_table_name} ( session_id NVARCHAR(255) NOT NULL, chunk_index INT NOT NULL, data NVARCHAR(3500) NOT NULL, - CONSTRAINT PK_{self._chunk_table_name} PRIMARY KEY (session_id, chunk_index), - CONSTRAINT FK_{self._chunk_table_name}_session FOREIGN KEY (session_id) + CONSTRAINT PK_{self._bare_chunk_table} PRIMARY KEY (session_id, chunk_index), + CONSTRAINT FK_{self._bare_chunk_table}_session FOREIGN KEY (session_id) REFERENCES {self._table_name}(session_id) ON DELETE CASCADE ); END; @@ -313,9 +319,11 @@ def table_ddl(self) -> str: def drop_statements(self) -> "list[str]": """Get SQL Server DROP TABLE statements.""" + chunk_target = f"{self._schema}.{self._bare_chunk_table}" + table_target = f"{self._schema}.{self._bare_table}" return [ - f"IF OBJECT_ID(N'dbo.{self._chunk_table_name}', N'U') IS NOT NULL DROP TABLE dbo.{self._chunk_table_name};", - f"IF OBJECT_ID(N'dbo.{self._table_name}', N'U') IS NOT NULL DROP TABLE dbo.{self._table_name};", + f"IF OBJECT_ID(N'{chunk_target}', N'U') IS NOT NULL DROP TABLE {chunk_target};", + f"IF OBJECT_ID(N'{table_target}', N'U') IS NOT NULL DROP TABLE {table_target};", ] @@ -329,33 +337,6 @@ def __init__(self, table_name: str) -> None: self._chunk_table_name = f"{table_name}_chunks" self._schema, self._catalog_table = split_db2_name(table_name) - def _index_name(self) -> str: - return f"IX_{self._catalog_table}_EXP" - - def _session_table_ddl(self) -> str: - return ( - f"CREATE TABLE {self._table_name} (" - "session_id VARCHAR(255) NOT NULL PRIMARY KEY, " - f"data VARCHAR({_MAX_INLINE_DATA_LENGTH}), " - "expires_at TIMESTAMP, " - "created_at TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, " - "updated_at TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP)" - ) - - def _index_ddl(self) -> str: - return f"CREATE INDEX {self._index_name()} ON {self._table_name}(expires_at)" - - def _chunk_table_ddl(self) -> str: - return ( - f"CREATE TABLE {self._chunk_table_name} (" - "session_id VARCHAR(255) NOT NULL, " - "chunk_index INTEGER NOT NULL, " - f"data VARCHAR({_MAX_INLINE_DATA_LENGTH}) NOT NULL, " - f"CONSTRAINT PK_{self._catalog_table}_CHUNKS PRIMARY KEY (session_id, chunk_index), " - f"CONSTRAINT FK_{self._catalog_table}_CHUNKS FOREIGN KEY (session_id) " - f"REFERENCES {self._table_name}(session_id) ON DELETE CASCADE)" - ) - def table_ddl(self) -> str: """Get the session table, expiry index and chunk table DDL as one script.""" return f"{self._session_table_ddl()};\n{self._index_ddl()};\n{self._chunk_table_ddl()}" @@ -421,6 +402,66 @@ def delete_expired(self) -> "tuple[str, tuple[Any, ...]]": """Get the statement deleting expired sessions.""" return (f"DELETE FROM {self._table_name} WHERE expires_at IS NOT NULL AND expires_at < ?", (_db2_now(),)) + def _index_name(self) -> str: + return f"IX_{self._catalog_table}_EXP" + + def _session_table_ddl(self) -> str: + return ( + f"CREATE TABLE {self._table_name} (" + "session_id VARCHAR(255) NOT NULL PRIMARY KEY, " + f"data VARCHAR({_MAX_INLINE_DATA_LENGTH}), " + "expires_at TIMESTAMP, " + "created_at TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, " + "updated_at TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP)" + ) + + def _index_ddl(self) -> str: + return f"CREATE INDEX {self._index_name()} ON {self._table_name}(expires_at)" + + def _chunk_table_ddl(self) -> str: + return ( + f"CREATE TABLE {self._chunk_table_name} (" + "session_id VARCHAR(255) NOT NULL, " + "chunk_index INTEGER NOT NULL, " + f"data VARCHAR({_MAX_INLINE_DATA_LENGTH}) NOT NULL, " + f"CONSTRAINT PK_{self._catalog_table}_CHUNKS PRIMARY KEY (session_id, chunk_index), " + f"CONSTRAINT FK_{self._catalog_table}_CHUNKS FOREIGN KEY (session_id) " + f"REFERENCES {self._table_name}(session_id) ON DELETE CASCADE)" + ) + + +@contextlib.contextmanager +def _atomic_write(driver: Any) -> "Iterator[None]": + if driver._connection_in_transaction(): # pyright: ignore[reportPrivateUsage] + yield + return + if not getattr(driver, "_connection_autocommit", True): + driver.begin() + try: + yield + except Exception: + with contextlib.suppress(Exception): + driver.rollback() + raise + else: + driver.commit() + + +def _strip_tsql_quotes(part: str) -> str: + stripped = part.strip() + if len(stripped) >= _QUALIFIED_PARTS_MIN and stripped.startswith("[") and stripped.endswith("]"): + return stripped[1:-1].replace("]]", "]") + if len(stripped) >= _QUALIFIED_PARTS_MIN and stripped.startswith('"') and stripped.endswith('"'): + return stripped[1:-1].replace('""', '"') + return stripped + + +def _split_tsql_table_name(table_name: str) -> "tuple[str, str]": + parts = [_strip_tsql_quotes(part) for part in split_qualified_identifier(table_name, quote_chars='"')] + if len(parts) < _QUALIFIED_PARTS_MIN: + return "dbo", parts[0] if parts else table_name + return parts[-2] or "dbo", parts[-1] + def _db2_now() -> "str | None": return db2_timestamp_text(datetime.now(UTC)) @@ -443,6 +484,14 @@ def _row_value(row: object, key: str, index: int) -> Any: def _normalize_utc(value: Any) -> "datetime | None": if value is None: return None + if isinstance(value, (bytes, bytearray)): + value = bytes(value).decode("utf-8") + if isinstance(value, str): + normalized = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value + try: + value = datetime.fromisoformat(normalized) + except ValueError: + return None if not isinstance(value, datetime): return None if value.tzinfo is None: diff --git a/sqlspec/adapters/arrow_odbc/type_converter.py b/sqlspec/adapters/arrow_odbc/type_converter.py index 250e52ea6..b239327da 100644 --- a/sqlspec/adapters/arrow_odbc/type_converter.py +++ b/sqlspec/adapters/arrow_odbc/type_converter.py @@ -2,13 +2,18 @@ from typing import TYPE_CHECKING, Any, Final, cast +from sqlspec.typing import import_optional from sqlspec.utils.module_loader import ensure_pyarrow if TYPE_CHECKING: import pyarrow as pa +else: + pa = import_optional("pyarrow") __all__ = ("odbc_type_to_arrow",) +_MAX_DECIMAL128_PRECISION: Final[int] = 38 +_MAX_DECIMAL256_PRECISION: Final[int] = 76 _ODBC_ARROW_TYPE_SPECS: Final[dict[str, tuple[str, tuple[Any, ...], dict[str, Any]]]] = { "bit": ("bool_", (), {}), "boolean": ("bool_", (), {}), @@ -65,7 +70,11 @@ def odbc_type_to_arrow(sql_type: str, *, precision: int | None = None, scale: in """Resolve an ODBC SQL type name to an Arrow data type.""" normalized_type = sql_type.lower().split("(", 1)[0].strip() if normalized_type in {"decimal", "numeric"} and precision is not None and scale is not None: - return _arrow_type("decimal128", (precision, scale)) + if 1 <= precision <= _MAX_DECIMAL128_PRECISION and 0 <= scale <= precision: + return _arrow_type("decimal128", (precision, scale)) + if _MAX_DECIMAL128_PRECISION < precision <= _MAX_DECIMAL256_PRECISION and 0 <= scale <= precision: + return _arrow_type("decimal256", (precision, scale)) + return _arrow_type("string") spec = _ODBC_ARROW_TYPE_SPECS.get(normalized_type) if spec is None: return _arrow_type("string") @@ -81,8 +90,6 @@ def _arrow_type(name: str, args: tuple[Any, ...] = (), kwargs: dict[str, Any] | return cast("pa.DataType", cached) ensure_pyarrow() - import pyarrow as pa - resolved = getattr(pa, name)(*args, **(kwargs or {})) _ARROW_TYPE_CACHE[cache_key] = resolved return cast("pa.DataType", resolved) diff --git a/sqlspec/adapters/asyncmy/__init__.py b/sqlspec/adapters/asyncmy/__init__.py index 36a3b9835..b6e1518d7 100644 --- a/sqlspec/adapters/asyncmy/__init__.py +++ b/sqlspec/adapters/asyncmy/__init__.py @@ -1,3 +1,5 @@ +"""AsyncMy database adapter for SQLSpec.""" + from sqlspec.adapters.asyncmy._typing import AsyncmyConnection, AsyncmyCursor from sqlspec.adapters.asyncmy.config import ( AsyncmyConfig, diff --git a/sqlspec/adapters/asyncmy/_typing.py b/sqlspec/adapters/asyncmy/_typing.py index 6f67a481e..0ebf568b0 100644 --- a/sqlspec/adapters/asyncmy/_typing.py +++ b/sqlspec/adapters/asyncmy/_typing.py @@ -8,19 +8,19 @@ import os from typing import TYPE_CHECKING, Any -import asyncmy as _asyncmy # pyright: ignore -from asyncmy import Connection # pyright: ignore -from asyncmy import errors as _asyncmy_errors # pyright: ignore -from asyncmy.connection import LoadLocalFile # pyright: ignore -from asyncmy.connection import MySQLResult as _AsyncmyResult # pyright: ignore -from asyncmy.constants import FIELD_TYPE as _ASYNCMY_FIELD_TYPE # pyright: ignore +import asyncmy as _asyncmy +from asyncmy import Connection +from asyncmy import errors as _asyncmy_errors +from asyncmy.connection import LoadLocalFile +from asyncmy.connection import MySQLResult as _AsyncmyResult +from asyncmy.constants import FIELD_TYPE as _ASYNCMY_FIELD_TYPE from asyncmy.cursors import RE_INSERT_VALUES as ASYNCMY_INSERT_VALUES_PATTERN -from asyncmy.cursors import Cursor as _AsyncmyCursor # pyright: ignore -from asyncmy.cursors import DictCursor as _AsyncmyDictCursor # pyright: ignore +from asyncmy.cursors import Cursor as _AsyncmyCursor +from asyncmy.cursors import DictCursor as _AsyncmyDictCursor from asyncmy.cursors import SSCursor as AsyncmySSCursor -from asyncmy.errors import ProgrammingError as AsyncmyProgrammingError -from asyncmy.pool import Pool as _AsyncmyPool # pyright: ignore -from asyncmy.protocol import LoadLocalPacketWrapper as _LoadLocalPacketWrapper # pyright: ignore +from asyncmy.errors import ProgrammingError as _AsyncmyProgrammingError +from asyncmy.pool import Pool as _AsyncmyPool +from asyncmy.protocol import LoadLocalPacketWrapper as _LoadLocalPacketWrapper from sqlspec.exceptions import SQLSpecError @@ -32,8 +32,21 @@ from sqlspec.adapters.asyncmy.driver import AsyncmyDriver from sqlspec.core import StatementConfig + class AsyncmyMySQLError(Exception): + errno: int + + class AsyncmyError(AsyncmyMySQLError): + pass + + class AsyncmyProgrammingError(AsyncmyError): + pass + class AsyncmyConnectionProtocol(Protocol): - def cursor(self) -> "AsyncmyRawCursor": ... + server_status: int + + def cursor(self, cursor: "type[Any] | None" = None) -> "AsyncmyRawCursor": ... + + async def begin(self) -> Any: ... async def commit(self) -> object: ... @@ -51,9 +64,7 @@ class AsyncmyFieldTypeProtocol(Protocol): AsyncmyConnection: TypeAlias = AsyncmyConnectionProtocol AsyncmyDictCursor: TypeAlias = _AsyncmyDictCursor - AsyncmyError: TypeAlias = _asyncmy_errors.Error AsyncmyFieldType: TypeAlias = AsyncmyFieldTypeProtocol - AsyncmyMySQLError: TypeAlias = _asyncmy_errors.MySQLError AsyncmyModule: TypeAlias = AsyncmyModuleProtocol AsyncmyPool: TypeAlias = _AsyncmyPool AsyncmyRawCursor: TypeAlias = _AsyncmyCursor @@ -64,6 +75,7 @@ class AsyncmyFieldTypeProtocol(Protocol): AsyncmyError = _asyncmy_errors.Error AsyncmyFieldType = _ASYNCMY_FIELD_TYPE AsyncmyMySQLError = _asyncmy_errors.MySQLError + AsyncmyProgrammingError = _AsyncmyProgrammingError AsyncmyModule = _asyncmy AsyncmyPool = _AsyncmyPool AsyncmyRawCursor = _AsyncmyCursor @@ -85,6 +97,8 @@ class AsyncmyFieldTypeProtocol(Protocol): "asyncmy_local_infile", ) +_resolved_driver_type: "type[AsyncmyDriver] | None" = None + class AsyncmyCursor: """Context manager for AsyncMy cursor operations. @@ -92,20 +106,31 @@ class AsyncmyCursor: Provides automatic cursor acquisition and cleanup for database operations. """ - __slots__ = ("connection", "cursor") + __slots__ = ("connection", "cursor", "cursor_class") - def __init__(self, connection: "AsyncmyConnection") -> None: + def __init__(self, connection: "AsyncmyConnection", cursor_class: "type[AsyncmyRawCursor] | None" = None) -> None: self.connection = connection + self.cursor_class = cursor_class self.cursor: AsyncmyRawCursor | None = None async def __aenter__(self) -> "AsyncmyRawCursor": - self.cursor = self.connection.cursor() + if self.cursor_class is None: + self.cursor = self.connection.cursor() + else: + try: + self.cursor = self.connection.cursor(self.cursor_class) + except TypeError: + self.cursor = self.connection.cursor() return self.cursor async def __aexit__(self, *_: object) -> None: if self.cursor is not None: with contextlib.suppress(Exception): - await self.cursor.close() + raw_cursor: Any = self.cursor + if hasattr(raw_cursor, "close"): + await raw_cursor.close() + elif hasattr(raw_cursor, "__aexit__"): + await raw_cursor.__aexit__(None, None, None) class AsyncmySessionContext: @@ -146,10 +171,9 @@ def __init__( self._driver: AsyncmyDriver | None = None async def __aenter__(self) -> "AsyncmyDriver": - from sqlspec.adapters.asyncmy.driver import AsyncmyDriver - + driver_cls = _get_asyncmy_driver_type() self._connection = await self._acquire_connection() - self._driver = AsyncmyDriver( + self._driver = driver_cls( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -180,16 +204,16 @@ def asyncmy_local_infile(connection: "AsyncmyConnection", filename: str) -> "Ite async def read_result(unbuffered: bool = False) -> None: raw._result = None - result = _AsyncmyLocalInfileResult(raw, filename) + result: Any = _AsyncmyLocalInfileResult(raw, filename) if unbuffered: try: - await result.init_unbuffered_query() # type: ignore[no-untyped-call] + await result.init_unbuffered_query() except BaseException: result.unbuffered_active = False result.connection = None raise else: - await result.read() # type: ignore[no-untyped-call] + await result.read() raw._result = result raw._affected_rows = result.affected_rows if result.server_status: @@ -224,9 +248,20 @@ async def _read_load_local_packet(self, first_packet: Any) -> None: if not self.connection._local_infile or os.fsdecode(request) != self._filename: msg = "MySQL requested an unexpected LOCAL INFILE payload." raise SQLSpecError(msg) - await LoadLocalFile(self._filename, self.connection).send_data() # type: ignore[no-untyped-call] + sender: Any = LoadLocalFile(self._filename, self.connection) + await sender.send_data() packet = await self.connection.read_packet() if not packet.is_ok_packet(): msg = "MySQL did not acknowledge the LOCAL INFILE payload." raise SQLSpecError(msg) - self._read_ok_packet(packet) # type: ignore[attr-defined] + raw_self: Any = self + raw_self._read_ok_packet(packet) + + +def _get_asyncmy_driver_type() -> "type[AsyncmyDriver]": + global _resolved_driver_type + if _resolved_driver_type is None: + from sqlspec.adapters.asyncmy.driver import AsyncmyDriver as _Driver + + _resolved_driver_type = _Driver + return _resolved_driver_type diff --git a/sqlspec/adapters/asyncmy/adk/store.py b/sqlspec/adapters/asyncmy/adk/store.py index 3f6be8238..3b335dc4b 100644 --- a/sqlspec/adapters/asyncmy/adk/store.py +++ b/sqlspec/adapters/asyncmy/adk/store.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, cast -from sqlspec.adapters.asyncmy._typing import AsyncmyProgrammingError +from sqlspec.adapters.asyncmy._typing import AsyncmyCursor, AsyncmyProgrammingError, AsyncmyRawCursor from sqlspec.adapters.asyncmy.core import resolve_rowcount from sqlspec.config import ADKConfig from sqlspec.extensions.adk import BaseAsyncADKStore, StoredEvent, StoredSession, normalize_session_list_options @@ -12,7 +12,7 @@ from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Sequence from datetime import datetime, timedelta from sqlspec.adapters.asyncmy.config import AsyncmyConfig @@ -55,11 +55,12 @@ class AsyncmyADKConfig(ADKConfig): class AsyncmyADKStore(BaseAsyncADKStore["AsyncmyConfig"]): """MySQL/MariaDB ADK store using AsyncMy driver.""" - __slots__ = () + __slots__ = ("_json_deserializer", "_json_serializer") def __init__(self, config: "AsyncmyConfig") -> None: """Initialize AsyncMy ADK store.""" super().__init__(config) + self._json_serializer, self._json_deserializer = _resolve_json_codecs(config) async def create_tables(self) -> None: """Create all ADK session tables if they don't exist.""" @@ -78,21 +79,25 @@ async def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: """Create a new session.""" + state_json = self._json_serializer(state) params: tuple[Any, ...] if self._owner_id_column_name: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, {self._owner_id_column_name}, state, create_time, update_time) VALUES (%s, %s, %s, %s, %s, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) """ - params = (session_id, app_name, user_id, owner_id, to_json(state)) + params = (session_id, app_name, user_id, owner_id, state_json) else: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time) VALUES (%s, %s, %s, %s, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) """ - params = (session_id, app_name, user_id, to_json(state)) + params = (session_id, app_name, user_id, state_json) - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, params) await conn.commit() @@ -107,7 +112,10 @@ async def get_session( ) -> "StoredSession | None": """Get session by scoped identifiers.""" try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): if renew_for is not None and self._calculate_expires_at(renew_for) is not None: await cursor.execute( f""" @@ -128,8 +136,8 @@ async def get_session( (app_name, user_id, session_id), ) row = await cursor.fetchone() - return _session_record_from_row(row) if row is not None else None - except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] + return _session_record_from_row(row, self._json_deserializer) if row is not None else None + except AsyncmyProgrammingError as exc: if _is_mysql_table_missing(exc): return None raise @@ -142,8 +150,11 @@ async def update_session_state(self, app_name: str, user_id: str, session_id: st WHERE app_name = %s AND user_id = %s AND id = %s """ - async with self._config.provide_connection() as conn, conn.cursor() as cursor: - await cursor.execute(sql, (to_json(state), app_name, user_id, session_id)) + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): + await cursor.execute(sql, (self._json_serializer(state), app_name, user_id, session_id)) await conn.commit() async def list_sessions( @@ -166,11 +177,14 @@ async def list_sessions( ) try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, params) rows = await cursor.fetchall() - return [_session_record_from_row(row) for row in rows] - except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] + return [_session_record_from_row(row, self._json_deserializer) for row in rows] + except AsyncmyProgrammingError as exc: if _is_mysql_table_missing(exc): return [] raise @@ -179,7 +193,10 @@ async def delete_session(self, app_name: str, user_id: str, session_id: str) -> """Delete session and all associated events.""" sql = f"DELETE FROM {self._session_table} WHERE app_name = %s AND user_id = %s AND id = %s" - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, (app_name, user_id, session_id)) await conn.commit() @@ -191,8 +208,11 @@ async def append_event(self, event_record: StoredEvent) -> None: ) VALUES (%s, %s, %s, %s, %s, %s, %s) """ - async with self._config.provide_connection() as conn, conn.cursor() as cursor: - await cursor.execute(sql, _event_insert_params(event_record)) + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): + await cursor.execute(sql, _event_insert_params(event_record, self._json_serializer)) await conn.commit() async def append_event_and_update_state( @@ -223,9 +243,12 @@ async def append_event_and_update_state( WHERE app_name = %s AND user_id = %s AND id = %s """ - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): try: - await cursor.execute(update_sql, (to_json(state), app_name, user_id, session_id)) + await cursor.execute(update_sql, (self._json_serializer(state), app_name, user_id, session_id)) await cursor.execute(select_sql, (app_name, user_id, session_id)) row = await cursor.fetchone() if row is None: @@ -239,23 +262,24 @@ async def append_event_and_update_state( session_id, event_record["invocation_id"], event_record["timestamp"], - _json_for_storage(event_record["event_data"]), + _json_for_storage(event_record["event_data"], self._json_serializer), ), ) if app_state is not None: await cursor.execute( - _mysql_upsert_app_state_sql(self._app_state_table), (app_name, to_json(app_state)) + _mysql_upsert_app_state_sql(self._app_state_table), (app_name, self._json_serializer(app_state)) ) if user_state is not None: await cursor.execute( - _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, to_json(user_state)) + _mysql_upsert_user_state_sql(self._user_state_table), + (app_name, user_id, self._json_serializer(user_state)), ) await conn.commit() except Exception: await conn.rollback() raise - return _session_record_from_row(row) + return _session_record_from_row(row, self._json_deserializer) async def get_events( self, @@ -287,11 +311,14 @@ async def get_events( """ try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, params) rows = await cursor.fetchall() - return [_event_record_from_row(row) for row in rows] - except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] + return [_event_record_from_row(row, self._json_deserializer) for row in rows] + except AsyncmyProgrammingError as exc: if _is_mysql_table_missing(exc): return [] raise @@ -305,11 +332,14 @@ async def delete_expired_events(self, before: "datetime", app_name: "str | None" params.append(app_name) try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, tuple(params)) await conn.commit() return resolve_rowcount(cursor) - except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] + except AsyncmyProgrammingError as exc: if _is_mysql_table_missing(exc): return 0 raise @@ -323,11 +353,14 @@ async def delete_idle_sessions(self, updated_before: "datetime", app_name: "str params.append(app_name) try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, tuple(params)) await conn.commit() return resolve_rowcount(cursor) - except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] + except AsyncmyProgrammingError as exc: if _is_mysql_table_missing(exc): return 0 raise @@ -341,11 +374,14 @@ async def delete_idle_user_states(self, updated_before: "datetime", app_name: "s params.append(app_name) try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, tuple(params)) await conn.commit() return resolve_rowcount(cursor) - except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] + except AsyncmyProgrammingError as exc: if _is_mysql_table_missing(exc): return 0 raise @@ -355,11 +391,14 @@ async def get_app_state(self, app_name: str) -> "dict[str, Any] | None": sql = f"SELECT state FROM {self._app_state_table} WHERE app_name = %s" try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, (app_name,)) row = await cursor.fetchone() - return _json_dict(row[0]) if row is not None else None - except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] + return _json_dict(_row_first_value(row, "state"), self._json_deserializer) if row is not None else None + except AsyncmyProgrammingError as exc: if _is_mysql_table_missing(exc): return None raise @@ -369,26 +408,37 @@ async def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | sql = f"SELECT state FROM {self._user_state_table} WHERE app_name = %s AND user_id = %s" try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, (app_name, user_id)) row = await cursor.fetchone() - return _json_dict(row[0]) if row is not None else None - except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] + return _json_dict(_row_first_value(row, "state"), self._json_deserializer) if row is not None else None + except AsyncmyProgrammingError as exc: if _is_mysql_table_missing(exc): return None raise async def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: """Insert or replace app-scoped state for an application.""" - async with self._config.provide_connection() as conn, conn.cursor() as cursor: - await cursor.execute(_mysql_upsert_app_state_sql(self._app_state_table), (app_name, to_json(state))) + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): + await cursor.execute( + _mysql_upsert_app_state_sql(self._app_state_table), (app_name, self._json_serializer(state)) + ) await conn.commit() async def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: """Insert or replace user-scoped state for an application user.""" - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute( - _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, to_json(state)) + _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, self._json_serializer(state)) ) await conn.commit() @@ -397,18 +447,24 @@ async def get_metadata(self, key: str) -> "str | None": sql = f"SELECT value FROM {self._metadata_table} WHERE `key` = %s" try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, (key,)) row = await cursor.fetchone() - return str(row[0]) if row is not None else None - except AsyncmyProgrammingError as exc: # pyright: ignore[reportAttributeAccessIssue] + return str(_row_first_value(row, "value")) if row is not None else None + except AsyncmyProgrammingError as exc: if _is_mysql_table_missing(exc): return None raise async def set_metadata(self, key: str, value: str) -> None: """Set a value in the ADK internal metadata table.""" - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(_mysql_upsert_metadata_sql(self._metadata_table), (key, value)) await conn.commit() @@ -464,11 +520,12 @@ def _drop_tables_sql(self) -> "list[str]": class AsyncmyADKMemoryStore(BaseAsyncADKMemoryStore["AsyncmyConfig"]): """MySQL/MariaDB ADK memory store using AsyncMy driver.""" - __slots__ = () + __slots__ = ("_json_deserializer", "_json_serializer") def __init__(self, config: "AsyncmyConfig") -> None: """Initialize AsyncMy memory store.""" super().__init__(config) + self._json_serializer, self._json_deserializer = _resolve_json_codecs(config) async def create_tables(self) -> None: """Create the memory table and indexes if they don't exist.""" @@ -509,8 +566,11 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " """ async with self._config.provide_connection() as conn: - async with conn.cursor() as cursor: + async with AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor: for entry in entries: + content_json = self._json_serializer(entry["content_json"]) + meta = entry.get("metadata_json") + metadata_json = self._json_serializer(meta) if meta is not None else None params: tuple[Any, ...] if self._owner_id_column_name: params = ( @@ -523,9 +583,9 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " entry.get("author"), owner_id, entry["timestamp"], - to_json(entry["content_json"]), + content_json, entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + metadata_json, entry["inserted_at"], ) else: @@ -538,9 +598,9 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " entry["event_id"], entry.get("author"), entry["timestamp"], - to_json(entry["content_json"]), + content_json, entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + metadata_json, entry["inserted_at"], ) await cursor.execute(sql, params) @@ -585,17 +645,28 @@ async def search_entries( """ params = (*scope_params, f"%{query}%", limit_value) - async with self._config.provide_connection() as conn, conn.cursor() as cursor: - await cursor.execute(sql, params) - rows = await cursor.fetchall() - columns = [col[0] for col in cursor.description or []] + try: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): + await cursor.execute(sql, params) + rows = await cursor.fetchall() + columns = [col[0] for col in cursor.description or []] + except AsyncmyProgrammingError as exc: + if _is_mysql_table_missing(exc): + return [] + raise records: list[StoredMemory] = [] for row in rows: - rec = cast("StoredMemory", dict(zip(columns, row, strict=False))) - rec["content_json"] = _json_dict(rec.get("content_json")) + row_dict = dict(row) if isinstance(row, Mapping) else dict(zip(columns, row, strict=False)) + rec = cast("StoredMemory", row_dict) + rec["content_json"] = _json_dict(rec.get("content_json"), self._json_deserializer) metadata_val = rec.get("metadata_json") - rec["metadata_json"] = _json_dict(metadata_val) if metadata_val is not None else None + rec["metadata_json"] = ( + _json_dict(metadata_val, self._json_deserializer) if metadata_val is not None else None + ) rec["embedding"] = None records.append(rec) return records @@ -607,10 +678,18 @@ async def delete_entries_by_session(self, session_id: str) -> int: raise RuntimeError(msg) sql = f"DELETE FROM {self._memory_table} WHERE session_id = %s" - async with self._config.provide_connection() as conn, conn.cursor() as cursor: - await cursor.execute(sql, (session_id,)) - await conn.commit() - return resolve_rowcount(cursor) + try: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): + await cursor.execute(sql, (session_id,)) + await conn.commit() + return resolve_rowcount(cursor) + except AsyncmyProgrammingError as exc: + if _is_mysql_table_missing(exc): + return 0 + raise async def delete_entries_older_than( self, days: int, app_name: "str | None" = None, scope: "str | None" = None @@ -631,10 +710,18 @@ async def delete_entries_older_than( where_sql = " AND ".join(clauses) sql = f"DELETE FROM {self._memory_table} WHERE {where_sql}" - async with self._config.provide_connection() as conn, conn.cursor() as cursor: - await cursor.execute(sql, tuple(params)) - await conn.commit() - return resolve_rowcount(cursor) + try: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): + await cursor.execute(sql, tuple(params)) + await conn.commit() + return resolve_rowcount(cursor) + except AsyncmyProgrammingError as exc: + if _is_mysql_table_missing(exc): + return 0 + raise async def _memory_table_ddl(self) -> str: """Get MySQL CREATE TABLE SQL for memory entries.""" @@ -677,6 +764,24 @@ def _drop_memory_table_sql(self) -> "list[str]": return [f"DROP TABLE IF EXISTS {self._memory_table}"] +def _resolve_json_codecs(config: Any) -> "tuple[Callable[[Any], str], Callable[[Any], Any]]": + driver_features = getattr(config, "driver_features", None) + if isinstance(driver_features, Mapping): + serializer = driver_features.get("json_serializer", to_json) + deserializer = driver_features.get("json_deserializer", from_json) + return cast( + "tuple[Callable[[Any], str], Callable[[Any], Any]]", + (serializer if callable(serializer) else to_json, deserializer if callable(deserializer) else from_json), + ) + return to_json, from_json + + +def _row_first_value(row: Any, key: str) -> Any: + if isinstance(row, Mapping): + return row[key] + return row[0] + + def _mysql_owner_id_column_parts(column_ddl: str) -> "tuple[str, str]": """Parse owner ID column DDL for MySQL FOREIGN KEY syntax. @@ -727,25 +832,49 @@ def _is_mysql_table_missing(exc: BaseException) -> bool: ) -def _json_for_storage(value: Any) -> str: - return value if isinstance(value, str) else to_json(value) +def _json_for_storage(value: Any, serializer: "Callable[[Any], str]" = to_json) -> str: + return value if isinstance(value, str) else serializer(value) -def _json_dict(value: Any) -> "dict[str, Any]": - if isinstance(value, bytearray): +def _json_dict(value: Any, deserializer: "Callable[[Any], Any]" = from_json) -> "dict[str, Any]": + if isinstance(value, (bytearray, memoryview)): value = bytes(value) if isinstance(value, (bytes, str)): - return cast("dict[str, Any]", from_json(value)) + return cast("dict[str, Any]", deserializer(value)) return cast("dict[str, Any]", value) -def _session_record_from_row(row: Any) -> StoredSession: +def _session_record_from_row(row: Any, deserializer: "Callable[[Any], Any]" = from_json) -> StoredSession: + if isinstance(row, Mapping): + return StoredSession( + id=row["id"], + app_name=row["app_name"], + user_id=row["user_id"], + state=_json_dict(row["state"], deserializer), + create_time=row["create_time"], + update_time=row["update_time"], + ) return StoredSession( - id=row[0], app_name=row[1], user_id=row[2], state=_json_dict(row[3]), create_time=row[4], update_time=row[5] + id=row[0], + app_name=row[1], + user_id=row[2], + state=_json_dict(row[3], deserializer), + create_time=row[4], + update_time=row[5], ) -def _event_record_from_row(row: Any) -> StoredEvent: +def _event_record_from_row(row: Any, deserializer: "Callable[[Any], Any]" = from_json) -> StoredEvent: + if isinstance(row, Mapping): + return StoredEvent( + id=row["id"], + app_name=row["app_name"], + user_id=row["user_id"], + session_id=row["session_id"], + invocation_id=row["invocation_id"], + timestamp=row["timestamp"], + event_data=_json_dict(row["event_data"], deserializer), + ) return StoredEvent( id=row[0], app_name=row[1], @@ -753,11 +882,11 @@ def _event_record_from_row(row: Any) -> StoredEvent: session_id=row[3], invocation_id=row[4], timestamp=row[5], - event_data=_json_dict(row[6]), + event_data=_json_dict(row[6], deserializer), ) -def _event_insert_params(event_record: StoredEvent) -> "tuple[Any, ...]": +def _event_insert_params(event_record: StoredEvent, serializer: "Callable[[Any], str]" = to_json) -> "tuple[Any, ...]": return ( event_record["id"], event_record["app_name"], @@ -765,7 +894,7 @@ def _event_insert_params(event_record: StoredEvent) -> "tuple[Any, ...]": event_record["session_id"], event_record["invocation_id"], event_record["timestamp"], - _json_for_storage(event_record["event_data"]), + _json_for_storage(event_record["event_data"], serializer), ) diff --git a/sqlspec/adapters/asyncmy/config.py b/sqlspec/adapters/asyncmy/config.py index 5f1a74b86..e8e53f942 100644 --- a/sqlspec/adapters/asyncmy/config.py +++ b/sqlspec/adapters/asyncmy/config.py @@ -2,7 +2,7 @@ import contextlib import inspect -from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, TypedDict, cast +from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NotRequired, TypedDict, cast from weakref import WeakSet from mypy_extensions import mypyc_attr @@ -50,16 +50,6 @@ asyncmy: "AsyncmyModule" = cast("AsyncmyModule", AsyncmyModule) -def _connect_parameter_names() -> "frozenset[str]": - try: - return frozenset(inspect.signature(asyncmy.connect).parameters) - except (TypeError, ValueError): - return frozenset() - - -_ASYNCMY_CONNECT_PARAMETER_NAMES = _connect_parameter_names() - - class AsyncmySSLParams(TypedDict): """Asyncmy TLS parameters.""" @@ -121,47 +111,6 @@ class AsyncmyPoolParams(AsyncmyConnectionParams): pool_recycle: NotRequired[int] -def _normalize_connection_config(connection_config: "Mapping[str, Any] | None") -> "dict[str, Any]": - """Normalize SQLSpec asyncmy config keys before storing them.""" - config = normalize_connection_config(connection_config) - - if "cursor_class" in config: - cursor_class = config.pop("cursor_class") - existing_cursor_cls = config.get("cursor_cls") - if existing_cursor_cls is not None and existing_cursor_cls is not cursor_class: - msg = "Asyncmy connection_config received conflicting 'cursor_cls' and legacy 'cursor_class' values." - raise ImproperConfigurationError(msg) - config["cursor_cls"] = cursor_class - - allow_local_infile = bool(config.pop("allow_local_infile", False)) - config["local_infile"] = bool(config.get("local_infile", False) or allow_local_infile) - - return config - - -def _split_pool_config(connection_config: "Mapping[str, Any]") -> "tuple[dict[str, Any], dict[str, Any]]": - """Split pool constructor settings from connection settings.""" - pool_kwargs: dict[str, Any] = {} - connection_kwargs: dict[str, Any] = {} - - for key, value in connection_config.items(): - if value is None: - continue - if key in _ASYNCMY_POOL_KEYS: - pool_kwargs[key] = value - continue - if key == "write_timeout" and key not in _ASYNCMY_CONNECT_PARAMETER_NAMES: - continue - connection_kwargs[key] = value - - return pool_kwargs, connection_kwargs - - -def _pool_config(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": - pool_kwargs, connection_kwargs = _split_pool_config(connection_config) - return {**connection_kwargs, **pool_kwargs} - - def build_connection_config( connection_config: "AsyncmyPoolParams | dict[str, Any] | Mapping[str, Any] | None", ) -> dict[str, Any]: @@ -171,6 +120,8 @@ def build_connection_config( user_alias = config.pop("username", None) if user_alias is not None and "user" not in config: config["user"] = user_alias + if "database" in config and "db" in config: + config.pop("db", None) if dsn is not None and isinstance(dsn, str): dsn_params = parse_mysql_dsn(dsn) for key, value in dsn_params.items(): @@ -227,10 +178,7 @@ def __init__(self, config: "AsyncmyConfig") -> None: self._contexts: dict[int, Any] = {} async def acquire_connection(self) -> "AsyncmyConnection": - pool = self._config.connection_instance - if pool is None: - pool = await self._config.create_pool() - self._config.connection_instance = pool + pool = await self._config.provide_pool() ctx = pool.acquire() connection = cast("AsyncmyConnection", await ctx.__aenter__()) self._contexts[id(connection)] = ctx @@ -247,6 +195,9 @@ async def acquire_connection(self) -> "AsyncmyConnection": async def release_connection(self, _conn: "AsyncmyConnection", **kwargs: Any) -> None: ctx = self._contexts.pop(id(_conn), None) if ctx is not None: + if _conn is not None and bool(getattr(_conn, "server_status", 0) & 1): + with contextlib.suppress(Exception): + await _conn.rollback() await ctx.__aexit__(kwargs.get("exc_type"), kwargs.get("exc_val"), kwargs.get("exc_tb")) @@ -260,10 +211,7 @@ def __init__(self, config: "AsyncmyConfig") -> None: self._ctx: Any = None async def __aenter__(self) -> AsyncmyConnection: - pool = self._config.connection_instance - if pool is None: - pool = await self._config.create_pool() - self._config.connection_instance = pool + pool = await self._config.provide_pool() ctx = pool.acquire() self._ctx = ctx connection = cast("AsyncmyConnection", await ctx.__aenter__()) @@ -282,7 +230,11 @@ async def __aenter__(self) -> AsyncmyConnection: async def __aexit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> bool | None: + conn = self._connection self._connection = None + if conn is not None and bool(getattr(conn, "server_status", 0) & 1): + with contextlib.suppress(Exception): + await conn.rollback() if self._ctx: ctx = self._ctx self._ctx = None @@ -294,6 +246,8 @@ async def __aexit__( class AsyncmyConfig(AsyncDatabaseConfig[AsyncmyConnection, "AsyncmyPool", AsyncmyDriver]): """Configuration for Asyncmy database connections.""" + __slots__ = ("_initialized_connections", "_user_connection_hook") + driver_type: ClassVar[type[AsyncmyDriver]] = AsyncmyDriver connection_type: "ClassVar[type[Any]]" = cast("type[Any]", AsyncmyConnection) supports_transactional_ddl: ClassVar[bool] = False @@ -363,35 +317,6 @@ def __init__( **kwargs, ) - async def _create_pool(self) -> "AsyncmyPool": - """Create the actual async connection pool. - - MySQL/MariaDB handle JSON types natively without requiring connection-level - type handlers. JSON serialization is handled via type_coercion_map in the - driver's statement_config (see driver.py). - - Future driver_features can be added here if needed. - """ - return await asyncmy.create_pool(**_pool_config(self.connection_config)) - - async def _ensure_connection(self, connection: "AsyncmyConnection") -> None: - """Ensure connection callback has been called exactly once for this connection. - - Uses WeakSet tracking to ensure the callback runs once per physical connection. - """ - if self._user_connection_hook is None: - return - if connection not in self._initialized_connections: - await self._user_connection_hook(connection) - self._initialized_connections.add(connection) - - async def _close_pool(self) -> None: - """Close the actual async connection pool.""" - if self.connection_instance: - self.connection_instance.close() - await self.connection_instance.wait_closed() - self.connection_instance = None - async def create_connection(self) -> AsyncmyConnection: """Open a standalone connection owned by the caller. @@ -445,3 +370,116 @@ def get_event_runtime_hints(self) -> "EventRuntimeHints": """Return queue polling defaults for Asyncmy adapters.""" return EventRuntimeHints(poll_interval=0.25, lease_seconds=5, select_for_update=True, skip_locked=True) + + async def _create_pool(self) -> "AsyncmyPool": + """Create the actual async connection pool. + + MySQL/MariaDB handle JSON types natively without requiring connection-level + type handlers. JSON serialization is handled via type_coercion_map in the + driver's statement_config (see driver.py). + + Future driver_features can be added here if needed. + """ + return await asyncmy.create_pool(**_pool_config(self.connection_config)) + + async def _ensure_connection(self, connection: "AsyncmyConnection") -> None: + """Ensure connection callback has been called exactly once for this connection. + + Uses WeakSet tracking to ensure the callback runs once per physical connection. + """ + if self._user_connection_hook is None: + return + if connection not in self._initialized_connections: + await self._user_connection_hook(connection) + self._initialized_connections.add(connection) + + async def _close_pool(self) -> None: + """Close the actual async connection pool.""" + if self.connection_instance: + self.connection_instance.close() + await self.connection_instance.wait_closed() + self.connection_instance = None + + +_FALLBACK_ASYNCMY_CONNECT_PARAMS: Final[frozenset[str]] = frozenset({ + "host", + "user", + "password", + "database", + "db", + "port", + "unix_socket", + "charset", + "sql_mode", + "read_default_file", + "conv", + "use_unicode", + "client_flag", + "cursor_cls", + "init_command", + "connect_timeout", + "read_default_group", + "autocommit", + "local_infile", + "max_allowed_packet", + "auth_plugin_map", + "read_timeout", + "binary_prefix", + "program_name", + "server_public_key", + "ssl", + "stmt_cache_size", + "echo", +}) + + +def _connect_parameter_names() -> "frozenset[str]": + try: + names = frozenset(inspect.signature(asyncmy.connect).parameters) + except (TypeError, ValueError): + return _FALLBACK_ASYNCMY_CONNECT_PARAMS + return names or _FALLBACK_ASYNCMY_CONNECT_PARAMS + + +_ASYNCMY_CONNECT_PARAMETER_NAMES = _connect_parameter_names() + + +def _normalize_connection_config(connection_config: "Mapping[str, Any] | None") -> "dict[str, Any]": + """Normalize SQLSpec asyncmy config keys before storing them.""" + config = normalize_connection_config(connection_config) + + if "cursor_class" in config: + cursor_class = config.pop("cursor_class") + existing_cursor_cls = config.get("cursor_cls") + if existing_cursor_cls is not None and existing_cursor_cls is not cursor_class: + msg = "Asyncmy connection_config received conflicting 'cursor_cls' and legacy 'cursor_class' values." + raise ImproperConfigurationError(msg) + config["cursor_cls"] = cursor_class + + allow_local_infile = bool(config.pop("allow_local_infile", False)) + config["local_infile"] = bool(config.get("local_infile", False) or allow_local_infile) + + return config + + +def _split_pool_config(connection_config: "Mapping[str, Any]") -> "tuple[dict[str, Any], dict[str, Any]]": + """Split pool constructor settings from connection settings.""" + pool_kwargs: dict[str, Any] = {} + connection_kwargs: dict[str, Any] = {} + + for key, value in connection_config.items(): + if value is None: + continue + if key in _ASYNCMY_POOL_KEYS: + pool_kwargs[key] = value + continue + if key == "write_timeout" and key not in _ASYNCMY_CONNECT_PARAMETER_NAMES: + continue + connection_kwargs[key] = value + + return pool_kwargs, connection_kwargs + + +def _pool_config(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": + pool_kwargs, connection_kwargs = _split_pool_config(connection_config) + return {**connection_kwargs, **pool_kwargs} diff --git a/sqlspec/adapters/asyncmy/core.py b/sqlspec/adapters/asyncmy/core.py index 536b7d478..bc5212d2c 100644 --- a/sqlspec/adapters/asyncmy/core.py +++ b/sqlspec/adapters/asyncmy/core.py @@ -8,6 +8,7 @@ from sqlspec.adapters.mysql_common import ( bool_to_int, build_insert_statement, + can_use_local_infile_records, collect_rows, collect_stream_rows, create_mapped_exception, @@ -25,6 +26,7 @@ resolve_rowcount, ) from sqlspec.core import DriverParameterProfile, ParameterStyle, StatementConfig, build_statement_config_from_profile +from sqlspec.utils.logging import get_logger from sqlspec.utils.serializers import from_json, to_json from sqlspec.utils.type_converters import build_uuid_coercions @@ -38,6 +40,7 @@ "build_load_data_statement", "build_profile", "build_statement_config", + "can_use_local_infile_records", "collect_rows", "collect_stream_rows", "create_mapped_exception", @@ -57,6 +60,8 @@ "resolve_rowcount", ) +logger = get_logger("sqlspec.adapters.asyncmy") + _MYSQL_ACCESS_ERROR_DISPATCH = mysql_common.MYSQL_ACCESS_ERROR_DISPATCH _MYSQL_CONNECTION_ERROR_DISPATCH = mysql_common.MYSQL_CONNECTION_ERROR_DISPATCH _MYSQL_CONSTRAINT_ERROR_DISPATCH = mysql_common.MYSQL_CONSTRAINT_ERROR_DISPATCH @@ -73,7 +78,16 @@ def build_load_data_statement(table: str, columns: "list[str]") -> str: class AsyncmyStreamSource: """Compiled async chunk source streaming dict rows from an asyncmy unbuffered ``SSCursor``.""" - __slots__ = ("_chunk_size", "_cursor", "_driver", "_json_type_codes", "_parameters", "_row_plan", "_sql") + __slots__ = ( + "_chunk_size", + "_cursor", + "_deserializer", + "_driver", + "_json_type_codes", + "_parameters", + "_row_plan", + "_sql", + ) def __init__(self, driver: Any, sql: str, parameters: Any, chunk_size: int, json_type_codes: "set[int]") -> None: self._driver = driver @@ -83,22 +97,16 @@ def __init__(self, driver: Any, sql: str, parameters: Any, chunk_size: int, json self._cursor: Any = None self._json_type_codes = json_type_codes self._row_plan: tuple[list[str], list[int] | None] | None = None + self._deserializer: Callable[[Any], Any] = cast( + "Callable[[Any], Any]", + getattr(driver, "_json_deserializer", None) or driver.driver_features.get("json_deserializer", from_json), + ) async def start(self) -> None: handler = self._driver.handle_database_exceptions() await self._driver._run_with_exception_handler(handler, self._start) self._driver._check_pending_exception(handler) - async def _start(self) -> None: - try: - cursor = self._driver.connection.cursor(AsyncmySSCursor) - self._cursor = cursor - await cursor.execute(self._sql, normalize_execute_parameters(self._parameters)) - self._row_plan = resolve_row_plan(self._cursor.description, self._json_type_codes) - except BaseException: - await self.close(error=True) - raise - async def fetch_chunk(self) -> "list[dict[str, Any]]": handler = self._driver.handle_database_exceptions() rows = await self._driver._run_with_exception_handler(handler, self._cursor.fetchmany, self._chunk_size) @@ -107,12 +115,7 @@ async def fetch_chunk(self) -> "list[dict[str, Any]]": return [] if self._row_plan is None: self._row_plan = resolve_row_plan(self._cursor.description, self._json_type_codes) - deserializer = cast( - "Callable[[Any], Any]", - getattr(self._driver, "_json_deserializer", None) - or self._driver.driver_features.get("json_deserializer", from_json), - ) - return collect_stream_rows(rows, self._row_plan, deserializer) + return collect_stream_rows(rows, self._row_plan, self._deserializer, logger=logger) async def close(self, error: bool = False) -> None: cursor = self._cursor @@ -121,6 +124,16 @@ async def close(self, error: bool = False) -> None: with contextlib.suppress(Exception): await cursor.close() + async def _start(self) -> None: + try: + cursor = self._driver.connection.cursor(AsyncmySSCursor) + self._cursor = cursor + await cursor.execute(self._sql, normalize_execute_parameters(self._parameters)) + self._row_plan = resolve_row_plan(self._cursor.description, self._json_type_codes) + except BaseException: + await self.close(error=True) + raise + def build_profile() -> "DriverParameterProfile": """Create the AsyncMy driver parameter profile.""" diff --git a/sqlspec/adapters/asyncmy/driver.py b/sqlspec/adapters/asyncmy/driver.py index eafe6e630..b1acdfec5 100644 --- a/sqlspec/adapters/asyncmy/driver.py +++ b/sqlspec/adapters/asyncmy/driver.py @@ -6,7 +6,6 @@ import tempfile from collections.abc import Sized -from datetime import timedelta from typing import TYPE_CHECKING, Any, Final, cast from sqlspec.adapters.asyncmy._typing import ( @@ -22,6 +21,7 @@ AsyncmyStreamSource, build_insert_statement, build_load_data_statement, + can_use_local_infile_records, collect_rows, create_mapped_exception, default_statement_config, @@ -54,7 +54,7 @@ __all__ = ("AsyncmyCursor", "AsyncmyDriver", "AsyncmyExceptionHandler", "AsyncmySessionContext") -logger = get_logger(__name__) +logger = get_logger("sqlspec.adapters.asyncmy") json_type_value = ( AsyncmyFieldType.JSON if AsyncmyFieldType is not None and supports_json_type(AsyncmyFieldType) else None @@ -134,21 +134,16 @@ def __init__( "Callable[[Any], Any]", features.get("json_deserializer", from_json) ) - async def _execute_cache_hit( - self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" - ) -> "SQLResult": - if ( - "%" in cached.compiled_sql - and escape_literal_percent( - cached.compiled_sql, params or (None,), self.statement_config.parameter_validator - ) - != cached.compiled_sql - ): - statement = self._cached_statement( - sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql - ) - return await self._execute_cached_statement(statement) - return await super()._execute_cache_hit(sql, params, cached) + @property + def data_dictionary(self) -> "AsyncmyDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = AsyncmyDataDictionary() + return self._data_dictionary async def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": """Execute single SQL statement. @@ -167,9 +162,9 @@ async def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionRes sql = escape_literal_percent(sql, prepared_parameters, self.statement_config.parameter_validator) await cursor.execute(sql, normalize_execute_parameters(prepared_parameters)) - if statement.returns_rows(): + description = getattr(cursor, "description", None) or None + if statement.returns_rows() or description is not None: fetched_data = await cursor.fetchall() - description = cursor.description or None row_plan = resolve_row_plan(description, ASYNCMY_JSON_TYPE_CODES) rows, column_names, row_format = collect_rows( fetched_data, row_plan, self._json_deserializer, logger=logger @@ -239,9 +234,15 @@ async def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "Execu successful_count = 0 last_cursor = cursor + normalized_params = normalize_execute_parameters(prepared_parameters) for stmt in statements: - await cursor.execute(stmt, normalize_execute_parameters(prepared_parameters)) + stmt_sql = ( + escape_literal_percent(stmt, prepared_parameters, self.statement_config.parameter_validator) + if normalized_params is not None + else stmt + ) + await cursor.execute(stmt_sql, normalized_params) successful_count += 1 return self.create_execution_result( @@ -259,6 +260,9 @@ async def begin(self) -> None: try: await self.connection.begin() except AsyncmyMySQLError as e: + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, Exception): + raise mapped from e msg = f"Failed to begin MySQL transaction: {e}" raise SQLSpecError(msg) from e @@ -271,6 +275,9 @@ async def commit(self) -> None: try: await self.connection.commit() except AsyncmyMySQLError as e: + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, Exception): + raise mapped from e msg = f"Failed to commit MySQL transaction: {e}" raise SQLSpecError(msg) from e @@ -283,6 +290,9 @@ async def rollback(self) -> None: try: await self.connection.rollback() except AsyncmyMySQLError as e: + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, Exception): + raise mapped from e msg = f"Failed to rollback MySQL transaction: {e}" raise SQLSpecError(msg) from e @@ -352,20 +362,16 @@ async def load_from_arrow( if overwrite: statement = f"TRUNCATE TABLE {format_identifier(table)}" exc_handler = self.handle_database_exceptions() - async with exc_handler, self.with_cursor(self.connection) as cursor: - await cursor.execute(statement) - if exc_handler.pending_exception is not None: - raise exc_handler.pending_exception from None + await self._run_with_exception_handler(exc_handler, self._execute_cursor_statement, statement) + self._check_pending_exception(exc_handler) columns, records = self._arrow_table_to_rows(arrow_table) if records: needs_preparation = self._arrow_rows_need_preparation(arrow_table) use_infile = ( - self.driver_features.get("enable_local_infile_bulk_load") + bool(self.driver_features.get("enable_local_infile_bulk_load")) and not needs_preparation - and not any( - isinstance(value, (bytes, bytearray, memoryview, timedelta)) for row in records for value in row - ) + and can_use_local_infile_records(arrow_table, records) ) if use_infile: await self._load_from_arrow_via_local_infile(table, columns, records) @@ -377,31 +383,16 @@ async def load_from_arrow( else records ) exc_handler = self.handle_database_exceptions() - async with exc_handler, self.with_cursor(self.connection) as cursor: - await cursor.executemany(insert_sql, prepared_records) - if exc_handler.pending_exception is not None: - raise exc_handler.pending_exception from None + await self._run_with_exception_handler( + exc_handler, self._executemany_cursor_statement, insert_sql, prepared_records + ) + self._check_pending_exception(exc_handler) telemetry_payload = self._ingest_telemetry(arrow_table) telemetry_payload["destination"] = table self._attach_partition_telemetry(telemetry_payload, partitioner) return self._storage_job(telemetry_payload, telemetry) - async def _load_from_arrow_via_local_infile( - self, table: str, columns: "list[str]", records: "list[tuple[Any, ...]]" - ) -> None: - with tempfile.TemporaryDirectory(prefix="sqlspec-asyncmy-") as directory: - with tempfile.NamedTemporaryFile(dir=directory, suffix=".tsv", delete=False) as payload: - payload.write(encode_records_for_local_infile(records)) - filename = payload.name - statement = build_load_data_statement(table, columns) - exc_handler = self.handle_database_exceptions() - async with exc_handler, self.with_cursor(self.connection) as cursor: - with asyncmy_local_infile(self.connection, filename): - await cursor.execute(statement, {"sqlspec_infile_path": filename}) - if exc_handler.pending_exception is not None: - raise exc_handler.pending_exception from exc_handler.pending_exception.__cause__ - async def load_from_storage( self, table: str, @@ -418,17 +409,6 @@ async def load_from_storage( table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound ) - @property - def data_dictionary(self) -> "AsyncmyDataDictionary": - """Get the data dictionary for this driver. - - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = AsyncmyDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect asyncmy rows for the direct execution path.""" description = cursor.description or None @@ -440,6 +420,42 @@ def resolve_rowcount(self, cursor: Any) -> int: """Resolve rowcount from asyncmy cursor for the direct execution path.""" return resolve_rowcount(cursor) + async def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + statement = self._cached_statement( + sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql + ) + return await self._execute_cached_statement(statement) + + async def _execute_cursor_statement(self, statement: str, parameters: Any = None) -> None: + async with self.with_cursor(self.connection) as cursor: + if parameters is None: + await cursor.execute(statement) + else: + await cursor.execute(statement, parameters) + + async def _executemany_cursor_statement(self, statement: str, parameters: Any) -> None: + async with self.with_cursor(self.connection) as cursor: + await cursor.executemany(statement, parameters) + + async def _execute_local_infile_cursor(self, statement: str, filename: str) -> None: + async with self.with_cursor(self.connection) as cursor: + with asyncmy_local_infile(self.connection, filename): + await cursor.execute(statement, {"sqlspec_infile_path": filename}) + + async def _load_from_arrow_via_local_infile( + self, table: str, columns: "list[str]", records: "list[tuple[Any, ...]]" + ) -> None: + with tempfile.TemporaryDirectory(prefix="sqlspec-asyncmy-") as directory: + with tempfile.NamedTemporaryFile(dir=directory, suffix=".tsv", delete=False) as payload: + payload.write(encode_records_for_local_infile(records)) + filename = payload.name + statement = build_load_data_statement(table, columns) + exc_handler = self.handle_database_exceptions() + await self._run_with_exception_handler(exc_handler, self._execute_local_infile_cursor, statement, filename) + self._check_pending_exception(exc_handler) + def _connection_in_transaction(self) -> bool: """Check if connection is in transaction. diff --git a/sqlspec/adapters/asyncmy/litestar/store.py b/sqlspec/adapters/asyncmy/litestar/store.py index c88cc68e8..0c0441c54 100644 --- a/sqlspec/adapters/asyncmy/litestar/store.py +++ b/sqlspec/adapters/asyncmy/litestar/store.py @@ -1,9 +1,10 @@ """AsyncMy session store for Litestar integration.""" +from collections.abc import Mapping from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, Final, NotRequired, cast -from sqlspec.adapters.asyncmy._typing import AsyncmyProgrammingError +from sqlspec.adapters.asyncmy._typing import AsyncmyCursor, AsyncmyProgrammingError, AsyncmyRawCursor from sqlspec.adapters.asyncmy.core import resolve_rowcount from sqlspec.config import LitestarConfig from sqlspec.exceptions import ImproperConfigurationError @@ -92,14 +93,21 @@ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "by """ try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, (key,)) row = await cursor.fetchone() if row is None: return None - data_value, expires_at = row + if isinstance(row, Mapping): + data_value = row["data"] + expires_at = row["expires_at"] + else: + data_value, expires_at = row if renew_for is not None and expires_at is not None: new_expires_at = self._calculate_expires_at(renew_for) @@ -114,8 +122,8 @@ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "by await conn.commit() return bytes(data_value) - except AsyncmyProgrammingError as e: # pyright: ignore - if "doesn't exist" in str(e) or e.args[0] == MYSQL_TABLE_NOT_FOUND_ERROR: + except AsyncmyProgrammingError as exc: + if _is_mysql_table_missing(exc): return None raise @@ -140,7 +148,10 @@ async def set(self, key: str, value: "str | bytes", expires_in: "int | timedelta updated_at = UTC_TIMESTAMP(6) """ - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, (key, data, naive_expires_at)) await conn.commit() @@ -152,7 +163,10 @@ async def delete(self, key: str) -> None: """ sql = f"DELETE FROM {self._table_name} WHERE session_id = %s" - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, (key,)) await conn.commit() @@ -162,12 +176,15 @@ async def delete_all(self) -> None: sql = f"DELETE FROM {self._table_name}" try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql) await conn.commit() self._log_delete_all() - except AsyncmyProgrammingError as e: # pyright: ignore - if "doesn't exist" in str(e) or e.args[0] == MYSQL_TABLE_NOT_FOUND_ERROR: + except AsyncmyProgrammingError as exc: + if _is_mysql_table_missing(exc): logger.debug("Table %s does not exist, skipping delete_all", self._table_name) return raise @@ -189,12 +206,15 @@ async def exists(self, key: str) -> bool: """ try: - async with self._config.provide_connection() as conn, conn.cursor() as cursor: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): await cursor.execute(sql, (key,)) result = await cursor.fetchone() return result is not None - except AsyncmyProgrammingError as e: # pyright: ignore - if "doesn't exist" in str(e) or e.args[0] == MYSQL_TABLE_NOT_FOUND_ERROR: + except AsyncmyProgrammingError as exc: + if _is_mysql_table_missing(exc): return False raise @@ -212,22 +232,32 @@ async def expires_in(self, key: str) -> "int | None": WHERE session_id = %s """ - async with self._config.provide_connection() as conn, conn.cursor() as cursor: - await cursor.execute(sql, (key,)) - row = await cursor.fetchone() + try: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): + await cursor.execute(sql, (key,)) + row = await cursor.fetchone() - if row is None or row[0] is None: - return None + if row is None: + return None + expires_at_naive = row["expires_at"] if isinstance(row, Mapping) else row[0] + if expires_at_naive is None: + return None - expires_at_naive = row[0] - expires_at_utc = expires_at_naive.replace(tzinfo=UTC) - now = datetime.now(UTC) + expires_at_utc = expires_at_naive.replace(tzinfo=UTC) + now = datetime.now(UTC) - if expires_at_utc <= now: - return 0 + if expires_at_utc <= now: + return 0 - delta = expires_at_utc - now - return int(delta.total_seconds()) + delta = expires_at_utc - now + return int(delta.total_seconds()) + except AsyncmyProgrammingError as exc: + if _is_mysql_table_missing(exc): + return None + raise async def delete_expired(self) -> int: """Delete all expired sessions. @@ -237,13 +267,21 @@ async def delete_expired(self) -> int: """ sql = f"DELETE FROM {self._table_name} WHERE expires_at <= UTC_TIMESTAMP(6)" - async with self._config.provide_connection() as conn, conn.cursor() as cursor: - await cursor.execute(sql) - await conn.commit() - count = resolve_rowcount(cursor) - if count > 0: - self._log_delete_expired(count) - return count + try: + async with ( + self._config.provide_connection() as conn, + AsyncmyCursor(conn, cursor_class=AsyncmyRawCursor) as cursor, + ): + await cursor.execute(sql) + await conn.commit() + count = resolve_rowcount(cursor) + if count > 0: + self._log_delete_expired(count) + return count + except AsyncmyProgrammingError as exc: + if _is_mysql_table_missing(exc): + return 0 + raise def _table_ddl(self) -> str: """Get MySQL CREATE TABLE SQL with optimized schema. @@ -274,6 +312,16 @@ def _drop_table_sql(self) -> "list[str]": ] +def _is_mysql_table_missing(exc: BaseException) -> bool: + args = getattr(exc, "args", ()) + errno = getattr(exc, "errno", None) + return ( + errno == MYSQL_TABLE_NOT_FOUND_ERROR + or "doesn't exist" in str(exc) + or bool(args and args[0] == MYSQL_TABLE_NOT_FOUND_ERROR) + ) + + def _mysql_table_options(litestar_config: "dict[str, Any]") -> str: """Format the litestar ``table_options`` config value for DDL interpolation. diff --git a/sqlspec/adapters/asyncpg/_typing.py b/sqlspec/adapters/asyncpg/_typing.py index 09c072449..1ea70b674 100644 --- a/sqlspec/adapters/asyncpg/_typing.py +++ b/sqlspec/adapters/asyncpg/_typing.py @@ -4,7 +4,7 @@ compilation to avoid ABI boundary issues. """ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import asyncpg as asyncpg_module from asyncpg import Connection as AsyncpgRawConnection @@ -116,11 +116,10 @@ def __init__( self._driver: AsyncpgDriver | None = None async def __aenter__(self) -> "AsyncpgDriver": - from sqlspec.adapters.asyncpg.driver import AsyncpgDriver - + driver_cls = _resolve_asyncpg_driver() self._connection = await self._acquire_connection() statement_config = self._statement_config() if callable(self._statement_config) else self._statement_config - self._driver = AsyncpgDriver( + self._driver = driver_cls( connection=self._connection, statement_config=statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -139,6 +138,8 @@ async def __aexit__( "AsyncpgCloudSqlConnector": ("google.cloud.sql.connector", "Connector"), } +_ASYNCPG_DRIVER_CLS: "type[AsyncpgDriver] | None" = None + def __getattr__(name: str) -> Any: """Resolve optional driver symbols only when a consumer requests them.""" @@ -152,3 +153,16 @@ def __getattr__(name: str) -> Any: msg = f"Cannot import {attribute!r} from {module_name!r}" raise ImportError(msg) return value + + +def _resolve_asyncpg_driver() -> "type[AsyncpgDriver]": + global _ASYNCPG_DRIVER_CLS + driver_cls = _ASYNCPG_DRIVER_CLS + if driver_cls is None: + resolved = import_optional_attr("sqlspec.adapters.asyncpg.driver", "AsyncpgDriver") + if resolved is None: + msg = "Cannot import 'AsyncpgDriver' from 'sqlspec.adapters.asyncpg.driver'" + raise ImportError(msg) + driver_cls = cast("type[AsyncpgDriver]", resolved) + _ASYNCPG_DRIVER_CLS = driver_cls + return driver_cls diff --git a/sqlspec/adapters/asyncpg/adk/store.py b/sqlspec/adapters/asyncpg/adk/store.py index 132a7ba1d..139e289d5 100644 --- a/sqlspec/adapters/asyncpg/adk/store.py +++ b/sqlspec/adapters/asyncpg/adk/store.py @@ -1,8 +1,10 @@ """AsyncPG ADK store for Google Agent Development Kit session/event storage.""" +from contextlib import nullcontext from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, cast from sqlspec.adapters.asyncpg._typing import asyncpg_module as asyncpg +from sqlspec.adapters.asyncpg.core import parse_status from sqlspec.config import ADKConfig, AsyncConfigT from sqlspec.extensions.adk import BaseAsyncADKStore, StoredEvent, StoredSession, normalize_session_list_options from sqlspec.extensions.adk.memory.store import BaseAsyncADKMemoryStore @@ -377,7 +379,7 @@ async def delete_expired_events(self, before: "datetime", app_name: "str | None" try: async with self._config.provide_connection() as conn: result = await conn.execute(sql, *params) - return int(result.split()[-1]) if result else 0 + return parse_status(result) except asyncpg.exceptions.UndefinedTableError: return 0 @@ -391,7 +393,7 @@ async def delete_idle_sessions(self, updated_before: "datetime", app_name: "str try: async with self._config.provide_connection() as conn: result = await conn.execute(sql, *params) - return int(result.split()[-1]) if result else 0 + return parse_status(result) except asyncpg.exceptions.UndefinedTableError: return 0 @@ -405,7 +407,7 @@ async def delete_idle_user_states(self, updated_before: "datetime", app_name: "s try: async with self._config.provide_connection() as conn: result = await conn.execute(sql, *params) - return int(result.split()[-1]) if result else 0 + return parse_status(result) except asyncpg.exceptions.UndefinedTableError: return 0 @@ -633,7 +635,7 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " return 0 inserted_count = 0 - async with self._config.provide_connection() as conn: + async with self._config.provide_connection() as conn, _optional_transaction(conn): for entry in entries: if self._owner_id_column_name: sql = f""" @@ -685,10 +687,7 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " entry["metadata_json"], entry["inserted_at"], ) - try: - inserted_count += int(result.rsplit(" ", 1)[-1]) - except (IndexError, ValueError): - continue + inserted_count += parse_status(result) return inserted_count @@ -795,10 +794,7 @@ async def delete_entries_by_session(self, session_id: str) -> int: sql = f"DELETE FROM {self._memory_table} WHERE session_id = $1" async with self._config.provide_connection() as conn: result = await conn.execute(sql, session_id) - try: - return int(result.split(" ")[1]) - except (IndexError, ValueError): - return 0 + return parse_status(result) async def delete_entries_older_than( self, days: int, app_name: "str | None" = None, scope: "str | None" = None @@ -823,10 +819,7 @@ async def delete_entries_older_than( sql = f"DELETE FROM {self._memory_table} WHERE {where_sql}" async with self._config.provide_connection() as conn: result = await conn.execute(sql, *params) - try: - return int(result.split(" ")[1]) - except (IndexError, ValueError): - return 0 + return parse_status(result) async def _memory_table_ddl(self) -> str: owner_id_line = "" @@ -888,6 +881,15 @@ def _drop_memory_table_sql(self) -> "list[str]": return [f"DROP TABLE IF EXISTS {self._memory_table}"] +def _optional_transaction(conn: Any) -> Any: + transaction_fn = getattr(conn, "transaction", None) + if callable(transaction_fn): + tx = transaction_fn() + if hasattr(tx, "__aenter__"): + return tx + return nullcontext() + + def _adk_config(config: Any) -> AsyncpgADKConfig: """Return asyncpg ADK extension settings from ``extension_config["adk"]``.""" diff --git a/sqlspec/adapters/asyncpg/config.py b/sqlspec/adapters/asyncpg/config.py index ce5d40b01..32423edd8 100644 --- a/sqlspec/adapters/asyncpg/config.py +++ b/sqlspec/adapters/asyncpg/config.py @@ -1,5 +1,6 @@ """AsyncPG database configuration with direct field-based configuration.""" +import asyncio from contextlib import suppress from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NotRequired, TypedDict, cast @@ -75,6 +76,8 @@ "reset", "setup", }) +_CLOUD_SQL_INSTANCE_PARTS: Final[int] = 3 +_ALLOYDB_URI_PARTS: Final[int] = 8 logger = get_logger(__name__) @@ -192,6 +195,8 @@ class AsyncpgDriverFeatures(TypedDict): - "notify_queue": Durable queue plus a PostgreSQL notification wakeup hint - "poll_queue": Durable queue discovered by polling Defaults to "notify". + on_connection_create: Async callback executed when a connection is initialized. + Receives the raw asyncpg connection after internal codec registration. pgbouncer: Enable PgBouncer transaction-pooling compatibility mode. Disables server-side prepared statement caching (statement_cache_size=0). type_codecs: Optional list of custom type codec specifications to register. @@ -213,7 +218,6 @@ class AsyncpgDriverFeatures(TypedDict): alloydb_ip_type: NotRequired[str] enable_events: NotRequired[bool] events_backend: NotRequired[Literal["notify", "notify_queue", "poll_queue"]] - connection_instance: NotRequired["AsyncpgPool"] on_connection_create: NotRequired["Callable[[AsyncpgConnection], Awaitable[None]]"] pgbouncer: NotRequired[bool] type_codecs: NotRequired["list[dict[str, Any]]"] @@ -337,12 +341,14 @@ def __init__( statement_config = statement_config or default_statement_config statement_config, driver_features = apply_driver_features(statement_config, driver_features) features_dict = dict(driver_features) - self._user_connection_hook: Callable[[AsyncpgConnection], Awaitable[None]] | None = features_dict.pop( - "on_connection_create", None + if connection_instance is None and "connection_instance" in features_dict: + connection_instance = features_dict.pop("connection_instance") + self._user_connection_hook: Callable[[AsyncpgConnection], Awaitable[None]] | None = features_dict.get( + "on_connection_create" ) if connection_config and connection_config.get("pgbouncer"): features_dict["pgbouncer"] = True - self._custom_type_codecs: list[dict[str, Any]] = list(features_dict.pop("type_codecs", None) or []) + self._custom_type_codecs: list[dict[str, Any]] = list(features_dict.get("type_codecs") or []) super().__init__( connection_config=build_connection_config(normalize_connection_config(connection_config)), @@ -355,6 +361,8 @@ def __init__( observability_config=observability_config, **kwargs, ) + if self._user_connection_hook is not None: + self.driver_features["on_connection_create"] = self._user_connection_hook self._cloud_sql_connector: Any | None = None self._alloydb_connector: Any | None = None @@ -362,9 +370,110 @@ def __init__( self._paradedb_available: bool | None = None self._pg_textsearch_available: bool | None = None self._pg_textsearch_probe_error: Exception | None = None + self._extension_probe_lock = asyncio.Lock() self._validate_connector_config() + @property + def pg_textsearch_available(self) -> bool: + """Return True if the pg_textsearch extension is available.""" + return bool(self._pg_textsearch_available) + + async def create_connection(self) -> "AsyncpgConnection": + """Open a standalone connection owned by the caller. + + The connection carries the same connection parameters and init hook the + pool applies, consumes no pool slot, and must be closed by the caller. + + Returns: + An AsyncPG connection instance. + """ + config = { + key: value for key, value in build_connection_config(self.connection_config).items() if value is not None + } + for key in _POOL_ONLY_CONFIG_KEYS: + config.pop(key, None) + + if self.driver_features.get("pgbouncer"): + config["statement_cache_size"] = 0 + + if self.driver_features.get("enable_cloud_sql", False): + self._setup_cloud_sql_connector(config) + elif self.driver_features.get("enable_alloydb", False): + self._setup_alloydb_connector(config) + + connect = config.pop("connect", None) + connection = await connect() if connect is not None else await asyncpg_connect(**config) + user_init = self.connection_config.get("init") + try: + await self._init_connection(connection) + if user_init is not None and user_init != self._init_connection: + await user_init(connection) + except BaseException: + with suppress(Exception): + await connection.close() + raise + return cast("AsyncpgConnection", connection) + + def provide_session( + self, *_args: Any, statement_config: "StatementConfig | None" = None, **_kwargs: Any + ) -> "AsyncpgSessionContext": + """Provide an async driver session context manager. + + Args: + *_args: Additional arguments. + statement_config: Optional statement configuration override. + **_kwargs: Additional keyword arguments. + + Returns: + An AsyncPG driver session context manager. + """ + factory = _AsyncpgSessionFactory(self) + return AsyncpgSessionContext( + acquire_connection=factory.acquire_connection, + release_connection=factory.release_connection, + statement_config=statement_config + or (lambda: resolve_runtime_statement_config(None, self.statement_config, default_statement_config)), + driver_features=self.driver_features, + prepare_driver=self._prepare_driver, + ) + + def get_signature_namespace(self) -> "dict[str, Any]": + """Get the signature namespace for AsyncPG types. + + This provides all AsyncPG-specific types that Litestar needs to recognize + to avoid serialization attempts. + + Returns: + Dictionary mapping type names to types. + """ + + namespace = super().get_signature_namespace() + namespace.update({ + "Connection": Connection, + "Pool": Pool, + "PoolConnectionProxy": PoolConnectionProxy, + "PoolConnectionProxyMeta": PoolConnectionProxyMeta, + "ConnectionMeta": ConnectionMeta, + "Record": Record, + "AsyncpgConnection": AsyncpgConnection, + "AsyncpgConnectionConfig": AsyncpgConnectionConfig, + "AsyncpgConnectionContext": AsyncpgConnectionContext, + "AsyncpgCursor": AsyncpgCursor, + "AsyncpgDriver": AsyncpgDriver, + "AsyncpgExceptionHandler": AsyncpgExceptionHandler, + "AsyncpgPool": AsyncpgPool, + "AsyncpgPoolConfig": AsyncpgPoolConfig, + "AsyncpgPreparedStatement": AsyncpgPreparedStatement, + "AsyncpgSessionContext": AsyncpgSessionContext, + }) + return namespace + + def get_event_runtime_hints(self) -> "EventRuntimeHints": + """Return polling defaults for PostgreSQL queue fallback.""" + + return EventRuntimeHints(poll_interval=0.5, select_for_update=True, skip_locked=True) + def _get_cloud_sql_connector(self) -> Any | None: """Return the configured Cloud SQL connector instance.""" return self._cloud_sql_connector @@ -397,12 +506,12 @@ def _validate_connector_config(self) -> None: raise MissingDependencyError(package="cloud-sql-python-connector", install_package="cloud-sql") instance = self.driver_features.get("cloud_sql_instance") - if not instance: + if not instance or not isinstance(instance, str): msg = "cloud_sql_instance required when enable_cloud_sql is True. Format: 'project:region:instance'" raise ImproperConfigurationError(msg) - cloud_sql_instance_parts_expected = 2 - if instance.count(":") != cloud_sql_instance_parts_expected: + parts = instance.split(":") + if len(parts) != _CLOUD_SQL_INSTANCE_PARTS or not all(part.strip() for part in parts): msg = f"Invalid Cloud SQL instance format: {instance}. Expected format: 'project:region:instance'" raise ImproperConfigurationError(msg) case (False, True): @@ -412,11 +521,19 @@ def _validate_connector_config(self) -> None: ) instance_uri = self.driver_features.get("alloydb_instance_uri") - if not instance_uri: + if not instance_uri or not isinstance(instance_uri, str): msg = "alloydb_instance_uri required when enable_alloydb is True. Format: 'projects/PROJECT/locations/REGION/clusters/CLUSTER/instances/INSTANCE'" raise ImproperConfigurationError(msg) - if not instance_uri.startswith("projects/"): + uri_parts = instance_uri.split("/") + if ( + len(uri_parts) != _ALLOYDB_URI_PARTS + or uri_parts[0] != "projects" + or uri_parts[2] != "locations" + or uri_parts[4] != "clusters" + or uri_parts[6] != "instances" + or not all(part.strip() for part in uri_parts) + ): msg = f"Invalid AlloyDB instance URI format: {instance_uri}. Expected format: 'projects/PROJECT/locations/REGION/clusters/CLUSTER/instances/INSTANCE'" raise ImproperConfigurationError(msg) @@ -456,6 +573,15 @@ def _setup_alloydb_connector(self, config: "dict[str, Any]") -> None: config["connect"] = _AsyncpgAlloydbConnector(self, user, password, database) + def _build_chained_init( + self, user_init: "Callable[[AsyncpgConnection], Awaitable[None]]" + ) -> "Callable[[AsyncpgConnection], Awaitable[None]]": + async def _chained_init(connection: "AsyncpgConnection") -> None: + await self._init_connection(connection) + await user_init(connection) + + return _chained_init + async def _create_pool(self) -> "Pool[Record]": """Create the actual async connection pool.""" config = { @@ -470,9 +596,24 @@ async def _create_pool(self) -> "Pool[Record]": elif self.driver_features.get("enable_alloydb", False): self._setup_alloydb_connector(config) - config.setdefault("init", self._init_connection) + user_init = config.pop("init", None) + if user_init is not None and user_init != self._init_connection: + config["init"] = self._build_chained_init(user_init) + else: + config["init"] = self._init_connection - return await asyncpg_create_pool(**config) + try: + return await asyncpg_create_pool(**config) + except BaseException: + if self._cloud_sql_connector is not None: + with suppress(Exception): + await self._cloud_sql_connector.close_async() + self._cloud_sql_connector = None + if self._alloydb_connector is not None: + with suppress(Exception): + await self._alloydb_connector.close() + self._alloydb_connector = None + raise async def _init_connection(self, connection: "AsyncpgConnection") -> None: """Initialize connection with JSON codecs, pgvector support, custom codecs, and user callback. @@ -487,22 +628,29 @@ async def _init_connection(self, connection: "AsyncpgConnection") -> None: decoder=self.driver_features.get("json_deserializer", from_json), ) - if self._pgvector_available is None: - detected_extensions: set[str] = set() - extensions = build_postgres_extension_probe_names(self.driver_features) - if extensions: - try: - results = await connection.fetch( - "SELECT extname FROM pg_extension WHERE extname = ANY($1::text[])", extensions + async with self._extension_probe_lock: + if self._pgvector_available is None: + detected_extensions: set[str] = set() + probe_failed = False + extensions = build_postgres_extension_probe_names(self.driver_features) + if extensions: + try: + results = await connection.fetch( + "SELECT extname FROM pg_extension WHERE extname = ANY($1::text[])", extensions + ) + detected_extensions = {r["extname"] for r in results} + except Exception as exc: + probe_failed = True + self._pg_textsearch_probe_error = exc + self._pg_textsearch_available = False + if not probe_failed: + self._pg_textsearch_probe_error = None + self.statement_config, self._pgvector_available, self._paradedb_available = ( + resolve_postgres_extension_state( + self.statement_config, self.driver_features, detected_extensions + ) ) - detected_extensions = {r["extname"] for r in results} - except Exception as exc: - detected_extensions = set() - self._pg_textsearch_probe_error = exc - self.statement_config, self._pgvector_available, self._paradedb_available = ( - resolve_postgres_extension_state(self.statement_config, self.driver_features, detected_extensions) - ) - self._pg_textsearch_available = is_postgres_extension_active(self.driver_features, "pg_textsearch") + self._pg_textsearch_available = is_postgres_extension_active(self.driver_features, "pg_textsearch") if self._pgvector_available: await register_pgvector_support(connection) @@ -519,11 +667,6 @@ async def _init_connection(self, connection: "AsyncpgConnection") -> None: if self._user_connection_hook is not None: await self._user_connection_hook(connection) - @property - def pg_textsearch_available(self) -> bool: - """Return True if the pg_textsearch extension is available.""" - return bool(self._pg_textsearch_available) - def _ensure_pg_textsearch_available(self) -> None: if self._pg_textsearch_available: return @@ -543,106 +686,3 @@ async def _close_pool(self) -> None: if self._alloydb_connector is not None: await self._alloydb_connector.close() self._alloydb_connector = None - - async def create_connection(self) -> "AsyncpgConnection": - """Open a standalone connection owned by the caller. - - The connection carries the same connection parameters and init hook the - pool applies, consumes no pool slot, and must be closed by the caller. - - Returns: - An AsyncPG connection instance. - """ - config = { - key: value for key, value in build_connection_config(self.connection_config).items() if value is not None - } - for key in _POOL_ONLY_CONFIG_KEYS: - config.pop(key, None) - - if self.driver_features.get("pgbouncer"): - config["statement_cache_size"] = 0 - - if self.driver_features.get("enable_cloud_sql", False): - self._setup_cloud_sql_connector(config) - elif self.driver_features.get("enable_alloydb", False): - self._setup_alloydb_connector(config) - - connect = config.pop("connect", None) - connection = await connect() if connect is not None else await asyncpg_connect(**config) - init = self.connection_config.get("init", self._init_connection) - try: - await init(connection) - except BaseException: - with suppress(Exception): - await connection.close() - raise - return cast("AsyncpgConnection", connection) - - def provide_session( - self, *_args: Any, statement_config: "StatementConfig | None" = None, **_kwargs: Any - ) -> "AsyncpgSessionContext": - """Provide an async driver session context manager. - - Args: - *_args: Additional arguments. - statement_config: Optional statement configuration override. - **_kwargs: Additional keyword arguments. - - Returns: - An AsyncPG driver session context manager. - """ - factory = _AsyncpgSessionFactory(self) - return AsyncpgSessionContext( - acquire_connection=factory.acquire_connection, - release_connection=factory.release_connection, - statement_config=statement_config - or (lambda: resolve_runtime_statement_config(None, self.statement_config, default_statement_config)), - driver_features=self.driver_features, - prepare_driver=self._prepare_driver, - ) - - async def provide_pool(self, *args: Any, **kwargs: Any) -> "Pool[Record]": - """Provide async pool instance. - - Returns: - The async connection pool. - """ - if not self.connection_instance: - self.connection_instance = await self.create_pool() - return self.connection_instance - - def get_signature_namespace(self) -> "dict[str, Any]": - """Get the signature namespace for AsyncPG types. - - This provides all AsyncPG-specific types that Litestar needs to recognize - to avoid serialization attempts. - - Returns: - Dictionary mapping type names to types. - """ - - namespace = super().get_signature_namespace() - namespace.update({ - "Connection": Connection, - "Pool": Pool, - "PoolConnectionProxy": PoolConnectionProxy, - "PoolConnectionProxyMeta": PoolConnectionProxyMeta, - "ConnectionMeta": ConnectionMeta, - "Record": Record, - "AsyncpgConnection": AsyncpgConnection, - "AsyncpgConnectionConfig": AsyncpgConnectionConfig, - "AsyncpgConnectionContext": AsyncpgConnectionContext, - "AsyncpgCursor": AsyncpgCursor, - "AsyncpgDriver": AsyncpgDriver, - "AsyncpgExceptionHandler": AsyncpgExceptionHandler, - "AsyncpgPool": AsyncpgPool, - "AsyncpgPoolConfig": AsyncpgPoolConfig, - "AsyncpgPreparedStatement": AsyncpgPreparedStatement, - "AsyncpgSessionContext": AsyncpgSessionContext, - }) - return namespace - - def get_event_runtime_hints(self) -> "EventRuntimeHints": - """Return polling defaults for PostgreSQL queue fallback.""" - - return EventRuntimeHints(poll_interval=0.5, select_for_update=True, skip_locked=True) diff --git a/sqlspec/adapters/asyncpg/core.py b/sqlspec/adapters/asyncpg/core.py index 94295400b..e3de29e89 100644 --- a/sqlspec/adapters/asyncpg/core.py +++ b/sqlspec/adapters/asyncpg/core.py @@ -1,7 +1,7 @@ """AsyncPG adapter compiled helpers.""" import contextlib -import datetime +import datetime as dt import re from collections.abc import Sized from typing import TYPE_CHECKING, Any, Final, NamedTuple @@ -190,9 +190,10 @@ async def invoke_prepared_statement( return prepared.get_statusmsg() if isinstance(parameters, dict): + values = tuple(parameters.values()) if fetch: - return await prepared.fetch(**parameters, **kwargs) - await prepared.fetch(**parameters, **kwargs) + return await prepared.fetch(*values, **kwargs) + await prepared.fetch(*values, **kwargs) return prepared.get_statusmsg() if fetch: @@ -277,6 +278,8 @@ def apply_driver_features( processed_features.setdefault("enable_pg_textsearch", True) processed_features.setdefault("enable_cloud_sql", False) processed_features.setdefault("enable_alloydb", False) + processed_features.setdefault("enable_events", True) + processed_features.setdefault("events_backend", "notify") parameter_config = configure_parameter_serializers( statement_config.parameter_config, serializer, deserializer=deserializer @@ -394,7 +397,6 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec return _create_postgres_error(error, error_code, termination_class, description) return _create_postgres_error(error, error_code, error_class, description) - # Priority 2: Fall back to SQLSTATE code mapping using centralized utility sqlstate_attr = error.sqlstate if has_sqlstate(error) else None sqlstate_code: str | None = sqlstate_attr if sqlstate_attr is not None else None if sqlstate_code: @@ -405,7 +407,6 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec if exc_class: return _create_postgres_error(error, sqlstate_code, exc_class, "database error") - # Priority 3: Default fallback return _create_postgres_error(error, sqlstate_code, SQLSpecError, "database error") @@ -453,17 +454,6 @@ async def start(self) -> None: await self._driver._run_with_exception_handler(handler, self._start) self._driver._check_pending_exception(handler) - async def _start(self) -> None: - transaction = self._driver.connection.transaction() - await transaction.start() - self._transaction = transaction - try: - self._cursor = await self._driver.connection.cursor(self._sql, *self._parameters, **self._timeout_args) - except BaseException: - await transaction.rollback() - self._transaction = None - raise - async def fetch_chunk(self) -> "list[dict[str, Any]]": handler = self._driver.handle_database_exceptions() records = await self._driver._run_with_exception_handler( @@ -487,12 +477,29 @@ async def close(self, error: bool = False) -> None: with contextlib.suppress(Exception): await transaction.rollback() + async def _start(self) -> None: + in_tx_fn = getattr(self._driver, "_connection_in_transaction", None) + already_in_tx = bool(in_tx_fn()) if callable(in_tx_fn) else False + if not already_in_tx: + transaction = self._driver.connection.transaction() + await transaction.start() + self._transaction = transaction + try: + self._cursor = await self._driver.connection.cursor(self._sql, *self._parameters, **self._timeout_args) + except BaseException: + transaction = self._transaction + self._transaction = None + if transaction is not None: + with contextlib.suppress(Exception): + await transaction.rollback() + raise + def _convert_datetime_param(value: Any) -> Any: """Convert datetime parameter, handling ISO strings.""" if isinstance(value, str): - return datetime.datetime.fromisoformat(value) + return dt.datetime.fromisoformat(value) return value @@ -500,7 +507,7 @@ def _convert_date_param(value: Any) -> Any: """Convert date parameter, handling ISO strings.""" if isinstance(value, str): - return datetime.date.fromisoformat(value) + return dt.date.fromisoformat(value) return value @@ -508,7 +515,7 @@ def _convert_time_param(value: Any) -> Any: """Convert time parameter, handling ISO strings.""" if isinstance(value, str): - return datetime.time.fromisoformat(value) + return dt.time.fromisoformat(value) return value @@ -516,30 +523,45 @@ def _custom_type_coercions() -> "dict[type, Callable[[Any], Any]]": """Return custom type coercions for AsyncPG.""" return { - datetime.datetime: _convert_datetime_param, - datetime.date: _convert_date_param, - datetime.time: _convert_time_param, + dt.datetime: _convert_datetime_param, + dt.date: _convert_date_param, + dt.time: _convert_time_param, **build_uuid_coercions(native=True), } +def _is_pre_serialized_json(value: str) -> bool: + stripped = value.strip() + if not stripped: + return False + first = stripped[0] + if first not in '{["-0123456789tfn': + return False + try: + from_json(stripped) + except Exception: + return False + return True + + def _encode_json_payload(value: Any, encoder: "Callable[[Any], str]") -> bytes: - if isinstance(value, bytes): - return value - if isinstance(value, bytearray): - return bytes(value) - if isinstance(value, memoryview): - return value.tobytes() - if isinstance(value, str): + if isinstance(value, (bytes, bytearray, memoryview)): + raw_bytes = value.tobytes() if isinstance(value, memoryview) else bytes(value) + if raw_bytes.startswith(_JSONB_BINARY_VERSION): + return raw_bytes[1:] + return raw_bytes + if isinstance(value, str) and _is_pre_serialized_json(value): return value.encode("utf-8") - encoded = encoder(value) + encoded: Any = encoder(value) if isinstance(encoded, bytes): - return encoded + return encoded[1:] if encoded.startswith(_JSONB_BINARY_VERSION) else encoded if isinstance(encoded, bytearray): - return bytes(encoded) + raw_encoded = bytes(encoded) + return raw_encoded[1:] if raw_encoded.startswith(_JSONB_BINARY_VERSION) else raw_encoded if isinstance(encoded, memoryview): - return encoded.tobytes() + raw_encoded = encoded.tobytes() + return raw_encoded[1:] if raw_encoded.startswith(_JSONB_BINARY_VERSION) else raw_encoded return str(encoded).encode("utf-8") diff --git a/sqlspec/adapters/asyncpg/data_dictionary.py b/sqlspec/adapters/asyncpg/data_dictionary.py index c5302da63..9077b2fc6 100644 --- a/sqlspec/adapters/asyncpg/data_dictionary.py +++ b/sqlspec/adapters/asyncpg/data_dictionary.py @@ -85,15 +85,6 @@ async def get_system_metadata_capabilities( requested_domains = tuple(_POSTGRES_SYSTEM_METADATA_QUERIES) if domains is None else tuple(domains) return tuple(_postgres_system_metadata_capability(domain) for domain in requested_domains) - async def _select_domain( - self, driver: "AsyncpgDriver", domain: str, query_name: str, **parameters: Any - ) -> MetadataResult: - query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) - if not query.is_supported or query.sql is None: - return MetadataResult(domain, capability=query.capability, warnings=query.warnings) - rows = await driver.select(query.sql, **parameters) - return _metadata_result(domain, _postgres_metadata_capability(domain), rows) - async def get_schemas(self, driver: "AsyncpgDriver") -> MetadataResult: """Get schema metadata.""" return await self._select_domain(driver, "schemas", "list") @@ -203,32 +194,34 @@ async def get_system_metadata( async def get_version(self, driver: "AsyncpgDriver") -> "VersionInfo | None": """Get PostgreSQL database version information. + Performs an inline cache check to avoid a cross-module method call + that can trigger a mypyc segmentation fault. + Args: driver: Async database driver instance. Returns: PostgreSQL version information or None if detection fails. """ - driver_id = id(driver) - # Inline cache check to avoid cross-module method call that causes mypyc segfault - if driver_id in self._version_fetch_attempted: - return self._version_cache.get(driver_id) + cache_key = id(getattr(driver, "connection", None) or driver) + if cache_key in self._version_fetch_attempted: + return self._version_cache.get(cache_key) version_value = await driver.select_value_or_none(self.get_query("version", "current")) if not version_value: self._log_version_unavailable(type(self).dialect, "missing") - self.cache_version(driver_id, None) + self.cache_version(cache_key, None) return None config = self.get_dialect_config() version_info = self.parse_version_with_pattern(config.version_pattern, str(version_value)) if version_info is None: self._log_version_unavailable(type(self).dialect, "parse_failed") - self.cache_version(driver_id, None) + self.cache_version(cache_key, None) return None self._log_version_detected(type(self).dialect, version_info) - self.cache_version(id(driver), version_info) + self.cache_version(cache_key, version_info) return version_info async def get_feature_flag(self, driver: "AsyncpgDriver", feature: str) -> bool: @@ -339,6 +332,15 @@ async def get_foreign_keys( schema_type=ForeignKeyMetadata, ) + async def _select_domain( + self, driver: "AsyncpgDriver", domain: str, query_name: str, **parameters: Any + ) -> MetadataResult: + query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) + if not query.is_supported or query.sql is None: + return MetadataResult(domain, capability=query.capability, warnings=query.warnings) + rows = await driver.select(query.sql, **parameters) + return _metadata_result(domain, _postgres_metadata_capability(domain), rows) + def _postgres_metadata_capability(domain: str) -> MetadataCapability: if domain == "system": diff --git a/sqlspec/adapters/asyncpg/driver.py b/sqlspec/adapters/asyncpg/driver.py index 60d775352..1fa49d816 100644 --- a/sqlspec/adapters/asyncpg/driver.py +++ b/sqlspec/adapters/asyncpg/driver.py @@ -1,25 +1,22 @@ """AsyncPG PostgreSQL driver implementation for async PostgreSQL operations.""" import re -from collections import OrderedDict from collections.abc import Mapping from contextlib import suppress from io import BytesIO -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Any, cast from sqlglot import exp, parse_one from sqlglot.errors import ParseError from sqlspec.adapters.asyncpg._typing import AsyncpgCursor, AsyncpgPostgresError, AsyncpgSessionContext from sqlspec.adapters.asyncpg.core import ( - PREPARED_STATEMENT_CACHE_SIZE, AsyncpgStreamSource, NormalizedStackOperation, collect_rows, create_mapped_exception, default_statement_config, driver_profile, - invoke_prepared_statement, parse_status, resolve_many_rowcount, ) @@ -28,7 +25,6 @@ SQL, StackResult, StatementStack, - create_sql_result, get_cache_config, is_copy_from_operation, is_copy_operation, @@ -49,7 +45,7 @@ if TYPE_CHECKING: from collections.abc import Sequence - from sqlspec.adapters.asyncpg._typing import AsyncpgConnection, AsyncpgPreparedStatement + from sqlspec.adapters.asyncpg._typing import AsyncpgConnection from sqlspec.core import ArrowResult, SQLResult, StatementConfig from sqlspec.driver import ExecutionResult from sqlspec.storage import StorageBridgeJob, StorageDestination, StorageFormat, StorageTelemetry @@ -58,9 +54,9 @@ __all__ = ("AsyncpgCursor", "AsyncpgDriver", "AsyncpgExceptionHandler", "AsyncpgSessionContext") _COPY_FROM_STDIN_RE: re.Pattern[str] = re.compile( - r'COPY\s+((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)(?:\s*\([^)]*\))?\s+FROM\s+STDIN', re.IGNORECASE + r'COPY\s+(?:ONLY\s+)?((?:"(?:[^"]|"")+"|\w+)(?:\s*\.\s*(?:"(?:[^"]|"")+"|\w+))?)(?:\s*\([^)]*\))?\s+FROM\s+STDIN', + re.IGNORECASE, ) -_QUALIFIED_TABLE_NAME_PARTS: Final = 2 logger = get_logger("sqlspec.adapters.asyncpg") @@ -95,7 +91,7 @@ class AsyncpgDriver(AsyncDriverAdapterBase): and caching, and parameter processing with type coercion. """ - __slots__ = ("_data_dictionary", "_prepared_statements", "_transaction") + __slots__ = ("_data_dictionary", "_transaction") dialect = "postgres" def __init__( @@ -111,16 +107,18 @@ def __init__( super().__init__(connection=connection, statement_config=statement_config, driver_features=driver_features) self._data_dictionary: AsyncpgDataDictionary | None = None - self._prepared_statements: OrderedDict[str, AsyncpgPreparedStatement] = OrderedDict() self._transaction: Any = None - def _timeout_arguments(self, config: "StatementConfig") -> "dict[str, Any]": - for options in (config.execution_args, self.statement_config.execution_args): - if options: - for key in ("timeout", "command_timeout"): - if key in options: - return {"timeout": options[key]} - return {} + @property + def data_dictionary(self) -> "AsyncpgDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = AsyncpgDataDictionary() + return self._data_dictionary async def dispatch_execute(self, cursor: "AsyncpgConnection", statement: "SQL") -> "ExecutionResult": """Execute single SQL statement. @@ -232,22 +230,17 @@ async def begin(self) -> None: The claim is therefore released here. """ if self._transaction is not None: - return + if self._connection_in_transaction(): + return + self._transaction = None transaction = self.connection.transaction() try: await transaction.start() except AsyncpgPostgresError as e: self._release_failed_transaction_claim(transaction) - msg = f"Failed to begin async transaction: {e}" - raise SQLSpecError(msg) from e + raise create_mapped_exception(e) from e self._transaction = transaction - def _release_failed_transaction_claim(self, transaction: Any) -> None: - """Clear the connection's transaction claim left by a failed start.""" - with suppress(Exception): - if getattr(self.connection, "_top_xact", None) is transaction: - self.connection._top_xact = None - async def commit(self) -> None: """Commit the current transaction.""" transaction = self._transaction @@ -255,11 +248,10 @@ async def commit(self) -> None: try: if transaction is not None: await transaction.commit() - else: + elif self._connection_in_transaction(): await self.connection.execute("COMMIT") except AsyncpgPostgresError as e: - msg = f"Failed to commit async transaction: {e}" - raise SQLSpecError(msg) from e + raise create_mapped_exception(e) from e async def rollback(self) -> None: """Rollback the current transaction.""" @@ -268,11 +260,10 @@ async def rollback(self) -> None: try: if transaction is not None: await transaction.rollback() - else: + elif self._connection_in_transaction(): await self.connection.execute("ROLLBACK") except AsyncpgPostgresError as e: - msg = f"Failed to rollback async transaction: {e}" - raise SQLSpecError(msg) from e + raise create_mapped_exception(e) from e async def set_migration_session_schema(self, schema: str) -> None: """Set the PostgreSQL search path for migration SQL.""" @@ -462,16 +453,19 @@ async def load_from_storage( table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound ) - @property - def data_dictionary(self) -> "AsyncpgDataDictionary": - """Get the data dictionary for this driver. + def _timeout_arguments(self, config: "StatementConfig") -> "dict[str, Any]": + for options in (config.execution_args, self.statement_config.execution_args): + if options: + for key in ("timeout", "command_timeout"): + if key in options: + return {"timeout": options[key]} + return {} - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = AsyncpgDataDictionary() - return self._data_dictionary + def _release_failed_transaction_claim(self, transaction: Any) -> None: + """Clear the connection's transaction claim left by a failed start.""" + with suppress(Exception): + if getattr(self.connection, "_top_xact", None) is transaction: + self.connection._top_xact = None @staticmethod def _copy_target(table: str) -> "tuple[str, str | None, str]": @@ -582,42 +576,15 @@ async def _run_stack_operations( await self._commit_stack_success() async def _execute_stack_operation_prepared(self, normalized: "NormalizedStackOperation") -> StackResult: - if self.driver_features.get("pgbouncer"): - result = await self._execute_cached_statement(normalized.statement) - return StackResult.from_sql_result(result) - timeout_args = self._timeout_arguments(normalized.statement.statement_config) - prepared = await self._get_prepared_statement(normalized.sql, timeout_args) - metadata = {"prepared_statement": True} - - if normalized.statement.returns_rows(): - rows = await invoke_prepared_statement(prepared, normalized.parameters, fetch=True, **timeout_args) - data, _ = collect_rows(rows) - sql_result = create_sql_result( - normalized.statement, data=data, rows_affected=len(data), metadata=metadata, row_format="record" - ) - return StackResult.from_sql_result(sql_result) - - status = await invoke_prepared_statement(prepared, normalized.parameters, fetch=False, **timeout_args) - rowcount = parse_status(status) - sql_result = create_sql_result(normalized.statement, rows_affected=rowcount, metadata=metadata) - return StackResult.from_sql_result(sql_result) + result = await self._execute_cached_statement(normalized.statement) + if not self.driver_features.get("pgbouncer"): + result.metadata["prepared_statement"] = True + return StackResult.from_sql_result(result) def _connection_in_transaction(self) -> bool: """Check if connection is in transaction.""" return bool(self.connection.is_in_transaction()) - async def _get_prepared_statement(self, sql: str, timeout_args: "dict[str, Any]") -> "AsyncpgPreparedStatement": - cached = self._prepared_statements.get(sql) - if cached is not None: - self._prepared_statements.move_to_end(sql) - return cached - - prepared = cast("AsyncpgPreparedStatement", await self.connection.prepare(sql, **timeout_args)) - self._prepared_statements[sql] = prepared - if len(self._prepared_statements) > PREPARED_STATEMENT_CACHE_SIZE: - self._prepared_statements.popitem(last=False) - return prepared - async def _handle_copy_operation(self, cursor: "AsyncpgConnection", statement: "SQL") -> None: """Handle PostgreSQL COPY operations. @@ -631,6 +598,7 @@ async def _handle_copy_operation(self, cursor: "AsyncpgConnection", statement: " execution_args = statement.statement_config.execution_args metadata: dict[str, Any] = dict(execution_args) if execution_args else {} + timeout_args = self._timeout_arguments(statement.statement_config) if statement.is_processed: sql_text = statement.get_processed_state().compiled_sql else: @@ -650,20 +618,16 @@ async def _handle_copy_operation(self, cursor: "AsyncpgConnection", statement: " data_str = str(copy_data) data_io = BytesIO(data_str.encode("utf-8")) - table_name = cast("str | None", metadata.get("postgres_copy_table")) - schema_name: str | None = None + raw_table = cast("str | None", metadata.get("postgres_copy_table")) + if raw_table is None: + raw_table = _extract_copy_from_stdin_table(sql_text) - if table_name is None: - match = _COPY_FROM_STDIN_RE.search(sql_text) - if match: - table_name = match.group(1) - - if table_name is None: + if raw_table is None: msg = "COPY FROM STDIN requires a table name or postgres_copy_table execution argument" raise SQLSpecError(msg) - schema_name, table_name = _split_copy_table_name(table_name) - copy_kwargs: dict[str, Any] = {"source": data_io} + table_name, schema_name, _ = self._copy_target(raw_table) + copy_kwargs: dict[str, Any] = {"source": data_io, **timeout_args} if schema_name is not None: copy_kwargs["schema_name"] = schema_name if "postgres_copy_columns" in metadata: @@ -676,14 +640,22 @@ async def _handle_copy_operation(self, cursor: "AsyncpgConnection", statement: " await cursor.copy_to_table(table_name, **copy_kwargs) return - await cursor.execute(sql_text) + await cursor.execute(sql_text, **timeout_args) -register_driver_profile("asyncpg", driver_profile) +def _extract_copy_from_stdin_table(sql_text: str) -> "str | None": + with suppress(Exception): + parsed = parse_one(sql_text, read="postgres") + if isinstance(parsed, exp.Copy): + target = parsed.this + if isinstance(target, exp.Schema): + target = target.this + if isinstance(target, exp.Table): + return target.sql(dialect="postgres") + match = _COPY_FROM_STDIN_RE.search(sql_text) + if match: + return match.group(1) + return None -def _split_copy_table_name(raw_name: str) -> "tuple[str | None, str]": - parts = raw_name.split(".", 1) - if len(parts) == _QUALIFIED_TABLE_NAME_PARTS: - return parts[0].strip('"'), parts[1].strip('"') - return None, raw_name.strip('"') +register_driver_profile("asyncpg", driver_profile) diff --git a/sqlspec/adapters/asyncpg/events/backend.py b/sqlspec/adapters/asyncpg/events/backend.py index 0668ebbdb..44f8eef83 100644 --- a/sqlspec/adapters/asyncpg/events/backend.py +++ b/sqlspec/adapters/asyncpg/events/backend.py @@ -169,24 +169,29 @@ async def _publish_durable( ) -> None: now = datetime.now(UTC) async with self._config.provide_session() as driver: - await driver.execute( - SQL( - self._queue._insert_statement, - { - "event_id": event_id, - "channel": channel, - "payload_json": to_json(payload), - "metadata_json": to_json(metadata) if metadata is not None else None, - "status": "pending", - "available_at": now, - "lease_expires_at": None, - "attempts": 0, - "created_at": now, - }, - statement_config=self._queue._statement_config, + await driver.begin() + try: + await driver.execute( + SQL( + self._queue._insert_statement, + { + "event_id": event_id, + "channel": channel, + "payload_json": to_json(payload), + "metadata_json": to_json(metadata) if metadata is not None else None, + "status": "pending", + "available_at": now, + "lease_expires_at": None, + "attempts": 0, + "created_at": now, + }, + statement_config=self._queue._statement_config, + ) ) - ) - await driver.execute(SQL("SELECT pg_notify($1, $2)", channel, to_json({"event_id": event_id}))) + await driver.execute(SQL("SELECT pg_notify($1, $2)", channel, to_json({"event_id": event_id}))) + except BaseException: + await driver.rollback() + raise await driver.commit() @@ -223,7 +228,6 @@ async def publish(self, channel: str, payload: "dict[str, Any]", metadata: "dict await driver.execute( SQL("SELECT pg_notify($1, $2)", channel, encode_notify_payload(event_id, payload, metadata)) ) - await driver.commit() self._runtime.increment_metric("events.publish.native") return event_id diff --git a/sqlspec/adapters/asyncpg/litestar/store.py b/sqlspec/adapters/asyncpg/litestar/store.py index 262dd09df..cb8ff5863 100644 --- a/sqlspec/adapters/asyncpg/litestar/store.py +++ b/sqlspec/adapters/asyncpg/litestar/store.py @@ -3,6 +3,8 @@ from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, NotRequired, cast +from sqlspec.adapters.asyncpg._typing import asyncpg_module as asyncpg +from sqlspec.adapters.asyncpg.core import parse_status from sqlspec.config import LitestarConfig from sqlspec.extensions.litestar.store import BaseSQLSpecStore @@ -79,36 +81,39 @@ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "by Returns: Session data as bytes if found and not expired, None otherwise. """ - if renew_for is not None: - new_expires_at = self._calculate_expires_at(renew_for) - if new_expires_at is not None: - update_sql = f""" - UPDATE {self._table_name} - SET expires_at = CASE WHEN expires_at IS NOT NULL THEN $1 ELSE expires_at END, - updated_at = CURRENT_TIMESTAMP - WHERE session_id = $2 - AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) - RETURNING data - """ - async with self._config.provide_connection() as conn: - row = await conn.fetchrow(update_sql, new_expires_at, key) - if row is None: - return None - return bytes(row["data"]) - - sql = f""" - SELECT data, expires_at FROM {self._table_name} - WHERE session_id = $1 - AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) - """ - - async with self._config.provide_connection() as conn: - row = await conn.fetchrow(sql, key) - - if row is None: - return None - - return bytes(row["data"]) + try: + if renew_for is not None: + new_expires_at = self._calculate_expires_at(renew_for) + if new_expires_at is not None: + update_sql = f""" + UPDATE {self._table_name} + SET expires_at = CASE WHEN expires_at IS NOT NULL THEN $1 ELSE expires_at END, + updated_at = CURRENT_TIMESTAMP + WHERE session_id = $2 + AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) + RETURNING data + """ + async with self._config.provide_connection() as conn: + row = await conn.fetchrow(update_sql, new_expires_at, key) + if row is None: + return None + return bytes(row["data"]) + + sql = f""" + SELECT data, expires_at FROM {self._table_name} + WHERE session_id = $1 + AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) + """ + + async with self._config.provide_connection() as conn: + row = await conn.fetchrow(sql, key) + + if row is None: + return None + + return bytes(row["data"]) + except asyncpg.exceptions.UndefinedTableError: + return None async def set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None: """Store a session value. @@ -142,15 +147,21 @@ async def delete(self, key: str) -> None: """ sql = f"DELETE FROM {self._table_name} WHERE session_id = $1" - async with self._config.provide_connection() as conn: - await conn.execute(sql, key) + try: + async with self._config.provide_connection() as conn: + await conn.execute(sql, key) + except asyncpg.exceptions.UndefinedTableError: + return async def delete_all(self) -> None: """Delete all sessions from the store.""" sql = f"DELETE FROM {self._table_name}" - async with self._config.provide_connection() as conn: - await conn.execute(sql) + try: + async with self._config.provide_connection() as conn: + await conn.execute(sql) + except asyncpg.exceptions.UndefinedTableError: + return self._log_delete_all() async def exists(self, key: str) -> bool: @@ -168,9 +179,12 @@ async def exists(self, key: str) -> bool: AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) """ - async with self._config.provide_connection() as conn: - result = await conn.fetchval(sql, key) - return result is not None + try: + async with self._config.provide_connection() as conn: + result = await conn.fetchval(sql, key) + return result is not None + except asyncpg.exceptions.UndefinedTableError: + return False async def expires_in(self, key: str) -> "int | None": """Get the time in seconds until the session expires. @@ -186,18 +200,21 @@ async def expires_in(self, key: str) -> "int | None": WHERE session_id = $1 """ - async with self._config.provide_connection() as conn: - expires_at = await conn.fetchval(sql, key) + try: + async with self._config.provide_connection() as conn: + expires_at = await conn.fetchval(sql, key) - if expires_at is None: - return None + if expires_at is None: + return None - now = datetime.now(UTC) - if expires_at <= now: - return 0 + now = datetime.now(UTC) + if expires_at <= now: + return 0 - delta = expires_at - now - return int(delta.total_seconds()) + delta = expires_at - now + return int(delta.total_seconds()) + except asyncpg.exceptions.UndefinedTableError: + return None async def delete_expired(self) -> int: """Delete all expired sessions. @@ -207,12 +224,15 @@ async def delete_expired(self) -> int: """ sql = f"DELETE FROM {self._table_name} WHERE expires_at <= CURRENT_TIMESTAMP" - async with self._config.provide_connection() as conn: - result = await conn.execute(sql) - count = int(result.split()[-1]) - if count > 0: - self._log_delete_expired(count) - return count + try: + async with self._config.provide_connection() as conn: + result = await conn.execute(sql) + count = parse_status(result) + if count > 0: + self._log_delete_expired(count) + return count + except asyncpg.exceptions.UndefinedTableError: + return 0 def _table_ddl(self) -> str: """Get PostgreSQL CREATE TABLE SQL with optimized schema. @@ -228,15 +248,14 @@ def _table_ddl(self) -> str: expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP - ) WITH (fillfactor = {fillfactor}); - - CREATE INDEX IF NOT EXISTS idx_{self._table_name}_expires_at - ON {self._table_name}(expires_at) WHERE expires_at IS NOT NULL; - - ALTER TABLE {self._table_name} SET ( + ) WITH ( + fillfactor = {fillfactor}, autovacuum_vacuum_scale_factor = {vacuum_scale:g}, autovacuum_analyze_scale_factor = {analyze_scale:g} ); + + CREATE INDEX IF NOT EXISTS idx_{self._table_name}_expires_at + ON {self._table_name}(expires_at) WHERE expires_at IS NOT NULL; """ def _drop_table_sql(self) -> "list[str]": diff --git a/sqlspec/adapters/bigquery/__init__.py b/sqlspec/adapters/bigquery/__init__.py index bf887626e..8960cb99f 100644 --- a/sqlspec/adapters/bigquery/__init__.py +++ b/sqlspec/adapters/bigquery/__init__.py @@ -1,3 +1,5 @@ +"""Google Cloud BigQuery adapter for SQLSpec.""" + from sqlspec.adapters.bigquery._typing import BigQueryConnection, BigQueryCursor from sqlspec.adapters.bigquery.config import ( BigQueryConfig, diff --git a/sqlspec/adapters/bigquery/_typing.py b/sqlspec/adapters/bigquery/_typing.py index bef2c4394..c4c1ddbc5 100644 --- a/sqlspec/adapters/bigquery/_typing.py +++ b/sqlspec/adapters/bigquery/_typing.py @@ -4,6 +4,8 @@ compilation to avoid ABI boundary issues. """ +import contextlib +from importlib import import_module from typing import TYPE_CHECKING, Any, TypeAlias import google.cloud.bigquery as bigquery_module @@ -12,7 +14,8 @@ from google.api_core.client_options import ClientOptions as BigQueryClientOptions from google.api_core.retry import Retry as BigQueryRetry from google.auth.credentials import Credentials as BigQueryCredentials -from google.cloud.bigquery import ArrayQueryParameter, Client, QueryJob, ScalarQueryParameter, StructQueryParameter +from google.cloud.bigquery import ArrayQueryParameter, Client, ScalarQueryParameter, StructQueryParameter +from google.cloud.bigquery import ConnectionProperty as BigQueryConnectionProperty from google.cloud.bigquery import LoadJobConfig as BigQueryLoadJobConfig from google.cloud.bigquery import QueryJob as BigQueryQueryJob from google.cloud.bigquery import QueryJobConfig as BigQueryQueryJobConfig @@ -44,6 +47,7 @@ "BigQueryClientInfo", "BigQueryClientOptions", "BigQueryConnection", + "BigQueryConnectionProperty", "BigQueryCredentials", "BigQueryCursor", "BigQueryLoadJobConfig", @@ -60,6 +64,8 @@ "bigquery_storage_read_module", ) +_DRIVER_TYPE: "type[BigQueryDriver] | None" = None + class BigQueryCursor: """BigQuery cursor with resource management.""" @@ -68,16 +74,18 @@ class BigQueryCursor: def __init__(self, connection: "BigQueryConnection") -> None: self.connection = connection - self.job: QueryJob | None = None + self.job: BigQueryQueryJob | None = None def __enter__(self) -> "BigQueryConnection": return self.connection def __exit__(self, *_: Any) -> None: """Clean up cursor resources including active QueryJobs.""" - from sqlspec.adapters.bigquery.driver import _close_bigquery_cursor - - _close_bigquery_cursor(self) + if self.job is not None: + with contextlib.suppress(Exception): + if getattr(self.job, "state", None) in {"PENDING", "RUNNING"}: + self.job.cancel() + self.job = None class BigQuerySessionContext: @@ -118,10 +126,9 @@ def __init__( self._driver: BigQueryDriver | None = None def __enter__(self) -> "BigQueryDriver": - from sqlspec.adapters.bigquery.driver import BigQueryDriver - + driver_cls = _get_driver_type() self._connection = self._acquire_connection() - self._driver = BigQueryDriver( + self._driver = driver_cls( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -129,7 +136,21 @@ def __enter__(self) -> "BigQueryDriver": def __exit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> "bool | None": + if exc_type is not None and self._driver is not None and self._driver._connection_in_transaction(): + with contextlib.suppress(Exception): + self._driver.rollback() if self._connection is not None: self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) self._connection = None + self._driver = None return None + + +def _get_driver_type() -> "type[BigQueryDriver]": + global _DRIVER_TYPE + driver_type = _DRIVER_TYPE + if driver_type is None: + module = import_module("sqlspec.adapters.bigquery.driver") + driver_type = module.BigQueryDriver + _DRIVER_TYPE = driver_type + return driver_type diff --git a/sqlspec/adapters/bigquery/adk/store.py b/sqlspec/adapters/bigquery/adk/store.py index 0070c98d5..254e53acb 100644 --- a/sqlspec/adapters/bigquery/adk/store.py +++ b/sqlspec/adapters/bigquery/adk/store.py @@ -15,15 +15,17 @@ from typing_extensions import TypedDict +from sqlspec.adapters.bigquery._typing import GoogleCloudError from sqlspec.adapters.bigquery._typing import bigquery_module as bigquery from sqlspec.adapters.bigquery.config import BigQueryConfig +from sqlspec.adapters.bigquery.core import build_dml_rowcount, create_mapped_exception from sqlspec.config import ADKConfig from sqlspec.extensions.adk import BaseSyncADKStore, StoredEvent, StoredSession, normalize_session_list_options from sqlspec.extensions.adk._config_utils import _adk_config_from_extension from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Callable, Iterable from sqlspec.extensions.adk import SessionOrderBy @@ -65,7 +67,15 @@ class BigQueryADKStore(BaseSyncADKStore[BigQueryConfig]): """ connector_name: ClassVar[str] = "bigquery" - __slots__ = ("_dataset_qualifier", "_lookup_window_days", "_partition_expiration_days", "_require_partition_filter") + __slots__ = ( + "_dataset_qualifier", + "_json_deserializer", + "_json_serializer", + "_last_dml_affected_rows", + "_lookup_window_days", + "_partition_expiration_days", + "_require_partition_filter", + ) def __init__(self, config: BigQueryConfig) -> None: """Initialize the BigQuery ADK store. @@ -83,8 +93,15 @@ def __init__(self, config: BigQueryConfig) -> None: max(1, math.ceil(int(ttl_seconds) / 86400)) if ttl_seconds else None ) self._require_partition_filter: bool = bool(adk_config.get("require_partition_filter", False)) + self._json_serializer: Callable[[Any], str] = cast( + "Callable[[Any], str]", config.driver_features.get("json_serializer", to_json) + ) + self._json_deserializer: Callable[[str], Any] = cast( + "Callable[[str], Any]", config.driver_features.get("json_deserializer", from_json) + ) + self._last_dml_affected_rows: int = 0 - dataset_id = config.connection_config.get("dataset_id") + dataset_id = _resolve_config_dataset_id(config) self._dataset_qualifier: str = f"{dataset_id}." if dataset_id else "" def create_tables(self) -> None: @@ -204,6 +221,8 @@ def set_metadata(self, key: str, value: str) -> None: def _qualified(self, table: str) -> str: """Return the dataset-qualified table identifier when available.""" + if "." in table: + return table return f"{self._dataset_qualifier}{table}" def _partition_filter(self, column: str, *, alias: str | None = None) -> str: @@ -214,19 +233,22 @@ def _partition_filter(self, column: str, *, alias: str | None = None) -> str: return f" AND {qualified_column} IS NOT NULL" def _run_query(self, sql: str, parameters: "Iterable[Any] | None" = None) -> "list[dict[str, Any]]": - client = self._config.create_connection() job_config = bigquery.QueryJobConfig(query_parameters=list(parameters)) if parameters is not None else None - job = client.query(sql, job_config=job_config) - return [dict(row) for row in job.result()] + try: + job = client.query(sql, job_config=job_config) + rows = [dict(row) for row in job.result()] + except GoogleCloudError as exc: + raise create_mapped_exception(exc) from exc + self._last_dml_affected_rows = build_dml_rowcount(job, 0) + return rows def _query_param(self, name: str, value: Any, *, bq_type: str = "STRING") -> Any: - return bigquery.ScalarQueryParameter(name, bq_type, value) def _json_param(self, name: str, value: "dict[str, Any] | None") -> Any: - - return bigquery.ScalarQueryParameter(name, "JSON", to_json(value) if value is not None else None) + normalized = from_json(self._json_serializer(value)) if value is not None else None + return bigquery.ScalarQueryParameter(name, "JSON", normalized) def _create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None @@ -258,7 +280,8 @@ def _create_session( self._query_param("update_time", now, bq_type="TIMESTAMP"), ] if self._owner_id_column_name: - params.append(self._query_param("owner_id", owner_id)) + owner_bq_type = "INT64" if isinstance(owner_id, int) and not isinstance(owner_id, bool) else "STRING" + params.append(self._query_param("owner_id", owner_id, bq_type=owner_bq_type)) self._run_query(sql, params) return { "id": session_id, @@ -292,7 +315,7 @@ def _get_session( ) if not rows: return None - return _session_record_from_row(rows[0]) + return _session_record_from_row(rows[0], deserializer=self._json_deserializer) def _update_session_touch(self, app_name: str, user_id: str, session_id: str) -> None: sql = f""" @@ -361,7 +384,7 @@ def _list_sessions( params.append(self._query_param("limit", page_limit, bq_type="INT64")) params.append(self._query_param("offset", page_offset, bq_type="INT64")) rows = self._run_query(sql, params) - return [_session_record_from_row(row) for row in rows] + return [_session_record_from_row(row, deserializer=self._json_deserializer) for row in rows] def _delete_session(self, app_name: str, user_id: str, session_id: str) -> None: events_sql = f"DELETE FROM {self._qualified(self._events_table)} WHERE app_name = @app_name AND user_id = @user_id AND session_id = @id{self._partition_filter('timestamp')}" @@ -442,7 +465,7 @@ def _get_events( sql += " LIMIT @row_limit" params.append(self._query_param("row_limit", limit, bq_type="INT64")) rows = self._run_query(sql, params) - return [_event_record_from_row(row) for row in rows] + return [_event_record_from_row(row, deserializer=self._json_deserializer) for row in rows] def _delete_expired_events(self, before: datetime, app_name: "str | None" = None) -> int: sql = f"DELETE FROM {self._qualified(self._events_table)} WHERE timestamp < @before{self._partition_filter('timestamp')}" @@ -450,8 +473,9 @@ def _delete_expired_events(self, before: datetime, app_name: "str | None" = None if app_name is not None: sql += " AND app_name = @app_name" params.append(self._query_param("app_name", app_name)) + self._last_dml_affected_rows = 0 self._run_query(sql, params) - return 0 + return self._last_dml_affected_rows def _delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" = None) -> int: sql = f"DELETE FROM {self._qualified(self._session_table)} WHERE update_time < @before{self._partition_filter('create_time')}" @@ -459,8 +483,9 @@ def _delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" if app_name is not None: sql += " AND app_name = @app_name" params.append(self._query_param("app_name", app_name)) + self._last_dml_affected_rows = 0 self._run_query(sql, params) - return 0 + return self._last_dml_affected_rows def _delete_idle_user_states(self, updated_before: datetime, app_name: "str | None" = None) -> int: sql = f"DELETE FROM {self._qualified(self._user_state_table)} WHERE update_time < @before" @@ -468,15 +493,16 @@ def _delete_idle_user_states(self, updated_before: datetime, app_name: "str | No if app_name is not None: sql += " AND app_name = @app_name" params.append(self._query_param("app_name", app_name)) + self._last_dml_affected_rows = 0 self._run_query(sql, params) - return 0 + return self._last_dml_affected_rows def _get_app_state(self, app_name: str) -> "dict[str, Any] | None": sql = f""" SELECT state FROM {self._qualified(self._app_state_table)} WHERE app_name = @app_name LIMIT 1 """ rows = self._run_query(sql, [self._query_param("app_name", app_name)]) - return self._decode_json(rows[0]["state"]) if rows else None + return self._decode_json(rows[0]["state"], deserializer=self._json_deserializer) if rows else None def _get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": sql = f""" @@ -485,7 +511,7 @@ def _get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None WHERE app_name = @app_name AND user_id = @user_id LIMIT 1 """ rows = self._run_query(sql, [self._query_param("app_name", app_name), self._query_param("user_id", user_id)]) - return self._decode_json(rows[0]["state"]) if rows else None + return self._decode_json(rows[0]["state"], deserializer=self._json_deserializer) if rows else None def _upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: sql = f""" @@ -632,31 +658,53 @@ def _create_tables(self) -> None: self._run_query(statement) @staticmethod - def _decode_json(value: Any) -> "dict[str, Any] | None": + def _decode_json(value: Any, *, deserializer: "Callable[[str], Any]" = from_json) -> "dict[str, Any] | None": if value is None: return None if isinstance(value, dict): return cast("dict[str, Any]", value) if isinstance(value, (bytes, bytearray)): - return cast("dict[str, Any]", from_json(bytes(value).decode("utf-8"))) + return cast("dict[str, Any]", deserializer(bytes(value).decode("utf-8"))) if isinstance(value, str): - return cast("dict[str, Any]", from_json(value)) + return cast("dict[str, Any]", deserializer(value)) msg = f"Unsupported JSON column representation from BigQuery: {type(value).__name__}" raise TypeError(msg) -def _session_record_from_row(row: "dict[str, Any]") -> StoredSession: +def _resolve_config_dataset_id(config: BigQueryConfig) -> str | None: + dataset_id = config.connection_config.get("dataset_id") + if isinstance(dataset_id, str) and dataset_id: + return dataset_id + default_job_config = config.connection_config.get("default_query_job_config") or config.driver_features.get( + "default_query_job_config" + ) + if default_job_config is None: + return None + default_dataset = getattr(default_job_config, "default_dataset", None) + if default_dataset is None: + return None + if isinstance(default_dataset, str): + return default_dataset or None + resolved_id = getattr(default_dataset, "dataset_id", None) + if resolved_id: + return str(resolved_id) + return str(default_dataset) + + +def _session_record_from_row( + row: "dict[str, Any]", *, deserializer: "Callable[[str], Any]" = from_json +) -> StoredSession: return { "id": row["id"], "app_name": row["app_name"], "user_id": row["user_id"], - "state": BigQueryADKStore._decode_json(row["state"]) or {}, + "state": BigQueryADKStore._decode_json(row["state"], deserializer=deserializer) or {}, "create_time": row["create_time"], "update_time": row["update_time"], } -def _event_record_from_row(row: "dict[str, Any]") -> StoredEvent: +def _event_record_from_row(row: "dict[str, Any]", *, deserializer: "Callable[[str], Any]" = from_json) -> StoredEvent: return { "id": row["id"], "app_name": row["app_name"], @@ -664,5 +712,5 @@ def _event_record_from_row(row: "dict[str, Any]") -> StoredEvent: "session_id": row["session_id"], "invocation_id": row["invocation_id"], "timestamp": row["timestamp"], - "event_data": BigQueryADKStore._decode_json(row["event_data"]) or {}, + "event_data": BigQueryADKStore._decode_json(row["event_data"], deserializer=deserializer) or {}, } diff --git a/sqlspec/adapters/bigquery/config.py b/sqlspec/adapters/bigquery/config.py index ebf14ae99..5f266e033 100644 --- a/sqlspec/adapters/bigquery/config.py +++ b/sqlspec/adapters/bigquery/config.py @@ -1,5 +1,7 @@ """BigQuery database configuration.""" +import contextlib +import copy import threading from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, TypedDict, cast @@ -11,7 +13,7 @@ ) from sqlspec.adapters.bigquery._typing import BigQueryLoadJobConfig as LoadJobConfig from sqlspec.adapters.bigquery._typing import BigQueryQueryJobConfig as QueryJobConfig -from sqlspec.adapters.bigquery.core import apply_driver_features, default_statement_config +from sqlspec.adapters.bigquery.core import apply_driver_features, copy_job_config, default_statement_config from sqlspec.adapters.bigquery.driver import BigQueryDriver, BigQueryExceptionHandler from sqlspec.config import ExtensionConfigs, NoPoolSyncConfig from sqlspec.core import TypeCoercionCapabilities @@ -33,6 +35,18 @@ __all__ = ("BigQueryConfig", "BigQueryConnectionParams", "BigQueryDriverFeatures", "build_connection_config") +_JOB_CONFIG_OVERRIDE_KEYS: tuple[str, ...] = ( + "dataset_id", + "use_query_cache", + "maximum_bytes_billed", + "query_timeout_ms", + "job_timeout_ms", + "labels", + "priority", + "reservation", + "max_slots", +) + class BigQueryConnectionParams(TypedDict): """Standard BigQuery connection parameters. @@ -83,7 +97,7 @@ class BigQueryDriverFeatures(TypedDict): Provides pub/sub capabilities via table-backed queue (BigQuery has no native pub/sub). Requires extension_config["events"] for migration setup. events_backend: Event channel backend selection. - Only option: "poll_queue" (durable table-backed queue with lease-based retries and acknowledgements). + Only option: "poll_queue" (durable table-backed queue with lease-based retries and acknowledgements). BigQuery does not have native pub/sub, so poll_queue is the only backend. Defaults to "poll_queue". job_retry_deadline: Total seconds to keep retrying transient job failures. Defaults to 60.0. @@ -106,6 +120,8 @@ class BigQueryDriverFeatures(TypedDict): enable_storage_write_api: Route ``load_from_arrow`` (append, ``overwrite=False``) through the BigQuery Storage Write API using native Arrow instead of a Parquet load job. Falls back to the Parquet path on any ``bigquery_storage`` import/availability failure. Defaults to False. + storage_write_stream_type: Write stream mode ("COMMITTED" or "PENDING") when + ``enable_storage_write_api`` is True. Defaults to "PENDING". enable_native_storage: Export eligible remote destinations through EXPORT DATA. Defaults to True. False retains the client Arrow writer. Local emulator endpoints, custom storage pipelines, and unsupported destinations/formats use the client path. @@ -242,8 +258,9 @@ def __init__( self._storage_write_client: Any = None self._storage_write_lock = threading.Lock() - if "default_query_job_config" not in self.connection_config: - self._setup_default_job_config() + self._setup_default_job_config() + if resolved_connection_instance is not None: + self._qualify_default_dataset(resolved_connection_instance) self._user_connection_hook = user_connection_hook @@ -262,6 +279,151 @@ def __init__( self.driver_features = driver_features self.driver_features["_storage_write_client_provider"] = self._provide_storage_write_client + def __deepcopy__(self, memo: "dict[int, Any]") -> "BigQueryConfig": + """Create a deep copy without attempting to copy threading locks or circular method bindings.""" + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + for key, value in self.__dict__.items(): + if key == "_storage_write_lock": + setattr(result, key, threading.Lock()) + elif key == "_storage_write_client": + setattr(result, key, None) + elif key in {"_connection_instance", "connection_instance"} and not self._owns_connection_instance: + setattr(result, key, value) + elif key == "driver_features" and isinstance(value, dict): + features_copy = { + k: copy.deepcopy(v, memo) for k, v in value.items() if k != "_storage_write_client_provider" + } + features_copy["_storage_write_client_provider"] = result._provide_storage_write_client + setattr(result, key, features_copy) + else: + setattr(result, key, copy.deepcopy(value, memo)) + return result + + def close_pool(self) -> None: + """Close the clients this configuration created. + + A client supplied by the caller is left open, since its lifetime belongs + to whoever created it. + """ + with self._storage_write_lock: + storage_write_client = self._storage_write_client + self._storage_write_client = None + if storage_write_client is not None: + transport = getattr(storage_write_client, "transport", None) + if transport is not None: + transport.close() + if self._owns_connection_instance and self._connection_instance is not None: + self._connection_instance.close() + self._connection_instance = None + + def create_connection(self) -> BigQueryConnection: + """Create and return a new BigQuery Client instance. + + Returns: + A new BigQuery Client instance. + + Raises: + ImproperConfigurationError: If the connection could not be established. + """ + + if self._connection_instance is not None: + return cast("BigQueryConnection", self._connection_instance) + + with self._storage_write_lock: + if self._connection_instance is not None: + return cast("BigQueryConnection", self._connection_instance) + + try: + client_fields = { + "project", + "location", + "credentials", + "client_options", + "client_info", + "default_query_job_config", + "default_load_job_config", + "default_job_creation_mode", + } + config_dict: dict[str, Any] = { + field: value + for field, value in self.connection_config.items() + if field in client_fields and value is not None and value is not Empty + } + connection = self.connection_type(**config_dict) + + self._qualify_default_dataset(connection) + + if self._user_connection_hook is not None: + self._user_connection_hook(connection) + + self._connection_instance = connection + except Exception as e: + project = self.connection_config.get("project", "Unknown") + msg = f"Could not configure BigQuery connection for project '{project}'. Error: {e}" + raise ImproperConfigurationError(msg) from e + return connection + + def provide_connection(self, *_args: Any, **_kwargs: Any) -> "BigQueryConnectionContext": + """Provide a BigQuery client within a context manager. + + Args: + *_args: Additional arguments. + **_kwargs: Additional keyword arguments. + + Returns: + A BigQuery connection context manager. + """ + return BigQueryConnectionContext(self) + + def provide_session( + self, *_args: Any, statement_config: "StatementConfig | None" = None, **_kwargs: Any + ) -> "BigQuerySessionContext": + """Provide a BigQuery driver session context manager. + + Args: + *_args: Additional arguments. + statement_config: Optional statement configuration override. + **_kwargs: Additional keyword arguments. + + Returns: + A BigQuery driver session context manager. + """ + handler = _BigQuerySessionConnectionHandler(self) + + return BigQuerySessionContext( + acquire_connection=handler.acquire_connection, + release_connection=handler.release_connection, + statement_config=statement_config or self.statement_config or default_statement_config, + driver_features=self.driver_features, + prepare_driver=self._prepare_driver, + ) + + def get_signature_namespace(self) -> "dict[str, Any]": + """Get the signature namespace for BigQuery types. + + Returns: + Dictionary mapping type names to types. + """ + namespace = super().get_signature_namespace() + namespace.update({ + "BigQueryConnectionContext": BigQueryConnectionContext, + "BigQueryConnection": BigQueryConnection, + "BigQueryConnectionParams": BigQueryConnectionParams, + "BigQueryCursor": BigQueryCursor, + "BigQueryDriver": BigQueryDriver, + "BigQueryDriverFeatures": BigQueryDriverFeatures, + "BigQueryExceptionHandler": BigQueryExceptionHandler, + "BigQuerySessionContext": BigQuerySessionContext, + }) + return namespace + + def get_event_runtime_hints(self) -> "EventRuntimeHints": + """Return polling defaults tuned for BigQuery latency.""" + + return EventRuntimeHints(poll_interval=2.0, lease_seconds=60, retention_seconds=172_800) + def _provide_storage_write_client(self, connection: "BigQueryConnection") -> Any: """Return the shared BigQuery Storage Write client, creating it on first use. @@ -289,23 +451,6 @@ def _provide_storage_write_client(self, connection: "BigQueryConnection") -> Any self._storage_write_client = BigQueryStorageWriteModule.BigQueryWriteClient(**client_kwargs) return self._storage_write_client - def close_pool(self) -> None: - """Close the clients this configuration created. - - A client supplied by the caller is left open, since its lifetime belongs - to whoever created it. - """ - with self._storage_write_lock: - storage_write_client = self._storage_write_client - self._storage_write_client = None - if storage_write_client is not None: - transport = getattr(storage_write_client, "transport", None) - if transport is not None: - transport.close() - if self._owns_connection_instance and self._connection_instance is not None: - self._connection_instance.close() - self._connection_instance = None - def _resolve_default_dataset(self) -> "str | None": """Resolve the fully qualified default dataset, if one can be determined. @@ -336,22 +481,37 @@ def _qualify_default_dataset(self, connection: "BigQueryConnection") -> None: Args: connection: The freshly created BigQuery client. """ - dataset_id = self.connection_config.get("dataset_id") - if not dataset_id or "." in dataset_id: - return - if self.connection_config.get("project"): - return job_config = self.connection_config.get("default_query_job_config") - if job_config is None or job_config.default_dataset is not None: + if job_config is None: return - project = connection.project - if project: - job_config.default_dataset = f"{project}.{dataset_id}" + dataset_id = self.connection_config.get("dataset_id") + if ( + dataset_id + and "." not in dataset_id + and not self.connection_config.get("project") + and job_config.default_dataset is None + ): + project = getattr(connection, "project", None) + if project: + job_config.default_dataset = f"{project}.{dataset_id}" + if getattr(connection, "_default_query_job_config", None) is None or ( + job_config.default_dataset is not None + and getattr(connection._default_query_job_config, "default_dataset", None) is None + ): + with contextlib.suppress(AttributeError, TypeError): + connection._default_query_job_config = job_config def _setup_default_job_config(self) -> None: """Set up default job configuration.""" - - job_config = QueryJobConfig() + existing_config = self.connection_config.get("default_query_job_config") + has_overrides = any(self.connection_config.get(key) is not None for key in _JOB_CONFIG_OVERRIDE_KEYS) + if existing_config is not None: + if not has_overrides: + return + job_config = QueryJobConfig() + copy_job_config(existing_config, job_config) + else: + job_config = QueryJobConfig() default_dataset = self._resolve_default_dataset() if default_dataset is not None: @@ -360,7 +520,7 @@ def _setup_default_job_config(self) -> None: use_query_cache = self.connection_config.get("use_query_cache") if use_query_cache is not None: job_config.use_query_cache = use_query_cache - else: + elif existing_config is None: job_config.use_query_cache = True maximum_bytes_billed = self.connection_config.get("maximum_bytes_billed") @@ -392,105 +552,3 @@ def _setup_default_job_config(self) -> None: job_config.max_slots = max_slots self.connection_config["default_query_job_config"] = job_config - - def create_connection(self) -> BigQueryConnection: - """Create and return a new BigQuery Client instance. - - Returns: - A new BigQuery Client instance. - - Raises: - ImproperConfigurationError: If the connection could not be established. - """ - - if self._connection_instance is not None: - return cast("BigQueryConnection", self._connection_instance) - - try: - client_fields = { - "project", - "location", - "credentials", - "client_options", - "client_info", - "default_query_job_config", - "default_load_job_config", - "default_job_creation_mode", - } - config_dict: dict[str, Any] = { - field: value - for field, value in self.connection_config.items() - if field in client_fields and value is not None and value is not Empty - } - connection = self.connection_type(**config_dict) - - self._qualify_default_dataset(connection) - - if self._user_connection_hook is not None: - self._user_connection_hook(connection) - - self._connection_instance = connection - except Exception as e: - project = self.connection_config.get("project", "Unknown") - msg = f"Could not configure BigQuery connection for project '{project}'. Error: {e}" - raise ImproperConfigurationError(msg) from e - return connection - - def provide_connection(self, *_args: Any, **_kwargs: Any) -> "BigQueryConnectionContext": - """Provide a BigQuery client within a context manager. - - Args: - *_args: Additional arguments. - **_kwargs: Additional keyword arguments. - - Returns: - A BigQuery connection context manager. - """ - return BigQueryConnectionContext(self) - - def provide_session( - self, *_args: Any, statement_config: "StatementConfig | None" = None, **_kwargs: Any - ) -> "BigQuerySessionContext": - """Provide a BigQuery driver session context manager. - - Args: - *_args: Additional arguments. - statement_config: Optional statement configuration override. - **_kwargs: Additional keyword arguments. - - Returns: - A BigQuery driver session context manager. - """ - handler = _BigQuerySessionConnectionHandler(self) - - return BigQuerySessionContext( - acquire_connection=handler.acquire_connection, - release_connection=handler.release_connection, - statement_config=statement_config or self.statement_config or default_statement_config, - driver_features=self.driver_features, - prepare_driver=self._prepare_driver, - ) - - def get_signature_namespace(self) -> "dict[str, Any]": - """Get the signature namespace for BigQuery types. - - Returns: - Dictionary mapping type names to types. - """ - namespace = super().get_signature_namespace() - namespace.update({ - "BigQueryConnectionContext": BigQueryConnectionContext, - "BigQueryConnection": BigQueryConnection, - "BigQueryConnectionParams": BigQueryConnectionParams, - "BigQueryCursor": BigQueryCursor, - "BigQueryDriver": BigQueryDriver, - "BigQueryDriverFeatures": BigQueryDriverFeatures, - "BigQueryExceptionHandler": BigQueryExceptionHandler, - "BigQuerySessionContext": BigQuerySessionContext, - }) - return namespace - - def get_event_runtime_hints(self) -> "EventRuntimeHints": - """Return polling defaults tuned for BigQuery latency.""" - - return EventRuntimeHints(poll_interval=2.0, lease_seconds=60, retention_seconds=172_800) diff --git a/sqlspec/adapters/bigquery/core.py b/sqlspec/adapters/bigquery/core.py index b45bb6156..2f632a681 100644 --- a/sqlspec/adapters/bigquery/core.py +++ b/sqlspec/adapters/bigquery/core.py @@ -1,9 +1,10 @@ """BigQuery adapter compiled helpers.""" import contextlib -import datetime +import datetime as dt import importlib import io +import ipaddress from decimal import Decimal from enum import Enum from typing import TYPE_CHECKING, Any, Protocol, cast, get_args @@ -44,6 +45,7 @@ _classify_timeout_or_cancellation, ) from sqlspec.utils.logging import get_logger +from sqlspec.utils.module_loader import ensure_pyarrow, import_optional from sqlspec.utils.serializers import from_json, to_json from sqlspec.utils.type_converters import build_uuid_coercions from sqlspec.utils.type_guards import has_errors @@ -58,12 +60,16 @@ BigQueryLoadFormat = Literal["jsonl", "json", "parquet", "arrow-ipc", "csv", "avro", "orc"] +_pa: Any = import_optional("pyarrow") +_pq: Any = import_optional("pyarrow.parquet") __all__ = ( "BigQueryStreamSource", "apply_driver_features", "build_arrow_write_stream_payload", "build_dml_rowcount", + "build_export_statement", + "build_export_uri", "build_inlined_script", "build_load_job_config", "build_load_job_telemetry", @@ -80,9 +86,12 @@ "is_simple_insert", "normalize_script_rowcount", "resolve_column_names", + "resolve_export_format", + "run_query_and_wait", "run_query_job", "storage_api_available", "try_bulk_insert", + "uses_local_bigquery_endpoint", ) HTTP_CONFLICT = 409 @@ -103,7 +112,7 @@ only converts hung servers (notably emulators that execute jobs synchronously inside the HTTP handler) into fast, retryable errors. """ -LOCAL_BIGQUERY_ENDPOINT_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1"}) # noqa: S104 +LOCAL_BIGQUERY_ENDPOINT_HOSTS = frozenset({"localhost", "127.0.0.1", ipaddress.IPv4Address(0).compressed, "::1"}) BQ_STORAGE_WRITE_MAX_APPEND_REQUEST_BYTES = 20_000_000 @@ -117,16 +126,18 @@ def storage_api_available() -> bool: _BIGQUERY_MODULE: Any | None = None -_BQ_TYPE_MAP: "dict[type, tuple[str, str | None]]" = { +_UUID_TYPES: tuple[type[Any], ...] = tuple(build_uuid_coercions().keys()) +_BQ_TYPE_MAP: "dict[type[Any], tuple[str, str | None]]" = { bool: ("BOOL", None), int: ("INT64", None), float: ("FLOAT64", None), Decimal: ("BIGNUMERIC", None), str: ("STRING", None), bytes: ("BYTES", None), - datetime.date: ("DATE", None), - datetime.time: ("TIME", None), + dt.date: ("DATE", None), + dt.time: ("TIME", None), dict: ("JSON", None), + **dict.fromkeys(_UUID_TYPES, ("STRING", None)), } @@ -138,6 +149,7 @@ def try_bulk_insert( *, allow_parse: bool = True, result_timeout: float | None = None, + default_dataset: str | None = None, ) -> "int | None": """Attempt bulk insert via Parquet load. @@ -148,34 +160,39 @@ def try_bulk_insert( expression: Optional parsed expression to reuse. allow_parse: Whether to parse SQL when expression is unavailable. result_timeout: Timeout forwarded to the load job request and result wait. + default_dataset: Optional default dataset used to qualify unqualified table names. Returns: Inserted row count if bulk insert succeeds, otherwise None. """ - if _uses_local_bigquery_endpoint(connection): + if uses_local_bigquery_endpoint(connection): return None table_name = extract_insert_table(sql, expression, allow_parse=allow_parse) if not table_name: return None + if "." not in table_name and default_dataset: + table_name = f"{default_dataset}.{table_name}" - if _has_synthetic_positional_keys(parameters): + remapped_parameters = _remap_bulk_insert_parameters(sql, parameters, expression, allow_parse=allow_parse) + if remapped_parameters is None: return None - try: - import pyarrow as pa - import pyarrow.parquet as pq + if _has_synthetic_positional_keys(remapped_parameters): + return None - arrow_table = pa.Table.from_pylist(parameters) + try: + ensure_pyarrow() + arrow_table = _pa.Table.from_pylist(remapped_parameters) buffer = io.BytesIO() - pq.write_table(arrow_table, buffer) + _pq.write_table(arrow_table, buffer) buffer.seek(0) job_config = build_load_job_config("parquet", overwrite=False) job = connection.load_table_from_file(buffer, table_name, job_config=job_config, timeout=result_timeout) job.result(timeout=result_timeout) - return len(parameters) + return len(remapped_parameters) except ImportError: logger.debug("pyarrow not available, falling back to literal inlining") return None @@ -260,14 +277,22 @@ def create_parameters(parameters: Any, json_serializer: "Callable[[Any], str] | if type(value) is TypedParameter: declared_type = value.original_type actual_value = value.value + if isinstance(actual_value, Enum): + actual_value = actual_value.value + elif isinstance(actual_value, _UUID_TYPES): + actual_value = str(actual_value) elif isinstance(value, Enum): actual_value = value.value + elif isinstance(value, _UUID_TYPES): + actual_value = str(value) else: actual_value = value param_type, array_element_type = _query_parameter_type(actual_value, declared_type) if param_type == "ARRAY" and array_element_type: - bq_parameters.append(_create_array_parameter(param_name_for_bq, actual_value, array_element_type)) + bq_parameters.append( + _create_array_parameter(param_name_for_bq, actual_value, array_element_type, json_serializer) + ) elif param_type == "JSON": bq_parameters.append(_create_json_parameter(param_name_for_bq, actual_value, json_serializer)) elif param_type: @@ -448,7 +473,16 @@ def is_simple_insert(sql: str, expression: "exp.Expr | None" = None, *, allow_pa parsed = expression or sqlglot.parse_one(sql, dialect="bigquery") if not isinstance(parsed, exp.Insert): return False - return parsed.expression is not None or parsed.find(exp.Values) is not None + values = parsed.args.get("expression") + if not isinstance(values, exp.Values): + return False + value_tuples = values.expressions + if len(value_tuples) != 1 or not isinstance(value_tuples[0], exp.Tuple): + return False + return all( + isinstance(item, (exp.Parameter, exp.Placeholder, exp.Var, exp.Literal, exp.Boolean, exp.Null)) + for item in value_tuples[0].expressions + ) except Exception: return False @@ -523,16 +557,20 @@ def __init__( def start(self) -> None: handler = self._driver.handle_database_exceptions() with handler: - page_size = None if _uses_local_bigquery_endpoint(self._driver.connection) else self._chunk_size + page_size = None if uses_local_bigquery_endpoint(self._driver.connection) else self._chunk_size deadline = self._driver._job_retry_deadline page_retry = DEFAULT_RETRY.with_timeout(deadline) if deadline > 0 else None job = self._driver._run_query_job(self._driver.connection, self._sql, self._parameters) - row_iterator = job.result( - page_size=page_size, - retry=page_retry, - job_retry=self._driver._job_retry, - timeout=self._driver._job_result_timeout, - ) + result_kwargs: dict[str, Any] = { + "page_size": page_size, + "retry": page_retry, + "job_retry": self._driver._job_retry, + "timeout": self._driver._job_result_timeout, + } + job_result_defaults = getattr(self._driver, "_job_result_kwargs_defaults", None) + if isinstance(job_result_defaults, dict) and "max_results" in job_result_defaults: + result_kwargs["max_results"] = job_result_defaults["max_results"] + row_iterator = job.result(**result_kwargs) self._job = job self._pages = row_iterator.pages self._driver._check_pending_exception(handler) @@ -646,9 +684,9 @@ def build_profile() -> "DriverParameterProfile": int: _identity, float: _identity, bytes: _identity, - datetime.datetime: _identity, - datetime.date: _identity, - datetime.time: _identity, + dt.datetime: _identity, + dt.date: _identity, + dt.time: _identity, Decimal: _identity, dict: _identity, list: _identity, @@ -748,7 +786,7 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec return _create_bigquery_error(error, status_code, SQLSpecError, "error") -def _resolve_export_format(format_hint: "StorageFormat | None") -> str | None: +def resolve_export_format(format_hint: "StorageFormat | None") -> str | None: """Map the Arrow writer's format default to a native export format.""" if format_hint is None or format_hint == "parquet": return "PARQUET" @@ -759,7 +797,10 @@ def _resolve_export_format(format_hint: "StorageFormat | None") -> str | None: return None -def _build_export_statement(sql: str, uri: str, export_format: str, connection: str | None) -> str: +_resolve_export_format = resolve_export_format + + +def build_export_statement(sql: str, uri: str, export_format: str, connection: str | None) -> str: """Wrap compiled SQL without changing its bound query parameters.""" connection_clause = "" if connection is not None: @@ -776,13 +817,16 @@ def _build_export_statement(sql: str, uri: str, export_format: str, connection: return f"EXPORT DATA{connection_clause} OPTIONS ({options}) AS {sql}" -def _build_export_uri(uri: str, format_hint: "StorageFormat | None" = None) -> str: +_build_export_statement = build_export_statement + + +def build_export_uri(uri: str, format_hint: "StorageFormat | None" = None) -> str: """Build a single-leaf-wildcard URI from a resolved storage destination. The filename suffix is preserved independently of the selected encoding. Directory destinations receive a filename matching the actual format. """ - format_name = _resolve_export_format(format_hint) + format_name = resolve_export_format(format_hint) if format_name is None: msg = "Unsupported native BigQuery export format." raise ImproperConfigurationError(msg) @@ -825,6 +869,64 @@ def _build_export_uri(uri: str, format_hint: "StorageFormat | None" = None) -> s return f"{scheme}://{authority}{parent}/{leaf}" +_build_export_uri = build_export_uri + + +def uses_local_bigquery_endpoint(connection: "BigQueryConnection") -> bool: + """Return True when a BigQuery client points at a local emulator endpoint.""" + bq_conn = cast("object", getattr(connection, "_connection", None)) + api_base_url = cast("str | None", getattr(bq_conn, "API_BASE_URL", None)) if bq_conn is not None else None + if not isinstance(api_base_url, str): + return False + try: + hostname = urlparse(api_base_url).hostname + except ValueError: + return False + return hostname in LOCAL_BIGQUERY_ENDPOINT_HOSTS + + +_uses_local_bigquery_endpoint = uses_local_bigquery_endpoint + + +def run_query_and_wait( + connection: "BigQueryConnection", + sql: str, + parameters: Any, + *, + default_job_config: "QueryJobConfig | None", + json_serializer: "Callable[[Any], str]", + retry: "Retry | None" = None, + wait_timeout: float | None = None, + polling_timeout: float | None = None, + job_retry: "Retry | None" = None, + page_size: int | None = None, + max_results: int | None = None, +) -> Any: + """Execute a BigQuery query via query_and_wait and return the row iterator.""" + final_job_config = QueryJobConfig() + if default_job_config: + copy_job_config(default_job_config, final_job_config) + final_job_config.query_parameters = create_parameters(parameters, json_serializer) + + query_kwargs: dict[str, Any] = {"job_config": final_job_config} + if retry is not None: + query_kwargs["retry"] = retry + if wait_timeout is not None: + query_kwargs["api_timeout"] = wait_timeout + if polling_timeout is not None: + query_kwargs["wait_timeout"] = polling_timeout + if job_retry is not None: + query_kwargs["job_retry"] = job_retry + if page_size is not None: + query_kwargs["page_size"] = page_size + if max_results is not None: + query_kwargs["max_results"] = max_results + return connection.query_and_wait(sql, **query_kwargs) + + +_run_query_and_wait = run_query_and_wait + + class _BigQueryRow(Protocol): def items(self) -> "Iterable[tuple[str, object]]": ... @@ -844,6 +946,58 @@ def _run_query_job( def _check_pending_exception(self, exc_handler: "SyncExceptionHandler") -> None: ... +def _remap_bulk_insert_parameters( + sql: str, parameters: "list[dict[str, Any]]", expression: "exp.Expr | None" = None, *, allow_parse: bool = True +) -> "list[dict[str, Any]] | None": + """Validate INSERT VALUES parameter expressions and map parameter keys to target column names.""" + parsed = expression + if parsed is None and allow_parse: + try: + parsed = sqlglot.parse_one(sql, dialect="bigquery") + except Exception: + parsed = None + if not isinstance(parsed, exp.Insert): + return parameters + values = parsed.args.get("expression") + if not isinstance(values, exp.Values) or len(values.expressions) != 1: + return None + value_tuple = values.expressions[0] + if not isinstance(value_tuple, exp.Tuple): + return None + param_names: list[str] = [] + for item in value_tuple.expressions: + if isinstance(item, exp.Parameter): + inner = item.this + name = inner.name if isinstance(inner, exp.Expr) else str(inner) + param_names.append(name.lstrip("@")) + elif isinstance(item, (exp.Placeholder, exp.Var)): + param_names.append(item.name.lstrip("@")) + else: + return None + schema_expr = parsed.this + if not isinstance(schema_expr, exp.Schema) or not schema_expr.expressions: + return parameters + col_names = [col.name for col in schema_expr.expressions] + if len(col_names) != len(param_names): + return None + if col_names == param_names: + return parameters + remapped: list[dict[str, Any]] = [] + for row in parameters: + remapped_row: dict[str, Any] = {} + for col_name, param_name in zip(col_names, param_names, strict=False): + if param_name in row: + remapped_row[col_name] = row[param_name] + elif f"@{param_name}" in row: + remapped_row[col_name] = row[f"@{param_name}"] + elif col_name in row: + remapped_row[col_name] = row[col_name] + else: + return None + remapped.append(remapped_row) + return remapped + + def _has_synthetic_positional_keys(parameters: "list[dict[str, Any]]") -> bool: """Return True when the first parameter mapping is keyed only by synthetic positional names. @@ -861,19 +1015,6 @@ def _has_synthetic_positional_keys(parameters: "list[dict[str, Any]]") -> bool: ) -def _uses_local_bigquery_endpoint(connection: "BigQueryConnection") -> bool: - """Return True when a BigQuery client points at a local emulator endpoint.""" - bq_conn = cast("object", getattr(connection, "_connection", None)) - api_base_url = cast("str | None", getattr(bq_conn, "API_BASE_URL", None)) if bq_conn is not None else None - if not isinstance(api_base_url, str): - return False - try: - hostname = urlparse(api_base_url).hostname - except ValueError: - return False - return hostname in LOCAL_BIGQUERY_ENDPOINT_HOSTS - - def _build_multi_row_insert_script( expression: "exp.Expr", parameters: "list[dict[str, Any]]", @@ -922,19 +1063,37 @@ def _build_multi_row_insert_script( return ";\n".join(statements) -def _create_array_parameter(name: str, value: Any, array_type: str) -> "BigQueryParam": +def _create_array_parameter( + name: str, value: Any, array_type: str, json_serializer: "Callable[[Any], str] | None" = None +) -> "BigQueryParam": """Create BigQuery ARRAY parameter. Args: name: Parameter name. value: Array value (converted to list, empty list if None). array_type: BigQuery array element type. + json_serializer: Optional JSON serializer for JSON array elements. Returns: ArrayQueryParameter instance. """ bigquery = _load_bigquery_module() - return cast("BigQueryParam", bigquery.ArrayQueryParameter(name, array_type, [] if value is None else list(value))) + if value is None: + normalized_items: list[Any] = [] + else: + normalized_items = [] + for item in value: + if item is None: + normalized_items.append(None) + elif isinstance(item, Enum): + normalized_items.append(item.value) + elif isinstance(item, _UUID_TYPES): + normalized_items.append(str(item)) + elif array_type == "JSON" and json_serializer is not None: + normalized_items.append(from_json(json_serializer(item))) + else: + normalized_items.append(item) + return cast("BigQueryParam", bigquery.ArrayQueryParameter(name, array_type, normalized_items)) def _create_json_parameter( @@ -957,7 +1116,7 @@ def _create_json_parameter( """ bigquery = _load_bigquery_module() normalized = value - if json_serializer is not None: + if value is not None and json_serializer is not None: normalized = from_json(json_serializer(value)) return cast("BigQueryParam", bigquery.ScalarQueryParameter(name, "JSON", normalized)) @@ -996,6 +1155,24 @@ def _load_bigquery_module() -> Any: return bigquery if _BIGQUERY_MODULE is None else _BIGQUERY_MODULE +def _resolve_declared_element_type(declared_type: "type[Any] | None") -> "str | None": + if declared_type is None: + return None + args = get_args(declared_type) + element_type = args[0] if args else declared_type + if isinstance(element_type, type) and issubclass(element_type, Enum): + first_member = next(iter(element_type), None) + if first_member is not None: + member_type, _ = _query_parameter_type(first_member.value) + return member_type or "STRING" + return "STRING" + if element_type in _BQ_TYPE_MAP and element_type not in (list, tuple): + return _BQ_TYPE_MAP[element_type][0] + if element_type is dt.datetime: + return "TIMESTAMP" + return None + + def _query_parameter_type(value: Any, declared_type: "type[Any] | None" = None) -> "tuple[str | None, str | None]": """Determine BigQuery parameter type from Python value. @@ -1007,16 +1184,31 @@ def _query_parameter_type(value: Any, declared_type: "type[Any] | None" = None) Returns: Tuple of (parameter_type, array_element_type) """ + if isinstance(value, Enum): + value = value.value + + if isinstance(value, _UUID_TYPES): + return ("STRING", None) + + bq_mod = _load_bigquery_module() + if isinstance(value, bq_mod.StructQueryParameter): + return ("STRUCT", None) + if value is None: + if isinstance(declared_type, type) and issubclass(declared_type, Enum): + first_member = next(iter(declared_type), None) + if first_member is not None: + return _query_parameter_type(first_member.value) + return ("STRING", None) if declared_type is not None and declared_type in _BQ_TYPE_MAP: return _BQ_TYPE_MAP[declared_type] - if declared_type is datetime.datetime: + if declared_type is dt.datetime: return ("TIMESTAMP", None) return ("STRING", None) value_type = type(value) - if value_type is datetime.datetime: + if value_type is dt.datetime: return ("TIMESTAMP" if value.tzinfo else "DATETIME", None) if value_type in _BQ_TYPE_MAP: @@ -1024,17 +1216,18 @@ def _query_parameter_type(value: Any, declared_type: "type[Any] | None" = None) if isinstance(value, (list, tuple)): if not value: - args = get_args(declared_type) - element_type = args[0] if args else declared_type - if element_type in _BQ_TYPE_MAP and element_type not in (list, tuple, dict): - return ("ARRAY", _BQ_TYPE_MAP[element_type][0]) - if element_type is datetime.datetime: - return ("ARRAY", "TIMESTAMP") + resolved_elem = _resolve_declared_element_type(declared_type) + if resolved_elem is not None: + return ("ARRAY", resolved_elem) msg = "Cannot determine BigQuery ARRAY type for empty sequence." raise SQLSpecError(msg) - element_type, _ = _query_parameter_type(value[0]) + first_non_null = next((item for item in value if item is not None), None) + if first_non_null is None: + resolved_elem = _resolve_declared_element_type(declared_type) + return ("ARRAY", resolved_elem or "STRING") + element_type, _ = _query_parameter_type(first_non_null) if element_type is None: - msg = f"Unsupported element type in ARRAY: {type(value[0])}" + msg = f"Unsupported element type in ARRAY: {type(first_non_null)}" raise SQLSpecError(msg) return "ARRAY", element_type @@ -1086,39 +1279,6 @@ def _copy_job_config_field(source_config: "QueryJobConfig", target_config: "Quer setattr(target_config, attr, value) -def _run_query_and_wait( - connection: "BigQueryConnection", - sql: str, - parameters: Any, - *, - default_job_config: "QueryJobConfig | None", - json_serializer: "Callable[[Any], str]", - retry: "Retry | None" = None, - wait_timeout: float | None = None, - job_retry: "Retry | None" = None, - page_size: int | None = None, - max_results: int | None = None, -) -> Any: - """Execute a BigQuery query via query_and_wait and return the row iterator.""" - final_job_config = QueryJobConfig() - if default_job_config: - copy_job_config(default_job_config, final_job_config) - final_job_config.query_parameters = create_parameters(parameters, json_serializer) - - query_kwargs: dict[str, Any] = {"job_config": final_job_config} - if retry is not None: - query_kwargs["retry"] = retry - if wait_timeout is not None: - query_kwargs["api_timeout"] = wait_timeout - if job_retry is not None: - query_kwargs["job_retry"] = job_retry - if page_size is not None: - query_kwargs["page_size"] = page_size - if max_results is not None: - query_kwargs["max_results"] = max_results - return connection.query_and_wait(sql, **query_kwargs) - - def _append_sized_arrow_requests( requests: "list[Any]", stream_name: str, batch: Any, schema_bytes: bytes, types: Any, *, max_request_bytes: int ) -> None: diff --git a/sqlspec/adapters/bigquery/data_dictionary.py b/sqlspec/adapters/bigquery/data_dictionary.py index 8bc39303b..3b9ac2006 100644 --- a/sqlspec/adapters/bigquery/data_dictionary.py +++ b/sqlspec/adapters/bigquery/data_dictionary.py @@ -72,6 +72,11 @@ def __init__(self, *, project: str | None = None, dataset: str | None = None, re self.dataset = _clean_bigquery_identifier_part(dataset) self.region = _clean_bigquery_identifier_part(region) + @property + def schema_name(self) -> str | None: + """Return the dataset name used by schema bind parameters.""" + return self.dataset + @classmethod def from_schema( cls, schema: str | None, *, project: str | None = None, dataset: str | None = None, region: str | None = None @@ -101,11 +106,6 @@ def from_schema( raise ValueError(msg) return cls(project=project or schema_project, dataset=dataset or schema_dataset, region=region) - @property - def schema_name(self) -> str | None: - """Return the dataset name used by schema bind parameters.""" - return self.dataset - def dataset_prefix(self) -> str: """Return a quoted dataset prefix for legacy INFORMATION_SCHEMA SQL.""" if self.dataset is None: @@ -184,9 +184,10 @@ def get_optimal_type(self, driver: "BigQueryDriver", type_category: str) -> str: def get_tables(self, driver: "BigQueryDriver", schema: "str | None" = None) -> "list[TableMetadata]": """Get tables sorted by topological dependency order using BigQuery catalog.""" - scope = BigQueryMetadataScope.from_schema(schema) + resolved_schema = _resolve_bigquery_schema(driver, schema) + scope = BigQueryMetadataScope.from_schema(resolved_schema) self._log_schema_introspect(driver, schema_name=scope.schema_name, table_name=None, operation="tables") - tables_table, kcu_table, rc_table = format_bigquery_information_schema_tables(schema) + tables_table, kcu_table, rc_table = format_bigquery_information_schema_tables(resolved_schema) query_text = self.get_query_text("tables", "by_schema").format( tables_table=tables_table, kcu_table=kcu_table, rc_table=rc_table @@ -197,8 +198,9 @@ def get_columns( self, driver: "BigQueryDriver", table: "str | None" = None, schema: "str | None" = None ) -> "list[ColumnMetadata]": """Get column information for a table or schema.""" - scope = BigQueryMetadataScope.from_schema(schema) - schema_prefix = format_bigquery_schema_prefix(schema) + resolved_schema = _resolve_bigquery_schema(driver, schema) + scope = BigQueryMetadataScope.from_schema(resolved_schema) + schema_prefix = format_bigquery_schema_prefix(resolved_schema) if table is None: self._log_schema_introspect(driver, schema_name=scope.schema_name, table_name=None, operation="columns") query_text = self.get_query_text("columns", "by_schema").format(schema_prefix=schema_prefix) @@ -212,7 +214,8 @@ def get_indexes( self, driver: "BigQueryDriver", table: "str | None" = None, schema: "str | None" = None ) -> "list[IndexMetadata]": """Get index metadata for a table or schema.""" - scope = BigQueryMetadataScope.from_schema(schema) + resolved_schema = _resolve_bigquery_schema(driver, schema) + scope = BigQueryMetadataScope.from_schema(resolved_schema) if scope.dataset is None: return [] if table is None: @@ -240,7 +243,8 @@ def get_foreign_keys( self, driver: "BigQueryDriver", table: "str | None" = None, schema: "str | None" = None ) -> "list[ForeignKeyMetadata]": """Get foreign key metadata.""" - scope = BigQueryMetadataScope.from_schema(schema) + resolved_schema = _resolve_bigquery_schema(driver, schema) + scope = BigQueryMetadataScope.from_schema(resolved_schema) if table is None: self._log_schema_introspect( driver, schema_name=scope.schema_name, table_name=None, operation="foreign_keys" @@ -269,6 +273,26 @@ def get_metadata_capabilities( return MetadataCapabilityProfile(self.dialect, adapter=type(self).__name__, capabilities=capabilities) +def _resolve_bigquery_schema(driver: "BigQueryDriver", schema: "str | None") -> "str | None": + if schema is not None: + return schema + default_job_config = getattr(driver, "_default_query_job_config", None) + if default_job_config is None: + return None + default_dataset = getattr(default_job_config, "default_dataset", None) + if default_dataset is None: + return None + if isinstance(default_dataset, str): + return default_dataset or None + project = getattr(default_dataset, "project", None) + dataset_id = getattr(default_dataset, "dataset_id", None) + if project and dataset_id: + return f"{project}.{dataset_id}" + if dataset_id: + return str(dataset_id) + return str(default_dataset) + + def _clean_bigquery_identifier_part(value: str | None) -> str | None: if value is None: return None diff --git a/sqlspec/adapters/bigquery/driver.py b/sqlspec/adapters/bigquery/driver.py index b2db2eb76..43a3d2f19 100644 --- a/sqlspec/adapters/bigquery/driver.py +++ b/sqlspec/adapters/bigquery/driver.py @@ -15,6 +15,7 @@ from sqlspec.adapters.bigquery._typing import BIGQUERY_POLLING_DEFAULT_VALUE as POLLING_DEFAULT_VALUE from sqlspec.adapters.bigquery._typing import ( BigQueryConnection, + BigQueryConnectionProperty, BigQueryCursor, BigQueryQueryJobConfig, BigQuerySessionContext, @@ -25,13 +26,10 @@ from sqlspec.adapters.bigquery.core import ( DEFAULT_REQUEST_TIMEOUT, BigQueryStreamSource, - _build_export_statement, - _build_export_uri, - _resolve_export_format, - _run_query_and_wait, - _uses_local_bigquery_endpoint, build_arrow_write_stream_payload, build_dml_rowcount, + build_export_statement, + build_export_uri, build_inlined_script, build_load_job_config, build_load_job_telemetry, @@ -43,9 +41,12 @@ is_simple_insert, normalize_script_rowcount, resolve_column_names, + resolve_export_format, + run_query_and_wait, run_query_job, storage_api_available, try_bulk_insert, + uses_local_bigquery_endpoint, ) from sqlspec.adapters.bigquery.data_dictionary import BigQueryDataDictionary from sqlspec.core import ( @@ -119,6 +120,7 @@ class BigQueryDriver(SyncDriverAdapterBase): "_data_dictionary", "_default_query_job_config", "_enable_storage_write_api", + "_in_transaction", "_job_result_kwargs_defaults", "_job_result_timeout", "_job_retry", @@ -126,6 +128,7 @@ class BigQueryDriver(SyncDriverAdapterBase): "_json_serializer", "_literal_inliner", "_request_timeout", + "_session_id", "_use_query_and_wait", ) dialect = "bigquery" @@ -166,20 +169,19 @@ def __init__( self._request_timeout = self._resolve_request_timeout(features) self._use_query_and_wait = bool(features.get("use_query_and_wait", True)) self._enable_storage_write_api = bool(features.get("enable_storage_write_api", False)) + self._in_transaction: bool = False + self._session_id: str | None = None - def _can_use_query_and_wait(self, statement: "SQL", sql: str) -> bool: - """Use the interactive fast path only for compatible query jobs.""" - if not self._use_query_and_wait or not hasattr(self.connection, "query_and_wait"): - return False - if self._job_result_timeout is not POLLING_DEFAULT_VALUE or _uses_local_bigquery_endpoint(self.connection): - return False - config = self._default_query_job_config - if config is not None: - if config.destination is not None or config.dry_run or config.create_session: - return False - if config.priority == "BATCH": - return False - return not isinstance(statement.expression, exp.Export) and not sql.lstrip().upper().startswith("EXPORT DATA") + @property + def data_dictionary(self) -> "BigQueryDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = BigQueryDataDictionary() + return self._data_dictionary def dispatch_execute(self, cursor: Any, statement: "SQL") -> ExecutionResult: """Execute single SQL statement with BigQuery data handling. @@ -193,7 +195,7 @@ def dispatch_execute(self, cursor: Any, statement: "SQL") -> ExecutionResult: """ sql, parameters = self._compiled_sql(statement, self.statement_config) if self._can_use_query_and_wait(statement, sql=sql): - row_iterator = _run_query_and_wait( + row_iterator = run_query_and_wait( cursor, sql, parameters, @@ -284,6 +286,7 @@ def dispatch_execute_many(self, cursor: Any, statement: "SQL") -> ExecutionResul parsed_expression, allow_parse=allow_parse, result_timeout=self._job_request_timeout(), + default_dataset=self._default_dataset_id(), ) if rowcount is not None: return self.create_execution_result(cursor, rowcount_override=rowcount, is_many_result=True) @@ -313,13 +316,103 @@ def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> ExecutionRes ) def begin(self) -> None: - """Begin transaction - BigQuery doesn't support transactions.""" + """Begin a BigQuery multi-statement session transaction.""" + self._in_transaction = True + if not hasattr(self.connection, "query"): + return + exc_handler = self.handle_database_exceptions() + with exc_handler: + job_config = ( + BigQueryQueryJobConfig(create_session=True) + if self._session_id is None + else BigQueryQueryJobConfig( + connection_properties=[BigQueryConnectionProperty("session_id", self._session_id)] + ) + ) + job = run_query_job( + self.connection, + "BEGIN TRANSACTION", + None, + default_job_config=self._default_query_job_config, + job_config=job_config, + json_serializer=self._json_serializer, + retry=self._job_retry, + timeout=self._job_request_timeout(), + job_retry=self._job_retry, + ) + result_fn = getattr(job, "result", None) + if callable(result_fn): + result_fn(job_retry=self._job_retry, timeout=self._job_result_timeout) + session_info = getattr(job, "session_info", None) + session_id = getattr(session_info, "session_id", None) + if isinstance(session_id, str) and session_id: + self._session_id = session_id + if exc_handler.pending_exception is not None: + raise exc_handler.pending_exception from None def commit(self) -> None: - """Commit transaction - BigQuery doesn't support transactions.""" + """Commit the active BigQuery session transaction.""" + was_in_tx = self._in_transaction + self._in_transaction = False + if not was_in_tx or not hasattr(self.connection, "query"): + return + exc_handler = self.handle_database_exceptions() + with exc_handler: + job = self._run_query_job(self.connection, "COMMIT TRANSACTION", None) + result_fn = getattr(job, "result", None) + if callable(result_fn): + result_fn(job_retry=self._job_retry, timeout=self._job_result_timeout) + if exc_handler.pending_exception is not None: + raise exc_handler.pending_exception from None def rollback(self) -> None: - """Rollback transaction - BigQuery doesn't support transactions.""" + """Rollback the active BigQuery session transaction.""" + was_in_tx = self._in_transaction + self._in_transaction = False + if not was_in_tx or not hasattr(self.connection, "query"): + return + exc_handler = self.handle_database_exceptions() + with exc_handler: + job = self._run_query_job(self.connection, "ROLLBACK TRANSACTION", None) + result_fn = getattr(job, "result", None) + if callable(result_fn): + result_fn(job_retry=self._job_retry, timeout=self._job_result_timeout) + if exc_handler.pending_exception is not None: + raise exc_handler.pending_exception from None + + def dry_run( + self, + statement: "Statement | QueryBuilder | SQL | str", + /, + *parameters: "StatementParameters | StatementFilter", + statement_config: "StatementConfig | None" = None, + **kwargs: Any, + ) -> int: + """Execute a dry-run query job and return total bytes processed.""" + config = statement_config or self.statement_config + prepared = self.prepare_statement(statement, parameters, statement_config=config, kwargs=kwargs) + sql, driver_params = self._compiled_sql(prepared, config) + job_config = BigQueryQueryJobConfig(dry_run=True, use_query_cache=False) + if self._session_id is not None: + job_config.connection_properties = [BigQueryConnectionProperty("session_id", self._session_id)] + exc_handler = self.handle_database_exceptions() + total_bytes = 0 + with exc_handler: + job = run_query_job( + self.connection, + sql, + driver_params, + default_job_config=self._default_query_job_config, + job_config=job_config, + json_serializer=self._json_serializer, + retry=self._job_retry, + timeout=self._job_request_timeout(), + job_retry=self._job_retry, + ) + total_bytes = int(getattr(job, "total_bytes_processed", 0) or 0) + if exc_handler.pending_exception is not None: + raise exc_handler.pending_exception from None + return total_bytes def create_savepoint(self, name: str) -> None: """Raise because BigQuery does not support savepoints. @@ -405,6 +498,28 @@ def select_to_arrow( ensure_pyarrow() if return_format in {"reader", "batches"}: + if uses_local_bigquery_endpoint(self.connection) or (native_only and not storage_api_available()): + if native_only: + msg = ( + "BigQuery native Arrow requires Storage API.\n" + "1. Install: pip install google-cloud-bigquery-storage\n" + "2. Enable API: https://console.cloud.google.com/apis/library/bigquerystorage.googleapis.com\n" + "3. Grant permissions: roles/bigquery.dataViewer\n" + "4. Use a real BigQuery endpoint instead of the local emulator" + ) + raise ImproperConfigurationError(msg) from RuntimeError(msg) + + return super().select_to_arrow( + statement, + *parameters, + statement_config=statement_config, + return_format=return_format, + native_only=native_only, + batch_size=batch_size, + arrow_schema=arrow_schema, + **kwargs, + ) + config = statement_config or self.statement_config prepared_statement = self.prepare_statement(statement, parameters, statement_config=config, kwargs=kwargs) sql, driver_params = self._compiled_sql(prepared_statement, config) @@ -417,31 +532,28 @@ def select_to_arrow( page_size=batch_size, job_retry=self._job_retry, timeout=self._job_result_timeout ) arrow_batches = row_iterator.to_arrow_iterable(bqstorage_client=self._bqstorage_client_or_none()) - arrow_reader = _bigquery_arrow_reader_from_iterable(arrow_batches) - if arrow_reader is not None: - streaming_result = build_arrow_result_from_reader( - prepared_statement, - arrow_reader, - return_format=return_format, - batch_size=batch_size, - arrow_schema=arrow_schema, - ) + fallback_schema = ( + arrow_schema + if arrow_schema is not None + else (query_job.schema or getattr(row_iterator, "schema", None)) + ) + arrow_reader = _bigquery_arrow_reader_from_iterable(arrow_batches, schema=fallback_schema) + streaming_result = build_arrow_result_from_reader( + prepared_statement, + arrow_reader, + return_format=return_format, + batch_size=batch_size, + arrow_schema=arrow_schema, + ) if exc_handler.pending_exception is not None: raise exc_handler.pending_exception from None - if streaming_result is not None: - return streaming_result + if streaming_result is None: + msg = "Unreachable" + raise RuntimeError(msg) + return streaming_result - return super().select_to_arrow( - prepared_statement, - statement_config=config, - return_format=return_format, - native_only=native_only, - batch_size=batch_size, - arrow_schema=arrow_schema, - ) - - native_arrow_available = storage_api_available() and not _uses_local_bigquery_endpoint(self.connection) + native_arrow_available = storage_api_available() and not uses_local_bigquery_endpoint(self.connection) if not native_arrow_available: if native_only: msg = ( @@ -492,7 +604,7 @@ def select_to_arrow( if arrow_result is None: msg = "Unreachable" - raise RuntimeError(msg) # pragma: no cover + raise RuntimeError(msg) return arrow_result @@ -515,24 +627,24 @@ def select_to_storage( """ self._require_capability("arrow_export_enabled") - export_format = _resolve_export_format(format_hint) + export_format = resolve_export_format(format_hint) native_uri = str(destination) if ( self.driver_features.get("enable_native_storage", True) and export_format is not None and self.storage_pipeline_factory is None - and not _uses_local_bigquery_endpoint(self.connection) + and not uses_local_bigquery_endpoint(self.connection) ): if native_uri.startswith("alias://"): native_uri = self._storage_pipeline().resolve_destination(destination).uri scheme = native_uri.partition("://")[0] connection = self.driver_features.get("native_export_connection") if scheme in {"gs", "gcs"} or (scheme in {"s3", "azure"} and connection is not None): - native_uri = _build_export_uri(native_uri, format_hint) + native_uri = build_export_uri(native_uri, format_hint) config = statement_config or self.statement_config prepared = self.prepare_statement(statement, parameters, statement_config=config, kwargs=kwargs) sql, driver_params = self._compiled_sql(prepared, config) - export_sql = _build_export_statement(sql, native_uri, export_format, connection) + export_sql = build_export_statement(sql, native_uri, export_format, connection) handler = self.handle_database_exceptions() native_telemetry: StorageTelemetry = { "destination": native_uri, @@ -576,11 +688,17 @@ def load_from_records( """Load in-memory records through BigQuery's newline-delimited JSON load API.""" rows = _records_to_json_rows(records, columns) job_config = build_load_job_config("jsonl", overwrite) - job = self.connection.load_table_from_json( - rows, table, job_config=job_config, timeout=self._job_request_timeout() - ) - job.result(timeout=self._job_request_timeout()) - telemetry_payload = build_load_job_telemetry(job, table, format_label="jsonl") + qualified_table = self._qualify_table_name(table) + exc_handler = self.handle_database_exceptions() + job: Any = None + with exc_handler: + job = self.connection.load_table_from_json( + rows, qualified_table, job_config=job_config, timeout=self._job_request_timeout() + ) + job.result(timeout=self._job_request_timeout()) + if exc_handler.pending_exception is not None: + raise exc_handler.pending_exception from None + telemetry_payload = build_load_job_telemetry(job, qualified_table, format_label="jsonl") return self._storage_job(telemetry_payload) def load_from_arrow( @@ -597,10 +715,11 @@ def load_from_arrow( self._require_capability("parquet_import_enabled") arrow_table = self._coerce_arrow_table(source) ensure_pyarrow() + qualified_table = self._qualify_table_name(table) - if self._enable_storage_write_api and not overwrite: + if self._enable_storage_write_api and not overwrite and not uses_local_bigquery_endpoint(self.connection): try: - telemetry_payload = self._load_arrow_via_storage_write_api(table, arrow_table) + telemetry_payload = self._load_arrow_via_storage_write_api(qualified_table, arrow_table) except ImportError as exc: logger.warning("Storage Write API unavailable, falling back to Parquet load job: %s", exc) else: @@ -614,11 +733,16 @@ def load_from_arrow( _pq.write_table(arrow_table, buffer) buffer.seek(0) job_config = build_load_job_config("parquet", overwrite) - job = self.connection.load_table_from_file( - buffer, table, job_config=job_config, timeout=self._job_request_timeout() - ) - job.result(timeout=self._job_request_timeout()) - telemetry_payload = build_load_job_telemetry(job, table, format_label="parquet") + exc_handler = self.handle_database_exceptions() + job: Any = None + with exc_handler: + job = self.connection.load_table_from_file( + buffer, qualified_table, job_config=job_config, timeout=self._job_request_timeout() + ) + job.result(timeout=self._job_request_timeout()) + if exc_handler.pending_exception is not None: + raise exc_handler.pending_exception from None + telemetry_payload = build_load_job_telemetry(job, qualified_table, format_label="parquet") if telemetry: telemetry_payload.setdefault("extra", {}) telemetry_payload["extra"]["arrow_rows"] = telemetry.get("rows_processed") @@ -637,42 +761,41 @@ def load_from_storage( """Load staged artifacts from storage into BigQuery.""" job_config = build_load_job_config(file_format, overwrite) + qualified_table = self._qualify_table_name(table) gcs_source = _normalize_bigquery_gcs_uri(source) - if gcs_source is not None: - job = self.connection.load_table_from_uri( - gcs_source, table, job_config=job_config, retry=self._job_retry, timeout=self._job_request_timeout() - ) - source_telemetry: StorageTelemetry | None = None - else: - buffer = io.BytesIO() - for chunk in self._storage_pipeline().stream_read(source): - buffer.write(chunk) - source_telemetry = { - "destination": str(source), - "bytes_processed": buffer.tell(), - "rows_processed": 0, - "format": file_format, - } - buffer.seek(0) - job = self.connection.load_table_from_file( - buffer, table, job_config=job_config, timeout=self._job_request_timeout() - ) - job.result(timeout=self._job_request_timeout()) - telemetry_payload = build_load_job_telemetry(job, table, format_label=file_format) + exc_handler = self.handle_database_exceptions() + job: Any = None + source_telemetry: StorageTelemetry | None = None + with exc_handler: + if gcs_source is not None: + job = self.connection.load_table_from_uri( + gcs_source, + qualified_table, + job_config=job_config, + retry=self._job_retry, + timeout=self._job_request_timeout(), + ) + else: + buffer = io.BytesIO() + for chunk in self._storage_pipeline().stream_read(source): + buffer.write(chunk) + source_telemetry = { + "destination": str(source), + "bytes_processed": buffer.tell(), + "rows_processed": 0, + "format": file_format, + } + buffer.seek(0) + job = self.connection.load_table_from_file( + buffer, qualified_table, job_config=job_config, timeout=self._job_request_timeout() + ) + job.result(timeout=self._job_request_timeout()) + if exc_handler.pending_exception is not None: + raise exc_handler.pending_exception from None + telemetry_payload = build_load_job_telemetry(job, qualified_table, format_label=file_format) self._attach_partition_telemetry(telemetry_payload, partitioner) return self._storage_job(telemetry_payload, source_telemetry) - @property - def data_dictionary(self) -> "BigQueryDataDictionary": - """Get the data dictionary for this driver. - - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = BigQueryDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect BigQuery rows for the direct execution path.""" schema = cursor.job.schema if cursor.job else None @@ -684,6 +807,46 @@ def resolve_rowcount(self, cursor: Any) -> int: """Resolve rowcount from BigQuery job for the direct execution path.""" return build_dml_rowcount(cursor.job, 0) if cursor.job else 0 + def _default_dataset_id(self) -> "str | None": + if self._default_query_job_config is None: + return None + default_dataset = getattr(self._default_query_job_config, "default_dataset", None) + if default_dataset is None: + return None + if isinstance(default_dataset, str): + return default_dataset or None + project = getattr(default_dataset, "project", None) + dataset_id = getattr(default_dataset, "dataset_id", None) + if project and dataset_id: + return f"{project}.{dataset_id}" + if dataset_id: + return str(dataset_id) + return str(default_dataset) + + def _qualify_table_name(self, table: str) -> str: + if "." in table: + return table + default_dataset = self._default_dataset_id() + if default_dataset: + return f"{default_dataset}.{table}" + return table + + def _can_use_query_and_wait(self, statement: "SQL", sql: str) -> bool: + """Use the interactive fast path only for compatible query jobs.""" + if not self._use_query_and_wait or not hasattr(self.connection, "query_and_wait"): + return False + if self._in_transaction or self._session_id is not None: + return False + if self._job_result_timeout is not POLLING_DEFAULT_VALUE or uses_local_bigquery_endpoint(self.connection): + return False + config = self._default_query_job_config + if config is not None: + if config.destination is not None or config.dry_run or config.create_session: + return False + if config.priority == "BATCH": + return False + return not isinstance(statement.expression, exp.Export) and not sql.lstrip().upper().startswith("EXPORT DATA") + def _resolve_request_timeout(self, features: "dict[str, Any]") -> float: timeout = features.get("request_timeout") if timeout is None: @@ -707,12 +870,17 @@ def _job_request_timeout(self) -> float: return self._request_timeout def _run_query_job(self, connection: "BigQueryConnection", sql: str, parameters: Any) -> "QueryJob": + job_config: QueryJobConfig | None = None + if self._session_id is not None: + job_config = BigQueryQueryJobConfig( + connection_properties=[BigQueryConnectionProperty("session_id", self._session_id)] + ) return run_query_job( connection, sql, parameters, default_job_config=self._default_query_job_config, - job_config=None, + job_config=job_config, json_serializer=self._json_serializer, retry=self._job_retry, timeout=self._job_request_timeout(), @@ -769,8 +937,7 @@ def _load_arrow_via_storage_write_api( if requests: for response in client.append_rows(requests=iter(requests)): if response.error.code: - msg = f"Storage Write API append failed: {response.error.message}" - raise StorageOperationFailedError(msg) # noqa: TRY301 + _raise_storage_write_append_failed(response.error.message) except BaseException: with suppress(Exception): @@ -791,14 +958,13 @@ def _load_arrow_via_storage_write_api( return telemetry_payload def _connection_in_transaction(self) -> bool: - """Check if connection is in transaction. + """Check if connection is in transaction.""" + return self._in_transaction - BigQuery does not support transactions. - Returns: - False - BigQuery has no transaction support. - """ - return False +def _raise_storage_write_append_failed(message: Any) -> None: + msg = f"Storage Write API append failed: {message}" + raise StorageOperationFailedError(msg) def _close_bigquery_cursor(cursor: BigQueryCursor) -> None: @@ -828,13 +994,21 @@ def _resolve_storage_write_table_path(table: str, default_project: str) -> tuple register_driver_profile("bigquery", driver_profile) -def _bigquery_arrow_reader_from_iterable(batches: "Iterable[ArrowRecordBatch]") -> "ArrowRecordBatchReader | None": +def _bigquery_arrow_reader_from_iterable( + batches: "Iterable[ArrowRecordBatch]", schema: Any = None +) -> "ArrowRecordBatchReader": ensure_pyarrow() iterator = iter(batches) try: first_batch = next(iterator) except StopIteration: - return None + if isinstance(schema, _pa.Schema): + empty_schema = schema + elif isinstance(schema, (list, tuple)): + empty_schema = _pa.schema([_pa.field(getattr(field, "name", str(field)), _pa.null()) for field in schema]) + else: + empty_schema = _pa.schema([]) + return cast("ArrowRecordBatchReader", _pa.RecordBatchReader.from_batches(empty_schema, ())) return cast( "ArrowRecordBatchReader", _pa.RecordBatchReader.from_batches(first_batch.schema, chain((first_batch,), iterator)), diff --git a/sqlspec/adapters/bigquery/events/store.py b/sqlspec/adapters/bigquery/events/store.py index 822c1104a..dc9fd9b3b 100644 --- a/sqlspec/adapters/bigquery/events/store.py +++ b/sqlspec/adapters/bigquery/events/store.py @@ -58,6 +58,9 @@ def __init__(self, config: BigQueryConfig) -> None: self.settings.get("partition_expiration_days"), "partition_expiration_days" ) self._require_partition_filter = bool(self.settings.get("require_partition_filter", False)) + dataset_id = _resolve_events_dataset_id(config) + if dataset_id and "." not in self._table_name: + self._table_name = f"{dataset_id}.{self._table_name}" def create_statements(self) -> "list[str]": """Return DDL statement for table creation. @@ -132,6 +135,26 @@ def _index_ddl(self) -> str | None: return None +def _resolve_events_dataset_id(config: BigQueryConfig) -> "str | None": + dataset_id = config.connection_config.get("dataset_id") + if isinstance(dataset_id, str) and dataset_id: + return dataset_id + default_job_config = config.connection_config.get("default_query_job_config") or config.driver_features.get( + "default_query_job_config" + ) + if default_job_config is None: + return None + default_dataset = getattr(default_job_config, "default_dataset", None) + if default_dataset is None: + return None + if isinstance(default_dataset, str): + return default_dataset or None + resolved_id = getattr(default_dataset, "dataset_id", None) + if resolved_id: + return str(resolved_id) + return str(default_dataset) + + def _positive_int_or_none(value: object, key: str) -> "int | None": if value is None: return None diff --git a/sqlspec/adapters/bigquery/litestar/store.py b/sqlspec/adapters/bigquery/litestar/store.py index cb742311c..c26aca41f 100644 --- a/sqlspec/adapters/bigquery/litestar/store.py +++ b/sqlspec/adapters/bigquery/litestar/store.py @@ -69,6 +69,9 @@ def __init__(self, config: "BigQueryConfig") -> None: settings.get("partition_expiration_days"), "partition_expiration_days" ) self._require_partition_filter = bool(settings.get("require_partition_filter", False)) + dataset_id = _resolve_litestar_dataset_id(config) + if dataset_id and "." not in self._table_name: + self._table_name = f"{dataset_id}.{self._table_name}" async def create_table(self) -> None: """Create the session table if it doesn't exist.""" @@ -331,7 +334,7 @@ def _expires_in(self, key: str) -> "int | None": def _delete_expired(self) -> int: """Synchronous implementation of delete_expired.""" - sql = f"DELETE FROM {self._table_name} WHERE expires_at <= CURRENT_TIMESTAMP()" + sql = f"DELETE FROM {self._table_name} WHERE expires_at <= CURRENT_TIMESTAMP(){self._partition_filter()}" with self._config.provide_session() as driver: result = driver.execute(sql) @@ -341,6 +344,26 @@ def _delete_expired(self) -> int: return count +def _resolve_litestar_dataset_id(config: "BigQueryConfig") -> "str | None": + dataset_id = config.connection_config.get("dataset_id") + if isinstance(dataset_id, str) and dataset_id: + return dataset_id + default_job_config = config.connection_config.get("default_query_job_config") or config.driver_features.get( + "default_query_job_config" + ) + if default_job_config is None: + return None + default_dataset = getattr(default_job_config, "default_dataset", None) + if default_dataset is None: + return None + if isinstance(default_dataset, str): + return default_dataset or None + resolved_id = getattr(default_dataset, "dataset_id", None) + if resolved_id: + return str(resolved_id) + return str(default_dataset) + + def _positive_int_or_none(value: object, key: str) -> "int | None": if value is None: return None diff --git a/sqlspec/adapters/cockroach_asyncpg/__init__.py b/sqlspec/adapters/cockroach_asyncpg/__init__.py index 38987b17e..78de4ad11 100644 --- a/sqlspec/adapters/cockroach_asyncpg/__init__.py +++ b/sqlspec/adapters/cockroach_asyncpg/__init__.py @@ -1,3 +1,5 @@ +"""CockroachDB AsyncPG adapter for SQLSpec.""" + from sqlspec.adapters.cockroach_asyncpg._typing import ( CockroachAsyncpgConnection, CockroachAsyncpgPool, diff --git a/sqlspec/adapters/cockroach_asyncpg/_typing.py b/sqlspec/adapters/cockroach_asyncpg/_typing.py index c71e745c8..a96674a1e 100644 --- a/sqlspec/adapters/cockroach_asyncpg/_typing.py +++ b/sqlspec/adapters/cockroach_asyncpg/_typing.py @@ -1,6 +1,6 @@ """CockroachDB AsyncPG adapter type definitions.""" -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import asyncpg as cockroach_asyncpg_module from asyncpg import Pool @@ -10,6 +10,8 @@ from asyncpg import create_pool as cockroach_asyncpg_create_pool from asyncpg.pool import PoolConnectionProxy +from sqlspec.typing import import_optional_attr + if TYPE_CHECKING: from collections.abc import Callable from types import TracebackType @@ -37,6 +39,8 @@ "cockroach_asyncpg_module", ) +_COCKROACH_ASYNCPG_DRIVER_CLS: "type[CockroachAsyncpgDriver] | None" = None + class CockroachAsyncpgSessionContext: """Async context manager for CockroachDB AsyncPG sessions.""" @@ -68,11 +72,10 @@ def __init__( self._driver: CockroachAsyncpgDriver | None = None async def __aenter__(self) -> "CockroachAsyncpgDriver": - from sqlspec.adapters.cockroach_asyncpg.driver import CockroachAsyncpgDriver - + driver_cls = _resolve_cockroach_asyncpg_driver() self._connection = await self._acquire_connection() statement_config = self._statement_config() if callable(self._statement_config) else self._statement_config - self._driver = CockroachAsyncpgDriver( + self._driver = driver_cls( connection=self._connection, statement_config=statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -84,3 +87,16 @@ async def __aexit__( await self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) self._connection = None return None + + +def _resolve_cockroach_asyncpg_driver() -> "type[CockroachAsyncpgDriver]": + global _COCKROACH_ASYNCPG_DRIVER_CLS + driver_cls = _COCKROACH_ASYNCPG_DRIVER_CLS + if driver_cls is None: + resolved = import_optional_attr("sqlspec.adapters.cockroach_asyncpg.driver", "CockroachAsyncpgDriver") + if resolved is None: + msg = "Cannot import 'CockroachAsyncpgDriver' from 'sqlspec.adapters.cockroach_asyncpg.driver'" + raise ImportError(msg) + driver_cls = cast("type[CockroachAsyncpgDriver]", resolved) + _COCKROACH_ASYNCPG_DRIVER_CLS = driver_cls + return driver_cls diff --git a/sqlspec/adapters/cockroach_asyncpg/adk/__init__.py b/sqlspec/adapters/cockroach_asyncpg/adk/__init__.py index 61abfa566..f0370dd04 100644 --- a/sqlspec/adapters/cockroach_asyncpg/adk/__init__.py +++ b/sqlspec/adapters/cockroach_asyncpg/adk/__init__.py @@ -1,3 +1,5 @@ +"""CockroachDB AsyncPG ADK store module.""" + from sqlspec.adapters.cockroach_asyncpg.adk.store import ( CockroachAsyncpgADKConfig, CockroachAsyncpgADKMemoryStore, diff --git a/sqlspec/adapters/cockroach_asyncpg/adk/store.py b/sqlspec/adapters/cockroach_asyncpg/adk/store.py index d75e16aef..f8e3a0876 100644 --- a/sqlspec/adapters/cockroach_asyncpg/adk/store.py +++ b/sqlspec/adapters/cockroach_asyncpg/adk/store.py @@ -1,9 +1,11 @@ """CockroachDB ADK store for Google Agent Development Kit session/event storage (asyncpg).""" +import re from typing import TYPE_CHECKING, Any, Literal, NotRequired, cast from sqlspec.adapters.cockroach_asyncpg._typing import cockroach_asyncpg_module as asyncpg from sqlspec.config import ADKConfig +from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.adk import BaseAsyncADKStore, StoredEvent, StoredSession, normalize_session_list_options from sqlspec.extensions.adk.memory.store import BaseAsyncADKMemoryStore from sqlspec.utils.logging import get_logger @@ -19,6 +21,10 @@ __all__ = ("CockroachAsyncpgADKConfig", "CockroachAsyncpgADKMemoryStore", "CockroachAsyncpgADKStore") logger = get_logger("sqlspec.adapters.cockroach_asyncpg.adk.store") +_LOCALITY_CLAUSE_PATTERN = re.compile( + r"^LOCALITY\s+(?:GLOBAL|REGIONAL(?:\s+BY\s+(?:TABLE(?:\s+IN\s+(?:PRIMARY\s+REGION|[A-Za-z0-9_-]+|\"[A-Za-z0-9_-]+\"))?|ROW(?:\s+AS\s+(?:[A-Za-z_][A-Za-z0-9_]*|\"[A-Za-z_][A-Za-z0-9_]*\"))?))?)$", + re.IGNORECASE, +) class CockroachAsyncpgADKConfig(ADKConfig): @@ -89,23 +95,32 @@ async def create_session( sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, {self._owner_id_column_name}, state, create_time, update_time) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id, app_name, user_id, state, create_time, update_time """ params = (session_id, app_name, user_id, owner_id, state) else: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time) VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id, app_name, user_id, state, create_time, update_time """ params = (session_id, app_name, user_id, state) async with self._config.provide_connection() as conn: - await conn.execute(sql, *params) + row = await conn.fetchrow(sql, *params) - result = await self.get_session(app_name, user_id, session_id) - if result is None: + if row is None: msg = "Session creation failed" raise RuntimeError(msg) - return result + + return StoredSession( + id=row["id"], + app_name=row["app_name"], + user_id=row["user_id"], + state=row["state"], + create_time=row["create_time"], + update_time=row["update_time"], + ) async def get_session( self, app_name: str, user_id: str, session_id: str, *, renew_for: "int | timedelta | None" = None @@ -459,6 +474,7 @@ async def _sessions_table_ddl(self) -> str: session_locality = _cockroach_table_locality_clause(adk_config, "session_table_locality") hash_shard_clause = _cockroach_hash_shard_clause(adk_config) session_storing_clause = _cockroach_storing_clause(adk_config, ("state", "create_time", "update_time")) + session_idx_name = self._session_table.replace(".", "_") return f""" CREATE TABLE IF NOT EXISTS {self._session_table} ( @@ -470,13 +486,13 @@ async def _sessions_table_ddl(self) -> str: update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ){session_locality}; - CREATE INDEX IF NOT EXISTS idx_{self._session_table}_app_user + CREATE INDEX IF NOT EXISTS idx_{session_idx_name}_app_user ON {self._session_table}(app_name, user_id){session_storing_clause}; - CREATE INDEX IF NOT EXISTS idx_{self._session_table}_update_time + CREATE INDEX IF NOT EXISTS idx_{session_idx_name}_update_time ON {self._session_table}(update_time DESC){hash_shard_clause}; - CREATE INDEX IF NOT EXISTS idx_{self._session_table}_state + CREATE INDEX IF NOT EXISTS idx_{session_idx_name}_state ON {self._session_table} USING GIN (state) WHERE state != '{{}}'::jsonb; """ @@ -486,6 +502,7 @@ async def _events_table_ddl(self) -> str: events_locality = _cockroach_table_locality_clause(adk_config, "events_table_locality") hash_shard_clause = _cockroach_hash_shard_clause(adk_config) events_storing_clause = _cockroach_storing_clause(adk_config, ("invocation_id", "event_data")) + events_idx_name = self._events_table.replace(".", "_") return f""" CREATE TABLE IF NOT EXISTS {self._events_table} ( @@ -499,13 +516,13 @@ async def _events_table_ddl(self) -> str: FOREIGN KEY (session_id) REFERENCES {self._session_table}(id) ON DELETE CASCADE ){events_locality}; - CREATE INDEX IF NOT EXISTS idx_{self._events_table}_session + CREATE INDEX IF NOT EXISTS idx_{events_idx_name}_session ON {self._events_table}(session_id, timestamp ASC){hash_shard_clause}{events_storing_clause}; - CREATE INDEX IF NOT EXISTS idx_{self._events_table}_event_data + CREATE INDEX IF NOT EXISTS idx_{events_idx_name}_event_data ON {self._events_table} USING GIN (event_data); - CREATE INDEX IF NOT EXISTS idx_{self._events_table}_app_timestamp + CREATE INDEX IF NOT EXISTS idx_{events_idx_name}_app_timestamp ON {self._events_table}(app_name, timestamp ASC); """ @@ -592,18 +609,28 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " if not entries: return 0 + if self._owner_id_column_name: + sql = f""" + INSERT INTO {self._memory_table} + (id, session_id, app_name, user_id, scope, event_id, author, + {self._owner_id_column_name}, timestamp, content_json, + content_text, metadata_json, inserted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ON CONFLICT (event_id) DO NOTHING + """ + else: + sql = f""" + INSERT INTO {self._memory_table} + (id, session_id, app_name, user_id, scope, event_id, author, + timestamp, content_json, content_text, metadata_json, inserted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (event_id) DO NOTHING + """ + inserted_count = 0 - async with self._config.provide_connection() as conn: + async with self._config.provide_connection() as conn, conn.transaction(): for entry in entries: if self._owner_id_column_name: - sql = f""" - INSERT INTO {self._memory_table} - (id, session_id, app_name, user_id, scope, event_id, author, - {self._owner_id_column_name}, timestamp, content_json, - content_text, metadata_json, inserted_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - ON CONFLICT (event_id) DO NOTHING - """ result = await conn.execute( sql, entry["id"], @@ -621,13 +648,6 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " entry["inserted_at"], ) else: - sql = f""" - INSERT INTO {self._memory_table} - (id, session_id, app_name, user_id, scope, event_id, author, - timestamp, content_json, content_text, metadata_json, inserted_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - ON CONFLICT (event_id) DO NOTHING - """ result = await conn.execute( sql, entry["id"], @@ -644,7 +664,8 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " entry["inserted_at"], ) - if result and result.split()[-1].isdigit() and int(result.split()[-1]) > 0: + parts = result.split() if result else [] + if parts and parts[-1].isdigit() and int(parts[-1]) > 0: inserted_count += 1 return inserted_count @@ -703,8 +724,11 @@ async def search_entries( params = (*scope_params, search_param, effective_limit) - async with self._config.provide_connection() as conn: - rows = await conn.fetch(sql, *params) + try: + async with self._config.provide_connection() as conn: + rows = await conn.fetch(sql, *params) + except asyncpg.exceptions.UndefinedTableError: + return [] records: list[StoredMemory] = [] for row in rows: @@ -730,9 +754,9 @@ async def delete_entries_older_than( msg = "Memory store is disabled" raise RuntimeError(msg) - clauses = [f"inserted_at < (CURRENT_TIMESTAMP - INTERVAL '{days} days')"] - params: list[Any] = [] - idx = 1 + clauses = ["inserted_at < (CURRENT_TIMESTAMP - ($1 * INTERVAL '1 day'))"] + params: list[Any] = [int(days)] + idx = 2 if app_name is not None: clauses.append(f"app_name = ${idx}") params.append(app_name) @@ -755,17 +779,18 @@ async def _memory_table_ddl(self) -> str: owner_id_line = f",\n {self._owner_id_column_ddl}" memory_locality = _cockroach_table_locality_clause(adk_config, "memory_table_locality") hash_shard_clause = _cockroach_hash_shard_clause(adk_config) + memory_idx_name = self._memory_table.replace(".", "_") fts_index = "" if self._use_fts: fts_index = f""" - CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_fts + CREATE INDEX IF NOT EXISTS idx_{memory_idx_name}_fts ON {self._memory_table} USING GIN (to_tsvector('english', content_text)); """ trigram_index = "" if adk_config.get("enable_memory_trigram_index", False): trigram_index = f""" - CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_content_trgm + CREATE INDEX IF NOT EXISTS idx_{memory_idx_name}_content_trgm ON {self._memory_table} USING GIN (content_text gin_trgm_ops); """ @@ -785,13 +810,13 @@ async def _memory_table_ddl(self) -> str: inserted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ){memory_locality}; - CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_app_scope_user_time + CREATE INDEX IF NOT EXISTS idx_{memory_idx_name}_app_scope_user_time ON {self._memory_table}(app_name, scope, user_id, timestamp DESC){hash_shard_clause}; - CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_scope + CREATE INDEX IF NOT EXISTS idx_{memory_idx_name}_scope ON {self._memory_table}(app_name, scope); - CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_session + CREATE INDEX IF NOT EXISTS idx_{memory_idx_name}_session ON {self._memory_table}(session_id); {fts_index} {trigram_index} @@ -822,6 +847,9 @@ def _cockroach_table_locality_clause(adk_config: CockroachAsyncpgADKConfig, tabl return "" if not clause.upper().startswith("LOCALITY "): clause = f"LOCALITY {clause}" + if not _LOCALITY_CLAUSE_PATTERN.fullmatch(clause): + msg = f"Invalid CockroachDB LOCALITY clause: {locality!r}" + raise ImproperConfigurationError(msg) return f"\n {clause}" diff --git a/sqlspec/adapters/cockroach_asyncpg/config.py b/sqlspec/adapters/cockroach_asyncpg/config.py index bdc2b4335..e17a00dad 100644 --- a/sqlspec/adapters/cockroach_asyncpg/config.py +++ b/sqlspec/adapters/cockroach_asyncpg/config.py @@ -3,12 +3,7 @@ from contextlib import suppress from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NotRequired, TypedDict, cast -from sqlspec.adapters.asyncpg.core import ( - apply_driver_features, - default_statement_config, - register_json_codecs, - register_pgvector_support, -) +from sqlspec.adapters.asyncpg.core import default_statement_config, register_json_codecs, register_pgvector_support from sqlspec.adapters.cockroach_asyncpg._typing import ( CockroachAsyncpgConnection, CockroachAsyncpgPool, @@ -17,7 +12,12 @@ from sqlspec.adapters.cockroach_asyncpg._typing import CockroachAsyncpgRecord as Record from sqlspec.adapters.cockroach_asyncpg._typing import cockroach_asyncpg_connect as asyncpg_connect from sqlspec.adapters.cockroach_asyncpg._typing import cockroach_asyncpg_create_pool as asyncpg_create_pool -from sqlspec.adapters.cockroach_asyncpg.core import build_connection_config, validate_follower_read_staleness +from sqlspec.adapters.cockroach_asyncpg.core import ( + CockroachAsyncpgRetryConfig, + apply_driver_features, + build_connection_config, + validate_follower_read_staleness, +) from sqlspec.adapters.cockroach_asyncpg.driver import CockroachAsyncpgDriver, CockroachAsyncpgExceptionHandler from sqlspec.config import AsyncDatabaseConfig, ExtensionConfigs from sqlspec.core.capabilities import TypeCoercionCapabilities @@ -25,7 +25,6 @@ from sqlspec.driver import AsyncPoolConnectionContext, AsyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.events import EventRuntimeHints -from sqlspec.utils.config_tools import normalize_connection_config from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: @@ -38,6 +37,7 @@ __all__ = ( "CockroachAsyncpgConfig", "CockroachAsyncpgConnectionConfig", + "CockroachAsyncpgConnectionContext", "CockroachAsyncpgDriverFeatures", "CockroachAsyncpgGSSLib", "CockroachAsyncpgPoolConfig", @@ -78,6 +78,8 @@ class CockroachAsyncpgConnectionConfig(TypedDict): connect_timeout: NotRequired[float] command_timeout: NotRequired[float] application_name: NotRequired[str] + cluster: NotRequired[str] + pgbouncer: NotRequired[bool] default_transaction_use_follower_reads: NotRequired[bool] results_buffer_size: NotRequired[int] statement_cache_size: NotRequired[int] @@ -114,33 +116,6 @@ class _NativeStorageCSVOptions(TypedDict): skip: NotRequired[int] -def _validate_follower_read_features(driver_features: "dict[str, Any]") -> None: - staleness = driver_features.get("default_staleness") - if staleness is None: - return - if not isinstance(staleness, str): - msg = "default_staleness must be a string." - raise ImproperConfigurationError(msg) - driver_features["default_staleness"] = validate_follower_read_staleness(staleness) - - -def _validate_native_storage_options(driver_features: "dict[str, Any]") -> None: - if "native_storage_csv_options" not in driver_features: - return - options = driver_features["native_storage_csv_options"] - if not isinstance(options, dict) or options.keys() - {"nullas", "nullif", "skip"}: - msg = "native_storage_csv_options must contain only nullas, nullif, and skip." - raise ImproperConfigurationError(msg) - for key in ("nullas", "nullif"): - if key in options and not isinstance(options[key], str): - msg = "native_storage_csv_options nullas and nullif must be strings." - raise ImproperConfigurationError(msg) - if "skip" in options and (type(options["skip"]) is not int or options["skip"] < 0): - msg = "native_storage_csv_options skip must be a nonnegative integer, excluding bool." - raise ImproperConfigurationError(msg) - driver_features["native_storage_csv_options"] = dict(options) - - class CockroachAsyncpgDriverFeatures(TypedDict): """Driver feature flags for CockroachDB AsyncPG adapter. @@ -175,7 +150,6 @@ class CockroachAsyncpgDriverFeatures(TypedDict): class _CockroachAsyncpgSessionFactory(AsyncPoolSessionFactory): """Uses pool.acquire() context manager pattern instead of direct acquire/release.""" - # _connection inherited from AsyncPoolSessionFactory.__slots__ is never written; this class uses _ctx exclusively via the pool.acquire() context manager pattern. __slots__ = ("_ctx",) def __init__(self, config: "CockroachAsyncpgConfig") -> None: @@ -183,10 +157,7 @@ def __init__(self, config: "CockroachAsyncpgConfig") -> None: self._ctx: Any | None = None async def acquire_connection(self) -> "CockroachAsyncpgConnection": - pool = self._config.connection_instance - if pool is None: - pool = await self._config.create_pool() - self._config.connection_instance = pool + pool = await self._config.provide_pool() ctx = pool.acquire() self._ctx = ctx return cast("CockroachAsyncpgConnection", await ctx.__aenter__()) @@ -238,11 +209,9 @@ def __init__( observability_config: "ObservabilityConfig | None" = None, **kwargs: Any, ) -> None: - raw_enable_pgvector = bool(driver_features and driver_features.get("enable_pgvector") is True) - connection_config = build_connection_config(normalize_connection_config(connection_config)) + connection_config = build_connection_config(connection_config) statement_config = statement_config or default_statement_config statement_config, driver_features = apply_driver_features(statement_config, driver_features) - driver_features["enable_pgvector"] = raw_enable_pgvector driver_features.setdefault("enable_native_storage", False) _validate_native_storage_options(driver_features) @@ -264,32 +233,8 @@ def __init__( observability_config=observability_config, **kwargs, ) - - async def _create_pool(self) -> "CockroachAsyncpgPool": - config = build_connection_config(self.connection_config) - config.setdefault("init", self._init_connection) - return await asyncpg_create_pool(**config) - - async def _init_connection(self, connection: "CockroachAsyncpgConnection") -> None: - """Initialize connection with JSON codecs and user callback.""" - if self.driver_features.get("enable_json_codecs", True): - await register_json_codecs( - connection, - encoder=self.driver_features.get("json_serializer", to_json), - decoder=self.driver_features.get("json_deserializer", from_json), - ) - - if self.driver_features.get("enable_pgvector") is True: - await register_pgvector_support(connection) - if self._user_connection_hook is not None: - await self._user_connection_hook(connection) - - async def _close_pool(self) -> None: - if not self.connection_instance: - return - await self.connection_instance.close() - self.connection_instance = None + self.driver_features["on_connection_create"] = self._user_connection_hook async def create_connection(self) -> "CockroachAsyncpgConnection": """Open a standalone connection owned by the caller. @@ -346,13 +291,72 @@ async def provide_pool(self, *args: Any, **kwargs: Any) -> "CockroachAsyncpgPool def get_signature_namespace(self) -> "dict[str, Any]": namespace = super().get_signature_namespace() namespace.update({ + "CockroachAsyncpgConfig": CockroachAsyncpgConfig, + "CockroachAsyncpgConnection": CockroachAsyncpgConnection, "CockroachAsyncpgConnectionConfig": CockroachAsyncpgConnectionConfig, - "CockroachAsyncpgPoolConfig": CockroachAsyncpgPoolConfig, + "CockroachAsyncpgConnectionContext": CockroachAsyncpgConnectionContext, "CockroachAsyncpgDriver": CockroachAsyncpgDriver, + "CockroachAsyncpgDriverFeatures": CockroachAsyncpgDriverFeatures, "CockroachAsyncpgExceptionHandler": CockroachAsyncpgExceptionHandler, + "CockroachAsyncpgPool": CockroachAsyncpgPool, + "CockroachAsyncpgPoolConfig": CockroachAsyncpgPoolConfig, + "CockroachAsyncpgRetryConfig": CockroachAsyncpgRetryConfig, "CockroachAsyncpgSessionContext": CockroachAsyncpgSessionContext, }) return namespace def get_event_runtime_hints(self) -> "EventRuntimeHints": return EventRuntimeHints(poll_interval=0.5, select_for_update=True, skip_locked=True) + + async def _create_pool(self) -> "CockroachAsyncpgPool": + config = build_connection_config(self.connection_config) + config.setdefault("init", self._init_connection) + return await asyncpg_create_pool(**config) + + async def _init_connection(self, connection: "CockroachAsyncpgConnection") -> None: + """Initialize connection with JSON codecs and user callback.""" + if self.driver_features.get("enable_json_codecs", True): + await register_json_codecs( + connection, + encoder=self.driver_features.get("json_serializer", to_json), + decoder=self.driver_features.get("json_deserializer", from_json), + ) + + if self.driver_features.get("enable_pgvector") is True: + await register_pgvector_support(connection) + + if self._user_connection_hook is not None: + await self._user_connection_hook(connection) + + async def _close_pool(self) -> None: + if not self.connection_instance: + return + await self.connection_instance.close() + self.connection_instance = None + + +def _validate_follower_read_features(driver_features: "dict[str, Any]") -> None: + staleness = driver_features.get("default_staleness") + if staleness is None: + return + if not isinstance(staleness, str): + msg = "default_staleness must be a string." + raise ImproperConfigurationError(msg) + driver_features["default_staleness"] = validate_follower_read_staleness(staleness) + + +def _validate_native_storage_options(driver_features: "dict[str, Any]") -> None: + if "native_storage_csv_options" not in driver_features: + return + options = driver_features["native_storage_csv_options"] + if not isinstance(options, dict) or options.keys() - {"nullas", "nullif", "skip"}: + msg = "native_storage_csv_options must contain only nullas, nullif, and skip." + raise ImproperConfigurationError(msg) + for key in ("nullas", "nullif"): + if key in options and not isinstance(options[key], str): + msg = "native_storage_csv_options nullas and nullif must be strings." + raise ImproperConfigurationError(msg) + if "skip" in options and (type(options["skip"]) is not int or options["skip"] < 0): + msg = "native_storage_csv_options skip must be a nonnegative integer, excluding bool." + raise ImproperConfigurationError(msg) + driver_features["native_storage_csv_options"] = dict(options) diff --git a/sqlspec/adapters/cockroach_asyncpg/core.py b/sqlspec/adapters/cockroach_asyncpg/core.py index 0bd5210b6..d6352d7e8 100644 --- a/sqlspec/adapters/cockroach_asyncpg/core.py +++ b/sqlspec/adapters/cockroach_asyncpg/core.py @@ -1,7 +1,8 @@ """CockroachDB AsyncPG adapter helpers.""" -import random import re +import secrets +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, cast from mypy_extensions import mypyc_attr @@ -9,17 +10,20 @@ from sqlglot.tokenizer_core import TokenType from sqlspec.adapters.asyncpg.core import build_connection_config as asyncpg_build_connection_config +from sqlspec.adapters.asyncpg.core import configure_parameter_serializers from sqlspec.exceptions import ImproperConfigurationError, SerializationConflictError, SQLSpecError +from sqlspec.utils.config_tools import normalize_connection_config +from sqlspec.utils.serializers import from_json, to_json from sqlspec.utils.text import quote_identifier, split_qualified_identifier from sqlspec.utils.type_guards import has_sqlstate if TYPE_CHECKING: - from collections.abc import Mapping - + from sqlspec.core import StatementConfig from sqlspec.storage import StorageTelemetry __all__ = ( "CockroachAsyncpgRetryConfig", + "apply_driver_features", "build_connection_config", "build_native_export", "build_native_import", @@ -31,11 +35,13 @@ "validate_follower_read_staleness", ) -# Retry configuration defaults (module-level for mypyc compatibility) _DEFAULT_MAX_RETRIES: Final[int] = 10 _DEFAULT_BASE_DELAY_MS: Final[float] = 50.0 _DEFAULT_MAX_DELAY_MS: Final[float] = 5000.0 _DEFAULT_ENABLE_LOGGING: Final[bool] = True +_MAX_CAUSE_CHAIN_DEPTH: Final[int] = 16 +_SYSTEM_RANDOM: Final[secrets.SystemRandom] = secrets.SystemRandom() +_CLUSTER_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"[A-Za-z0-9._-]+") @mypyc_attr(allow_interpreted_subclasses=False) @@ -51,6 +57,15 @@ def __init__( max_delay_ms: float = _DEFAULT_MAX_DELAY_MS, enable_logging: bool = _DEFAULT_ENABLE_LOGGING, ) -> None: + if max_retries < 0: + msg = "max_retries must be non-negative" + raise ImproperConfigurationError(msg) + if base_delay_ms < 0: + msg = "base_delay_ms must be non-negative" + raise ImproperConfigurationError(msg) + if max_delay_ms < 0: + msg = "max_delay_ms must be non-negative" + raise ImproperConfigurationError(msg) self.max_retries = max_retries self.base_delay_ms = base_delay_ms self.max_delay_ms = max_delay_ms @@ -67,12 +82,49 @@ def from_features(cls, driver_features: "Mapping[str, Any]") -> "CockroachAsyncp ) -def build_connection_config(config: "dict[str, Any]") -> "dict[str, Any]": +def apply_driver_features( + statement_config: "StatementConfig", driver_features: "Mapping[str, Any] | None" +) -> "tuple[StatementConfig, dict[str, Any]]": + """Apply CockroachDB AsyncPG driver feature defaults to statement config.""" + processed_features: dict[str, Any] = dict(driver_features) if driver_features else {} + serializer = processed_features.setdefault("json_serializer", to_json) + deserializer = processed_features.setdefault("json_deserializer", from_json) + processed_features.setdefault("enable_json_codecs", True) + processed_features["enable_pgvector"] = bool(driver_features and driver_features.get("enable_pgvector") is True) + processed_features.setdefault("enable_events", True) + processed_features.setdefault("events_backend", "poll_queue") + parameter_config = configure_parameter_serializers( + statement_config.parameter_config, serializer, deserializer=deserializer + ) + statement_config = statement_config.replace(parameter_config=parameter_config) + return statement_config, processed_features + + +def build_connection_config(config: "Mapping[str, Any] | None") -> "dict[str, Any]": """Prepare CockroachDB AsyncPG connection config, extracting multi-region server settings.""" - result = asyncpg_build_connection_config(config) + normalized = normalize_connection_config(config) + extra = normalized.pop("extra", None) + if isinstance(extra, Mapping): + for key, value in extra.items(): + if value is not None: + normalized.setdefault(key, value) + result = asyncpg_build_connection_config(normalized) server_settings = dict(result.get("server_settings") or {}) if "application_name" in result: server_settings.setdefault("application_name", str(result.pop("application_name"))) + if "cluster" in result: + cluster = result.pop("cluster") + if cluster: + name = str(cluster) + if _CLUSTER_NAME_PATTERN.fullmatch(name) is None: + msg = ( + f"CockroachDB cluster name {name!r} may contain only letters, " + "digits, hyphens, underscores, and periods." + ) + raise ImproperConfigurationError(msg) + routing = f"--cluster={name}" + existing = server_settings.get("options") + server_settings["options"] = f"{existing} {routing}" if existing else routing if "default_transaction_use_follower_reads" in result: val = result.pop("default_transaction_use_follower_reads") if not isinstance(val, bool): @@ -114,15 +166,20 @@ def is_retryable_error(error: BaseException) -> bool: """ seen: set[int] = set() current: BaseException | None = error - while current is not None and id(current) not in seen: + depth = 0 + while current is not None and id(current) not in seen and depth < _MAX_CAUSE_CHAIN_DEPTH: seen.add(id(current)) + depth += 1 if isinstance(current, SerializationConflictError): return True if has_sqlstate(current) and str(current.sqlstate) == "40001": return True if not isinstance(current, SQLSpecError): return False - current = cast("BaseException | None", cast("Any", current).__cause__) + next_exc = cast("BaseException | None", cast("Any", current).__cause__) + if next_exc is None and not getattr(current, "__suppress_context__", False): + next_exc = cast("BaseException | None", cast("Any", current).__context__) + current = next_exc return False @@ -136,7 +193,7 @@ def calculate_backoff_seconds(attempt: int, config: "CockroachAsyncpgRetryConfig capped_ms: float = min(config.base_delay_ms * (2**attempt), config.max_delay_ms) if capped_ms <= 0.0: return 0.0 - return random.uniform(capped_ms / 2.0, capped_ms) / 1000.0 # noqa: S311 + return _SYSTEM_RANDOM.uniform(capped_ms / 2.0, capped_ms) / 1000.0 _STALENESS_LITERAL: Final[re.Pattern[str]] = re.compile(r"'[^'\\;]+'") diff --git a/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py b/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py index 6839999c3..1de19ecfb 100644 --- a/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py +++ b/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py @@ -3,6 +3,8 @@ from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, ClassVar +from mypy_extensions import mypyc_attr + from sqlspec.data_dictionary import ( ColumnMetadata, DDLResult, @@ -55,6 +57,7 @@ _COCKROACH_SUPPORTED_DOMAINS = frozenset(_COCKROACH_METADATA_DOMAINS) - {"crdb_internal", "system"} +@mypyc_attr(allow_interpreted_subclasses=True, native_class=False) class CockroachAsyncpgDataDictionary(AsyncDataDictionaryBase): """CockroachDB async data dictionary (AsyncPG).""" @@ -74,15 +77,6 @@ async def get_system_metadata_capabilities( requested_domains = ("system",) if domains is None else tuple(domains) return tuple(unsupported_system_metadata_capability(domain) for domain in requested_domains) - async def _select_domain( - self, driver: "CockroachAsyncpgDriver", domain: str, query_name: str, **parameters: Any - ) -> MetadataResult: - query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) - if not query.is_supported or query.sql is None: - return MetadataResult(domain, capability=query.capability, warnings=query.warnings) - rows = await driver.select(query.sql, **parameters) - return _metadata_result(domain, _cockroach_metadata_capability(domain), rows) - async def get_schemas(self, driver: "CockroachAsyncpgDriver") -> MetadataResult: """Get schema metadata.""" return await self._select_domain(driver, "schemas", "list") @@ -175,7 +169,7 @@ async def get_system_metadata( return system_metadata_gated_result(metadata_request, capability) async def get_version(self, driver: "CockroachAsyncpgDriver") -> "VersionInfo | None": - driver_id = id(driver) + driver_id = id(getattr(driver, "connection", None) or driver) if driver_id in self._version_fetch_attempted: return self._version_cache.get(driver_id) @@ -279,6 +273,15 @@ async def get_foreign_keys( schema_type=ForeignKeyMetadata, ) + async def _select_domain( + self, driver: "CockroachAsyncpgDriver", domain: str, query_name: str, **parameters: Any + ) -> MetadataResult: + query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) + if not query.is_supported or query.sql is None: + return MetadataResult(domain, capability=query.capability, warnings=query.warnings) + rows = await driver.select(query.sql, **parameters) + return _metadata_result(domain, _cockroach_metadata_capability(domain), rows) + def _cockroach_metadata_capability(domain: str) -> MetadataCapability: if domain == "ddl": diff --git a/sqlspec/adapters/cockroach_asyncpg/driver.py b/sqlspec/adapters/cockroach_asyncpg/driver.py index 578b8f72e..1b55c7b1b 100644 --- a/sqlspec/adapters/cockroach_asyncpg/driver.py +++ b/sqlspec/adapters/cockroach_asyncpg/driver.py @@ -1,10 +1,11 @@ """CockroachDB AsyncPG driver implementation.""" import asyncio +from contextlib import suppress from typing import TYPE_CHECKING, Any, TypeVar, cast from sqlspec.adapters.asyncpg.core import create_mapped_exception, driver_profile -from sqlspec.adapters.asyncpg.driver import AsyncpgDriver +from sqlspec.adapters.asyncpg.driver import AsyncpgDriver, AsyncpgExceptionHandler from sqlspec.adapters.cockroach_asyncpg._typing import CockroachAsyncpgPostgresError, CockroachAsyncpgSessionContext from sqlspec.adapters.cockroach_asyncpg.core import ( CockroachAsyncpgRetryConfig, @@ -19,7 +20,6 @@ ) from sqlspec.adapters.cockroach_asyncpg.data_dictionary import CockroachAsyncpgDataDictionary from sqlspec.core import SQL, register_driver_profile -from sqlspec.driver import BaseAsyncExceptionHandler from sqlspec.utils.logging import get_logger from sqlspec.utils.type_guards import has_sqlstate @@ -37,7 +37,7 @@ _T = TypeVar("_T") -class CockroachAsyncpgExceptionHandler(BaseAsyncExceptionHandler): +class CockroachAsyncpgExceptionHandler(AsyncpgExceptionHandler): """Async context manager for CockroachDB AsyncPG exceptions.""" __slots__ = () @@ -66,9 +66,14 @@ def __init__( self._retry_config = CockroachAsyncpgRetryConfig.from_features(self.driver_features) self._enable_retry = bool(self.driver_features.get("enable_auto_retry", True)) self._follower_staleness = cast("str | None", self.driver_features.get("default_staleness")) - # Data dictionary is lazily initialized in property; use parent slot self._data_dictionary = None + @property + def data_dictionary(self) -> "CockroachAsyncpgDataDictionary": # type: ignore[override] + if self._data_dictionary is None: + object.__setattr__(self, "_data_dictionary", CockroachAsyncpgDataDictionary()) + return cast("CockroachAsyncpgDataDictionary", self._data_dictionary) + async def select_to_storage( self, statement: "SQL | str", @@ -157,18 +162,6 @@ async def load_from_storage( table, source, file_format=file_format, partitioner=partitioner, overwrite=overwrite ) - def _native_storage_ready(self) -> bool: - return bool(self.driver_features.get("enable_native_storage")) and not self._connection_in_transaction() - - async def _execute_native_storage(self, command: str, parameters: "list[Any]") -> "list[dict[str, Any]]": - handler = self.handle_database_exceptions() - rows = [] - async with handler: - rows = [dict(row) for row in await self.connection.fetch(command, *parameters)] - if handler.pending_exception is not None: - raise handler.pending_exception - return rows - async def begin(self) -> None: """Begin a transaction and apply follower-read staleness to it. @@ -216,23 +209,32 @@ async def run_transaction_with_retry(self, operation: "Callable[[], Awaitable[_T attempt += 1 async def dispatch_execute(self, cursor: Any, statement: SQL) -> "ExecutionResult": - return await self._dispatch_execute_impl(cursor, statement) - - async def dispatch_execute_many(self, cursor: Any, statement: SQL) -> "ExecutionResult": - return await self._dispatch_execute_many_impl(cursor, statement) - - async def dispatch_execute_script(self, cursor: Any, statement: SQL) -> "ExecutionResult": - return await self._dispatch_execute_script_impl(cursor, statement) + auto_started = await self._begin_follower_read_transaction() if statement.returns_rows() else False + try: + result = await super().dispatch_execute(cursor, statement) + except BaseException: + if auto_started and self._connection_in_transaction(): + with suppress(Exception): + await self.rollback() + raise + if auto_started and self._connection_in_transaction(): + await self.commit() + return result - def handle_database_exceptions(self) -> "CockroachAsyncpgExceptionHandler": # type: ignore[override] + def handle_database_exceptions(self) -> "CockroachAsyncpgExceptionHandler": return CockroachAsyncpgExceptionHandler() - @property - def data_dictionary(self) -> "CockroachAsyncpgDataDictionary": # type: ignore[override] - if self._data_dictionary is None: - # Intentionally assign CockroachDB-specific data dictionary to parent slot - object.__setattr__(self, "_data_dictionary", CockroachAsyncpgDataDictionary()) - return cast("CockroachAsyncpgDataDictionary", self._data_dictionary) + def _native_storage_ready(self) -> bool: + return bool(self.driver_features.get("enable_native_storage")) and not self._connection_in_transaction() + + async def _execute_native_storage(self, command: str, parameters: "list[Any]") -> "list[dict[str, Any]]": + handler = self.handle_database_exceptions() + rows = [] + async with handler: + rows = [dict(row) for row in await self.connection.fetch(command, *parameters)] + if handler.pending_exception is not None: + raise handler.pending_exception + return rows async def _apply_follower_reads(self) -> None: if not self.driver_features.get("enable_follower_reads", False): @@ -242,7 +244,7 @@ async def _apply_follower_reads(self) -> None: staleness = validate_follower_read_staleness(self._follower_staleness) await self.connection.execute(f"SET TRANSACTION AS OF SYSTEM TIME {staleness}") - async def _begin_follower_read_transaction(self) -> None: + async def _begin_follower_read_transaction(self) -> bool: """Open the transaction a follower read needs so the staleness clause can lead it. A statement run outside a transaction gets its own implicit one, which @@ -250,27 +252,13 @@ async def _begin_follower_read_transaction(self) -> None: caller has not already done so. """ if not self.driver_features.get("enable_follower_reads", False): - return + return False if not self._follower_staleness: - return + return False if self._connection_in_transaction(): - return + return False await self.begin() - - async def _dispatch_execute_impl(self, cursor: "CockroachAsyncpgConnection", statement: SQL) -> "ExecutionResult": - if statement.returns_rows(): - await self._begin_follower_read_transaction() - return await super().dispatch_execute(cursor, statement) - - async def _dispatch_execute_many_impl( - self, cursor: "CockroachAsyncpgConnection", statement: SQL - ) -> "ExecutionResult": - return await AsyncpgDriver.dispatch_execute_many(self, cursor, statement) - - async def _dispatch_execute_script_impl( - self, cursor: "CockroachAsyncpgConnection", statement: SQL - ) -> "ExecutionResult": - return await AsyncpgDriver.dispatch_execute_script(self, cursor, statement) + return True register_driver_profile("cockroach_asyncpg", driver_profile) diff --git a/sqlspec/adapters/cockroach_asyncpg/events/__init__.py b/sqlspec/adapters/cockroach_asyncpg/events/__init__.py index 80772e7d9..7ced8a5bf 100644 --- a/sqlspec/adapters/cockroach_asyncpg/events/__init__.py +++ b/sqlspec/adapters/cockroach_asyncpg/events/__init__.py @@ -1,3 +1,5 @@ +"""CockroachDB AsyncPG events module.""" + from sqlspec.adapters.cockroach_asyncpg.events.store import CockroachAsyncpgEventQueueStore __all__ = ("CockroachAsyncpgEventQueueStore",) diff --git a/sqlspec/adapters/cockroach_asyncpg/litestar/__init__.py b/sqlspec/adapters/cockroach_asyncpg/litestar/__init__.py index 8212cf0cf..34f01164f 100644 --- a/sqlspec/adapters/cockroach_asyncpg/litestar/__init__.py +++ b/sqlspec/adapters/cockroach_asyncpg/litestar/__init__.py @@ -1,3 +1,5 @@ +"""CockroachDB AsyncPG Litestar session store module.""" + from sqlspec.adapters.cockroach_asyncpg.litestar.store import CockroachAsyncpgLitestarConfig, CockroachAsyncpgStore __all__ = ("CockroachAsyncpgLitestarConfig", "CockroachAsyncpgStore") diff --git a/sqlspec/adapters/cockroach_asyncpg/litestar/store.py b/sqlspec/adapters/cockroach_asyncpg/litestar/store.py index b01cf5af1..dde1b2f42 100644 --- a/sqlspec/adapters/cockroach_asyncpg/litestar/store.py +++ b/sqlspec/adapters/cockroach_asyncpg/litestar/store.py @@ -153,6 +153,7 @@ def _table_ddl(self) -> str: """Get CockroachDB CREATE TABLE SQL with optimized schema.""" settings = cast("dict[str, Any]", self._config.extension_config.get("litestar", {})) table_options, hash_clause = _cockroach_storage_clauses(settings) + idx_table_name = self._table_name.replace(".", "_") return f""" CREATE TABLE IF NOT EXISTS {self._table_name} ( session_id TEXT PRIMARY KEY, @@ -162,12 +163,13 @@ def _table_ddl(self) -> str: updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ){table_options}; - CREATE INDEX IF NOT EXISTS idx_{self._table_name}_expires_at + CREATE INDEX IF NOT EXISTS idx_{idx_table_name}_expires_at ON {self._table_name}(expires_at){hash_clause} WHERE expires_at IS NOT NULL; """ def _drop_table_sql(self) -> "list[str]": - return [f"DROP INDEX IF EXISTS idx_{self._table_name}_expires_at", f"DROP TABLE IF EXISTS {self._table_name}"] + idx_table_name = self._table_name.replace(".", "_") + return [f"DROP INDEX IF EXISTS idx_{idx_table_name}_expires_at", f"DROP TABLE IF EXISTS {self._table_name}"] def _cockroach_storage_clauses(settings: "dict[str, Any]") -> "tuple[str, str]": diff --git a/sqlspec/adapters/cockroach_psycopg/__init__.py b/sqlspec/adapters/cockroach_psycopg/__init__.py index 963f80a06..ed44c1bfe 100644 --- a/sqlspec/adapters/cockroach_psycopg/__init__.py +++ b/sqlspec/adapters/cockroach_psycopg/__init__.py @@ -1,3 +1,5 @@ +"""CockroachDB psycopg adapter for SQLSpec.""" + from sqlspec.adapters.cockroach_psycopg._typing import ( CockroachAsyncConnection, CockroachPsycopgAsyncSessionContext, diff --git a/sqlspec/adapters/cockroach_psycopg/_typing.py b/sqlspec/adapters/cockroach_psycopg/_typing.py index 6a6e52624..f9db5c984 100644 --- a/sqlspec/adapters/cockroach_psycopg/_typing.py +++ b/sqlspec/adapters/cockroach_psycopg/_typing.py @@ -4,19 +4,24 @@ compilation to avoid ABI boundary issues. """ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import psycopg as cockroach_psycopg_module from psycopg import AsyncCursor, Cursor from psycopg import crdb as cockroach_psycopg_crdb from psycopg import errors as cockroach_psycopg_errors from psycopg import sql as cockroach_psycopg_sql +from psycopg.adapt import AdaptersMap as CockroachPsycopgAdaptersMap from psycopg.rows import DictRow as PsycopgDictRow from psycopg.rows import dict_row as cockroach_psycopg_dict_row from psycopg.types.json import Jsonb as CockroachPsycopgJsonb +from psycopg.types.json import set_json_dumps as cockroach_psycopg_set_json_dumps +from psycopg.types.json import set_json_loads as cockroach_psycopg_set_json_loads from psycopg_pool import AsyncConnectionPool as CockroachPsycopgAsyncConnectionPool from psycopg_pool import ConnectionPool as CockroachPsycopgConnectionPool +from sqlspec.typing import import_optional_attr + if TYPE_CHECKING: from collections.abc import Callable from types import TracebackType @@ -40,6 +45,7 @@ __all__ = ( "CockroachAsyncConnection", "CockroachAsyncCursor", + "CockroachPsycopgAdaptersMap", "CockroachPsycopgAsyncConnectionPool", "CockroachPsycopgAsyncSessionContext", "CockroachPsycopgConnectionPool", @@ -52,9 +58,14 @@ "cockroach_psycopg_dict_row", "cockroach_psycopg_errors", "cockroach_psycopg_module", + "cockroach_psycopg_set_json_dumps", + "cockroach_psycopg_set_json_loads", "cockroach_psycopg_sql", ) +_COCKROACH_PSYCOPG_SYNC_DRIVER_CLS: "type[CockroachPsycopgSyncDriver] | None" = None +_COCKROACH_PSYCOPG_ASYNC_DRIVER_CLS: "type[CockroachPsycopgAsyncDriver] | None" = None + class CockroachPsycopgSyncSessionContext: """Sync context manager for CockroachDB psycopg sessions.""" @@ -86,11 +97,10 @@ def __init__( self._driver: CockroachPsycopgSyncDriver | None = None def __enter__(self) -> "CockroachPsycopgSyncDriver": - from sqlspec.adapters.cockroach_psycopg.driver import CockroachPsycopgSyncDriver - + driver_cls = _resolve_cockroach_psycopg_sync_driver() self._connection = self._acquire_connection() statement_config = self._statement_config() if callable(self._statement_config) else self._statement_config - self._driver = CockroachPsycopgSyncDriver( + self._driver = driver_cls( connection=self._connection, statement_config=statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -134,11 +144,10 @@ def __init__( self._driver: CockroachPsycopgAsyncDriver | None = None async def __aenter__(self) -> "CockroachPsycopgAsyncDriver": - from sqlspec.adapters.cockroach_psycopg.driver import CockroachPsycopgAsyncDriver - + driver_cls = _resolve_cockroach_psycopg_async_driver() self._connection = await self._acquire_connection() statement_config = self._statement_config() if callable(self._statement_config) else self._statement_config - self._driver = CockroachPsycopgAsyncDriver( + self._driver = driver_cls( connection=self._connection, statement_config=statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -150,3 +159,29 @@ async def __aexit__( await self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) self._connection = None return None + + +def _resolve_cockroach_psycopg_sync_driver() -> "type[CockroachPsycopgSyncDriver]": + global _COCKROACH_PSYCOPG_SYNC_DRIVER_CLS + driver_cls = _COCKROACH_PSYCOPG_SYNC_DRIVER_CLS + if driver_cls is None: + resolved = import_optional_attr("sqlspec.adapters.cockroach_psycopg.driver", "CockroachPsycopgSyncDriver") + if resolved is None: + msg = "Cannot import 'CockroachPsycopgSyncDriver' from 'sqlspec.adapters.cockroach_psycopg.driver'" + raise ImportError(msg) + driver_cls = cast("type[CockroachPsycopgSyncDriver]", resolved) + _COCKROACH_PSYCOPG_SYNC_DRIVER_CLS = driver_cls + return driver_cls + + +def _resolve_cockroach_psycopg_async_driver() -> "type[CockroachPsycopgAsyncDriver]": + global _COCKROACH_PSYCOPG_ASYNC_DRIVER_CLS + driver_cls = _COCKROACH_PSYCOPG_ASYNC_DRIVER_CLS + if driver_cls is None: + resolved = import_optional_attr("sqlspec.adapters.cockroach_psycopg.driver", "CockroachPsycopgAsyncDriver") + if resolved is None: + msg = "Cannot import 'CockroachPsycopgAsyncDriver' from 'sqlspec.adapters.cockroach_psycopg.driver'" + raise ImportError(msg) + driver_cls = cast("type[CockroachPsycopgAsyncDriver]", resolved) + _COCKROACH_PSYCOPG_ASYNC_DRIVER_CLS = driver_cls + return driver_cls diff --git a/sqlspec/adapters/cockroach_psycopg/adk/__init__.py b/sqlspec/adapters/cockroach_psycopg/adk/__init__.py index 157738ece..268bb334c 100644 --- a/sqlspec/adapters/cockroach_psycopg/adk/__init__.py +++ b/sqlspec/adapters/cockroach_psycopg/adk/__init__.py @@ -1,3 +1,5 @@ +"""CockroachDB psycopg ADK store module.""" + from sqlspec.adapters.cockroach_psycopg.adk.store import ( CockroachPsycopgADKConfig, CockroachPsycopgAsyncADKMemoryStore, diff --git a/sqlspec/adapters/cockroach_psycopg/adk/store.py b/sqlspec/adapters/cockroach_psycopg/adk/store.py index e7dcbb5f0..12eac5f36 100644 --- a/sqlspec/adapters/cockroach_psycopg/adk/store.py +++ b/sqlspec/adapters/cockroach_psycopg/adk/store.py @@ -1,5 +1,6 @@ """CockroachDB ADK store for Google Agent Development Kit session/event storage (psycopg).""" +import re from typing import TYPE_CHECKING, Any, Literal, NoReturn, NotRequired, cast from sqlspec.adapters.cockroach_psycopg._typing import CockroachPsycopgJsonb as Jsonb @@ -7,6 +8,7 @@ from sqlspec.adapters.cockroach_psycopg._typing import cockroach_psycopg_errors as errors from sqlspec.adapters.cockroach_psycopg._typing import cockroach_psycopg_sql as pg_sql from sqlspec.config import ADKConfig +from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.adk import ( BaseAsyncADKStore, BaseSyncADKStore, @@ -34,6 +36,10 @@ ) logger = get_logger("sqlspec.adapters.cockroach_psycopg.adk.store") +_LOCALITY_CLAUSE_PATTERN = re.compile( + r"^LOCALITY\s+(?:GLOBAL|REGIONAL(?:\s+BY\s+(?:TABLE(?:\s+IN\s+(?:PRIMARY\s+REGION|[A-Za-z0-9_-]+|\"[A-Za-z0-9_-]+\"))?|ROW(?:\s+AS\s+(?:[A-Za-z_][A-Za-z0-9_]*|\"[A-Za-z_][A-Za-z0-9_]*\"))?))?)$", + re.IGNORECASE, +) _ADK_SESSIONS_TABLE_DDL_TEMPLATE = ",\n {0}" @@ -218,6 +224,7 @@ async def create_tables(self) -> None: await driver.execute_script(await self._app_states_table_ddl()) await driver.execute_script(await self._user_states_table_ddl()) await driver.execute_script(await self._metadata_table_ddl()) + await driver.commit() async def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None @@ -614,18 +621,19 @@ async def _sessions_table_ddl(self) -> str: session_locality = _cockroach_table_locality_clause(adk_config, "session_table_locality") hash_shard_clause = _cockroach_hash_shard_clause(adk_config) session_storing_clause = _cockroach_storing_clause(adk_config, ("state", "create_time", "update_time")) + session_idx_name = self._session_table.replace(".", "_") return _ADK_SESSIONS_TABLE_DDL_TEMPLATE_2.format( self._session_table, owner_id_line, session_locality, - self._session_table, + session_idx_name, self._session_table, session_storing_clause, - self._session_table, + session_idx_name, self._session_table, hash_shard_clause, - self._session_table, + session_idx_name, self._session_table, ) @@ -634,18 +642,19 @@ async def _events_table_ddl(self) -> str: events_locality = _cockroach_table_locality_clause(adk_config, "events_table_locality") hash_shard_clause = _cockroach_hash_shard_clause(adk_config) events_storing_clause = _cockroach_storing_clause(adk_config, ("invocation_id", "event_data")) + events_idx_name = self._events_table.replace(".", "_") return _ADK_EVENTS_TABLE_DDL_TEMPLATE.format( self._events_table, self._session_table, events_locality, - self._events_table, + events_idx_name, self._events_table, hash_shard_clause, events_storing_clause, + events_idx_name, self._events_table, - self._events_table, - self._events_table, + events_idx_name, self._events_table, ) @@ -706,6 +715,7 @@ def create_tables(self) -> None: driver.execute_script(self._app_states_table_ddl()) driver.execute_script(self._user_states_table_ddl()) driver.execute_script(self._metadata_table_ddl()) + driver.commit() def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None @@ -1098,18 +1108,19 @@ def _sessions_table_ddl(self) -> str: session_locality = _cockroach_table_locality_clause(adk_config, "session_table_locality") hash_shard_clause = _cockroach_hash_shard_clause(adk_config) session_storing_clause = _cockroach_storing_clause(adk_config, ("state", "create_time", "update_time")) + session_idx_name = self._session_table.replace(".", "_") return _ADK_SESSIONS_TABLE_DDL_TEMPLATE_2.format( self._session_table, owner_id_line, session_locality, - self._session_table, + session_idx_name, self._session_table, session_storing_clause, - self._session_table, + session_idx_name, self._session_table, hash_shard_clause, - self._session_table, + session_idx_name, self._session_table, ) @@ -1118,18 +1129,19 @@ def _events_table_ddl(self) -> str: events_locality = _cockroach_table_locality_clause(adk_config, "events_table_locality") hash_shard_clause = _cockroach_hash_shard_clause(adk_config) events_storing_clause = _cockroach_storing_clause(adk_config, ("invocation_id", "event_data")) + events_idx_name = self._events_table.replace(".", "_") return _ADK_EVENTS_TABLE_DDL_TEMPLATE.format( self._events_table, self._session_table, events_locality, - self._events_table, + events_idx_name, self._events_table, hash_shard_clause, events_storing_clause, + events_idx_name, self._events_table, - self._events_table, - self._events_table, + events_idx_name, self._events_table, ) @@ -1212,6 +1224,7 @@ async def create_tables(self) -> None: async with self._config.provide_session() as driver: await driver.execute_script(await self._memory_table_ddl()) + await driver.commit() async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: if not self._enabled: @@ -1222,6 +1235,7 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " return 0 inserted_count = 0 + table_ident = pg_sql.Identifier(*self._memory_table.split(".")) if self._owner_id_column_name: query = pg_sql.SQL(""" INSERT INTO {table} ( @@ -1232,9 +1246,7 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) ON CONFLICT (event_id) DO NOTHING - """).format( - table=pg_sql.Identifier(self._memory_table), owner_id_col=pg_sql.Identifier(self._owner_id_column_name) - ) + """).format(table=table_ident, owner_id_col=pg_sql.Identifier(self._owner_id_column_name)) else: query = pg_sql.SQL(""" INSERT INTO {table} ( @@ -1244,7 +1256,7 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) ON CONFLICT (event_id) DO NOTHING - """).format(table=pg_sql.Identifier(self._memory_table)) + """).format(table=table_ident) async with self._config.provide_connection() as conn, conn.cursor() as cur: for entry in entries: @@ -1330,8 +1342,8 @@ async def delete_entries_older_than( msg = "Memory store is disabled" raise RuntimeError(msg) - clauses = [f"inserted_at < CURRENT_TIMESTAMP - INTERVAL '{days} days'"] - params: list[Any] = [] + clauses = ["inserted_at < (CURRENT_TIMESTAMP - (%s * INTERVAL '1 day'))"] + params: list[Any] = [int(days)] if app_name is not None: clauses.append("app_name = %s") params.append(app_name) @@ -1341,7 +1353,7 @@ async def delete_entries_older_than( where_sql = " AND ".join(clauses) sql = f"DELETE FROM {self._memory_table} WHERE {where_sql}" async with self._config.provide_connection() as conn, conn.cursor() as cur: - await cur.execute(sql.encode(), tuple(params) if params else None) + await cur.execute(sql.encode(), tuple(params)) await conn.commit() return cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0 @@ -1352,22 +1364,23 @@ async def _memory_table_ddl(self) -> str: owner_id_line = _ADK_SESSIONS_TABLE_DDL_TEMPLATE.format(self._owner_id_column_ddl) memory_locality = _cockroach_table_locality_clause(adk_config, "memory_table_locality") hash_shard_clause = _cockroach_hash_shard_clause(adk_config) + memory_idx_name = self._memory_table.replace(".", "_") fts_index = "" if self._use_fts: - fts_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE.format(self._memory_table, self._memory_table) + fts_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE.format(memory_idx_name, self._memory_table) trigram_index = "" if adk_config.get("enable_memory_trigram_index", False): - trigram_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE_2.format(self._memory_table, self._memory_table) + trigram_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE_2.format(memory_idx_name, self._memory_table) return _ADK_MEMORY_TABLE_DDL_TEMPLATE_3.format( self._memory_table, owner_id_line, memory_locality, - self._memory_table, + memory_idx_name, self._memory_table, hash_shard_clause, - self._memory_table, + memory_idx_name, self._memory_table, fts_index, trigram_index, @@ -1396,6 +1409,7 @@ def create_tables(self) -> None: with self._config.provide_session() as driver: driver.execute_script(self._memory_table_ddl()) + driver.commit() def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Bulk insert memory entries with deduplication.""" @@ -1407,6 +1421,7 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object return 0 inserted_count = 0 + table_ident = pg_sql.Identifier(*self._memory_table.split(".")) if self._owner_id_column_name: query = pg_sql.SQL(""" INSERT INTO {table} ( @@ -1417,9 +1432,7 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) ON CONFLICT (event_id) DO NOTHING - """).format( - table=pg_sql.Identifier(self._memory_table), owner_id_col=pg_sql.Identifier(self._owner_id_column_name) - ) + """).format(table=table_ident, owner_id_col=pg_sql.Identifier(self._owner_id_column_name)) else: query = pg_sql.SQL(""" INSERT INTO {table} ( @@ -1429,7 +1442,7 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) ON CONFLICT (event_id) DO NOTHING - """).format(table=pg_sql.Identifier(self._memory_table)) + """).format(table=table_ident) with self._config.provide_connection() as conn, conn.cursor() as cur: for entry in entries: @@ -1515,8 +1528,8 @@ def delete_entries_older_than(self, days: int, app_name: "str | None" = None, sc msg = "Memory store is disabled" raise RuntimeError(msg) - clauses = [f"inserted_at < CURRENT_TIMESTAMP - INTERVAL '{days} days'"] - params: list[Any] = [] + clauses = ["inserted_at < (CURRENT_TIMESTAMP - (%s * INTERVAL '1 day'))"] + params: list[Any] = [int(days)] if app_name is not None: clauses.append("app_name = %s") params.append(app_name) @@ -1526,7 +1539,7 @@ def delete_entries_older_than(self, days: int, app_name: "str | None" = None, sc where_sql = " AND ".join(clauses) sql = f"DELETE FROM {self._memory_table} WHERE {where_sql}" with self._config.provide_connection() as conn, conn.cursor() as cur: - cur.execute(sql.encode(), tuple(params) if params else None) + cur.execute(sql.encode(), tuple(params)) conn.commit() return cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0 @@ -1537,22 +1550,23 @@ def _memory_table_ddl(self) -> str: owner_id_line = _ADK_SESSIONS_TABLE_DDL_TEMPLATE.format(self._owner_id_column_ddl) memory_locality = _cockroach_table_locality_clause(adk_config, "memory_table_locality") hash_shard_clause = _cockroach_hash_shard_clause(adk_config) + memory_idx_name = self._memory_table.replace(".", "_") fts_index = "" if self._use_fts: - fts_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE.format(self._memory_table, self._memory_table) + fts_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE.format(memory_idx_name, self._memory_table) trigram_index = "" if adk_config.get("enable_memory_trigram_index", False): - trigram_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE_2.format(self._memory_table, self._memory_table) + trigram_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE_2.format(memory_idx_name, self._memory_table) return _ADK_MEMORY_TABLE_DDL_TEMPLATE_3.format( self._memory_table, owner_id_line, memory_locality, - self._memory_table, + memory_idx_name, self._memory_table, hash_shard_clause, - self._memory_table, + memory_idx_name, self._memory_table, fts_index, trigram_index, @@ -1623,6 +1637,9 @@ def _cockroach_table_locality_clause(adk_config: CockroachPsycopgADKConfig, tabl return "" if not clause.upper().startswith("LOCALITY "): clause = f"LOCALITY {clause}" + if not _LOCALITY_CLAUSE_PATTERN.fullmatch(clause): + msg = f"Invalid CockroachDB LOCALITY clause: {locality!r}" + raise ImproperConfigurationError(msg) return f"\n {clause}" diff --git a/sqlspec/adapters/cockroach_psycopg/config.py b/sqlspec/adapters/cockroach_psycopg/config.py index 619360d36..c8166af7a 100644 --- a/sqlspec/adapters/cockroach_psycopg/config.py +++ b/sqlspec/adapters/cockroach_psycopg/config.py @@ -10,10 +10,14 @@ CockroachPsycopgSyncSessionContext, CockroachSyncConnection, ) +from sqlspec.adapters.cockroach_psycopg._typing import CockroachPsycopgAdaptersMap as AdaptersMap from sqlspec.adapters.cockroach_psycopg._typing import CockroachPsycopgAsyncConnectionPool as AsyncConnectionPool from sqlspec.adapters.cockroach_psycopg._typing import CockroachPsycopgConnectionPool as ConnectionPool from sqlspec.adapters.cockroach_psycopg._typing import cockroach_psycopg_crdb as psycopg_crdb +from sqlspec.adapters.cockroach_psycopg._typing import cockroach_psycopg_set_json_dumps as set_json_dumps +from sqlspec.adapters.cockroach_psycopg._typing import cockroach_psycopg_set_json_loads as set_json_loads from sqlspec.adapters.cockroach_psycopg.core import ( + CockroachPsycopgRetryConfig, apply_driver_features, build_connection_config, build_statement_config, @@ -46,10 +50,12 @@ __all__ = ( "CockroachPsycopgAsyncConfig", + "CockroachPsycopgAsyncConnectionContext", "CockroachPsycopgConnectionConfig", "CockroachPsycopgDriverFeatures", "CockroachPsycopgPoolConfig", "CockroachPsycopgSyncConfig", + "CockroachPsycopgSyncConnectionContext", "build_connection_config", ) @@ -242,57 +248,8 @@ def __init__( observability_config=observability_config, **kwargs, ) - - def _create_pool(self) -> "ConnectionPool": - all_config = dict(self.connection_config) - - pool_parameters = { - "min_size": all_config.pop("min_size", 4), - "max_size": all_config.pop("max_size", None), - "name": all_config.pop("name", None), - "timeout": all_config.pop("timeout", 30.0), - "max_waiting": all_config.pop("max_waiting", 0), - "max_lifetime": all_config.pop("max_lifetime", 3600.0), - "max_idle": all_config.pop("max_idle", 600.0), - "reconnect_timeout": all_config.pop("reconnect_timeout", 300.0), - "reconnect_failed": all_config.pop("reconnect_failed", None), - "num_workers": all_config.pop("num_workers", 3), - "check": all_config.pop("check", None), - "reset": all_config.pop("reset", None), - "open": all_config.pop("open", True), - "close_returns": all_config.pop("close_returns", False), - } - - pool_parameters["configure"] = all_config.pop("configure", self._configure_connection) - pool_parameters = {k: v for k, v in pool_parameters.items() if v is not None} - - conninfo = all_config.pop("conninfo", None) - kwargs = all_config.pop("kwargs", {}) - all_config.update(kwargs) - _apply_cluster_option(all_config) - if conninfo: - return ConnectionPool( - conninfo, kwargs=all_config, connection_class=psycopg_crdb.CrdbConnection, **pool_parameters - ) - - return ConnectionPool("", kwargs=all_config, connection_class=psycopg_crdb.CrdbConnection, **pool_parameters) - - def _configure_connection(self, conn: "CockroachSyncConnection") -> None: - autocommit_setting = self.connection_config.get("autocommit") - if autocommit_setting is not None: - conn.autocommit = autocommit_setting - - # Call user-provided callback after internal setup if self._user_connection_hook is not None: - self._user_connection_hook(conn) - - def _close_pool(self) -> None: - if not self.connection_instance: - return - try: - self.connection_instance.close() - finally: - self.connection_instance = None + self.driver_features["on_connection_create"] = self._user_connection_hook def create_connection(self) -> "CockroachSyncConnection": """Open a standalone connection owned by the caller. @@ -344,17 +301,82 @@ def get_signature_namespace(self) -> "dict[str, Any]": namespace = super().get_signature_namespace() namespace.update({ "CockroachPsycopgConnectionConfig": CockroachPsycopgConnectionConfig, + "CockroachPsycopgConnectionPool": ConnectionPool, + "CockroachPsycopgDriverFeatures": CockroachPsycopgDriverFeatures, "CockroachPsycopgPoolConfig": CockroachPsycopgPoolConfig, - "CockroachSyncConnection": CockroachSyncConnection, + "CockroachPsycopgRetryConfig": CockroachPsycopgRetryConfig, + "CockroachPsycopgSyncConfig": CockroachPsycopgSyncConfig, + "CockroachPsycopgSyncConnectionContext": CockroachPsycopgSyncConnectionContext, "CockroachPsycopgSyncDriver": CockroachPsycopgSyncDriver, "CockroachPsycopgSyncExceptionHandler": CockroachPsycopgSyncExceptionHandler, "CockroachPsycopgSyncSessionContext": CockroachPsycopgSyncSessionContext, + "CockroachSyncConnection": CockroachSyncConnection, }) return namespace def get_event_runtime_hints(self) -> "EventRuntimeHints": return EventRuntimeHints(poll_interval=0.5, select_for_update=True, skip_locked=True) + def _create_pool(self) -> "ConnectionPool": + all_config = dict(self.connection_config) + + pool_parameters = { + "min_size": all_config.pop("min_size", 4), + "max_size": all_config.pop("max_size", None), + "name": all_config.pop("name", None), + "timeout": all_config.pop("timeout", 30.0), + "max_waiting": all_config.pop("max_waiting", 0), + "max_lifetime": all_config.pop("max_lifetime", 3600.0), + "max_idle": all_config.pop("max_idle", 600.0), + "reconnect_timeout": all_config.pop("reconnect_timeout", 300.0), + "reconnect_failed": all_config.pop("reconnect_failed", None), + "num_workers": all_config.pop("num_workers", 3), + "check": all_config.pop("check", None), + "reset": all_config.pop("reset", None), + "open": all_config.pop("open", True), + "close_returns": all_config.pop("close_returns", False), + } + + pool_parameters["configure"] = all_config.pop("configure", self._configure_connection) + pool_parameters = {k: v for k, v in pool_parameters.items() if v is not None} + + conninfo = all_config.pop("conninfo", None) + kwargs = all_config.pop("kwargs", {}) + extra = all_config.pop("extra", {}) + all_config.update(kwargs) + all_config.update(extra) + _apply_cluster_option(all_config) + if conninfo: + return ConnectionPool( + conninfo, kwargs=all_config, connection_class=psycopg_crdb.CrdbConnection, **pool_parameters + ) + + return ConnectionPool("", kwargs=all_config, connection_class=psycopg_crdb.CrdbConnection, **pool_parameters) + + def _configure_connection(self, conn: "CockroachSyncConnection") -> None: + autocommit_setting = self.connection_config.get("autocommit") + if autocommit_setting is not None: + conn.autocommit = autocommit_setting + + if isinstance(getattr(conn, "adapters", None), AdaptersMap): + serializer = self.driver_features.get("json_serializer") + deserializer = self.driver_features.get("json_deserializer") + if serializer is not None: + set_json_dumps(serializer, conn) + if deserializer is not None: + set_json_loads(deserializer, conn) + + if self._user_connection_hook is not None: + self._user_connection_hook(conn) + + def _close_pool(self) -> None: + if not self.connection_instance: + return + try: + self.connection_instance.close() + finally: + self.connection_instance = None + class CockroachPsycopgAsyncConnectionContext(AsyncPoolConnectionContext): """Async context manager for CockroachDB psycopg connections.""" @@ -366,13 +388,9 @@ def __init__(self, config: "CockroachPsycopgAsyncConfig") -> None: self._ctx: Any = None async def __aenter__(self) -> "CockroachAsyncConnection": - if self._config.connection_instance is None: - self._config.connection_instance = await self._config.create_pool() - if self._config.connection_instance: - self._ctx = self._config.connection_instance.connection() - return cast("CockroachAsyncConnection", await self._ctx.__aenter__()) - msg = "Connection pool is not initialized" - raise ImproperConfigurationError(msg) + pool = await self._config.provide_pool() + self._ctx = pool.connection() + return cast("CockroachAsyncConnection", await self._ctx.__aenter__()) async def __aexit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" @@ -390,10 +408,7 @@ def __init__(self, config: "CockroachPsycopgAsyncConfig") -> None: self._ctx: Any = None async def acquire_connection(self) -> "CockroachAsyncConnection": - pool = self._config.connection_instance - if pool is None: - pool = await self._config.create_pool() - self._config.connection_instance = pool + pool = await self._config.provide_pool() ctx = pool.connection() self._ctx = ctx return cast("CockroachAsyncConnection", await ctx.__aenter__()) @@ -469,64 +484,8 @@ def __init__( observability_config=observability_config, **kwargs, ) - - async def _create_pool(self) -> "AsyncConnectionPool": - all_config = dict(self.connection_config) - - pool_parameters = { - "min_size": all_config.pop("min_size", 4), - "max_size": all_config.pop("max_size", None), - "name": all_config.pop("name", None), - "timeout": all_config.pop("timeout", 30.0), - "max_waiting": all_config.pop("max_waiting", 0), - "max_lifetime": all_config.pop("max_lifetime", 3600.0), - "max_idle": all_config.pop("max_idle", 600.0), - "reconnect_timeout": all_config.pop("reconnect_timeout", 300.0), - "reconnect_failed": all_config.pop("reconnect_failed", None), - "num_workers": all_config.pop("num_workers", 3), - "check": all_config.pop("check", None), - "reset": all_config.pop("reset", None), - "close_returns": all_config.pop("close_returns", False), - } - open_pool = all_config.pop("open", True) - pool_parameters["open"] = False if open_pool is True else open_pool - - pool_parameters["configure"] = all_config.pop("configure", self._configure_async_connection) - pool_parameters = {k: v for k, v in pool_parameters.items() if v is not None} - - conninfo = all_config.pop("conninfo", None) - kwargs = all_config.pop("kwargs", {}) - all_config.update(kwargs) - _apply_cluster_option(all_config) - if conninfo: - pool = AsyncConnectionPool( - conninfo, kwargs=all_config, connection_class=psycopg_crdb.AsyncCrdbConnection, **pool_parameters - ) - else: - pool = AsyncConnectionPool( - "", kwargs=all_config, connection_class=psycopg_crdb.AsyncCrdbConnection, **pool_parameters - ) - - if open_pool is True: - await pool.open() - return cast("AsyncConnectionPool", pool) - - async def _configure_async_connection(self, conn: "CockroachAsyncConnection") -> None: - autocommit_setting = self.connection_config.get("autocommit") - if autocommit_setting is not None: - await conn.set_autocommit(autocommit_setting) - - # Call user-provided callback after internal setup if self._user_connection_hook is not None: - await self._user_connection_hook(conn) - - async def _close_pool(self) -> None: - if not self.connection_instance: - return - try: - await self.connection_instance.close() - finally: - self.connection_instance = None + self.driver_features["on_connection_create"] = self._user_connection_hook async def create_connection(self) -> "CockroachAsyncConnection": """Open a standalone connection owned by the caller. @@ -578,17 +537,89 @@ def get_signature_namespace(self) -> "dict[str, Any]": namespace = super().get_signature_namespace() namespace.update({ "CockroachAsyncConnection": CockroachAsyncConnection, + "CockroachPsycopgAsyncConfig": CockroachPsycopgAsyncConfig, + "CockroachPsycopgAsyncConnectionContext": CockroachPsycopgAsyncConnectionContext, + "CockroachPsycopgAsyncConnectionPool": AsyncConnectionPool, "CockroachPsycopgAsyncDriver": CockroachPsycopgAsyncDriver, "CockroachPsycopgAsyncExceptionHandler": CockroachPsycopgAsyncExceptionHandler, "CockroachPsycopgAsyncSessionContext": CockroachPsycopgAsyncSessionContext, "CockroachPsycopgConnectionConfig": CockroachPsycopgConnectionConfig, + "CockroachPsycopgDriverFeatures": CockroachPsycopgDriverFeatures, "CockroachPsycopgPoolConfig": CockroachPsycopgPoolConfig, + "CockroachPsycopgRetryConfig": CockroachPsycopgRetryConfig, }) return namespace def get_event_runtime_hints(self) -> "EventRuntimeHints": return EventRuntimeHints(poll_interval=0.5, select_for_update=True, skip_locked=True) + async def _create_pool(self) -> "AsyncConnectionPool": + all_config = dict(self.connection_config) + + pool_parameters = { + "min_size": all_config.pop("min_size", 4), + "max_size": all_config.pop("max_size", None), + "name": all_config.pop("name", None), + "timeout": all_config.pop("timeout", 30.0), + "max_waiting": all_config.pop("max_waiting", 0), + "max_lifetime": all_config.pop("max_lifetime", 3600.0), + "max_idle": all_config.pop("max_idle", 600.0), + "reconnect_timeout": all_config.pop("reconnect_timeout", 300.0), + "reconnect_failed": all_config.pop("reconnect_failed", None), + "num_workers": all_config.pop("num_workers", 3), + "check": all_config.pop("check", None), + "reset": all_config.pop("reset", None), + "close_returns": all_config.pop("close_returns", False), + } + open_pool = all_config.pop("open", True) + pool_parameters["open"] = False if open_pool is True else open_pool + + pool_parameters["configure"] = all_config.pop("configure", self._configure_async_connection) + pool_parameters = {k: v for k, v in pool_parameters.items() if v is not None} + + conninfo = all_config.pop("conninfo", None) + kwargs = all_config.pop("kwargs", {}) + extra = all_config.pop("extra", {}) + all_config.update(kwargs) + all_config.update(extra) + _apply_cluster_option(all_config) + if conninfo: + pool = AsyncConnectionPool( + conninfo, kwargs=all_config, connection_class=psycopg_crdb.AsyncCrdbConnection, **pool_parameters + ) + else: + pool = AsyncConnectionPool( + "", kwargs=all_config, connection_class=psycopg_crdb.AsyncCrdbConnection, **pool_parameters + ) + + if open_pool is True: + await pool.open() + return cast("AsyncConnectionPool", pool) + + async def _configure_async_connection(self, conn: "CockroachAsyncConnection") -> None: + autocommit_setting = self.connection_config.get("autocommit") + if autocommit_setting is not None: + await conn.set_autocommit(autocommit_setting) + + if isinstance(getattr(conn, "adapters", None), AdaptersMap): + serializer = self.driver_features.get("json_serializer") + deserializer = self.driver_features.get("json_deserializer") + if serializer is not None: + set_json_dumps(serializer, conn) + if deserializer is not None: + set_json_loads(deserializer, conn) + + if self._user_connection_hook is not None: + await self._user_connection_hook(conn) + + async def _close_pool(self) -> None: + if not self.connection_instance: + return + try: + await self.connection_instance.close() + finally: + self.connection_instance = None + _POOL_ONLY_CONFIG_KEYS: Final[frozenset[str]] = frozenset({ "check", diff --git a/sqlspec/adapters/cockroach_psycopg/core.py b/sqlspec/adapters/cockroach_psycopg/core.py index 7157e9726..89f7b4ec4 100644 --- a/sqlspec/adapters/cockroach_psycopg/core.py +++ b/sqlspec/adapters/cockroach_psycopg/core.py @@ -1,14 +1,15 @@ """CockroachDB psycopg adapter compiled helpers.""" -import random import re +import secrets from typing import TYPE_CHECKING, Any, Final, cast from mypy_extensions import mypyc_attr from sqlglot import tokenize from sqlglot.tokenizer_core import TokenType -from sqlspec.adapters.psycopg.core import apply_driver_features, build_statement_config, driver_profile +from sqlspec.adapters.psycopg.core import apply_driver_features as psycopg_apply_driver_features +from sqlspec.adapters.psycopg.core import build_statement_config, driver_profile from sqlspec.exceptions import ImproperConfigurationError, SerializationConflictError, SQLSpecError from sqlspec.utils.config_tools import normalize_connection_config from sqlspec.utils.text import quote_identifier, split_qualified_identifier @@ -18,6 +19,7 @@ from collections.abc import Mapping from sqlspec.adapters.cockroach_psycopg.config import CockroachPsycopgPoolConfig + from sqlspec.core import StatementConfig from sqlspec.storage import StorageTelemetry __all__ = ( @@ -36,14 +38,15 @@ "validate_follower_read_staleness", ) -# Retry configuration defaults (module-level for mypyc compatibility) _DEFAULT_MAX_RETRIES: Final[int] = 10 _DEFAULT_BASE_DELAY_MS: Final[float] = 50.0 _DEFAULT_MAX_DELAY_MS: Final[float] = 5000.0 _DEFAULT_ENABLE_LOGGING: Final[bool] = True +_MAX_CAUSE_CHAIN_DEPTH: Final[int] = 16 +_SYSTEM_RANDOM: Final[secrets.SystemRandom] = secrets.SystemRandom() +_LITERAL_PERCENT_PATTERN: Final[re.Pattern[str]] = re.compile(r"%(?!s)") -# Keep this in sync with cockroach_asyncpg.core.CockroachAsyncpgRetryConfig. @mypyc_attr(allow_interpreted_subclasses=False) class CockroachPsycopgRetryConfig: """CockroachDB psycopg transaction retry configuration.""" @@ -57,6 +60,15 @@ def __init__( max_delay_ms: float = _DEFAULT_MAX_DELAY_MS, enable_logging: bool = _DEFAULT_ENABLE_LOGGING, ) -> None: + if max_retries < 0: + msg = "max_retries must be non-negative" + raise ImproperConfigurationError(msg) + if base_delay_ms < 0: + msg = "base_delay_ms must be non-negative" + raise ImproperConfigurationError(msg) + if max_delay_ms < 0: + msg = "max_delay_ms must be non-negative" + raise ImproperConfigurationError(msg) self.max_retries = max_retries self.base_delay_ms = base_delay_ms self.max_delay_ms = max_delay_ms @@ -73,6 +85,20 @@ def from_features(cls, driver_features: "Mapping[str, Any]") -> "CockroachPsycop ) +def apply_driver_features( + statement_config: "StatementConfig", driver_features: "Mapping[str, Any] | None" +) -> "tuple[StatementConfig, dict[str, Any]]": + """Apply CockroachDB psycopg driver feature defaults to statement config.""" + raw = dict(driver_features) if driver_features else {} + updated_statement_config, features = psycopg_apply_driver_features(statement_config, raw) + for pg_only_key in ("enable_pgvector", "enable_paradedb", "enable_pg_textsearch"): + if pg_only_key not in raw: + features.pop(pg_only_key, None) + features.setdefault("enable_events", True) + features.setdefault("events_backend", "poll_queue") + return updated_statement_config, features + + def build_connection_config( connection_config: "CockroachPsycopgPoolConfig | Mapping[str, Any] | None", ) -> dict[str, Any]: @@ -138,15 +164,20 @@ def is_retryable_error(error: BaseException) -> bool: """ seen: set[int] = set() current: BaseException | None = error - while current is not None and id(current) not in seen: + depth = 0 + while current is not None and id(current) not in seen and depth < _MAX_CAUSE_CHAIN_DEPTH: seen.add(id(current)) + depth += 1 if isinstance(current, SerializationConflictError): return True if has_sqlstate(current) and str(current.sqlstate) == "40001": return True if not isinstance(current, SQLSpecError): return False - current = cast("BaseException | None", cast("Any", current).__cause__) + next_exc = cast("BaseException | None", cast("Any", current).__cause__) + if next_exc is None and not getattr(current, "__suppress_context__", False): + next_exc = cast("BaseException | None", cast("Any", current).__context__) + current = next_exc return False @@ -160,7 +191,7 @@ def calculate_backoff_seconds(attempt: int, config: "CockroachPsycopgRetryConfig capped_ms: float = min(config.base_delay_ms * (2**attempt), config.max_delay_ms) if capped_ms <= 0.0: return 0.0 - return random.uniform(capped_ms / 2.0, capped_ms) / 1000.0 # noqa: S311 + return _SYSTEM_RANDOM.uniform(capped_ms / 2.0, capped_ms) / 1000.0 _STALENESS_LITERAL: Final[re.Pattern[str]] = re.compile(r"'[^'\\;]+'") @@ -204,6 +235,8 @@ def build_native_export( format_sql = {"csv": "CSV", "parquet": "PARQUET"}[file_format] if not parameters: query = query.replace("%", "%%") + elif "%" in query: + query = _LITERAL_PERCENT_PATTERN.sub("%%", query) values: list[Any] = [uri] command = "EXPORT INTO " + format_sql + " " + "%s" if file_format == "csv" and "nullas" in options: diff --git a/sqlspec/adapters/cockroach_psycopg/data_dictionary.py b/sqlspec/adapters/cockroach_psycopg/data_dictionary.py index b9a8a78ad..c58d27320 100644 --- a/sqlspec/adapters/cockroach_psycopg/data_dictionary.py +++ b/sqlspec/adapters/cockroach_psycopg/data_dictionary.py @@ -80,15 +80,6 @@ def get_system_metadata_capabilities( requested_domains = ("system",) if domains is None else tuple(domains) return tuple(unsupported_system_metadata_capability(domain) for domain in requested_domains) - def _select_domain( - self, driver: "CockroachPsycopgSyncDriver", domain: str, query_name: str, **parameters: Any - ) -> MetadataResult: - query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) - if not query.is_supported or query.sql is None: - return MetadataResult(domain, capability=query.capability, warnings=query.warnings) - rows = driver.select(query.sql, **parameters) - return _metadata_result(domain, _cockroach_metadata_capability(domain), rows) - def get_schemas(self, driver: "CockroachPsycopgSyncDriver") -> MetadataResult: """Get schema metadata.""" return self._select_domain(driver, "schemas", "list") @@ -182,7 +173,7 @@ def get_system_metadata( def get_version(self, driver: "CockroachPsycopgSyncDriver") -> "VersionInfo | None": """Get CockroachDB version information.""" - driver_id = id(driver) + driver_id = id(getattr(driver, "connection", None) or driver) if driver_id in self._version_fetch_attempted: return self._version_cache.get(driver_id) @@ -292,6 +283,15 @@ def get_foreign_keys( schema_type=ForeignKeyMetadata, ) + def _select_domain( + self, driver: "CockroachPsycopgSyncDriver", domain: str, query_name: str, **parameters: Any + ) -> MetadataResult: + query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) + if not query.is_supported or query.sql is None: + return MetadataResult(domain, capability=query.capability, warnings=query.warnings) + rows = driver.select(query.sql, **parameters) + return _metadata_result(domain, _cockroach_metadata_capability(domain), rows) + @mypyc_attr(allow_interpreted_subclasses=True, native_class=False) class CockroachPsycopgAsyncDataDictionary(AsyncDataDictionaryBase): @@ -316,15 +316,6 @@ async def get_system_metadata_capabilities( requested_domains = ("system",) if domains is None else tuple(domains) return tuple(unsupported_system_metadata_capability(domain) for domain in requested_domains) - async def _select_domain( - self, driver: "CockroachPsycopgAsyncDriver", domain: str, query_name: str, **parameters: Any - ) -> MetadataResult: - query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) - if not query.is_supported or query.sql is None: - return MetadataResult(domain, capability=query.capability, warnings=query.warnings) - rows = await driver.select(query.sql, **parameters) - return _metadata_result(domain, _cockroach_metadata_capability(domain), rows) - async def get_schemas(self, driver: "CockroachPsycopgAsyncDriver") -> MetadataResult: """Get schema metadata.""" return await self._select_domain(driver, "schemas", "list") @@ -418,7 +409,7 @@ async def get_system_metadata( async def get_version(self, driver: "CockroachPsycopgAsyncDriver") -> "VersionInfo | None": """Get CockroachDB version information.""" - driver_id = id(driver) + driver_id = id(getattr(driver, "connection", None) or driver) if driver_id in self._version_fetch_attempted: return self._version_cache.get(driver_id) @@ -530,6 +521,15 @@ async def get_foreign_keys( schema_type=ForeignKeyMetadata, ) + async def _select_domain( + self, driver: "CockroachPsycopgAsyncDriver", domain: str, query_name: str, **parameters: Any + ) -> MetadataResult: + query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) + if not query.is_supported or query.sql is None: + return MetadataResult(domain, capability=query.capability, warnings=query.warnings) + rows = await driver.select(query.sql, **parameters) + return _metadata_result(domain, _cockroach_metadata_capability(domain), rows) + def _cockroach_metadata_capability(domain: str) -> MetadataCapability: if domain == "ddl": diff --git a/sqlspec/adapters/cockroach_psycopg/driver.py b/sqlspec/adapters/cockroach_psycopg/driver.py index d5421d567..679b83121 100644 --- a/sqlspec/adapters/cockroach_psycopg/driver.py +++ b/sqlspec/adapters/cockroach_psycopg/driver.py @@ -2,6 +2,7 @@ import asyncio import time +from contextlib import suppress from typing import TYPE_CHECKING, Any, TypeVar, cast from sqlspec.adapters.cockroach_psycopg._typing import ( @@ -11,7 +12,6 @@ CockroachSyncConnection, ) from sqlspec.adapters.cockroach_psycopg._typing import cockroach_psycopg_dict_row as dict_row -from sqlspec.adapters.cockroach_psycopg._typing import cockroach_psycopg_module as psycopg from sqlspec.adapters.cockroach_psycopg.core import ( CockroachPsycopgRetryConfig, build_native_export, @@ -29,10 +29,13 @@ CockroachPsycopgAsyncDataDictionary, CockroachPsycopgSyncDataDictionary, ) -from sqlspec.adapters.psycopg.core import create_mapped_exception -from sqlspec.adapters.psycopg.driver import PsycopgAsyncDriver, PsycopgSyncDriver +from sqlspec.adapters.psycopg.driver import ( + PsycopgAsyncDriver, + PsycopgAsyncExceptionHandler, + PsycopgSyncDriver, + PsycopgSyncExceptionHandler, +) from sqlspec.core import SQL, StatementConfig, get_cache_config, register_driver_profile -from sqlspec.driver import BaseAsyncExceptionHandler, BaseSyncExceptionHandler from sqlspec.utils.logging import get_logger if TYPE_CHECKING: @@ -56,33 +59,17 @@ _T = TypeVar("_T") -class CockroachPsycopgSyncExceptionHandler(BaseSyncExceptionHandler): +class CockroachPsycopgSyncExceptionHandler(PsycopgSyncExceptionHandler): """Context manager for handling CockroachDB psycopg exceptions.""" __slots__ = () - def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "BaseException") -> bool: - if exc_type is None: - return False - if issubclass(exc_type, psycopg.Error): - self.pending_exception = create_mapped_exception(exc_val) - return True - return False - -class CockroachPsycopgAsyncExceptionHandler(BaseAsyncExceptionHandler): +class CockroachPsycopgAsyncExceptionHandler(PsycopgAsyncExceptionHandler): """Async context manager for handling CockroachDB psycopg exceptions.""" __slots__ = () - def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "BaseException") -> bool: - if exc_type is None: - return False - if issubclass(exc_type, psycopg.Error): - self.pending_exception = create_mapped_exception(exc_val) - return True - return False - class CockroachPsycopgSyncDriver(PsycopgSyncDriver): """CockroachDB sync driver using psycopg.crdb.""" @@ -108,12 +95,11 @@ def __init__( self._follower_staleness = cast("str | None", self.driver_features.get("default_staleness")) self._data_dictionary = None - def _execute_cache_hit( - self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" - ) -> "SQLResult": - if cached.operation_profile.returns_rows: - self._begin_follower_read_transaction() - return super()._execute_cache_hit(sql, params, cached) + @property + def data_dictionary(self) -> "CockroachPsycopgSyncDataDictionary": # type: ignore[override] + if self._data_dictionary is None: + self._data_dictionary = CockroachPsycopgSyncDataDictionary() # type: ignore[assignment] + return cast("CockroachPsycopgSyncDataDictionary", self._data_dictionary) def select_to_storage( self, @@ -203,24 +189,6 @@ def load_from_storage( table, source, file_format=file_format, partitioner=partitioner, overwrite=overwrite ) - def _native_storage_ready(self) -> bool: - return ( - bool(self.driver_features.get("enable_native_storage")) - and self.connection.autocommit - and not self._connection_in_transaction() - ) - - def _execute_native_storage(self, command: str, parameters: "list[Any]") -> "list[dict[str, Any]]": - handler = self.handle_database_exceptions() - rows = [] - with self.with_cursor(self.connection) as cursor, handler: - cursor.row_factory = dict_row - cursor.execute(command.encode("utf-8"), parameters) - rows = cursor.fetchall() - if handler.pending_exception is not None: - raise handler.pending_exception - return rows - def begin(self) -> None: """Begin a transaction and apply follower-read staleness to it. @@ -268,22 +236,53 @@ def run_transaction_with_retry(self, operation: "Callable[[], _T]") -> _T: attempt += 1 def dispatch_execute(self, cursor: "CockroachSyncCursor", statement: SQL) -> "ExecutionResult": - return self._dispatch_execute_impl(cursor, statement) + auto_started = self._begin_follower_read_transaction() if statement.returns_rows() else False + try: + result = super().dispatch_execute(cursor, statement) + except BaseException: + if auto_started and self._connection_in_transaction(): + with suppress(Exception): + self.rollback() + raise + if auto_started and self._connection_in_transaction(): + self.commit() + return result - def dispatch_execute_many(self, cursor: "CockroachSyncCursor", statement: SQL) -> "ExecutionResult": - return self._dispatch_execute_many_impl(cursor, statement) + def handle_database_exceptions(self) -> "CockroachPsycopgSyncExceptionHandler": + return CockroachPsycopgSyncExceptionHandler() - def dispatch_execute_script(self, cursor: "CockroachSyncCursor", statement: SQL) -> "ExecutionResult": - return self._dispatch_execute_script_impl(cursor, statement) + def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + auto_started = self._begin_follower_read_transaction() if cached.operation_profile.returns_rows else False + try: + result = super()._execute_cache_hit(sql, params, cached) + except BaseException: + if auto_started and self._connection_in_transaction(): + with suppress(Exception): + self.rollback() + raise + if auto_started and self._connection_in_transaction(): + self.commit() + return result - def handle_database_exceptions(self) -> "CockroachPsycopgSyncExceptionHandler": # type: ignore[override] - return CockroachPsycopgSyncExceptionHandler() + def _native_storage_ready(self) -> bool: + return ( + bool(self.driver_features.get("enable_native_storage")) + and self.connection.autocommit + and not self._connection_in_transaction() + ) - @property - def data_dictionary(self) -> "CockroachPsycopgSyncDataDictionary": # type: ignore[override] - if self._data_dictionary is None: - self._data_dictionary = CockroachPsycopgSyncDataDictionary() # type: ignore[assignment] - return cast("CockroachPsycopgSyncDataDictionary", self._data_dictionary) + def _execute_native_storage(self, command: str, parameters: "list[Any]") -> "list[dict[str, Any]]": + handler = self.handle_database_exceptions() + rows = [] + with self.with_cursor(self.connection) as cursor, handler: + cursor.row_factory = dict_row + cursor.execute(command.encode("utf-8"), parameters) + rows = cursor.fetchall() + if handler.pending_exception is not None: + raise handler.pending_exception + return rows def _apply_follower_reads(self) -> None: if not self.driver_features.get("enable_follower_reads", False): @@ -293,7 +292,7 @@ def _apply_follower_reads(self) -> None: staleness = validate_follower_read_staleness(self._follower_staleness) self.connection.execute(cast("Any", f"SET TRANSACTION AS OF SYSTEM TIME {staleness}")).close() - def _begin_follower_read_transaction(self) -> None: + def _begin_follower_read_transaction(self) -> bool: """Open the transaction a follower read needs so the staleness clause can lead it. psycopg opens a transaction on the first statement, which would leave the @@ -301,23 +300,13 @@ def _begin_follower_read_transaction(self) -> None: not already done so. """ if not self.driver_features.get("enable_follower_reads", False): - return + return False if not self._follower_staleness: - return + return False if self._connection_in_transaction(): - return + return False self.begin() - - def _dispatch_execute_impl(self, cursor: "CockroachSyncCursor", statement: SQL) -> "ExecutionResult": - if statement.returns_rows(): - self._begin_follower_read_transaction() - return super().dispatch_execute(cursor, statement) - - def _dispatch_execute_many_impl(self, cursor: "CockroachSyncCursor", statement: SQL) -> "ExecutionResult": - return PsycopgSyncDriver.dispatch_execute_many(self, cursor, statement) - - def _dispatch_execute_script_impl(self, cursor: "CockroachSyncCursor", statement: SQL) -> "ExecutionResult": - return PsycopgSyncDriver.dispatch_execute_script(self, cursor, statement) + return True class CockroachPsycopgAsyncDriver(PsycopgAsyncDriver): @@ -344,12 +333,11 @@ def __init__( self._follower_staleness = cast("str | None", self.driver_features.get("default_staleness")) self._data_dictionary = None - async def _execute_cache_hit( - self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" - ) -> "SQLResult": - if cached.operation_profile.returns_rows: - await self._begin_follower_read_transaction() - return await super()._execute_cache_hit(sql, params, cached) + @property + def data_dictionary(self) -> "CockroachPsycopgAsyncDataDictionary": # type: ignore[override] + if self._data_dictionary is None: + self._data_dictionary = CockroachPsycopgAsyncDataDictionary() # type: ignore[assignment] + return cast("CockroachPsycopgAsyncDataDictionary", self._data_dictionary) async def select_to_storage( self, @@ -439,24 +427,6 @@ async def load_from_storage( table, source, file_format=file_format, partitioner=partitioner, overwrite=overwrite ) - def _native_storage_ready(self) -> bool: - return ( - bool(self.driver_features.get("enable_native_storage")) - and self.connection.autocommit - and not self._connection_in_transaction() - ) - - async def _execute_native_storage(self, command: str, parameters: "list[Any]") -> "list[dict[str, Any]]": - handler = self.handle_database_exceptions() - rows = [] - async with self.with_cursor(self.connection) as cursor, handler: - cursor.row_factory = dict_row - await cursor.execute(command.encode("utf-8"), parameters) - rows = await cursor.fetchall() - if handler.pending_exception is not None: - raise handler.pending_exception - return rows - async def begin(self) -> None: """Begin a transaction and apply follower-read staleness to it. @@ -504,22 +474,53 @@ async def run_transaction_with_retry(self, operation: "Callable[[], Awaitable[_T attempt += 1 async def dispatch_execute(self, cursor: "CockroachAsyncCursor", statement: SQL) -> "ExecutionResult": - return await self._dispatch_execute_impl(cursor, statement) + auto_started = await self._begin_follower_read_transaction() if statement.returns_rows() else False + try: + result = await super().dispatch_execute(cursor, statement) + except BaseException: + if auto_started and self._connection_in_transaction(): + with suppress(Exception): + await self.rollback() + raise + if auto_started and self._connection_in_transaction(): + await self.commit() + return result - async def dispatch_execute_many(self, cursor: "CockroachAsyncCursor", statement: SQL) -> "ExecutionResult": - return await self._dispatch_execute_many_impl(cursor, statement) + def handle_database_exceptions(self) -> "CockroachPsycopgAsyncExceptionHandler": + return CockroachPsycopgAsyncExceptionHandler() - async def dispatch_execute_script(self, cursor: "CockroachAsyncCursor", statement: SQL) -> "ExecutionResult": - return await self._dispatch_execute_script_impl(cursor, statement) + async def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + auto_started = await self._begin_follower_read_transaction() if cached.operation_profile.returns_rows else False + try: + result = await super()._execute_cache_hit(sql, params, cached) + except BaseException: + if auto_started and self._connection_in_transaction(): + with suppress(Exception): + await self.rollback() + raise + if auto_started and self._connection_in_transaction(): + await self.commit() + return result - def handle_database_exceptions(self) -> "CockroachPsycopgAsyncExceptionHandler": # type: ignore[override] - return CockroachPsycopgAsyncExceptionHandler() + def _native_storage_ready(self) -> bool: + return ( + bool(self.driver_features.get("enable_native_storage")) + and self.connection.autocommit + and not self._connection_in_transaction() + ) - @property - def data_dictionary(self) -> "CockroachPsycopgAsyncDataDictionary": # type: ignore[override] - if self._data_dictionary is None: - self._data_dictionary = CockroachPsycopgAsyncDataDictionary() # type: ignore[assignment] - return cast("CockroachPsycopgAsyncDataDictionary", self._data_dictionary) + async def _execute_native_storage(self, command: str, parameters: "list[Any]") -> "list[dict[str, Any]]": + handler = self.handle_database_exceptions() + rows = [] + async with self.with_cursor(self.connection) as cursor, handler: + cursor.row_factory = dict_row + await cursor.execute(command.encode("utf-8"), parameters) + rows = await cursor.fetchall() + if handler.pending_exception is not None: + raise handler.pending_exception + return rows async def _apply_follower_reads(self) -> None: if not self.driver_features.get("enable_follower_reads", False): @@ -530,7 +531,7 @@ async def _apply_follower_reads(self) -> None: cursor = await self.connection.execute(cast("Any", f"SET TRANSACTION AS OF SYSTEM TIME {staleness}")) await cursor.close() - async def _begin_follower_read_transaction(self) -> None: + async def _begin_follower_read_transaction(self) -> bool: """Open the transaction a follower read needs so the staleness clause can lead it. psycopg opens a transaction on the first statement, which would leave the @@ -538,23 +539,13 @@ async def _begin_follower_read_transaction(self) -> None: not already done so. """ if not self.driver_features.get("enable_follower_reads", False): - return + return False if not self._follower_staleness: - return + return False if self._connection_in_transaction(): - return + return False await self.begin() - - async def _dispatch_execute_impl(self, cursor: "CockroachAsyncCursor", statement: SQL) -> "ExecutionResult": - if statement.returns_rows(): - await self._begin_follower_read_transaction() - return await super().dispatch_execute(cursor, statement) - - async def _dispatch_execute_many_impl(self, cursor: "CockroachAsyncCursor", statement: SQL) -> "ExecutionResult": - return await PsycopgAsyncDriver.dispatch_execute_many(self, cursor, statement) - - async def _dispatch_execute_script_impl(self, cursor: "CockroachAsyncCursor", statement: SQL) -> "ExecutionResult": - return await PsycopgAsyncDriver.dispatch_execute_script(self, cursor, statement) + return True register_driver_profile("cockroach_psycopg", driver_profile) diff --git a/sqlspec/adapters/cockroach_psycopg/events/__init__.py b/sqlspec/adapters/cockroach_psycopg/events/__init__.py index ef07ce83d..046047525 100644 --- a/sqlspec/adapters/cockroach_psycopg/events/__init__.py +++ b/sqlspec/adapters/cockroach_psycopg/events/__init__.py @@ -1,3 +1,5 @@ +"""CockroachDB psycopg events module.""" + from sqlspec.adapters.cockroach_psycopg.events.store import ( CockroachPsycopgAsyncEventQueueStore, CockroachPsycopgSyncEventQueueStore, diff --git a/sqlspec/adapters/cockroach_psycopg/litestar/__init__.py b/sqlspec/adapters/cockroach_psycopg/litestar/__init__.py index 242b58e2a..4dc7bc1c9 100644 --- a/sqlspec/adapters/cockroach_psycopg/litestar/__init__.py +++ b/sqlspec/adapters/cockroach_psycopg/litestar/__init__.py @@ -1,3 +1,5 @@ +"""CockroachDB psycopg Litestar session store module.""" + from sqlspec.adapters.cockroach_psycopg.litestar.store import ( CockroachPsycopgAsyncStore, CockroachPsycopgLitestarConfig, diff --git a/sqlspec/adapters/cockroach_psycopg/litestar/store.py b/sqlspec/adapters/cockroach_psycopg/litestar/store.py index 194f9eddb..9f48254f3 100644 --- a/sqlspec/adapters/cockroach_psycopg/litestar/store.py +++ b/sqlspec/adapters/cockroach_psycopg/litestar/store.py @@ -173,6 +173,7 @@ def _table_ddl(self) -> str: """Get CockroachDB CREATE TABLE SQL with optimized schema.""" settings = cast("dict[str, Any]", self._config.extension_config.get("litestar", {})) table_options, hash_clause = _cockroach_storage_clauses(settings) + idx_table_name = self._table_name.replace(".", "_") return f""" CREATE TABLE IF NOT EXISTS {self._table_name} ( session_id TEXT PRIMARY KEY, @@ -182,12 +183,13 @@ def _table_ddl(self) -> str: updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ){table_options}; - CREATE INDEX IF NOT EXISTS idx_{self._table_name}_expires_at + CREATE INDEX IF NOT EXISTS idx_{idx_table_name}_expires_at ON {self._table_name}(expires_at){hash_clause} WHERE expires_at IS NOT NULL; """ def _drop_table_sql(self) -> "list[str]": - return [f"DROP INDEX IF EXISTS idx_{self._table_name}_expires_at", f"DROP TABLE IF EXISTS {self._table_name}"] + idx_table_name = self._table_name.replace(".", "_") + return [f"DROP INDEX IF EXISTS idx_{idx_table_name}_expires_at", f"DROP TABLE IF EXISTS {self._table_name}"] class CockroachPsycopgSyncStore(BaseSQLSpecStore["CockroachPsycopgSyncConfig"]): @@ -234,6 +236,7 @@ async def delete_expired(self) -> int: def _table_ddl(self) -> str: settings = cast("dict[str, Any]", self._config.extension_config.get("litestar", {})) table_options, hash_clause = _cockroach_storage_clauses(settings) + idx_table_name = self._table_name.replace(".", "_") return f""" CREATE TABLE IF NOT EXISTS {self._table_name} ( session_id TEXT PRIMARY KEY, @@ -243,12 +246,13 @@ def _table_ddl(self) -> str: updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ){table_options}; - CREATE INDEX IF NOT EXISTS idx_{self._table_name}_expires_at + CREATE INDEX IF NOT EXISTS idx_{idx_table_name}_expires_at ON {self._table_name}(expires_at){hash_clause} WHERE expires_at IS NOT NULL; """ def _drop_table_sql(self) -> "list[str]": - return [f"DROP INDEX IF EXISTS idx_{self._table_name}_expires_at", f"DROP TABLE IF EXISTS {self._table_name}"] + idx_table_name = self._table_name.replace(".", "_") + return [f"DROP INDEX IF EXISTS idx_{idx_table_name}_expires_at", f"DROP TABLE IF EXISTS {self._table_name}"] def _create_table(self) -> None: sql = self._table_ddl() diff --git a/sqlspec/adapters/db2/_typing.py b/sqlspec/adapters/db2/_typing.py index a9323476a..ad5fafdb3 100644 --- a/sqlspec/adapters/db2/_typing.py +++ b/sqlspec/adapters/db2/_typing.py @@ -1,7 +1,8 @@ """IBM Db2 adapter type definitions and context managers.""" import contextlib -from typing import TYPE_CHECKING, Any +import importlib +from typing import TYPE_CHECKING, Any, cast from sqlspec.exceptions import MissingDependencyError from sqlspec.typing import import_optional_attr @@ -116,6 +117,7 @@ class Db2SyncSessionContext: "_connection", "_driver", "_driver_features", + "_driver_type", "_prepare_driver", "_release_connection", "_statement_config", @@ -129,6 +131,7 @@ def __init__( driver_features: dict[str, Any], prepare_driver: "Callable[[Any], Any]", *, + driver_type: "Callable[..., Any] | None" = None, autocommit_baseline: bool = True, begin_transaction: bool = False, ) -> None: @@ -140,6 +143,7 @@ def __init__( statement_config: SQL compilation configuration. driver_features: Feature flags and hooks for driver instance. prepare_driver: Hook to customize or decorate driver before yielding. + driver_type: Optional driver class/factory override. autocommit_baseline: Autocommit mode the pool opens connections in. begin_transaction: Begin a transaction before yielding the driver. """ @@ -148,6 +152,7 @@ def __init__( self._statement_config = statement_config self._driver_features = driver_features self._prepare_driver = prepare_driver + self._driver_type = driver_type self._autocommit_baseline = autocommit_baseline self._begin_transaction = begin_transaction self._connection: Any = None @@ -159,16 +164,15 @@ def __enter__(self) -> Any: Returns: Any: Initialized driver adapter. """ - from sqlspec.adapters.db2.driver import Db2SyncDriver - + driver_factory = self._driver_type or _resolve_sync_driver_type() self._connection = self._acquire_connection() try: - self._driver = Db2SyncDriver( + self._driver = driver_factory( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features, ) - if self._begin_transaction: + if self._begin_transaction and self._driver is not None: self._driver.begin() return self._prepare_driver(self._driver) except BaseException as exc: @@ -236,6 +240,7 @@ class Db2AsyncSessionContext: "_connection", "_driver", "_driver_features", + "_driver_type", "_prepare_driver", "_release_connection", "_statement_config", @@ -249,6 +254,7 @@ def __init__( driver_features: dict[str, Any], prepare_driver: "Callable[[Any], Any]", *, + driver_type: "Callable[..., Any] | None" = None, autocommit_baseline: bool = True, begin_transaction: bool = False, ) -> None: @@ -260,6 +266,7 @@ def __init__( statement_config: SQL compilation configuration. driver_features: Feature flags and hooks for driver instance. prepare_driver: Hook to customize or decorate driver before yielding. + driver_type: Optional driver class/factory override. autocommit_baseline: Autocommit mode the pool opens connections in. begin_transaction: Begin a transaction before yielding the driver. """ @@ -268,6 +275,7 @@ def __init__( self._statement_config = statement_config self._driver_features = driver_features self._prepare_driver = prepare_driver + self._driver_type = driver_type self._autocommit_baseline = autocommit_baseline self._begin_transaction = begin_transaction self._connection: Any = None @@ -279,16 +287,15 @@ async def __aenter__(self) -> Any: Returns: Any: Initialized driver adapter. """ - from sqlspec.adapters.db2.driver import Db2AsyncDriver - + driver_factory = self._driver_type or _resolve_async_driver_type() self._connection = await self._acquire_connection() try: - self._driver = Db2AsyncDriver( + self._driver = driver_factory( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features, ) - if self._begin_transaction: + if self._begin_transaction and self._driver is not None: await self._driver.begin() return self._prepare_driver(self._driver) except BaseException as exc: @@ -309,3 +316,13 @@ async def __aexit__( self._connection = None self._driver = None return None + + +def _resolve_sync_driver_type() -> "Callable[..., Any]": + """Resolve Db2SyncDriver without a function-scoped import.""" + return cast("Callable[..., Any]", importlib.import_module("sqlspec.adapters.db2.driver").Db2SyncDriver) + + +def _resolve_async_driver_type() -> "Callable[..., Any]": + """Resolve Db2AsyncDriver without a function-scoped import.""" + return cast("Callable[..., Any]", importlib.import_module("sqlspec.adapters.db2.driver").Db2AsyncDriver) diff --git a/sqlspec/adapters/db2/adk/store.py b/sqlspec/adapters/db2/adk/store.py index 938b21444..72dc4fad5 100644 --- a/sqlspec/adapters/db2/adk/store.py +++ b/sqlspec/adapters/db2/adk/store.py @@ -313,15 +313,15 @@ def _fetch_scoped_row(self, sql: str, params: tuple[Any, ...]) -> dict[str, Any] raise def _delete_counted(self, count_sql: str, delete_sql: str, params: tuple[Any, ...]) -> int: - """Count, then delete, the matching rows; a missing table deletes nothing.""" + """Delete the matching rows in a single session; a missing table deletes nothing.""" + _ = count_sql try: - count = self._select_count(count_sql, params) - self._execute(delete_sql, params, commit=True) + count = self._execute(delete_sql, params, commit=True) except SQLSpecError as exc: if _is_table_missing(exc): return 0 raise - return count + return max(count, 0) def _execute_fetchone(self, sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None: with self._config.provide_session() as driver: @@ -338,11 +338,6 @@ def _execute(self, sql: str, params: tuple[Any, ...] = (), *, commit: bool = Fal driver.commit() return int(result.rows_affected) - def _select_count(self, sql: str, params: tuple[Any, ...] = ()) -> int: - with self._config.provide_session() as driver: - value = driver.select_value(sql, params) - return int(value or 0) - class Db2AsyncADKStore(_Db2ADKStoreMixin, BaseAsyncADKStore[Db2AsyncConfig]): """Asynchronous IBM Db2 ADK session/event store.""" @@ -566,15 +561,15 @@ async def _fetch_scoped_row(self, sql: str, params: tuple[Any, ...]) -> dict[str raise async def _delete_counted(self, count_sql: str, delete_sql: str, params: tuple[Any, ...]) -> int: - """Count, then delete, the matching rows; a missing table deletes nothing.""" + """Delete the matching rows in a single session; a missing table deletes nothing.""" + _ = count_sql try: - count = await self._select_count(count_sql, params) - await self._execute(delete_sql, params, commit=True) + count = await self._execute(delete_sql, params, commit=True) except SQLSpecError as exc: if _is_table_missing(exc): return 0 raise - return count + return max(count, 0) async def _execute_fetchall(self, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]: async with self._config.provide_session() as driver: @@ -587,11 +582,6 @@ async def _execute(self, sql: str, params: tuple[Any, ...] = (), *, commit: bool await driver.commit() return int(result.rows_affected) - async def _select_count(self, sql: str, params: tuple[Any, ...] = ()) -> int: - async with self._config.provide_session() as driver: - value = await driver.select_value(sql, params) - return int(value or 0) - class _Db2ADKMemoryStoreMixin: """Memory-table index and drop builders shared by the Db2 ADK memory stores.""" @@ -663,21 +653,35 @@ def search_entries( sql, params = _memory_search_statement( self._memory_table, query, app_name, user_id, scope_filter, effective_limit ) - return [_memory_record_from_row(row) for row in self._execute_fetchall(sql, params)] + try: + rows = self._execute_fetchall(sql, params) + except SQLSpecError as exc: + if _is_table_missing(exc): + return [] + raise + return [_memory_record_from_row(row) for row in rows] def delete_entries_by_session(self, session_id: str) -> int: """Delete all memory entries for a specific session.""" - count_sql, delete_sql = _memory_session_delete_statements(self._memory_table) - count = self._select_count(count_sql, (session_id,)) - self._execute(delete_sql, (session_id,), commit=True) - return count + _, delete_sql = _memory_session_delete_statements(self._memory_table) + try: + count = self._execute(delete_sql, (session_id,), commit=True) + except SQLSpecError as exc: + if _is_table_missing(exc): + return 0 + raise + return max(count, 0) def delete_entries_older_than(self, days: int, app_name: str | None = None, scope: str | None = None) -> int: """Delete memory entries older than specified days.""" - count_sql, delete_sql, params = _memory_retention_statements(self._memory_table, days, app_name, scope) - count = self._select_count(count_sql, params) - self._execute(delete_sql, params, commit=True) - return count + _, delete_sql, params = _memory_retention_statements(self._memory_table, days, app_name, scope) + try: + count = self._execute(delete_sql, params, commit=True) + except SQLSpecError as exc: + if _is_table_missing(exc): + return 0 + raise + return max(count, 0) def _memory_table_ddl(self) -> str: return _memory_table_ddl(self._memory_table, self._owner_id_column_ddl) @@ -693,11 +697,6 @@ def _execute(self, sql: str, params: tuple[Any, ...] = (), *, commit: bool = Fal driver.commit() return int(result.rows_affected) - def _select_count(self, sql: str, params: tuple[Any, ...] = ()) -> int: - with self._config.provide_session() as driver: - value = driver.select_value(sql, params) - return int(value or 0) - class Db2AsyncADKMemoryStore(_Db2ADKMemoryStoreMixin, BaseAsyncADKMemoryStore[Db2AsyncConfig]): """IBM Db2 ADK memory store for asynchronous configs.""" @@ -757,21 +756,35 @@ async def search_entries( sql, params = _memory_search_statement( self._memory_table, query, app_name, user_id, scope_filter, effective_limit ) - return [_memory_record_from_row(row) for row in await self._execute_fetchall(sql, params)] + try: + rows = await self._execute_fetchall(sql, params) + except SQLSpecError as exc: + if _is_table_missing(exc): + return [] + raise + return [_memory_record_from_row(row) for row in rows] async def delete_entries_by_session(self, session_id: str) -> int: """Delete all memory entries for a specific session.""" - count_sql, delete_sql = _memory_session_delete_statements(self._memory_table) - count = await self._select_count(count_sql, (session_id,)) - await self._execute(delete_sql, (session_id,), commit=True) - return count + _, delete_sql = _memory_session_delete_statements(self._memory_table) + try: + count = await self._execute(delete_sql, (session_id,), commit=True) + except SQLSpecError as exc: + if _is_table_missing(exc): + return 0 + raise + return max(count, 0) async def delete_entries_older_than(self, days: int, app_name: str | None = None, scope: str | None = None) -> int: """Delete memory entries older than specified days.""" - count_sql, delete_sql, params = _memory_retention_statements(self._memory_table, days, app_name, scope) - count = await self._select_count(count_sql, params) - await self._execute(delete_sql, params, commit=True) - return count + _, delete_sql, params = _memory_retention_statements(self._memory_table, days, app_name, scope) + try: + count = await self._execute(delete_sql, params, commit=True) + except SQLSpecError as exc: + if _is_table_missing(exc): + return 0 + raise + return max(count, 0) async def _memory_table_ddl(self) -> str: return _memory_table_ddl(self._memory_table, self._owner_id_column_ddl) @@ -787,14 +800,15 @@ async def _execute(self, sql: str, params: tuple[Any, ...] = (), *, commit: bool await driver.commit() return int(result.rows_affected) - async def _select_count(self, sql: str, params: tuple[Any, ...] = ()) -> int: - async with self._config.provide_session() as driver: - value = await driver.select_value(sql, params) - return int(value or 0) + +def _constraint_prefix(table: str) -> str: + """Return a dot-free identifier prefix for constraint and index names.""" + return table.replace(".", "_").replace('"', "") def _sessions_table_ddl(table: str, owner_id_column_ddl: str | None) -> str: owner_line = f",\n {owner_id_column_ddl}" if owner_id_column_ddl else "" + prefix = _constraint_prefix(table) return f""" CREATE TABLE {table} ( id VARCHAR(128) NOT NULL, @@ -803,16 +817,18 @@ def _sessions_table_ddl(table: str, owner_id_column_ddl: str | None) -> str: state {JSON_COLUMN_TYPE} NOT NULL, create_time TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, update_time TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, - CONSTRAINT pk_{table}_id PRIMARY KEY (id) + CONSTRAINT pk_{prefix}_id PRIMARY KEY (id) ) """ def _sessions_index_specs(table: str) -> list[tuple[str, str, str]]: - return [(f"idx_{table}_app_user", table, "app_name, user_id"), (f"idx_{table}_update_time", table, "update_time")] + prefix = _constraint_prefix(table) + return [(f"idx_{prefix}_app_user", table, "app_name, user_id"), (f"idx_{prefix}_update_time", table, "update_time")] def _events_table_ddl(table: str, session_table: str) -> str: + prefix = _constraint_prefix(table) return f""" CREATE TABLE {table} ( id VARCHAR(128) NOT NULL, @@ -822,52 +838,56 @@ def _events_table_ddl(table: str, session_table: str) -> str: invocation_id VARCHAR(256) NOT NULL, timestamp TIMESTAMP NOT NULL, event_data {JSON_COLUMN_TYPE} NOT NULL, - CONSTRAINT pk_{table}_id PRIMARY KEY (id), - CONSTRAINT fk_{table}_session FOREIGN KEY (session_id) + CONSTRAINT pk_{prefix}_id PRIMARY KEY (id), + CONSTRAINT fk_{prefix}_session FOREIGN KEY (session_id) REFERENCES {session_table}(id) ON DELETE CASCADE ) """ def _events_index_specs(table: str) -> list[tuple[str, str, str]]: + prefix = _constraint_prefix(table) return [ - (f"idx_{table}_scope", table, "app_name, user_id, session_id, timestamp"), - (f"idx_{table}_session", table, "session_id, timestamp"), - (f"idx_{table}_invocation", table, "invocation_id"), - (f"idx_{table}_timestamp", table, "timestamp"), - (f"idx_{table}_app_timestamp", table, "app_name, timestamp"), + (f"idx_{prefix}_scope", table, "app_name, user_id, session_id, timestamp"), + (f"idx_{prefix}_session", table, "session_id, timestamp"), + (f"idx_{prefix}_invocation", table, "invocation_id"), + (f"idx_{prefix}_timestamp", table, "timestamp"), + (f"idx_{prefix}_app_timestamp", table, "app_name, timestamp"), ] def _app_states_table_ddl(table: str) -> str: + prefix = _constraint_prefix(table) return f""" CREATE TABLE {table} ( app_name VARCHAR(128) NOT NULL, state {JSON_COLUMN_TYPE} NOT NULL, update_time TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, - CONSTRAINT pk_{table}_app_name PRIMARY KEY (app_name) + CONSTRAINT pk_{prefix}_app_name PRIMARY KEY (app_name) ) """ def _user_states_table_ddl(table: str) -> str: + prefix = _constraint_prefix(table) return f""" CREATE TABLE {table} ( app_name VARCHAR(128) NOT NULL, user_id VARCHAR(128) NOT NULL, state {JSON_COLUMN_TYPE} NOT NULL, update_time TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, - CONSTRAINT pk_{table}_app_user PRIMARY KEY (app_name, user_id) + CONSTRAINT pk_{prefix}_app_user PRIMARY KEY (app_name, user_id) ) """ def _metadata_table_ddl(table: str) -> str: + prefix = _constraint_prefix(table) return f""" CREATE TABLE {table} ( "KEY" VARCHAR(128) NOT NULL, value VARCHAR(512) NOT NULL, - CONSTRAINT pk_{table}_key PRIMARY KEY ("KEY") + CONSTRAINT pk_{prefix}_key PRIMARY KEY ("KEY") ) """ @@ -1115,8 +1135,8 @@ def _drop_table_sql(table: str) -> str: def _catalog_names(name: str) -> "tuple[str | None, str]": - """Return the catalog schema and name of an object whose DDL writes the name unquoted.""" - return split_db2_table_name(name.upper()) + """Return the catalog schema and name of an ADK table or index.""" + return split_db2_table_name(name) def _create_missing(driver: "Db2SyncDriver", probe_sql: str, name: str, ddl: str) -> None: @@ -1237,6 +1257,7 @@ def _metadata_value(row: "dict[str, Any] | None") -> "str | None": def _memory_table_ddl(table: str, owner_id_column_ddl: "str | None") -> str: owner_line = f",\n {owner_id_column_ddl}" if owner_id_column_ddl else "" + prefix = _constraint_prefix(table) return f""" CREATE TABLE {table} ( id VARCHAR(128) NOT NULL, @@ -1251,17 +1272,18 @@ def _memory_table_ddl(table: str, owner_id_column_ddl: "str | None") -> str: content_text CLOB(1M) NOT NULL, metadata_json {JSON_COLUMN_TYPE}, inserted_at TIMESTAMP NOT NULL{owner_line}, - CONSTRAINT pk_{table}_id PRIMARY KEY (id), - CONSTRAINT uq_{table}_event UNIQUE (event_id) + CONSTRAINT pk_{prefix}_id PRIMARY KEY (id), + CONSTRAINT uq_{prefix}_event UNIQUE (event_id) ) """ def _memory_index_specs(table: str) -> "list[tuple[str, str, str]]": + prefix = _constraint_prefix(table) return [ - (f"idx_{table}_scope", table, "app_name, user_id, scope, timestamp"), - (f"idx_{table}_session", table, "session_id, timestamp"), - (f"idx_{table}_inserted", table, "inserted_at"), + (f"idx_{prefix}_scope", table, "app_name, user_id, scope, timestamp"), + (f"idx_{prefix}_session", table, "session_id, timestamp"), + (f"idx_{prefix}_inserted", table, "inserted_at"), ] @@ -1295,7 +1317,7 @@ def _memory_search_statement( timestamp, content_json, content_text, metadata_json, inserted_at FROM {table} WHERE {where_scope} - AND POSSTR(LOWER(content_text), LOWER(?)) > 0 + AND LOCATE(LOWER(CAST(? AS VARCHAR(4000))), LOWER(content_text)) > 0 ORDER BY timestamp DESC FETCH FIRST {int(limit)} ROWS ONLY """ diff --git a/sqlspec/adapters/db2/config.py b/sqlspec/adapters/db2/config.py index b410bfbe7..4788c5198 100644 --- a/sqlspec/adapters/db2/config.py +++ b/sqlspec/adapters/db2/config.py @@ -34,7 +34,7 @@ from sqlspec.extensions.events import EventRuntimeHints if TYPE_CHECKING: - from collections.abc import Awaitable, Callable + from collections.abc import Awaitable, Callable, Mapping from sqlspec.core import StatementConfig from sqlspec.observability import ObservabilityConfig @@ -193,6 +193,7 @@ def __init__( connection_config = {"database": "SAMPLE"} normalized_connection_config = build_connection_config(connection_config) + raw_driver_features = dict(driver_features) if driver_features else {} statement_config = statement_config or default_statement_config statement_config, driver_features = apply_driver_features(statement_config, driver_features) @@ -200,6 +201,9 @@ def __init__( self._user_connection_hook: Callable[[Db2SyncConnection], None] | None = features_dict.pop( "on_connection_create", None ) + resolved_extension_config = _resolve_events_extension_config( + extension_config, raw_driver_features, features_dict + ) super().__init__( connection_config=normalized_connection_config, @@ -208,11 +212,46 @@ def __init__( statement_config=statement_config, driver_features=features_dict, bind_key=bind_key, - extension_config=extension_config, + extension_config=resolved_extension_config, observability_config=observability_config, **kwargs, ) + def create_connection(self) -> "Db2SyncConnection": + """Open a standalone connection owned by the caller. + + Returns: + Db2SyncConnection: A newly opened physical connection. + """ + return self.provide_pool().new_connection() + + def get_connection_string(self) -> str: + """Generate a valid IBM Db2 CLI DSN connection string.""" + return build_dsn_string(self.connection_config) + + def get_signature_namespace(self) -> "dict[str, Any]": + """Get namespace for dependency injection resolution.""" + namespace = super().get_signature_namespace() + namespace.update({ + "Db2SyncConfig": Db2SyncConfig, + "Db2SyncConnection": Db2SyncConnection, + "Db2SyncConnectionContext": Db2SyncConnectionContext, + "Db2ConnectionParams": Db2ConnectionParams, + "Db2SyncConnectionPool": Db2SyncConnectionPool, + "Db2SyncCursor": Db2SyncCursor, + "Db2SyncDriver": Db2SyncDriver, + "Db2DriverFeatures": Db2DriverFeatures, + "Db2SyncExceptionHandler": Db2SyncExceptionHandler, + "Db2PoolParams": Db2PoolParams, + "Db2RawCursor": Db2RawCursor, + "Db2SyncSessionContext": Db2SyncSessionContext, + }) + return namespace + + def get_event_runtime_hints(self) -> "EventRuntimeHints": + """Return runtime hints for Db2 event channels.""" + return EventRuntimeHints(poll_interval=0.25, lease_seconds=5) + def _create_pool(self) -> "Db2SyncConnectionPool": """Create a new thread-local connection pool.""" config = dict(self.connection_config) @@ -248,6 +287,7 @@ def _provide_session_impl( prepare_driver=self._prepare_driver, autocommit_baseline=bool(self.connection_config.get("autocommit", True)), begin_transaction=transaction, + driver_type=self.driver_type, ) def _close_pool(self) -> None: @@ -256,41 +296,6 @@ def _close_pool(self) -> None: self.connection_instance.close() self.connection_instance = None - def create_connection(self) -> "Db2SyncConnection": - """Open a standalone connection owned by the caller. - - Returns: - Db2SyncConnection: A newly opened physical connection. - """ - return self.provide_pool().new_connection() - - def get_connection_string(self) -> str: - """Generate a valid IBM Db2 CLI DSN connection string.""" - return build_dsn_string(self.connection_config) - - def get_signature_namespace(self) -> "dict[str, Any]": - """Get namespace for dependency injection resolution.""" - namespace = super().get_signature_namespace() - namespace.update({ - "Db2SyncConfig": Db2SyncConfig, - "Db2SyncConnection": Db2SyncConnection, - "Db2SyncConnectionContext": Db2SyncConnectionContext, - "Db2ConnectionParams": Db2ConnectionParams, - "Db2SyncConnectionPool": Db2SyncConnectionPool, - "Db2SyncCursor": Db2SyncCursor, - "Db2SyncDriver": Db2SyncDriver, - "Db2DriverFeatures": Db2DriverFeatures, - "Db2SyncExceptionHandler": Db2SyncExceptionHandler, - "Db2PoolParams": Db2PoolParams, - "Db2RawCursor": Db2RawCursor, - "Db2SyncSessionContext": Db2SyncSessionContext, - }) - return namespace - - def get_event_runtime_hints(self) -> "EventRuntimeHints": - """Return runtime hints for Db2 event channels.""" - return EventRuntimeHints(poll_interval=0.25, lease_seconds=5) - class Db2AsyncConnectionContext(AsyncPoolConnectionContext): """Async context manager for IBM Db2 connections.""" @@ -355,6 +360,7 @@ def __init__( normalized_connection_config = build_connection_config(raw_connection_config) normalized_connection_config.update(pool_options) + raw_driver_features = dict(driver_features) if driver_features else {} statement_config = statement_config or default_statement_config statement_config, driver_features = apply_driver_features(statement_config, driver_features) @@ -362,6 +368,9 @@ def __init__( self._user_connection_hook: Callable[[Db2AsyncConnection], Awaitable[None] | None] | None = features_dict.pop( "on_connection_create", None ) + resolved_extension_config = _resolve_events_extension_config( + extension_config, raw_driver_features, features_dict + ) super().__init__( connection_config=normalized_connection_config, @@ -370,11 +379,47 @@ def __init__( statement_config=statement_config, driver_features=features_dict, bind_key=bind_key, - extension_config=extension_config, + extension_config=resolved_extension_config, observability_config=observability_config, **kwargs, ) + async def create_connection(self) -> "Db2AsyncConnection": + """Open a standalone connection owned by the caller. + + Returns: + Db2AsyncConnection: A newly opened ``ibm_db_dbi.AsyncConnection``. + """ + pool = await self.provide_pool() + return cast("Db2AsyncConnection", await pool.new_connection()) + + def get_connection_string(self) -> str: + """Generate a valid IBM Db2 CLI DSN connection string.""" + return build_dsn_string(self.connection_config) + + def get_signature_namespace(self) -> "dict[str, Any]": + """Get namespace for dependency injection resolution.""" + namespace = super().get_signature_namespace() + namespace.update({ + "Db2AsyncConfig": Db2AsyncConfig, + "Db2AsyncConnection": Db2AsyncConnection, + "Db2AsyncConnectionContext": Db2AsyncConnectionContext, + "Db2AsyncConnectionPool": Db2AsyncConnectionPool, + "Db2AsyncCursor": Db2AsyncCursor, + "Db2AsyncDriver": Db2AsyncDriver, + "Db2AsyncExceptionHandler": Db2AsyncExceptionHandler, + "Db2AsyncPoolParams": Db2AsyncPoolParams, + "Db2AsyncRawCursor": Db2AsyncRawCursor, + "Db2AsyncSessionContext": Db2AsyncSessionContext, + "Db2ConnectionParams": Db2ConnectionParams, + "Db2DriverFeatures": Db2DriverFeatures, + }) + return namespace + + def get_event_runtime_hints(self) -> "EventRuntimeHints": + """Return runtime hints for Db2 event channels.""" + return EventRuntimeHints(poll_interval=0.25, lease_seconds=5) + async def _create_pool(self) -> "Db2AsyncConnectionPool": """Create a new bounded asyncio connection pool.""" config = dict(self.connection_config) @@ -414,6 +459,7 @@ def _provide_session_impl( prepare_driver=self._prepare_driver, autocommit_baseline=bool(self.connection_config.get("autocommit", True)), begin_transaction=transaction, + driver_type=self.driver_type, ) async def _close_pool(self) -> None: @@ -422,38 +468,29 @@ async def _close_pool(self) -> None: await self.connection_instance.close() self.connection_instance = None - async def create_connection(self) -> "Db2AsyncConnection": - """Open a standalone connection owned by the caller. - - Returns: - Db2AsyncConnection: A newly opened ``ibm_db_dbi.AsyncConnection``. - """ - pool = await self.provide_pool() - return cast("Db2AsyncConnection", await pool.new_connection()) - - def get_connection_string(self) -> str: - """Generate a valid IBM Db2 CLI DSN connection string.""" - return build_dsn_string(self.connection_config) - def get_signature_namespace(self) -> "dict[str, Any]": - """Get namespace for dependency injection resolution.""" - namespace = super().get_signature_namespace() - namespace.update({ - "Db2AsyncConfig": Db2AsyncConfig, - "Db2AsyncConnection": Db2AsyncConnection, - "Db2AsyncConnectionContext": Db2AsyncConnectionContext, - "Db2AsyncConnectionPool": Db2AsyncConnectionPool, - "Db2AsyncCursor": Db2AsyncCursor, - "Db2AsyncDriver": Db2AsyncDriver, - "Db2AsyncExceptionHandler": Db2AsyncExceptionHandler, - "Db2AsyncPoolParams": Db2AsyncPoolParams, - "Db2AsyncRawCursor": Db2AsyncRawCursor, - "Db2AsyncSessionContext": Db2AsyncSessionContext, - "Db2ConnectionParams": Db2ConnectionParams, - "Db2DriverFeatures": Db2DriverFeatures, - }) - return namespace - - def get_event_runtime_hints(self) -> "EventRuntimeHints": - """Return runtime hints for Db2 event channels.""" - return EventRuntimeHints(poll_interval=0.25, lease_seconds=5) +def _resolve_events_extension_config( + extension_config: "ExtensionConfigs | None", + raw_driver_features: "Mapping[str, Any]", + features_dict: "Mapping[str, Any]", +) -> "ExtensionConfigs | None": + """Propagate Db2 event driver features into ``extension_config['events']``.""" + if features_dict.get("enable_events") is False: + if extension_config and "events" in extension_config: + updated = cast("ExtensionConfigs", dict(extension_config)) + updated.pop("events", None) + return updated + return extension_config + has_explicit_events_feature = bool(raw_driver_features.get("enable_events")) or ( + "events_backend" in raw_driver_features + ) + has_events_extension = bool(extension_config and "events" in extension_config) + if not has_explicit_events_feature and not has_events_extension: + return extension_config + resolved: dict[str, Any] = dict(extension_config) if extension_config else {} + events_cfg = dict(resolved.get("events") or {}) + events_backend = features_dict.get("events_backend") + if events_backend: + events_cfg.setdefault("backend", events_backend) + resolved["events"] = events_cfg + return cast("ExtensionConfigs", resolved) diff --git a/sqlspec/adapters/db2/core.py b/sqlspec/adapters/db2/core.py index 0580cc0d1..d9c4f88b6 100644 --- a/sqlspec/adapters/db2/core.py +++ b/sqlspec/adapters/db2/core.py @@ -23,7 +23,7 @@ ) from sqlspec.utils.config_tools import parse_odbc_connection_string from sqlspec.utils.serializers import from_json, to_json -from sqlspec.utils.text import normalize_identifier, quote_identifier, split_qualified_identifier +from sqlspec.utils.text import normalize_identifier, quote_identifier from sqlspec.utils.type_converters import build_uuid_coercions from sqlspec.utils.type_guards import has_rowcount @@ -83,13 +83,17 @@ "health_check_interval", "pool_recycle_seconds", }) +_ASYNC_POOL_KEYS: Final[tuple[str, ...]] = ("max_size", "acquire_timeout") _INTEGER_KEYS: Final[tuple[str, ...]] = ("port", "connect_timeout") _TRUE_VALUES: Final[frozenset[str]] = frozenset({"1", "true", "yes", "on"}) _FALSE_VALUES: Final[frozenset[str]] = frozenset({"0", "false", "no", "off"}) _CLI_KEYWORD_PATTERN: Final[re.Pattern[str]] = re.compile(r"[A-Za-z][A-Za-z0-9_]*") _BRACED_MIN_LENGTH: Final[int] = 2 _DEFAULT_PORT: Final[int] = 50000 +_MIN_PORT: Final[int] = 1 +_MAX_PORT: Final[int] = 65535 _DEFAULT_PROTOCOL: Final[str] = "TCPIP" +_COLUMN_CACHE_MAX_SIZE: Final[int] = 256 IMPLICIT_UPPER_COLUMN_PATTERN: Final[re.Pattern[str]] = re.compile(r"^(?!\d)(?:[A-Z0-9_]+)$") _SQLSTATE_PATTERN: Final[re.Pattern[str]] = re.compile( r"(?:SQLSTATE[=:\s]+|state[:=\s]+)([0-9A-Z]{5})\b", re.IGNORECASE @@ -125,6 +129,7 @@ _SQLCODE_MAP: Final[dict[int, tuple[type[SQLSpecError], str]]] = { 803: (UniqueViolationError, "unique constraint violation"), 530: (ForeignKeyViolationError, "foreign key constraint violation"), + 532: (ForeignKeyViolationError, "foreign key constraint violation"), 407: (NotNullViolationError, "not-null constraint violation"), 545: (CheckViolationError, "check constraint violation"), 551: (PermissionDeniedError, "permission denied"), @@ -133,6 +138,9 @@ 1042: (DatabaseConnectionError, "unexpected system error occurred"), 30081: (DatabaseConnectionError, "communication failure"), 104: (SQLParsingError, "sql syntax error"), + 302: (DataError, "numeric or string value out of range"), + 433: (DataError, "value is too long"), + 952: (QueryTimeoutError, "statement execution interrupted or timed out"), } @@ -161,63 +169,6 @@ def extract_sqlstate(error: BaseException) -> str | None: return None -def _extract_sqlcode(error: BaseException) -> int | None: - """Extract the absolute SQLCODE number from an exception. - - Integer ``sqlcode``/``error_code`` attributes are used directly; string attributes and the - message are searched for ``SQLnnnnN``/``C``/``W`` message identifiers or ``SQLCODE=-n``. - - Args: - error: Driver error. - - Returns: - int | None: The SQLCODE without sign, or None when the error carries none. - """ - explicit_code = getattr(error, "sqlcode", None) - if explicit_code is None: - explicit_code = getattr(error, "error_code", None) - if isinstance(explicit_code, int) and not isinstance(explicit_code, bool): - return abs(explicit_code) - candidates: list[str] = [] - if isinstance(explicit_code, str) and explicit_code.strip(): - code_text = explicit_code.strip() - if _INTEGER_TEXT_PATTERN.fullmatch(code_text): - return abs(int(code_text)) - candidates.append(code_text) - candidates.append(str(error)) - for text in candidates: - match = _SQLCODE_PATTERN.search(text) - if match is not None: - return abs(int(match.group(1))) - return None - - -def _extract_reason_code(message: str) -> int | None: - """Extract a Db2 reason code such as ``Reason code "68"`` from message text. - - Args: - message: Error message. - - Returns: - int | None: The reason code, or None when the message has none. - """ - match = _REASON_CODE_PATTERN.search(message) - return int(match.group(1)) if match is not None else None - - -def _build_mapped_exception( - error_cls: "type[SQLSpecError]", description: str, sqlstate: str | None, sqlcode: int | None, error: BaseException -) -> SQLSpecError: - """Build a mapped exception whose message leads with the SQLSTATE when one is known. - - Returns: - SQLSpecError: The mapped exception. - """ - if sqlstate is not None: - return error_cls(f"Db2 SQLSTATE {sqlstate}: {description}. Original error: {error}") - return error_cls(f"Db2 SQLCODE {sqlcode}: {description}. Original error: {error}") - - def create_mapped_exception(error: BaseException, *, logger: "Logger | None" = None) -> SQLSpecError: """Map a Db2 driver error to the SQLSpec exception its diagnostics describe. @@ -289,8 +240,8 @@ def to_db_timestamp(value: "datetime | None") -> "datetime | None": def split_db2_table_name(name: str) -> "tuple[str | None, str]": """Split a possibly schema-qualified table name into catalog-folded parts. - Each part is folded with ``normalize_identifier(..., "db2")``: all-lowercase parts become - uppercase and any other part keeps its case. + Unquoted parts are upper-folded while double-quoted parts keep their case via + ``normalize_identifier(..., "db2")``. Args: name: Table name, optionally qualified as ``schema.table``. @@ -298,7 +249,12 @@ def split_db2_table_name(name: str) -> "tuple[str | None, str]": Returns: The schema, or ``None`` when unqualified, and the table name. """ - parts = [normalize_identifier(part, "db2") for part in split_qualified_identifier(name, quote_chars='"')] + parts = [ + normalize_identifier(part, "db2") + if len(part) >= _BRACED_MIN_LENGTH and part[0] == part[-1] == '"' + else normalize_identifier(part.upper(), "db2") + for part in _split_db2_raw_parts(name) + ] if not parts: return None, name if len(parts) == 1: @@ -346,24 +302,6 @@ def normalize_execute_many_parameters(parameters: Any) -> Any: return parameters -def _normalize_column_names(column_names: "list[str]", lowercase: bool) -> "list[str]": - """Lowercase column names that Db2 folded to uppercase. - - Names made only of uppercase letters, digits and underscores (and not starting with a digit) - are lowercased; any other name, such as a quoted mixed-case identifier, is returned unchanged. - - Args: - column_names: Column names as reported by the cursor description. - lowercase: Whether to lowercase implicit-uppercase names. - - Returns: - Normalized column names in their original order. - """ - if not lowercase: - return column_names - return [name.lower() if name and IMPLICIT_UPPER_COLUMN_PATTERN.fullmatch(name) else name for name in column_names] - - def resolve_column_names( description: Sequence[Any] | None, column_name_cache: dict[int, tuple[Any, list[str]]] | None = None, @@ -389,6 +327,8 @@ def resolve_column_names( if cached is not None and cached[0] is description: return cached[1] names = _normalize_column_names([str(desc[0]) for desc in description], lowercase) + if len(column_name_cache) >= _COLUMN_CACHE_MAX_SIZE: + column_name_cache.pop(next(iter(column_name_cache))) column_name_cache[cache_key] = (description, names) return names @@ -442,11 +382,6 @@ def collect_rows( return list(fetched_data or []), column_names, "tuple" -def _custom_type_coercions() -> dict[type, Callable[[Any], Any]]: - """Return custom parameter type coercions for Db2.""" - return dict(build_uuid_coercions()) - - def build_profile() -> DriverParameterProfile: """Construct the Db2 driver parameter profile.""" return DriverParameterProfile( @@ -486,6 +421,8 @@ def apply_driver_features( json_serializer = features.setdefault("json_serializer", to_json) json_deserializer = features.setdefault("json_deserializer", from_json) features.setdefault("enable_lowercase_column_names", True) + features.setdefault("enable_events", True) + features.setdefault("events_backend", "poll_queue") if json_serializer is not None: parameter_config = statement_config.parameter_config.with_json_serializers( @@ -496,6 +433,168 @@ def apply_driver_features( return statement_config, features +def build_connection_config(connection_config: Mapping[str, Any]) -> dict[str, Any]: + """Validate and normalize a Db2 connection configuration. + + A ``dsn`` is parsed and merged underneath the explicit keys (explicit keys and explicit + ``extra`` entries win). ``port`` defaults to ``50000`` and ``protocol`` to ``TCPIP`` only when + ``hostname`` is set; without a hostname the database name is treated as a cataloged alias. + + Args: + connection_config: Connection parameters using the canonical keys of + ``Db2PoolParams``. + + Returns: + dict[str, Any]: Normalized parameters without ``dsn``. + + Raises: + ImproperConfigurationError: When an unsupported key is present, ``database`` is missing, + or a keyword or value cannot be rendered into a CLI connection string. + """ + config = {key: value for key, value in connection_config.items() if value is not None} + for key in _ASYNC_POOL_KEYS: + config.pop(key, None) + unsupported = sorted(key for key in config if key not in _SUPPORTED_CONNECTION_KEYS) + if unsupported: + msg = f"Unsupported Db2 connection parameter(s): {', '.join(unsupported)}" + raise ImproperConfigurationError(msg) + + explicit_extra = config.pop("extra", None) or {} + if not isinstance(explicit_extra, Mapping): + msg = "Db2 connection parameter 'extra' must be a mapping of CLI keywords to values" + raise ImproperConfigurationError(msg) + dsn_extra: dict[str, Any] = {} + dsn = config.pop("dsn", None) + if dsn is not None: + if not isinstance(dsn, str): + msg = "Db2 connection parameter 'dsn' must be a string" + raise ImproperConfigurationError(msg) + dsn_params = _parse_db2_dsn(dsn) + dsn_extra = dsn_params.pop("extra", {}) + for key, value in dsn_params.items(): + config.setdefault(key, value) + + if not config.get("database"): + msg = "Db2 connection requires a 'database' name" + raise ImproperConfigurationError(msg) + if config.get("hostname"): + config.setdefault("port", _DEFAULT_PORT) + config.setdefault("protocol", _DEFAULT_PROTOCOL) + for key in _INTEGER_KEYS: + if key in config: + config[key] = _parse_int(key, config[key]) + + extra = _merge_extra(dsn_extra, explicit_extra) + if extra: + config["extra"] = extra + build_dsn_string(config) + return config + + +def build_dsn_string(config: Mapping[str, Any]) -> str: + """Render a normalized Db2 configuration as an IBM CLI connection string. + + Modeled parameters render first in a fixed keyword order, followed by ``extra`` keywords in + insertion order. ``autocommit`` and pool settings are never rendered. + + Args: + config: Normalized connection configuration. + + Returns: + str: Connection string such as ``DATABASE=sample;HOSTNAME=db;PORT=50000;PROTOCOL=TCPIP;``. + """ + parts: list[str] = [] + for key, keyword in _CLI_KEYWORDS: + value = config.get(key) + if value is not None: + parts.append(_format_cli_value(keyword, value)) + extra = config.get("extra") or {} + for extra_keyword, extra_value in extra.items(): + if extra_value is not None: + parts.append(_format_cli_value(str(extra_keyword), extra_value)) + return "".join(f"{part};" for part in parts) + + +def _extract_sqlcode(error: BaseException) -> int | None: + """Extract the absolute SQLCODE number from an exception. + + Integer ``sqlcode``/``error_code`` attributes are used directly; string attributes and the + message are searched for ``SQLnnnnN``/``C``/``W`` message identifiers or ``SQLCODE=-n``. + + Args: + error: Driver error. + + Returns: + int | None: The SQLCODE without sign, or None when the error carries none. + """ + explicit_code = getattr(error, "sqlcode", None) + if explicit_code is None: + explicit_code = getattr(error, "error_code", None) + if isinstance(explicit_code, int) and not isinstance(explicit_code, bool): + return abs(explicit_code) + candidates: list[str] = [] + if isinstance(explicit_code, str) and explicit_code.strip(): + code_text = explicit_code.strip() + if _INTEGER_TEXT_PATTERN.fullmatch(code_text): + return abs(int(code_text)) + candidates.append(code_text) + candidates.append(str(error)) + for text in candidates: + match = _SQLCODE_PATTERN.search(text) + if match is not None: + return abs(int(match.group(1))) + return None + + +def _extract_reason_code(message: str) -> int | None: + """Extract a Db2 reason code such as ``Reason code "68"`` from message text. + + Args: + message: Error message. + + Returns: + int | None: The reason code, or None when the message has none. + """ + match = _REASON_CODE_PATTERN.search(message) + return int(match.group(1)) if match is not None else None + + +def _build_mapped_exception( + error_cls: "type[SQLSpecError]", description: str, sqlstate: str | None, sqlcode: int | None, error: BaseException +) -> SQLSpecError: + """Build a mapped exception whose message leads with the SQLSTATE when one is known. + + Returns: + SQLSpecError: The mapped exception. + """ + if sqlstate is not None: + return error_cls(f"Db2 SQLSTATE {sqlstate}: {description}. Original error: {error}") + return error_cls(f"Db2 SQLCODE {sqlcode}: {description}. Original error: {error}") + + +def _normalize_column_names(column_names: "list[str]", lowercase: bool) -> "list[str]": + """Lowercase column names that Db2 folded to uppercase. + + Names made only of uppercase letters, digits and underscores (and not starting with a digit) + are lowercased; any other name, such as a quoted mixed-case identifier, is returned unchanged. + + Args: + column_names: Column names as reported by the cursor description. + lowercase: Whether to lowercase implicit-uppercase names. + + Returns: + Normalized column names in their original order. + """ + if not lowercase: + return column_names + return [name.lower() if name and IMPLICIT_UPPER_COLUMN_PATTERN.fullmatch(name) else name for name in column_names] + + +def _custom_type_coercions() -> dict[type, Callable[[Any], Any]]: + """Return custom parameter type coercions for Db2.""" + return {**build_uuid_coercions(), datetime: to_db_timestamp} + + def _parse_bool(key: str, value: str) -> bool: """Parse a boolean connection value. @@ -529,18 +628,26 @@ def _parse_int(key: str, value: Any) -> int: int: The parsed integer. Raises: - ImproperConfigurationError: When the value is not an integer. + ImproperConfigurationError: When the value is not an integer or is out of bounds. """ if isinstance(value, bool): msg = f"Db2 connection parameter {key!r} must be an integer" raise ImproperConfigurationError(msg) if isinstance(value, int): - return value - text = str(value).strip() - if not text.isdigit(): - msg = f"Db2 connection parameter {key!r} must be an integer" + parsed = value + else: + text = str(value).strip() + if not _INTEGER_TEXT_PATTERN.fullmatch(text): + msg = f"Db2 connection parameter {key!r} must be an integer" + raise ImproperConfigurationError(msg) + parsed = int(text) + if key == "port" and not (_MIN_PORT <= parsed <= _MAX_PORT): + msg = f"Db2 connection parameter 'port' must be between 1 and 65535, got {parsed}" + raise ImproperConfigurationError(msg) + if key == "connect_timeout" and parsed < 0: + msg = f"Db2 connection parameter 'connect_timeout' must be non-negative, got {parsed}" raise ImproperConfigurationError(msg) - return int(text) + return parsed def _assign_dsn_value(params: dict[str, Any], extra: dict[str, Any], keyword: str, value: str) -> None: @@ -638,62 +745,6 @@ def _merge_extra(dsn_extra: Mapping[str, Any], explicit_extra: Mapping[str, Any] return dict(merged.values()) -def build_connection_config(connection_config: Mapping[str, Any]) -> dict[str, Any]: - """Validate and normalize a Db2 connection configuration. - - A ``dsn`` is parsed and merged underneath the explicit keys (explicit keys and explicit - ``extra`` entries win). ``port`` defaults to ``50000`` and ``protocol`` to ``TCPIP`` only when - ``hostname`` is set; without a hostname the database name is treated as a cataloged alias. - - Args: - connection_config: Connection parameters using the canonical keys of - ``Db2PoolParams``. - - Returns: - dict[str, Any]: Normalized parameters without ``dsn``. - - Raises: - ImproperConfigurationError: When an unsupported key is present, ``database`` is missing, - or a keyword or value cannot be rendered into a CLI connection string. - """ - config = {key: value for key, value in connection_config.items() if value is not None} - unsupported = sorted(key for key in config if key not in _SUPPORTED_CONNECTION_KEYS) - if unsupported: - msg = f"Unsupported Db2 connection parameter(s): {', '.join(unsupported)}" - raise ImproperConfigurationError(msg) - - explicit_extra = config.pop("extra", None) or {} - if not isinstance(explicit_extra, Mapping): - msg = "Db2 connection parameter 'extra' must be a mapping of CLI keywords to values" - raise ImproperConfigurationError(msg) - dsn_extra: dict[str, Any] = {} - dsn = config.pop("dsn", None) - if dsn is not None: - if not isinstance(dsn, str): - msg = "Db2 connection parameter 'dsn' must be a string" - raise ImproperConfigurationError(msg) - dsn_params = _parse_db2_dsn(dsn) - dsn_extra = dsn_params.pop("extra", {}) - for key, value in dsn_params.items(): - config.setdefault(key, value) - - if not config.get("database"): - msg = "Db2 connection requires a 'database' name" - raise ImproperConfigurationError(msg) - if config.get("hostname"): - config.setdefault("port", _DEFAULT_PORT) - config.setdefault("protocol", _DEFAULT_PROTOCOL) - for key in _INTEGER_KEYS: - if key in config: - config[key] = _parse_int(key, config[key]) - - extra = _merge_extra(dsn_extra, explicit_extra) - if extra: - config["extra"] = extra - build_dsn_string(config) - return config - - def _format_cli_value(keyword: str, value: Any) -> str: """Render one ``KEYWORD=value`` pair for a Db2 CLI connection string. @@ -725,28 +776,46 @@ def _format_cli_value(keyword: str, value: Any) -> str: return f"{keyword}={text}" -def build_dsn_string(config: Mapping[str, Any]) -> str: - """Render a normalized Db2 configuration as an IBM CLI connection string. - - Modeled parameters render first in a fixed keyword order, followed by ``extra`` keywords in - insertion order. ``autocommit`` and pool settings are never rendered. - - Args: - config: Normalized connection configuration. - - Returns: - str: Connection string such as ``DATABASE=sample;HOSTNAME=db;PORT=50000;PROTOCOL=TCPIP;``. - """ +def _split_db2_raw_parts(name: str) -> list[str]: + """Split a qualified Db2 identifier on dots outside double-quoted segments.""" + cleaned = name.strip() + if not cleaned: + return [] parts: list[str] = [] - for key, keyword in _CLI_KEYWORDS: - value = config.get(key) - if value is not None: - parts.append(_format_cli_value(keyword, value)) - extra = config.get("extra") or {} - for extra_keyword, extra_value in extra.items(): - if extra_value is not None: - parts.append(_format_cli_value(str(extra_keyword), extra_value)) - return "".join(f"{part};" for part in parts) + chars: list[str] = [] + in_quotes = False + index = 0 + length = len(cleaned) + while index < length: + char = cleaned[index] + if in_quotes: + chars.append(char) + if char == '"': + if index + 1 < length and cleaned[index + 1] == '"': + chars.append('"') + index += 2 + continue + in_quotes = False + index += 1 + continue + if char == '"': + in_quotes = True + chars.append(char) + index += 1 + continue + if char == ".": + part = "".join(chars).strip() + if part: + parts.append(part) + chars.clear() + index += 1 + continue + chars.append(char) + index += 1 + part = "".join(chars).strip() + if part: + parts.append(part) + return parts driver_profile = build_profile() diff --git a/sqlspec/adapters/db2/driver.py b/sqlspec/adapters/db2/driver.py index c60f9b0ca..8a39a9fdd 100644 --- a/sqlspec/adapters/db2/driver.py +++ b/sqlspec/adapters/db2/driver.py @@ -37,13 +37,16 @@ rows_to_dicts, validate_savepoint_name, ) -from sqlspec.exceptions import SQLSpecError +from sqlspec.exceptions import MissingDependencyError, SQLSpecError from sqlspec.utils.logging import get_logger, log_with_context from sqlspec.utils.text import normalize_identifier, quote_identifier if TYPE_CHECKING: from collections.abc import Sequence + from sqlspec.core import SQLResult + from sqlspec.driver._common import CachedQuery + __all__ = ( "Db2AsyncCursor", "Db2AsyncDriver", @@ -162,6 +165,30 @@ def __init__( self._restore_autocommit = False self._migration_schema_restore: str | None = None + @property + def data_dictionary(self) -> "Db2SyncDataDictionary": + """Return the Db2 data dictionary bound to this driver. + + Returns: + The lazily created data dictionary instance. + """ + if self._data_dictionary is None: + self._data_dictionary = Db2SyncDataDictionary() + return self._data_dictionary + + def prepare_driver_parameters( + self, + parameters: Any, + statement_config: "StatementConfig", + is_many: bool = False, + prepared_statement: "SQL | None" = None, + ) -> Any: + """Prepare parameters and normalize non-many sequence parameters to tuples for ibm_db_dbi.""" + prepared = super().prepare_driver_parameters(parameters, statement_config, is_many, prepared_statement) + if not is_many: + return normalize_execute_parameters(prepared) + return prepared + def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) cursor.execute(sql, normalize_execute_parameters(prepared_parameters)) @@ -194,11 +221,12 @@ def dispatch_execute_many(self, cursor: Any, statement: "SQL") -> "ExecutionResu def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) - statements = self.split_script_statements(sql, statement.statement_config, strip_trailing_semicolon=True) + statements = self.split_script_statements(sql, self.statement_config, strip_trailing_semicolon=True) + statement_params = _resolve_script_statement_params(statements, prepared_parameters) successful_count = 0 - for stmt in statements: - cursor.execute(stmt, normalize_execute_parameters(prepared_parameters)) + for stmt, params in zip(statements, statement_params, strict=False): + cursor.execute(stmt, params) successful_count += 1 return self.create_execution_result( cursor, statement_count=len(statements), successful_statements=successful_count, is_script_result=True @@ -254,39 +282,6 @@ def rollback(self) -> None: self._transaction_active = False self._restore_connection_autocommit() - def _release_open_work(self, *, autocommit_baseline: bool) -> None: - """Roll back work left open before the connection is returned to its pool. - - A transaction started by this driver is always rolled back; on a connection whose - autocommit baseline is off, any pending unit of work is rolled back as well. A rollback - failure is logged and not raised, so it never masks an error from the session body. - - Args: - autocommit_baseline: Autocommit mode the connection was opened in. - """ - if not self._transaction_active and autocommit_baseline: - return - try: - self.rollback() - except SQLSpecError as exc: - log_with_context(logger, logging.DEBUG, "db2.session.rollback_failed", error=str(exc)) - - def _restore_connection_autocommit(self) -> None: - """Switch autocommit back on when ``begin()`` turned it off. - - Raises: - SQLSpecError: When the driver reports an error. - """ - restore_autocommit = self._restore_autocommit - self._restore_autocommit = False - if not restore_autocommit: - return - try: - self.connection.set_autocommit(True) - except Db2Error as exc: - msg = f"Failed to restore Db2 autocommit: {exc}" - raise SQLSpecError(msg) from exc - def with_cursor(self, connection: Any) -> "Db2SyncCursor": return Db2SyncCursor(connection) @@ -339,17 +334,6 @@ def has_schema(self, schema: str) -> bool: cursor.execute("SELECT 1 FROM SYSCAT.SCHEMATA WHERE SCHEMANAME = ?", (normalize_identifier(schema, "db2"),)) return cursor.fetchone() is not None - @property - def data_dictionary(self) -> "Db2SyncDataDictionary": - """Return the Db2 data dictionary bound to this driver. - - Returns: - The lazily created data dictionary instance. - """ - if self._data_dictionary is None: - self._data_dictionary = Db2SyncDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": column_names = resolve_column_names( cursor.description or None, self._column_name_cache, lowercase=self._lowercase_columns @@ -359,9 +343,54 @@ def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], l def resolve_rowcount(self, cursor: Any) -> int: return resolve_rowcount(cursor) + def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + """Normalize cached fast-path parameters to a tuple before calling ibm_db_dbi.""" + return super()._execute_cache_hit(sql, normalize_execute_parameters(params) or (), cached) + + def _release_open_work(self, *, autocommit_baseline: bool) -> None: + """Roll back work left open before the connection is returned to its pool. + + A transaction started by this driver is always rolled back; on a connection whose + autocommit baseline is off, any pending unit of work is rolled back as well. A rollback + failure is logged and not raised, so it never masks an error from the session body. + + Args: + autocommit_baseline: Autocommit mode the connection was opened in. + """ + if not self._transaction_active and autocommit_baseline: + return + try: + self.rollback() + except SQLSpecError as exc: + log_with_context(logger, logging.DEBUG, "db2.session.rollback_failed", error=str(exc)) + + def _restore_connection_autocommit(self) -> None: + """Switch autocommit back on when ``begin()`` turned it off. + + Raises: + SQLSpecError: When the driver reports an error. + """ + if not self._restore_autocommit: + return + try: + self.connection.set_autocommit(True) + except Db2Error as exc: + msg = f"Failed to restore Db2 autocommit: {exc}" + raise SQLSpecError(msg) from exc + self._restore_autocommit = False + def _connection_in_transaction(self) -> bool: - """Return whether a transaction opened by this driver remains active.""" - return self._transaction_active + """Return whether a transaction remains active on the connection.""" + if self._transaction_active: + return True + if self._restore_autocommit: + return False + try: + return not connection_autocommit_enabled(self.connection) + except (MissingDependencyError, Db2Error, AttributeError, KeyError, TypeError, ValueError): + return False class Db2AsyncExceptionHandler(BaseAsyncExceptionHandler): @@ -467,6 +496,30 @@ def __init__( self._restore_autocommit = False self._migration_schema_restore: str | None = None + @property + def data_dictionary(self) -> "Db2AsyncDataDictionary": + """Return the Db2 async data dictionary bound to this driver. + + Returns: + The lazily created data dictionary instance. + """ + if self._data_dictionary is None: + self._data_dictionary = Db2AsyncDataDictionary() + return self._data_dictionary + + def prepare_driver_parameters( + self, + parameters: Any, + statement_config: "StatementConfig", + is_many: bool = False, + prepared_statement: "SQL | None" = None, + ) -> Any: + """Prepare parameters and normalize non-many sequence parameters to tuples for ibm_db_dbi.""" + prepared = super().prepare_driver_parameters(parameters, statement_config, is_many, prepared_statement) + if not is_many: + return normalize_execute_parameters(prepared) + return prepared + async def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) await cursor.execute(sql, normalize_execute_parameters(prepared_parameters)) @@ -499,11 +552,12 @@ async def dispatch_execute_many(self, cursor: Any, statement: "SQL") -> "Executi async def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) - statements = self.split_script_statements(sql, statement.statement_config, strip_trailing_semicolon=True) + statements = self.split_script_statements(sql, self.statement_config, strip_trailing_semicolon=True) + statement_params = _resolve_script_statement_params(statements, prepared_parameters) successful_count = 0 - for stmt in statements: - await cursor.execute(stmt, normalize_execute_parameters(prepared_parameters)) + for stmt, params in zip(statements, statement_params, strict=False): + await cursor.execute(stmt, params) successful_count += 1 return self.create_execution_result( cursor, statement_count=len(statements), successful_statements=successful_count, is_script_result=True @@ -559,39 +613,6 @@ async def rollback(self) -> None: self._transaction_active = False await self._restore_connection_autocommit() - async def _release_open_work(self, *, autocommit_baseline: bool) -> None: - """Roll back work left open before the connection is returned to its pool. - - A transaction started by this driver is always rolled back; on a connection whose - autocommit baseline is off, any pending unit of work is rolled back as well. A rollback - failure is logged and not raised, so it never masks an error from the session body. - - Args: - autocommit_baseline: Autocommit mode the connection was opened in. - """ - if not self._transaction_active and autocommit_baseline: - return - try: - await self.rollback() - except SQLSpecError as exc: - log_with_context(logger, logging.DEBUG, "db2.session.rollback_failed", error=str(exc)) - - async def _restore_connection_autocommit(self) -> None: - """Switch autocommit back on when ``begin()`` turned it off. - - Raises: - SQLSpecError: When the driver reports an error. - """ - restore_autocommit = self._restore_autocommit - self._restore_autocommit = False - if not restore_autocommit: - return - try: - await self.connection.set_autocommit(True) - except Db2Error as exc: - msg = f"Failed to restore Db2 autocommit: {exc}" - raise SQLSpecError(msg) from exc - def with_cursor(self, connection: Any) -> "Db2AsyncCursor": return Db2AsyncCursor(connection) @@ -646,17 +667,6 @@ async def has_schema(self, schema: str) -> bool: ) return await cursor.fetchone() is not None - @property - def data_dictionary(self) -> "Db2AsyncDataDictionary": - """Return the Db2 async data dictionary bound to this driver. - - Returns: - The lazily created data dictionary instance. - """ - if self._data_dictionary is None: - self._data_dictionary = Db2AsyncDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": column_names = resolve_column_names( cursor.description or None, self._column_name_cache, lowercase=self._lowercase_columns @@ -666,9 +676,78 @@ def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], l def resolve_rowcount(self, cursor: Any) -> int: return resolve_rowcount(cursor) + async def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + """Normalize cached fast-path parameters to a tuple before calling ibm_db_dbi.""" + return await super()._execute_cache_hit(sql, normalize_execute_parameters(params) or (), cached) + + async def _release_open_work(self, *, autocommit_baseline: bool) -> None: + """Roll back work left open before the connection is returned to its pool. + + A transaction started by this driver is always rolled back; on a connection whose + autocommit baseline is off, any pending unit of work is rolled back as well. A rollback + failure is logged and not raised, so it never masks an error from the session body. + + Args: + autocommit_baseline: Autocommit mode the connection was opened in. + """ + if not self._transaction_active and autocommit_baseline: + return + try: + await self.rollback() + except SQLSpecError as exc: + log_with_context(logger, logging.DEBUG, "db2.session.rollback_failed", error=str(exc)) + + async def _restore_connection_autocommit(self) -> None: + """Switch autocommit back on when ``begin()`` turned it off. + + Raises: + SQLSpecError: When the driver reports an error. + """ + if not self._restore_autocommit: + return + try: + await self.connection.set_autocommit(True) + except Db2Error as exc: + msg = f"Failed to restore Db2 autocommit: {exc}" + raise SQLSpecError(msg) from exc + self._restore_autocommit = False + def _connection_in_transaction(self) -> bool: - """Return whether a transaction opened by this driver remains active.""" - return self._transaction_active + """Return whether a transaction remains active on the connection.""" + if self._transaction_active: + return True + if self._restore_autocommit: + return False + try: + return not connection_autocommit_enabled(self.connection) + except (MissingDependencyError, Db2Error, AttributeError, KeyError, TypeError, ValueError): + return False + + +def _resolve_script_statement_params( + statements: "list[str]", prepared_parameters: Any +) -> "list[tuple[Any, ...] | None]": + """Resolve positional parameter tuples for each statement in a split script.""" + if len(statements) <= 1: + return [normalize_execute_parameters(prepared_parameters) for _ in statements] + if not prepared_parameters: + return [None for _ in statements] + if isinstance(prepared_parameters, (list, tuple)): + offset = 0 + resolved: list[tuple[Any, ...] | None] = [] + for stmt in statements: + count = stmt.count("?") + if count > 0: + slice_params = tuple(prepared_parameters[offset : offset + count]) + offset += count + resolved.append(slice_params or None) + else: + resolved.append(None) + return resolved + normalized = normalize_execute_parameters(prepared_parameters) + return [normalized if index == 0 else None for index in range(len(statements))] register_driver_profile("db2", driver_profile) diff --git a/sqlspec/adapters/db2/litestar/store.py b/sqlspec/adapters/db2/litestar/store.py index aab14100b..787fbe6c5 100644 --- a/sqlspec/adapters/db2/litestar/store.py +++ b/sqlspec/adapters/db2/litestar/store.py @@ -14,17 +14,17 @@ def session_catalog_names(table: str) -> "tuple[str | None, str]": - """Return the catalog schema and table name for an unquoted session table name. + """Return the catalog schema and table name for a session table name. - Session DDL writes the table name unquoted, so Db2 folds it to uppercase. + Unquoted table names are upper-folded while quoted identifiers keep their case. Args: table: Session table name. Returns: - The schema, or ``None`` for ``CURRENT SCHEMA``, and the upper-folded table name. + The schema, or ``None`` for ``CURRENT SCHEMA``, and the catalog-folded table name. """ - return split_db2_table_name(table.upper()) + return split_db2_table_name(table) def session_index_name(table: str) -> str: @@ -212,28 +212,6 @@ def drop_session_table_sql(table: str) -> str: return f"DROP TABLE {table}" -def _coerce_bytes(value: Any) -> "bytes | None": - """Coerce row data into bytes if not None.""" - if value is None: - return None - if isinstance(value, bytes): - return value - if isinstance(value, memoryview): - return value.tobytes() - return bytes(value) - - -def _normalize_utc(dt: Any) -> "datetime | None": - """Return a stored naive-UTC timestamp as an aware UTC datetime.""" - if dt is None: - return None - if isinstance(dt, datetime): - if dt.tzinfo is None: - return dt.replace(tzinfo=UTC) - return dt.astimezone(UTC) - return None - - class Db2SyncStore(BaseSQLSpecStore["Db2SyncConfig"]): """IBM Db2-backed session store using synchronous Db2 sessions. @@ -463,3 +441,25 @@ async def _create_table(self) -> None: await driver.execute(session_index_ddl(self._table_name)) await driver.commit() self._log_table_created() + + +def _coerce_bytes(value: Any) -> "bytes | None": + """Coerce row data into bytes if not None.""" + if value is None: + return None + if isinstance(value, bytes): + return value + if isinstance(value, memoryview): + return value.tobytes() + return bytes(value) + + +def _normalize_utc(dt: Any) -> "datetime | None": + """Return a stored naive-UTC timestamp as an aware UTC datetime.""" + if dt is None: + return None + if isinstance(dt, datetime): + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt.astimezone(UTC) + return None diff --git a/sqlspec/adapters/db2/migrations.py b/sqlspec/adapters/db2/migrations.py index 54ae26069..9a9b415b8 100644 --- a/sqlspec/adapters/db2/migrations.py +++ b/sqlspec/adapters/db2/migrations.py @@ -47,14 +47,13 @@ def _tracking_table_ddl(self) -> "CreateTable": def _tracking_table_exists_sql(self) -> "tuple[str, tuple[str | None, str]]": """Return the catalog probe for the tracking table and its bound names. - The tracker's DDL and DML render the table name unquoted, so Db2 stores it uppercase; the - probe binds the upper-folded names. + Unquoted identifiers are upper-folded while quoted identifiers keep their case. Returns: The probe SQL and its ``(schema, table)`` parameters; the schema is ``None`` for an unqualified table so the probe checks ``CURRENT SCHEMA``. """ - return TABLE_EXISTS_SQL, split_db2_table_name(self.version_table.upper()) + return TABLE_EXISTS_SQL, split_db2_table_name(self.version_table) def _record_migration_statement( self, diff --git a/sqlspec/adapters/db2/pool.py b/sqlspec/adapters/db2/pool.py index 801bba8bc..12ba77161 100644 --- a/sqlspec/adapters/db2/pool.py +++ b/sqlspec/adapters/db2/pool.py @@ -26,23 +26,6 @@ _IBM_DB_DBI: "Any | None" = None -def _require_ibm_db_dbi() -> Any: - """Return the ``ibm_db_dbi`` module, importing it on first use. - - Returns: - Any: The ``ibm_db_dbi`` module. - - Raises: - MissingDependencyError: When ibm_db is not installed. - """ - global _IBM_DB_DBI - if _IBM_DB_DBI is None: - _IBM_DB_DBI = import_optional("ibm_db_dbi") - if _IBM_DB_DBI is None: - raise MissingDependencyError(package="ibm_db", install_package="db2") - return _IBM_DB_DBI - - class Db2SyncConnectionPool: """Thread-local connection manager for IBM Db2.""" @@ -94,15 +77,6 @@ def _database_name(self) -> str: """Get sanitized database name for logging.""" return str(self._connection_parameters.get("database", "unknown")) - def _create_connection(self) -> Any: - """Open a new connection and register it in the shutdown registry.""" - connection = self.new_connection() - - with self._registry_lock: - self._connection_registry.add(connection) - - return connection - def new_connection(self) -> Any: """Open a standalone connection configured like a pooled one. @@ -131,6 +105,58 @@ def new_connection(self) -> Any: return connection + @contextmanager + def get_connection(self) -> "Generator[Any, None, None]": + """Context manager to yield a thread-local connection. + + Yields: + A thread-local Db2 database connection. + """ + connection = self._get_thread_connection() + try: + yield connection + except Exception: + with contextlib.suppress(Exception): + self._close_thread_connection() + raise + + def close(self) -> None: + """Close every connection this pool opened across all threads.""" + self._close_thread_connection() + with self._registry_lock: + orphaned = list(self._connection_registry) + self._connection_registry.clear() + self._generation += 1 + for connection in orphaned: + with contextlib.suppress(Exception): + connection.close() + + def acquire(self) -> Any: + """Acquire a thread-local connection.""" + return self._get_thread_connection() + + def release(self, connection: Any) -> None: + """Release connection back to the thread-local pool.""" + _ = connection + + def size(self) -> int: + """Return the total number of active connections registered across all threads.""" + with self._registry_lock: + return len(self._connection_registry) + + def checked_out(self) -> int: + """Return the number of checked out connections from the perspective of this thread.""" + return 0 + + def _create_connection(self) -> Any: + """Open a new connection and register it in the shutdown registry.""" + connection = self.new_connection() + + with self._registry_lock: + self._connection_registry.add(connection) + + return connection + def _is_connection_alive(self, connection: Any) -> bool: """Perform a lightweight ping to verify that the physical connection is alive. @@ -217,49 +243,6 @@ def _close_thread_connection(self) -> None: if "last_used" in thread_state: del self._thread_local.last_used - @contextmanager - def get_connection(self) -> "Generator[Any, None, None]": - """Context manager to yield a thread-local connection. - - Yields: - A thread-local Db2 database connection. - """ - connection = self._get_thread_connection() - try: - yield connection - except Exception: - with contextlib.suppress(Exception): - self._close_thread_connection() - raise - - def close(self) -> None: - """Close every connection this pool opened across all threads.""" - self._close_thread_connection() - with self._registry_lock: - orphaned = list(self._connection_registry) - self._connection_registry.clear() - self._generation += 1 - for connection in orphaned: - with contextlib.suppress(Exception): - connection.close() - - def acquire(self) -> Any: - """Acquire a thread-local connection.""" - return self._get_thread_connection() - - def release(self, connection: Any) -> None: - """Release connection back to the thread-local pool.""" - _ = connection - - def size(self) -> int: - """Return the total number of active connections registered across all threads.""" - with self._registry_lock: - return len(self._connection_registry) - - def checked_out(self) -> int: - """Return the number of checked out connections from the perspective of this thread.""" - return 0 - class _Db2PooledConnection: """Idle-stack record for one pooled async connection.""" @@ -471,7 +454,10 @@ async def release(self, connection: Any) -> None: record.last_used = time.monotonic() self._idle.append(record) finally: - self._semaphore.release() + if self._semaphore_instance is not None: + self._semaphore_instance.release() + if self._closed and not self._checked_out: + self._semaphore_instance = None def get_connection(self) -> "Db2AsyncPoolConnectionContext": """Return an async context manager that acquires and releases a pooled connection. @@ -491,6 +477,8 @@ async def close(self) -> None: self._idle = [] for record in idle: await self._close_connection(record.connection) + if not self._checked_out: + self._semaphore_instance = None def size(self) -> int: """Return the number of open connections owned by the pool.""" @@ -562,3 +550,20 @@ async def _close_connection(self, connection: Any) -> None: """Close a connection, suppressing driver errors.""" with contextlib.suppress(Exception): await connection.close() + + +def _require_ibm_db_dbi() -> Any: + """Return the ``ibm_db_dbi`` module, importing it on first use. + + Returns: + Any: The ``ibm_db_dbi`` module. + + Raises: + MissingDependencyError: When ibm_db is not installed. + """ + global _IBM_DB_DBI + if _IBM_DB_DBI is None: + _IBM_DB_DBI = import_optional("ibm_db_dbi") + if _IBM_DB_DBI is None: + raise MissingDependencyError(package="ibm_db", install_package="db2") + return _IBM_DB_DBI diff --git a/sqlspec/adapters/duckdb/adk/store.py b/sqlspec/adapters/duckdb/adk/store.py index f3d43cc7e..0b634baa2 100644 --- a/sqlspec/adapters/duckdb/adk/store.py +++ b/sqlspec/adapters/duckdb/adk/store.py @@ -429,52 +429,13 @@ def _drop_tables_sql(self) -> "list[str]": def _create_tables(self) -> None: """Synchronous implementation of create_tables.""" with self._config.provide_connection() as conn: - conn.execute(self._sync_sessions_table_ddl()) - conn.execute(self._sync_events_table_ddl()) + conn.execute(self._sessions_table_ddl()) + conn.execute(self._events_table_ddl()) conn.execute(self._app_states_table_ddl()) conn.execute(self._user_states_table_ddl()) conn.execute(self._metadata_table_ddl()) conn.commit() - def _sync_sessions_table_ddl(self) -> str: - """Synchronous version of DDL generation for use in _create_tables.""" - owner_id_line = "" - if self._owner_id_column_ddl: - owner_id_line = f",\n {self._owner_id_column_ddl}" - - return f""" - CREATE TABLE IF NOT EXISTS {self._session_table} ( - id VARCHAR PRIMARY KEY, - app_name VARCHAR NOT NULL, - user_id VARCHAR NOT NULL{owner_id_line}, - state JSON NOT NULL, - create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_{self._session_table}_app_user ON {self._session_table}(app_name, user_id); - CREATE INDEX IF NOT EXISTS idx_{self._session_table}_update_time ON {self._session_table}(update_time DESC); - """ - - def _sync_events_table_ddl(self) -> str: - """Synchronous version of DDL generation for use in _create_tables.""" - generated_columns = self._event_generated_columns_sql() - generated_indexes = self._event_generated_column_indexes_sql() - return f""" - CREATE TABLE IF NOT EXISTS {self._events_table} ( - id VARCHAR PRIMARY KEY, - app_name VARCHAR NOT NULL, - user_id VARCHAR NOT NULL, - session_id VARCHAR NOT NULL, - invocation_id VARCHAR, - timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - event_data JSON NOT NULL{generated_columns}, - FOREIGN KEY (session_id) REFERENCES {self._session_table}(id) - ); - CREATE INDEX IF NOT EXISTS idx_{self._events_table}_session ON {self._events_table}(session_id, timestamp ASC); - CREATE INDEX IF NOT EXISTS idx_{self._events_table}_app_timestamp ON {self._events_table}(app_name, timestamp ASC); - {generated_indexes} - """ - def _create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: @@ -781,14 +742,12 @@ def _delete_expired_events(self, before: "datetime", app_name: "str | None" = No if app_name is not None: where += " AND app_name = ?" params = (before, app_name) - count_sql = f"SELECT COUNT(*) FROM {self._events_table} {where}" - delete_sql = f"DELETE FROM {self._events_table} {where}" + delete_sql = f"DELETE FROM {self._events_table} {where} RETURNING 1" try: with self._config.provide_connection() as conn: - count_row = conn.execute(count_sql, params).fetchone() - count = int(count_row[0]) if count_row is not None else 0 - conn.execute(delete_sql, params) + cursor = conn.execute(delete_sql, params) + count = len(cursor.fetchall()) conn.commit() return count except Exception as e: @@ -802,19 +761,17 @@ def _delete_idle_sessions(self, updated_before: "datetime", app_name: "str | Non if app_name is not None: where += " AND app_name = ?" params = (updated_before, app_name) - count_sql = f"SELECT COUNT(*) FROM {self._session_table} {where}" delete_events_sql = f""" DELETE FROM {self._events_table} WHERE session_id IN (SELECT id FROM {self._session_table} {where}) """ - delete_sessions_sql = f"DELETE FROM {self._session_table} {where}" + delete_sessions_sql = f"DELETE FROM {self._session_table} {where} RETURNING 1" try: with self._config.provide_connection() as conn: - count_row = conn.execute(count_sql, params).fetchone() - count = int(count_row[0]) if count_row is not None else 0 conn.execute(delete_events_sql, params) - conn.execute(delete_sessions_sql, params) + cursor = conn.execute(delete_sessions_sql, params) + count = len(cursor.fetchall()) conn.commit() return count except Exception as e: @@ -828,14 +785,12 @@ def _delete_idle_user_states(self, updated_before: "datetime", app_name: "str | if app_name is not None: where += " AND app_name = ?" params = (updated_before, app_name) - count_sql = f"SELECT COUNT(*) FROM {self._user_state_table} {where}" - delete_sql = f"DELETE FROM {self._user_state_table} {where}" + delete_sql = f"DELETE FROM {self._user_state_table} {where} RETURNING 1" try: with self._config.provide_connection() as conn: - count_row = conn.execute(count_sql, params).fetchone() - count = int(count_row[0]) if count_row is not None else 0 - conn.execute(delete_sql, params) + cursor = conn.execute(delete_sql, params) + count = len(cursor.fetchall()) conn.commit() return count except Exception as e: @@ -988,6 +943,10 @@ def delete_entries_older_than(self, days: int, app_name: "str | None" = None, sc def _ensure_fts_extension(self, conn: Any) -> bool: """Ensure the DuckDB FTS extension is available for this connection.""" + with contextlib.suppress(Exception): + conn.execute("LOAD fts") + return True + with contextlib.suppress(Exception): conn.execute("INSTALL fts") @@ -1092,16 +1051,12 @@ def _create_tables(self) -> None: if not self._enabled: return - ddl = self._sync_memory_table_ddl() + ddl = self._memory_table_ddl() with self._config.provide_connection() as conn: conn.execute(ddl) if self._use_fts: self._create_fts_index(conn) - def _sync_memory_table_ddl(self) -> str: - """Synchronous version of DDL generation for use in _create_tables.""" - return self._memory_table_ddl() - def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Synchronous implementation of insert_memory_entries.""" if not self._enabled: @@ -1131,44 +1086,50 @@ def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "objec """ with self._config.provide_connection() as conn: - for entry in entries: - params: tuple[Any, ...] - scope_value = entry.get("scope", "user") - metadata_json = entry.get("metadata_json") - if self._owner_id_column_name: - params = ( - entry["id"], - entry["session_id"], - entry["app_name"], - entry["user_id"], - scope_value, - entry["event_id"], - entry.get("author"), - owner_id, - entry["timestamp"], - to_json(entry["content_json"]), - entry["content_text"], - to_json(metadata_json) if metadata_json is not None else None, - entry["inserted_at"], - ) - else: - params = ( - entry["id"], - entry["session_id"], - entry["app_name"], - entry["user_id"], - scope_value, - entry["event_id"], - entry.get("author"), - entry["timestamp"], - to_json(entry["content_json"]), - entry["content_text"], - to_json(metadata_json) if metadata_json is not None else None, - entry["inserted_at"], - ) - result = conn.execute(sql, params) - inserted_count += len(result.fetchall()) - conn.commit() + conn.begin() + try: + for entry in entries: + params: tuple[Any, ...] + scope_value = entry.get("scope", "user") + metadata_json = entry.get("metadata_json") + if self._owner_id_column_name: + params = ( + entry["id"], + entry["session_id"], + entry["app_name"], + entry["user_id"], + scope_value, + entry["event_id"], + entry.get("author"), + owner_id, + entry["timestamp"], + to_json(entry["content_json"]), + entry["content_text"], + to_json(metadata_json) if metadata_json is not None else None, + entry["inserted_at"], + ) + else: + params = ( + entry["id"], + entry["session_id"], + entry["app_name"], + entry["user_id"], + scope_value, + entry["event_id"], + entry.get("author"), + entry["timestamp"], + to_json(entry["content_json"]), + entry["content_text"], + to_json(metadata_json) if metadata_json is not None else None, + entry["inserted_at"], + ) + result = conn.execute(sql, params) + inserted_count += len(result.fetchall()) + conn.commit() + except Exception: + with contextlib.suppress(Exception): + conn.rollback() + raise if self._use_fts and inserted_count > 0: self._refresh_fts_index(conn) diff --git a/sqlspec/adapters/duckdb/config.py b/sqlspec/adapters/duckdb/config.py index 3fea1f17f..6cf4ddff7 100644 --- a/sqlspec/adapters/duckdb/config.py +++ b/sqlspec/adapters/duckdb/config.py @@ -303,42 +303,6 @@ def __init__( **kwargs, ) - def _create_pool(self) -> DuckDBConnectionPool: - """Create connection pool from configuration.""" - connection_config = build_connection_config(self.connection_config) - - extensions = cast("list[dict[str, Any]] | None", self.driver_features.get("extensions", None)) - secrets = cast("list[dict[str, Any]] | None", self.driver_features.get("secrets", None)) - extensions_dicts = [dict(ext) for ext in extensions] if extensions else None - secrets_dicts = [dict(secret) for secret in secrets] if secrets else None - - pool_recycle_seconds = self.connection_config.get("pool_recycle_seconds") - health_check_interval = self.connection_config.get("health_check_interval") - pool_kwargs: dict[str, Any] = {} - if pool_recycle_seconds is not None: - pool_kwargs["pool_recycle_seconds"] = pool_recycle_seconds - if health_check_interval is not None: - pool_kwargs["health_check_interval"] = health_check_interval - - return DuckDBConnectionPool( - connection_config=connection_config, - extensions=extensions_dicts, - secrets=secrets_dicts, - on_connection_create=self._user_connection_hook, - **pool_kwargs, - ) - - def _close_pool(self) -> None: - """Close the connection pool.""" - if self.connection_instance: - self.connection_instance.close() - - def _prepare_driver(self, driver: DuckDBDriver) -> DuckDBDriver: - driver = super()._prepare_driver(driver) - if self.connection_instance is not None: - driver.driver_features.update(self.connection_instance._storage_settings(driver.connection)) - return driver - def create_connection(self) -> DuckDBConnection: """Open a standalone connection owned by the caller. @@ -382,3 +346,39 @@ def get_event_runtime_hints(self) -> "EventRuntimeHints": """Return polling defaults optimized for DuckDB.""" return EventRuntimeHints(poll_interval=0.15, lease_seconds=15) + + def _create_pool(self) -> DuckDBConnectionPool: + """Create connection pool from configuration.""" + connection_config = build_connection_config(self.connection_config) + + extensions = cast("list[dict[str, Any]] | None", self.driver_features.get("extensions", None)) + secrets = cast("list[dict[str, Any]] | None", self.driver_features.get("secrets", None)) + extensions_dicts = [dict(ext) for ext in extensions] if extensions else None + secrets_dicts = [dict(secret) for secret in secrets] if secrets else None + + pool_recycle_seconds = self.connection_config.get("pool_recycle_seconds") + health_check_interval = self.connection_config.get("health_check_interval") + pool_kwargs: dict[str, Any] = {} + if pool_recycle_seconds is not None: + pool_kwargs["pool_recycle_seconds"] = pool_recycle_seconds + if health_check_interval is not None: + pool_kwargs["health_check_interval"] = health_check_interval + + return DuckDBConnectionPool( + connection_config=connection_config, + extensions=extensions_dicts, + secrets=secrets_dicts, + on_connection_create=self._user_connection_hook, + **pool_kwargs, + ) + + def _close_pool(self) -> None: + """Close the connection pool.""" + if self.connection_instance: + self.connection_instance.close() + + def _prepare_driver(self, driver: DuckDBDriver) -> DuckDBDriver: + driver = super()._prepare_driver(driver) + if self.connection_instance is not None: + driver.driver_features.update(self.connection_instance._storage_settings(driver.connection)) + return driver diff --git a/sqlspec/adapters/duckdb/core.py b/sqlspec/adapters/duckdb/core.py index abef4c75a..d8c756588 100644 --- a/sqlspec/adapters/duckdb/core.py +++ b/sqlspec/adapters/duckdb/core.py @@ -36,6 +36,9 @@ SQLSpecError, UniqueViolationError, ) +from sqlspec.storage import ResolvedStorageTarget +from sqlspec.storage.backends.fsspec import FSSpecBackend +from sqlspec.storage.backends.obstore import ObStoreBackend from sqlspec.utils.serializers import to_json from sqlspec.utils.text import quote_identifier, split_qualified_identifier from sqlspec.utils.type_converters import build_decimal_converter, build_uuid_coercions, time_iso_convert @@ -45,7 +48,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Mapping - from sqlspec.storage import ResolvedStorageTarget, StorageDestination, SyncStoragePipeline + from sqlspec.storage import StorageDestination, SyncStoragePipeline __all__ = ( @@ -175,14 +178,12 @@ def resolve_rowcount(cursor: Any) -> int: Returns: Rowcount value derived from cursor output. """ - try: + with contextlib.suppress(Exception): result = cursor.fetchone() if result and isinstance(result, tuple) and len(result) == 1: return int(result[0]) - except Exception: - if has_rowcount(cursor): - return max(cursor.rowcount, 0) - return 0 + if has_rowcount(cursor): + return max(cursor.rowcount, 0) return 0 @@ -202,7 +203,7 @@ def build_profile() -> "DriverParameterProfile": preserve_original_params_for_many=False, json_serializer_strategy="helper", custom_type_coercions={ - bool: _bool_to_int, + bool: _identity, datetime: _TIME_TO_ISO, date: _TIME_TO_ISO, Decimal: _DECIMAL_TO_STRING, @@ -217,6 +218,8 @@ def apply_driver_features( ) -> "tuple[StatementConfig, dict[str, Any]]": """Apply DuckDB-specific driver features to statement configuration.""" features: dict[str, Any] = dict(driver_features) if driver_features else {} + features.setdefault("enable_events", True) + features.setdefault("events_backend", "poll_queue") param_config = statement_config.parameter_config json_serializer = features.get("json_serializer") @@ -384,10 +387,6 @@ def _resolve_native_storage_target( native secret, and that account name is not treated as a backend option because the provisioned secret owns the credentials. """ - from sqlspec.storage import ResolvedStorageTarget - from sqlspec.storage.backends.fsspec import FSSpecBackend - from sqlspec.storage.backends.obstore import ObStoreBackend - protocols = driver_features.get("_duckdb_storage_protocols", ()) if not driver_features.get("_duckdb_storage_extensions") and not protocols: return None @@ -658,8 +657,8 @@ def _map_transaction_exception(error: "BaseException") -> SQLSpecError: return _create_duckdb_error(error, OperationalError, "transaction error") -def _bool_to_int(value: bool) -> int: - return int(value) +def _identity(value: Any) -> Any: + return value class _DuckDBStreamSource: diff --git a/sqlspec/adapters/duckdb/driver.py b/sqlspec/adapters/duckdb/driver.py index e16888c45..3c24579ea 100644 --- a/sqlspec/adapters/duckdb/driver.py +++ b/sqlspec/adapters/duckdb/driver.py @@ -36,6 +36,7 @@ from sqlspec.driver import BaseSyncExceptionHandler, SyncDriverAdapterBase, SyncRowStream from sqlspec.exceptions import SQLSpecError from sqlspec.typing import import_optional +from sqlspec.utils.arrow_helpers import arrow_reader_with_deferred_close from sqlspec.utils.logging import get_logger from sqlspec.utils.module_loader import ensure_pyarrow from sqlspec.utils.text import quote_identifier @@ -60,24 +61,6 @@ logger = get_logger("sqlspec.adapters.duckdb") -def _is_plain_values_insert(expression: exp.Insert, column_count: int) -> bool: - if any(expression.args.get(key) for key in ("conflict", "alternative", "with_")): - return False - values = expression.expression - if not isinstance(values, exp.Values) or len(values.expressions) != 1: - return False - row = values.expressions[0] - if not isinstance(row, exp.Tuple) or not column_count or len(row.expressions) != column_count: - return False - for index, item in enumerate(row.expressions, 1): - if isinstance(item, exp.Placeholder): - if item.name not in ("", "?", str(index)): - return False - elif not isinstance(item, exp.Parameter) or item.name != str(index): - return False - return True - - class DuckDBExceptionHandler(BaseSyncExceptionHandler): """Context manager for handling DuckDB database exceptions. @@ -130,6 +113,17 @@ def __init__( self._data_dictionary: DuckDBDataDictionary | None = None self._transaction_active = False + @property + def data_dictionary(self) -> "DuckDBDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = DuckDBDataDictionary() + return self._data_dictionary + def dispatch_execute(self, cursor: "DuckDBConnection", statement: SQL) -> "ExecutionResult": """Execute single SQL statement with data handling. @@ -170,8 +164,9 @@ def dispatch_execute(self, cursor: "DuckDBConnection", statement: SQL) -> "Execu def dispatch_execute_many(self, cursor: "DuckDBConnection", statement: SQL) -> "ExecutionResult": """Execute SQL with multiple parameter sets using batch processing. - Uses DuckDB's executemany method for batch operations and calculates - row counts for both data modification and query operations. + Uses DuckDB's Arrow bulk insert fast path for simple INSERT batches, + falling back to executemany and calculating row counts for both data + modification and query operations. Args: cursor: DuckDB cursor object @@ -180,9 +175,20 @@ def dispatch_execute_many(self, cursor: "DuckDBConnection", statement: SQL) -> " Returns: ExecutionResult with batch execution metadata """ - sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) + cached_statement, prepared_parameters = self._compiled_statement(statement, self.statement_config) + sql = cached_statement.compiled_sql if prepared_parameters: + parsed_expression = cached_statement.expression or statement.expression + if isinstance(parsed_expression, exp.Insert) and not parsed_expression.args.get("returning"): + bulk_result = self._execute_bulk_insert_many(parsed_expression, prepared_parameters) + if bulk_result is not None: + return self.create_execution_result( + cursor, + rowcount_override=bulk_result.rows_affected, + special_data={"dml_result": bulk_result}, + is_many_result=True, + ) parameter_sets = cast("list[Any]", prepared_parameters) cursor.executemany(sql, parameter_sets) @@ -192,26 +198,12 @@ def dispatch_execute_many(self, cursor: "DuckDBConnection", statement: SQL) -> " return self.create_execution_result(cursor, rowcount_override=row_count, is_many_result=True) - def execute_many( - self, - statement: "SQL | Statement | QueryBuilder", - /, - parameters: "Sequence[StatementParameters]", - *filters: "StatementParameters | StatementFilter", - statement_config: "StatementConfig | None" = None, - **kwargs: Any, - ) -> "SQLResult": - """Execute many with a DuckDB bulk insert fast path for simple INSERT batches.""" - config = statement_config or self.statement_config - if isinstance(statement, str) and not filters and not kwargs and config is self.statement_config: - prepared_statement = SQL(statement, tuple(parameters), statement_config=config, is_many=True) - cached_statement, prepared_parameters = self._compiled_statement(prepared_statement, config) - parsed_expression = cached_statement.expression - if isinstance(parsed_expression, exp.Insert) and not parsed_expression.args.get("returning"): - bulk_result = self._execute_bulk_insert_many(parsed_expression, prepared_parameters) - if bulk_result is not None: - return bulk_result - return super().execute_many(statement, parameters, *filters, statement_config=statement_config, **kwargs) + def build_statement_result(self, statement: SQL, execution_result: "ExecutionResult") -> "SQLResult": + """Build statement result, returning DMLResult when produced by bulk insert.""" + if execution_result.special_data and isinstance(execution_result.special_data.get("dml_result"), DMLResult): + statement._pooled = False + return cast("DMLResult", execution_result.special_data["dml_result"]) + return super().build_statement_result(statement, execution_result) def dispatch_execute_script(self, cursor: "DuckDBConnection", statement: SQL) -> "ExecutionResult": """Execute SQL script with statement splitting and parameter handling. @@ -252,8 +244,7 @@ def begin(self) -> None: try: self.connection.execute("BEGIN TRANSACTION") except duckdb.Error as e: - msg = f"Failed to begin DuckDB transaction: {e}" - raise SQLSpecError(msg) from e + raise create_mapped_exception(e) from e self._transaction_active = True def commit(self) -> None: @@ -261,17 +252,16 @@ def commit(self) -> None: try: self.connection.commit() except duckdb.Error as e: - msg = f"Failed to commit DuckDB transaction: {e}" - raise SQLSpecError(msg) from e - self._transaction_active = False + raise create_mapped_exception(e) from e + finally: + self._transaction_active = False def rollback(self) -> None: """Rollback the current transaction.""" try: self.connection.rollback() except duckdb.Error as e: - msg = f"Failed to rollback DuckDB transaction: {e}" - raise SQLSpecError(msg) from e + raise create_mapped_exception(e) from e finally: self._transaction_active = False @@ -371,25 +361,37 @@ def select_to_arrow( exc_handler = self.handle_database_exceptions() arrow_result: ArrowResult | None = None - with self.with_cursor(self.connection) as cursor, exc_handler: - sql, driver_params = self._compiled_sql(prepared_statement, config) - - cursor.execute(sql, driver_params or ()) - - if return_format in {"reader", "batches"}: - arrow_reader = ( - cursor.to_arrow_reader(batch_size) if batch_size is not None else cursor.to_arrow_reader() - ) - arrow_result = build_arrow_result_from_reader( - prepared_statement, - arrow_reader, - return_format=return_format, - batch_size=batch_size, - arrow_schema=arrow_schema, - ) - else: + if return_format in {"reader", "batches"}: + stream_cursor, close_cursor = self._acquire_stream_cursor() + try: + with exc_handler: + sql, driver_params = self._compiled_sql(prepared_statement, config) + stream_cursor.execute(sql, driver_params or ()) + arrow_reader = ( + stream_cursor.to_arrow_reader(batch_size) + if batch_size is not None + else stream_cursor.to_arrow_reader() + ) + if close_cursor: + arrow_reader = arrow_reader_with_deferred_close(arrow_reader, stream_cursor.close) + arrow_result = build_arrow_result_from_reader( + prepared_statement, + arrow_reader, + return_format=return_format, + batch_size=batch_size, + arrow_schema=arrow_schema, + ) + self._check_pending_exception(exc_handler) + except BaseException: + if close_cursor: + with contextlib.suppress(Exception): + stream_cursor.close() + raise + else: + with self.with_cursor(self.connection) as cursor, exc_handler: + sql, driver_params = self._compiled_sql(prepared_statement, config) + cursor.execute(sql, driver_params or ()) arrow_table = cursor.to_arrow_table() - arrow_result = build_arrow_result_from_table( prepared_statement, arrow_table, @@ -397,8 +399,7 @@ def select_to_arrow( batch_size=batch_size, arrow_schema=arrow_schema, ) - - self._check_pending_exception(exc_handler) + self._check_pending_exception(exc_handler) if arrow_result is None: msg = "Unreachable" @@ -480,16 +481,30 @@ def load_from_arrow( arrow_source = arrow_table table_name = format_identifier(table) temp_view = f"_sqlspec_arrow_{uuid4().hex}" - if overwrite: - self.connection.execute(f"TRUNCATE TABLE {table_name}") - self.connection.register(temp_view, arrow_source) inserted_rows = 0 - try: - insert_result = self.connection.execute(f"INSERT INTO {table_name} SELECT * FROM {temp_view}") - inserted_rows = _resolve_duckdb_inserted_rows(insert_result) - finally: - with contextlib.suppress(Exception): - self.connection.unregister(temp_view) + manage_transaction = overwrite and not self._connection_in_transaction() + exc_handler = self.handle_database_exceptions() + with exc_handler: + if manage_transaction: + self.connection.execute("BEGIN TRANSACTION") + try: + if overwrite: + self.connection.execute(f"TRUNCATE TABLE {table_name}") + self.connection.register(temp_view, arrow_source) + try: + insert_result = self.connection.execute(f"INSERT INTO {table_name} SELECT * FROM {temp_view}") + inserted_rows = _resolve_duckdb_inserted_rows(insert_result) + finally: + with contextlib.suppress(Exception): + self.connection.unregister(temp_view) + if manage_transaction: + self.connection.commit() + except BaseException: + if manage_transaction: + with contextlib.suppress(Exception): + self.connection.rollback() + raise + self._check_pending_exception(exc_handler) if isinstance(source_data, pa.RecordBatchReader): telemetry_payload: StorageTelemetry = { @@ -535,16 +550,14 @@ def load_from_storage( arrow_table, inbound = self._read_storage_arrow(source, file_format=file_format) return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound) - @property - def data_dictionary(self) -> "DuckDBDataDictionary": - """Get the data dictionary for this driver. + def collect_rows(self, cursor: "DuckDBConnection", fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": + """Collect DuckDB rows for the direct execution path.""" + data, column_names = collect_rows(cast("list[Any] | None", fetched), cursor.description) + return data, column_names, len(data) - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = DuckDBDataDictionary() - return self._data_dictionary + def resolve_rowcount(self, cursor: "DuckDBConnection") -> int: + """Resolve rowcount from DuckDB cursor for the direct execution path.""" + return resolve_rowcount(cursor) def _native_storage_job( self, @@ -594,15 +607,6 @@ def _execute_storage_sql(self, sql: str, parameters: "list[Any]") -> int: raise SQLSpecError(msg) return int(result[0]) - def collect_rows(self, cursor: "DuckDBConnection", fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": - """Collect DuckDB rows for the direct execution path.""" - data, column_names = collect_rows(cast("list[Any] | None", fetched), cursor.description) - return data, column_names, len(data) - - def resolve_rowcount(self, cursor: "DuckDBConnection") -> int: - """Resolve rowcount from DuckDB cursor for the direct execution path.""" - return resolve_rowcount(cursor) - def _execute_bulk_insert_many(self, expression: exp.Insert, prepared_parameters: Any) -> "DMLResult | None": """Execute a batch INSERT via Arrow registration when the payload is simple.""" if not isinstance(prepared_parameters, list) or not prepared_parameters: @@ -650,15 +654,6 @@ def _build_arrow_table(rows: "list[Any]", column_names: "list[str]") -> Any | No return None first_row = rows[0] - if isinstance(first_row, dict): - keys = column_names or list(first_row.keys()) - if any(not isinstance(row, dict) for row in rows): - return None - try: - return pa.table({key: [row.get(key) for row in rows] for key in keys}) - except Exception: - return None - if isinstance(first_row, (list, tuple)): values = list(first_row) if not column_names: @@ -683,23 +678,68 @@ def _connection_in_transaction(self) -> bool: """ return self._transaction_active + def _acquire_stream_cursor(self) -> "tuple[Any, bool]": + """Acquire a dedicated cursor for Arrow streaming when not in an active transaction.""" + if not self._connection_in_transaction(): + cursor_factory = getattr(self.connection, "cursor", None) + if callable(cursor_factory): + stream_cursor = cursor_factory() + if stream_cursor is not None and stream_cursor is not self.connection: + return stream_cursor, True + return self.connection, False + def _open_stream_reader(self, sql: str, parameters: Any, chunk_size: int) -> "tuple[Any, list[Any] | None]": """Open a native Arrow reader through DuckDB's exception boundary.""" ensure_pyarrow() handler = self.handle_database_exceptions() description: list[Any] | None = None reader: Any | None = None - with handler: - result = self.connection.execute(sql, normalize_execute_parameters(parameters)) - description = result.description - reader = result.to_arrow_reader(chunk_size) - self._check_pending_exception(handler) - if reader is None: - msg = "DuckDB did not return an Arrow record batch reader." - raise SQLSpecError(msg) + stream_cursor, close_cursor = self._acquire_stream_cursor() + try: + with handler: + result = stream_cursor.execute(sql, normalize_execute_parameters(parameters)) + description = result.description + reader = result.to_arrow_reader(chunk_size) + self._check_pending_exception(handler) + reader = _ensure_arrow_reader(reader) + if close_cursor: + reader = arrow_reader_with_deferred_close(reader, stream_cursor.close) + except BaseException: + if close_cursor: + with contextlib.suppress(Exception): + stream_cursor.close() + raise return reader, description +register_driver_profile("duckdb", driver_profile) + + +def _ensure_arrow_reader(reader: Any) -> Any: + if reader is None: + msg = "DuckDB did not return an Arrow record batch reader." + raise SQLSpecError(msg) + return reader + + +def _is_plain_values_insert(expression: exp.Insert, column_count: int) -> bool: + if any(expression.args.get(key) for key in ("conflict", "alternative", "with_")): + return False + values = expression.expression + if not isinstance(values, exp.Values) or len(values.expressions) != 1: + return False + row = values.expressions[0] + if not isinstance(row, exp.Tuple) or not column_count or len(row.expressions) != column_count: + return False + for index, item in enumerate(row.expressions, 1): + if isinstance(item, exp.Placeholder): + if item.name not in ("", "?", str(index)): + return False + elif not isinstance(item, exp.Parameter) or item.name != str(index): + return False + return True + + def _resolve_duckdb_inserted_rows(result: object) -> int: fetchall = getattr(result, "fetchall", None) if not callable(fetchall): @@ -716,6 +756,3 @@ def _resolve_duckdb_inserted_rows(result: object) -> int: if isinstance(first_row, int): return max(first_row, 0) return 0 - - -register_driver_profile("duckdb", driver_profile) diff --git a/sqlspec/adapters/duckdb/litestar/store.py b/sqlspec/adapters/duckdb/litestar/store.py index fdcb4509a..3031bf7c6 100644 --- a/sqlspec/adapters/duckdb/litestar/store.py +++ b/sqlspec/adapters/duckdb/litestar/store.py @@ -124,9 +124,9 @@ def _table_ddl(self) -> str: CREATE TABLE IF NOT EXISTS {self._table_name} ( session_id VARCHAR PRIMARY KEY, data BLOB NOT NULL, - expires_at TIMESTAMP, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_{self._table_name}_expires_at ON {self._table_name}(expires_at); @@ -141,7 +141,7 @@ def _drop_table_sql(self) -> "list[str]": return [f"DROP INDEX IF EXISTS idx_{self._table_name}_expires_at", f"DROP TABLE IF EXISTS {self._table_name}"] def _datetime_to_timestamp(self, dt: "datetime | None") -> "str | None": - """Convert datetime to ISO 8601 string for DuckDB TIMESTAMP storage. + """Convert datetime to ISO 8601 string for DuckDB TIMESTAMPTZ storage. Args: dt: Datetime to convert (must be UTC-aware). @@ -167,11 +167,11 @@ def _timestamp_to_datetime(self, ts: "str | datetime | None") -> "datetime | Non if isinstance(ts, datetime): if ts.tzinfo is None: return ts.replace(tzinfo=UTC) - return ts + return ts.astimezone(UTC) dt = datetime.fromisoformat(ts) if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) - return dt + return dt.astimezone(UTC) def _create_table(self) -> None: """Synchronous implementation of create_table.""" diff --git a/sqlspec/adapters/duckdb/pool.py b/sqlspec/adapters/duckdb/pool.py index 19591a13b..1882f1b33 100644 --- a/sqlspec/adapters/duckdb/pool.py +++ b/sqlspec/adapters/duckdb/pool.py @@ -127,13 +127,6 @@ def _database_name(self) -> str: return ":memory:" return str(db) - def _create_connection(self) -> DuckDBConnection: - """Create a pool-owned connection and record it for shutdown.""" - connection = self.new_connection(record_thread_state=True) - with self._lock: - self._connection_registry.add(connection) - return connection - def new_connection(self, record_thread_state: bool = False) -> DuckDBConnection: """Open a standalone connection with extensions and secrets applied. @@ -233,7 +226,9 @@ def new_connection(self, record_thread_state: bool = False) -> DuckDBConnection: ] if self._on_connection_create: - self._on_connection_create(connection) + modified = self._on_connection_create(connection) + if modified is not None: + connection = modified storage_protocols.update(connection.list_filesystems()) if record_thread_state: self._thread_local.storage_extensions = frozenset(loaded_extensions) @@ -241,6 +236,88 @@ def new_connection(self, record_thread_state: bool = False) -> DuckDBConnection: self._thread_local.storage_protocols = frozenset(storage_protocols) return connection + @contextmanager + def get_connection(self) -> "Generator[DuckDBConnection, None, None]": + """Get a thread-local connection. + + Each thread gets its own dedicated DuckDB connection to prevent + thread-safety issues with concurrent cursor operations. + + Re-entrant checkouts on the same thread reuse the active connection + without committing, rolling back, or closing on inner exits. + + Yields: + DuckDBConnection: A thread-local connection. + """ + connection = self._get_thread_connection() + depth = int(getattr(self._thread_local, "depth", 0)) + self._thread_local.depth = depth + 1 + try: + yield connection + except Exception: + if depth == 0: + with suppress(Exception): + connection.rollback() + if not self._is_memory_db: + self._close_thread_connection() + raise + else: + if depth == 0: + try: + connection.commit() + except Exception: + with suppress(Exception): + connection.rollback() + if not self._is_memory_db: + self._close_thread_connection() + raise + finally: + self._thread_local.depth = depth + + def close(self) -> None: + """Close every connection this pool opened, on any thread.""" + self._close_thread_connection() + with self._lock: + orphaned = list(self._connection_registry) + self._connection_registry.clear() + self._generation += 1 + for connection in orphaned: + with suppress(Exception): + connection.close() + + def size(self) -> int: + """Get current pool size (always 1 for thread-local).""" + return 1 if "connection" in self._thread_local.__dict__ else 0 + + def checked_out(self) -> int: + """Get number of checked out connections (always 0 for thread-local).""" + return 0 + + def acquire(self) -> DuckDBConnection: + """Acquire a thread-local connection. + + Each thread gets its own dedicated DuckDB connection to prevent + thread-safety issues with concurrent cursor operations. + + Returns: + DuckDBConnection: A thread-local connection + """ + return self._get_thread_connection() + + def release(self, connection: DuckDBConnection) -> None: + """Release a connection (no-op for thread-local connections). + + Args: + connection: The connection to release (ignored) + """ + + def _create_connection(self) -> DuckDBConnection: + """Create a pool-owned connection and record it for shutdown.""" + connection = self.new_connection(record_thread_state=True) + with self._lock: + self._connection_registry.add(connection) + return connection + def _storage_settings(self, connection: DuckDBConnection) -> "dict[str, Any]": """Return storage setup completed for this thread's current connection.""" state = self._thread_local.__dict__ @@ -372,76 +449,6 @@ def _is_connection_alive(self, connection: DuckDBConnection) -> bool: return False return True - @contextmanager - def get_connection(self) -> "Generator[DuckDBConnection, None, None]": - """Get a thread-local connection. - - Each thread gets its own dedicated DuckDB connection to prevent - thread-safety issues with concurrent cursor operations. - - For file-based databases, the connection is closed when the context - manager exits to release DuckDB's file lock, allowing subsequent - connections with different configurations. - - For in-memory databases, connections are kept alive to preserve data, - as in-memory data is lost when the last connection closes. - - Yields: - DuckDBConnection: A thread-local connection. - """ - connection = self._get_thread_connection() - try: - yield connection - except Exception: - with suppress(Exception): - connection.rollback() - if not self._is_memory_db: - self._close_thread_connection() - raise - else: - try: - connection.commit() - finally: - if not self._is_memory_db: - self._close_thread_connection() - - def close(self) -> None: - """Close every connection this pool opened, on any thread.""" - self._close_thread_connection() - with self._lock: - orphaned = list(self._connection_registry) - self._connection_registry.clear() - self._generation += 1 - for connection in orphaned: - with suppress(Exception): - connection.close() - - def size(self) -> int: - """Get current pool size (always 1 for thread-local).""" - return 1 if "connection" in self._thread_local.__dict__ else 0 - - def checked_out(self) -> int: - """Get number of checked out connections (always 0 for thread-local).""" - return 0 - - def acquire(self) -> DuckDBConnection: - """Acquire a thread-local connection. - - Each thread gets its own dedicated DuckDB connection to prevent - thread-safety issues with concurrent cursor operations. - - Returns: - DuckDBConnection: A thread-local connection - """ - return self._get_thread_connection() - - def release(self, connection: DuckDBConnection) -> None: - """Release a connection (no-op for thread-local connections). - - Args: - connection: The connection to release (ignored) - """ - def _validate_sql_identifier(value: str, field_name: str) -> None: """Raise ValueError if value is not safe to interpolate as a SQL identifier.""" diff --git a/sqlspec/adapters/mssql_python/_typing.py b/sqlspec/adapters/mssql_python/_typing.py index 27f48317c..d5fc593ba 100644 --- a/sqlspec/adapters/mssql_python/_typing.py +++ b/sqlspec/adapters/mssql_python/_typing.py @@ -8,7 +8,10 @@ from mssql_python.connection import Connection, TokenProvider # pyright: ignore from mssql_python.cursor import Cursor # pyright: ignore +from sqlspec.utils.module_loader import import_optional_attr + MSSQL_PYTHON_MODULE: Any = _mssql_python +_DRIVER_CLS: Any = None if TYPE_CHECKING: from collections.abc import Callable @@ -85,21 +88,24 @@ def __init__( self._driver: MssqlPythonDriver | None = None def __enter__(self) -> "MssqlPythonDriver": - from sqlspec.adapters.mssql_python.driver import MssqlPythonDriver - + global _DRIVER_CLS + if _DRIVER_CLS is None: + _DRIVER_CLS = import_optional_attr("sqlspec.adapters.mssql_python.driver", "MssqlPythonDriver") self._connection = self._acquire_connection() - self._driver = MssqlPythonDriver( + self._driver = _DRIVER_CLS( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) + assert self._driver is not None return self._prepare_driver(self._driver) def __exit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> "bool | None": - if exc_type is not None and self._driver is not None: + if self._driver is not None and (exc_type is not None or self._driver._connection_in_transaction()): with contextlib.suppress(Exception): self._driver.rollback() if self._connection is not None: self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) self._connection = None + self._driver = None return None diff --git a/sqlspec/adapters/mssql_python/adk/store.py b/sqlspec/adapters/mssql_python/adk/store.py index a2e7bbd7c..8340300d9 100644 --- a/sqlspec/adapters/mssql_python/adk/store.py +++ b/sqlspec/adapters/mssql_python/adk/store.py @@ -4,7 +4,13 @@ from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NotRequired, cast from sqlspec.adapters.mssql_python._typing import MssqlPythonCursor, MssqlPythonError -from sqlspec.adapters.mssql_python.core import extract_error_number +from sqlspec.adapters.mssql_python.core import ( + escape_sql_literal, + extract_error_number, + quote_bracket_identifier, + resolve_rowcount, + split_schema_and_table, +) from sqlspec.adapters.mssql_python.data_dictionary import MssqlVersionInfo from sqlspec.config import ADKConfig from sqlspec.extensions.adk import BaseSyncADKStore, StoredEvent, StoredSession, normalize_session_list_options @@ -67,19 +73,19 @@ def create_tables(self) -> None: driver.execute_script(self._app_states_table_ddl()) driver.execute_script(self._user_states_table_ddl()) driver.execute_script(self._metadata_table_ddl()) - existing_indexes = _casefold_names( - driver.data_dictionary.get_indexes(driver, schema=MSSQL_SCHEMA), "index_name" - ) - for index_name, index_table, columns in self._index_specs(): + index_specs = self._index_specs() + existing_indexes = _existing_index_names(driver, index_specs) + for index_name, index_table, columns in index_specs: if _bare_name(index_name) not in existing_indexes: driver.execute(_create_index_sql(index_table, index_name, columns)) driver.commit() + self.reconcile_schema(assume_existing=True) def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: """Create a new ADK session.""" - owner_column = f", {_quote_identifier(self._owner_id_column_name)}" if self._owner_id_column_name else "" + owner_column = f", {quote_bracket_identifier(self._owner_id_column_name)}" if self._owner_id_column_name else "" owner_param = ", ?" if self._owner_id_column_name else "" sql = f""" INSERT INTO {_table_ref(self._session_table)} ( @@ -285,7 +291,7 @@ def get_app_state(self, app_name: str) -> "dict[str, Any] | None": if _is_mssql_table_missing(exc): return None raise - return _json_dict(row[0]) if row is not None else None + return _json_dict(_row_value(row, "state", 0)) if row is not None else None def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": """Return user-scoped state.""" @@ -302,7 +308,7 @@ def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None" if _is_mssql_table_missing(exc): return None raise - return _json_dict(row[0]) if row is not None else None + return _json_dict(_row_value(row, "state", 0)) if row is not None else None def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: """Insert or replace app-scoped state.""" @@ -322,7 +328,7 @@ def get_metadata(self, key: str) -> "str | None": if _is_mssql_table_missing(exc): return None raise - return str(row[0]) if row is not None else None + return str(_row_value(row, "value", 0)) if row is not None else None def set_metadata(self, key: str, value: str) -> None: """Set an ADK metadata value.""" @@ -416,7 +422,7 @@ def _execute_fetchall(self, sql: str, params: "tuple[Any, ...]" = ()) -> "list[A def _execute(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = False) -> int: with self._config.provide_connection() as conn, MssqlPythonCursor(conn) as cursor: cursor.execute(sql, params) - rowcount = _cursor_rowcount(cursor) + rowcount = resolve_rowcount(cursor) if commit: conn.commit() return rowcount @@ -425,10 +431,14 @@ def _execute(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = F class MssqlPythonADKMemoryStore(BaseSyncADKMemoryStore["MssqlPythonConfig"]): """SQL Server ADK memory store using mssql-python.""" - __slots__ = () + __slots__ = ("_json_column_type", "_native_json") def __init__(self, config: "MssqlPythonConfig") -> None: super().__init__(config) + adk_config = _adk_config(config) + native_json = adk_config.get("native_json") + self._native_json: bool | None = native_json if isinstance(native_json, bool) else None + self._json_column_type: str | None = None def create_tables(self) -> None: """Create the memory table (idempotent T-SQL) and DD-gated indexes.""" @@ -439,14 +449,19 @@ def create_tables(self) -> None: if not self._enabled: return with self._config.provide_session() as driver: + if self._json_column_type is None: + configured = _configured_json_column_type(self._native_json) + self._json_column_type = ( + configured if configured is not None else _json_column_type_from_sync_driver(driver) + ) driver.execute_script(self._memory_table_ddl()) - existing_indexes = _casefold_names( - driver.data_dictionary.get_indexes(driver, schema=MSSQL_SCHEMA), "index_name" - ) - for index_name, index_table, columns in self._memory_index_specs(): + index_specs = self._memory_index_specs() + existing_indexes = _existing_index_names(driver, index_specs) + for index_name, index_table, columns in index_specs: if _bare_name(index_name) not in existing_indexes: driver.execute(_create_index_sql(index_table, index_name, columns)) driver.commit() + self.reconcile_schema(assume_existing=True) def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Bulk insert memory entries with event-id deduplication.""" @@ -456,7 +471,7 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object if not entries: return 0 - owner_column = f", {_quote_identifier(self._owner_id_column_name)}" if self._owner_id_column_name else "" + owner_column = f", {quote_bracket_identifier(self._owner_id_column_name)}" if self._owner_id_column_name else "" owner_value = ", ?" if self._owner_id_column_name else "" sql = f""" INSERT INTO {_table_ref(self._memory_table)} ( @@ -471,24 +486,28 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object """ inserted = 0 with self._config.provide_connection() as conn, MssqlPythonCursor(conn) as cursor: - for entry in entries: - params: tuple[Any, ...] = ( - entry["id"], - entry["session_id"], - entry["app_name"], - entry["user_id"], - entry.get("scope", "user"), - entry["event_id"], - entry.get("author"), - entry["timestamp"], - to_json(entry["content_json"]), - entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, - ) - if self._owner_id_column_name: - params = (*params, owner_id) - cursor.execute(sql, (*params, entry["event_id"])) - inserted += _cursor_rowcount(cursor) + try: + for entry in entries: + params: tuple[Any, ...] = ( + entry["id"], + entry["session_id"], + entry["app_name"], + entry["user_id"], + entry.get("scope", "user"), + entry["event_id"], + entry.get("author"), + entry["timestamp"], + to_json(entry["content_json"]), + entry["content_text"], + to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + ) + if self._owner_id_column_name: + params = (*params, owner_id) + cursor.execute(sql, (*params, entry["event_id"])) + inserted += resolve_rowcount(cursor) + except Exception: + conn.rollback() + raise conn.commit() return inserted @@ -507,15 +526,16 @@ def search_entries( raise RuntimeError(msg) limit_value = limit or self._max_results where_scope, scope_params = _build_mssql_scope_where(app_name, user_id, scope_filter) + escaped_query = _escape_like_pattern(query) sql = f""" SELECT TOP (?) id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at FROM {_table_ref(self._memory_table)} - WHERE {where_scope} AND content_text LIKE ? + WHERE {where_scope} AND content_text LIKE ? ESCAPE N'\\' ORDER BY timestamp DESC """ - rows = self._execute_fetchall(sql, (limit_value, *scope_params, f"%{query}%")) + rows = self._execute_fetchall(sql, (limit_value, *scope_params, f"%{escaped_query}%")) return [_memory_record_from_row(row) for row in rows] def delete_entries_by_session(self, session_id: str) -> int: @@ -541,9 +561,11 @@ def delete_entries_older_than(self, days: int, app_name: "str | None" = None, sc def _memory_table_ddl(self) -> str: owner_line = f",\n {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" + json_column_type = self._json_column_type_sync() + schema_name, table_name = split_schema_and_table(self._memory_table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(self._memory_table)}' - AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' + AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(self._memory_table)} ( id NVARCHAR(128) NOT NULL, @@ -554,9 +576,9 @@ def _memory_table_ddl(self) -> str: event_id NVARCHAR(128) NOT NULL, author NVARCHAR(256) NULL, timestamp DATETIME2(6) NOT NULL, - content_json NVARCHAR(MAX) NOT NULL, + content_json {json_column_type} NOT NULL, content_text NVARCHAR(MAX) NOT NULL, - metadata_json NVARCHAR(MAX) NULL, + metadata_json {json_column_type} NULL, inserted_at DATETIME2(6) NOT NULL CONSTRAINT {_constraint_ref("df", self._memory_table, "inserted_at")} DEFAULT SYSUTCDATETIME(){owner_line}, CONSTRAINT {_constraint_ref("pk", self._memory_table, "id")} PRIMARY KEY (id), @@ -567,20 +589,31 @@ def _memory_table_ddl(self) -> str: def _memory_index_specs(self) -> "list[tuple[str, str, str]]": """Return ``(index_name, table, columns)`` specs for memory-table indexes.""" + _, table_name = split_schema_and_table(self._memory_table) return [ - ( - f"idx_{self._memory_table}_app_scope_user_time", - self._memory_table, - "app_name, scope, user_id, timestamp DESC", - ), - (f"idx_{self._memory_table}_scope", self._memory_table, "app_name, scope"), - (f"idx_{self._memory_table}_session", self._memory_table, "session_id"), - (f"idx_{self._memory_table}_timestamp", self._memory_table, "timestamp DESC"), + (f"idx_{table_name}_app_scope_user_time", self._memory_table, "app_name, scope, user_id, timestamp DESC"), + (f"idx_{table_name}_scope", self._memory_table, "app_name, scope"), + (f"idx_{table_name}_session", self._memory_table, "session_id"), + (f"idx_{table_name}_timestamp", self._memory_table, "timestamp DESC"), ] def _drop_memory_table_sql(self) -> "list[str]": return [f"DROP TABLE IF EXISTS {_table_ref(self._memory_table)}"] + def _json_column_type_sync(self) -> str: + if self._json_column_type is not None: + return self._json_column_type + configured = _configured_json_column_type(self._native_json) + if configured is not None: + self._json_column_type = configured + return configured + try: + with self._config.provide_session() as driver: + self._json_column_type = _json_column_type_from_sync_driver(driver) + except Exception: + return JSON_FALLBACK_COLUMN_TYPE + return self._json_column_type + def _execute_fetchall(self, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": with self._config.provide_connection() as conn, MssqlPythonCursor(conn) as cursor: cursor.execute(sql, params) @@ -589,7 +622,7 @@ def _execute_fetchall(self, sql: str, params: "tuple[Any, ...]" = ()) -> "list[A def _execute(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = False) -> int: with self._config.provide_connection() as conn, MssqlPythonCursor(conn) as cursor: cursor.execute(sql, params) - rowcount = _cursor_rowcount(cursor) + rowcount = resolve_rowcount(cursor) if commit: conn.commit() return rowcount @@ -620,10 +653,19 @@ def _json_column_type_from_sync_driver(driver: "MssqlPythonDriver") -> str: return JSON_FALLBACK_COLUMN_TYPE +def _existing_index_names(driver: "MssqlPythonDriver", index_specs: "list[tuple[str, str, str]]") -> "set[str]": + schemas = {split_schema_and_table(index_table)[0] for _, index_table, _ in index_specs} or {MSSQL_SCHEMA} + existing: set[str] = set() + for schema_name in schemas: + existing.update(_casefold_names(driver.data_dictionary.get_indexes(driver, schema=schema_name), "index_name")) + return existing + + def _sessions_table_ddl(table: str, json_column_type: str, owner_id_column_ddl: "str | None") -> str: owner_line = f",\n {owner_id_column_ddl}" if owner_id_column_ddl else "" + schema_name, table_name = split_schema_and_table(table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(table)}' AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(table)} ( row_id UNIQUEIDENTIFIER NOT NULL CONSTRAINT {_constraint_ref("df", table, "row_id")} DEFAULT NEWSEQUENTIALID(), @@ -641,15 +683,17 @@ def _sessions_table_ddl(table: str, json_column_type: str, owner_id_column_ddl: def _sessions_index_specs(table: str) -> "list[tuple[str, str, str]]": + _, table_name = split_schema_and_table(table) return [ - (f"idx_{table}_app_user", table, "app_name, user_id"), - (f"idx_{table}_update_time", table, "update_time DESC"), + (f"idx_{table_name}_app_user", table, "app_name, user_id"), + (f"idx_{table_name}_update_time", table, "update_time DESC"), ] def _events_table_ddl(table: str, session_table: str, json_column_type: str) -> str: + schema_name, table_name = split_schema_and_table(table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(table)}' AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(table)} ( row_id UNIQUEIDENTIFIER NOT NULL CONSTRAINT {_constraint_ref("df", table, "row_id")} DEFAULT NEWSEQUENTIALID(), @@ -670,18 +714,20 @@ def _events_table_ddl(table: str, session_table: str, json_column_type: str) -> def _events_index_specs(table: str) -> "list[tuple[str, str, str]]": + _, table_name = split_schema_and_table(table) return [ - (f"idx_{table}_scope", table, "app_name, user_id, session_id, timestamp ASC"), - (f"idx_{table}_session", table, "session_id, timestamp ASC"), - (f"idx_{table}_invocation", table, "invocation_id"), - (f"idx_{table}_timestamp", table, "timestamp ASC"), - (f"idx_{table}_app_timestamp", table, "app_name, timestamp ASC"), + (f"idx_{table_name}_scope", table, "app_name, user_id, session_id, timestamp ASC"), + (f"idx_{table_name}_session", table, "session_id, timestamp ASC"), + (f"idx_{table_name}_invocation", table, "invocation_id"), + (f"idx_{table_name}_timestamp", table, "timestamp ASC"), + (f"idx_{table_name}_app_timestamp", table, "app_name, timestamp ASC"), ] def _app_states_table_ddl(table: str, json_column_type: str) -> str: + schema_name, table_name = split_schema_and_table(table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(table)}' AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(table)} ( app_name NVARCHAR(128) NOT NULL, @@ -694,8 +740,9 @@ def _app_states_table_ddl(table: str, json_column_type: str) -> str: def _user_states_table_ddl(table: str, json_column_type: str) -> str: + schema_name, table_name = split_schema_and_table(table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(table)}' AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(table)} ( app_name NVARCHAR(128) NOT NULL, @@ -709,8 +756,9 @@ def _user_states_table_ddl(table: str, json_column_type: str) -> str: def _metadata_table_ddl(table: str) -> str: + schema_name, table_name = split_schema_and_table(table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(table)}' AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(table)} ( [key] NVARCHAR(128) NOT NULL, @@ -722,7 +770,7 @@ def _metadata_table_ddl(table: str) -> str: def _create_index_sql(table: str, index_name: str, columns: str) -> str: - return f"CREATE INDEX {_quote_identifier(index_name)} ON {_table_ref(table)} ({columns})" + return f"CREATE INDEX {quote_bracket_identifier(index_name)} ON {_table_ref(table)} ({columns})" def _casefold_names(rows: "list[Any]", key: str) -> "set[str]": @@ -746,13 +794,14 @@ def _insert_event_sql(table: str) -> str: def _upsert_state_sql(table: str, key_columns: "tuple[str, ...]", key_params: "tuple[str, ...]") -> str: source_columns = ", ".join( - f"{param} AS {_quote_identifier(column)}" for column, param in zip(key_columns, key_params, strict=False) + f"{param} AS {quote_bracket_identifier(column)}" for column, param in zip(key_columns, key_params, strict=False) ) source_columns = f"{source_columns}, ? AS state" - insert_columns = ", ".join(_quote_identifier(column) for column in (*key_columns, "state", "update_time")) - insert_values = ", ".join(f"source.{_quote_identifier(column)}" for column in (*key_columns, "state")) + insert_columns = ", ".join(quote_bracket_identifier(column) for column in (*key_columns, "state", "update_time")) + insert_values = ", ".join(f"source.{quote_bracket_identifier(column)}" for column in (*key_columns, "state")) match_clause = " AND ".join( - f"target.{_quote_identifier(column)} = source.{_quote_identifier(column)}" for column in key_columns + f"target.{quote_bracket_identifier(column)} = source.{quote_bracket_identifier(column)}" + for column in key_columns ) return f""" MERGE INTO {_table_ref(table)} WITH (HOLDLOCK) AS target @@ -810,40 +859,59 @@ def _event_insert_params(event_record: StoredEvent) -> "tuple[Any, ...]": ) +def _row_value(row: object, key: str, index: int) -> Any: + if isinstance(row, dict): + if key in row: + return row[key] + upper_key = key.upper() + if upper_key in row: + return row[upper_key] + return None + if isinstance(row, (list, tuple)) and len(row) > index: + return row[index] + return getattr(row, key, None) + + def _session_record_from_row(row: Any) -> StoredSession: return StoredSession( - id=row[0], app_name=row[1], user_id=row[2], state=_json_dict(row[3]), create_time=row[4], update_time=row[5] + id=_row_value(row, "id", 0), + app_name=_row_value(row, "app_name", 1), + user_id=_row_value(row, "user_id", 2), + state=_json_dict(_row_value(row, "state", 3)), + create_time=_row_value(row, "create_time", 4), + update_time=_row_value(row, "update_time", 5), ) def _event_record_from_row(row: Any) -> StoredEvent: return StoredEvent( - id=row[0], - app_name=row[1], - user_id=row[2], - session_id=row[3], - invocation_id=row[4], - timestamp=row[5], - event_data=_json_dict(row[6]), + id=_row_value(row, "id", 0), + app_name=_row_value(row, "app_name", 1), + user_id=_row_value(row, "user_id", 2), + session_id=_row_value(row, "session_id", 3), + invocation_id=_row_value(row, "invocation_id", 4), + timestamp=_row_value(row, "timestamp", 5), + event_data=_json_dict(_row_value(row, "event_data", 6)), ) def _memory_record_from_row(row: Any) -> "StoredMemory": + metadata_raw = _row_value(row, "metadata_json", 10) return cast( "StoredMemory", { - "id": row[0], - "session_id": row[1], - "app_name": row[2], - "user_id": row[3], - "scope": row[4], - "event_id": row[5], - "author": row[6], - "timestamp": row[7], - "content_json": _json_dict(row[8]), - "content_text": row[9], - "metadata_json": _json_dict(row[10]) if row[10] is not None else None, - "inserted_at": row[11], + "id": _row_value(row, "id", 0), + "session_id": _row_value(row, "session_id", 1), + "app_name": _row_value(row, "app_name", 2), + "user_id": _row_value(row, "user_id", 3), + "scope": _row_value(row, "scope", 4), + "event_id": _row_value(row, "event_id", 5), + "author": _row_value(row, "author", 6), + "timestamp": _row_value(row, "timestamp", 7), + "content_json": _json_dict(_row_value(row, "content_json", 8)), + "content_text": _row_value(row, "content_text", 9), + "metadata_json": _json_dict(metadata_raw) if metadata_raw is not None else None, + "inserted_at": _row_value(row, "inserted_at", 11), "embedding": None, }, ) @@ -859,6 +927,10 @@ def _build_mssql_scope_where( return "app_name = ? AND scope = 'app'", (app_name,) +def _escape_like_pattern(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_").replace("[", "\\[") + + def _json_dict(value: Any) -> "dict[str, Any]": if value is None: return {} @@ -871,30 +943,19 @@ def _json_dict(value: Any) -> "dict[str, Any]": return cast("dict[str, Any]", from_json(str(value))) -def _cursor_rowcount(cursor: Any) -> int: - rowcount = getattr(cursor, "rowcount", 0) - return rowcount if isinstance(rowcount, int) and rowcount > 0 else 0 - - def _is_mssql_table_missing(exc: BaseException) -> bool: text = str(exc).lower() return "invalid object name" in text or extract_error_number(exc) == MSSQL_TABLE_NOT_FOUND_ERROR -def _quote_identifier(identifier: str) -> str: - return f"[{identifier.replace(']', ']]')}]" - - def _table_ref(table: str) -> str: - return f"{_quote_identifier(MSSQL_SCHEMA)}.{_quote_identifier(table)}" + schema_name, table_name = split_schema_and_table(table) + return f"{quote_bracket_identifier(schema_name)}.{quote_bracket_identifier(table_name)}" def _constraint_ref(prefix: str, table: str, suffix: str) -> str: - return _quote_identifier(f"{prefix}_{table}_{suffix}") - - -def _escape_sql_literal(value: str) -> str: - return value.replace("'", "''") + _, table_name = split_schema_and_table(table) + return quote_bracket_identifier(f"{prefix}_{table_name}_{suffix}") def _raise_session_not_found(session_id: str) -> None: @@ -930,3 +991,6 @@ def _session_list_query( ORDER BY {column} {direction}, id {direction}{page_clause} """ return sql, tuple(params) + + +_escape_sql_literal = escape_sql_literal diff --git a/sqlspec/adapters/mssql_python/config.py b/sqlspec/adapters/mssql_python/config.py index 89a667b8b..79be01bf8 100644 --- a/sqlspec/adapters/mssql_python/config.py +++ b/sqlspec/adapters/mssql_python/config.py @@ -1,6 +1,7 @@ """mssql-python database configuration.""" -from typing import TYPE_CHECKING, Any, ClassVar, NotRequired, TypedDict, cast +import contextlib +from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, TypedDict, cast from sqlspec.adapters.mssql_python._typing import MssqlPythonConnection, MssqlPythonSessionContext, TokenProvider from sqlspec.adapters.mssql_python.core import apply_driver_features, build_connection_config, default_statement_config @@ -10,8 +11,8 @@ from sqlspec.config import ExtensionConfigs, SyncDatabaseConfig from sqlspec.core import TypeCoercionCapabilities from sqlspec.driver import SyncPoolConnectionContext, SyncPoolSessionFactory +from sqlspec.extensions.events import EventRuntimeHints from sqlspec.utils.config_tools import normalize_connection_config -from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: from collections.abc import Callable @@ -20,13 +21,7 @@ from sqlspec.core import StatementConfig from sqlspec.observability import ObservabilityConfig -__all__ = ( - "MssqlPythonConfig", - "MssqlPythonConnectionParams", - "MssqlPythonConnectionPool", - "MssqlPythonDriverFeatures", - "MssqlPythonPoolParams", -) +__all__ = ("MssqlPythonConfig", "MssqlPythonConnectionParams", "MssqlPythonDriverFeatures", "MssqlPythonPoolParams") class MssqlPythonConnectionParams(TypedDict): @@ -98,6 +93,7 @@ class MssqlPythonDriverFeatures(TypedDict): json_deserializer: "NotRequired[Callable[[str], Any]]" on_connection_create: "NotRequired[Callable[[MssqlPythonConnection], None]]" enable_events: NotRequired[bool] + events_backend: NotRequired[Literal["poll_queue"]] class MssqlPythonConnectionContext(SyncPoolConnectionContext): @@ -119,6 +115,9 @@ def __exit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> "bool | None": if self._conn is not None: + if exc_type is not None: + with contextlib.suppress(Exception): + self._conn.rollback() self._config.provide_pool().release(self._conn) self._conn = None return None @@ -187,7 +186,7 @@ def __init__( connection_config, driver_features ) self._user_connection_hook = user_connection_hook - statement_config = _apply_json_serializer_override(statement_config or default_statement_config, features_dict) + statement_config, _ = apply_driver_features(statement_config or default_statement_config, features_dict) super().__init__( bind_key=bind_key, connection_config=normalized, @@ -219,6 +218,10 @@ def get_signature_namespace(self) -> "dict[str, Any]": }) return namespace + def get_event_runtime_hints(self) -> "EventRuntimeHints": + """Return polling defaults for SQL Server queue fallback.""" + return EventRuntimeHints(poll_interval=0.25, lease_seconds=5) + def _create_pool(self) -> "MssqlPythonConnectionPool": return _create_mssql_python_pool(dict(self.connection_config), self.driver_features, self._user_connection_hook) @@ -228,20 +231,6 @@ def _close_pool(self) -> None: self.connection_instance = None -def _apply_json_serializer_override(statement_config: Any, features_dict: dict[str, Any]) -> Any: - serializer = cast("Callable[[Any], str] | None", features_dict.get("json_serializer")) - deserializer = cast("Callable[[str], Any] | None", features_dict.get("json_deserializer")) - if statement_config is not default_statement_config: - return statement_config - if serializer is to_json and deserializer is from_json: - return statement_config - return statement_config.replace( - parameter_config=statement_config.parameter_config.with_json_serializers( - serializer or to_json, deserializer=deserializer - ) - ) - - def _create_mssql_python_pool( connection_config: "dict[str, Any]", driver_features: "dict[str, Any]", diff --git a/sqlspec/adapters/mssql_python/core.py b/sqlspec/adapters/mssql_python/core.py index 4811f9e8c..251e6227a 100644 --- a/sqlspec/adapters/mssql_python/core.py +++ b/sqlspec/adapters/mssql_python/core.py @@ -1,8 +1,9 @@ """mssql-python adapter core helpers.""" import re +from collections.abc import Sized from importlib.metadata import PackageNotFoundError, version -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, Literal from sqlspec.core.parameters import ParameterStyle from sqlspec.core.parameters._registry import build_statement_config_from_profile @@ -18,12 +19,16 @@ OperationalError, PermissionDeniedError, QueryTimeoutError, + SerializationConflictError, + SQLParsingError, SQLSpecError, UniqueViolationError, ) from sqlspec.utils.config_tools import parse_odbc_connection_string from sqlspec.utils.serializers import from_json, to_json +from sqlspec.utils.text import split_qualified_identifier from sqlspec.utils.type_converters import build_uuid_coercions +from sqlspec.utils.type_guards import has_rowcount if TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence @@ -35,17 +40,27 @@ "MSSQL_PYTHON_VERSION", "apply_driver_features", "build_connection_config", + "build_insert_statement", "build_profile", "build_statement_config", + "collect_rows", "create_mapped_exception", "default_statement_config", "driver_profile", + "escape_sql_literal", "extract_error_number", + "format_identifier", "materialize_tuple_rows", + "quote_bracket_identifier", + "resolve_column_names", + "resolve_many_rowcount", + "resolve_rowcount", + "split_schema_and_table", ) _ERROR_NUMBER_PATTERN: Final[re.Pattern[str]] = re.compile(r"(?:\(([-]?\d+)(?:,|\))|\bMsg\s+([-]?\d+)\b)") _MSSQL_CONSTRAINT_547: Final[int] = 547 +_COLUMN_CACHE_MAX_SIZE: Final[int] = 256 _VERSION_PATTERN: Final[re.Pattern[str]] = re.compile(r"(\d+)") _VERSION_PART_COUNT: Final[int] = 3 _CONNECTION_STRING_KEYS: Final[tuple[tuple[tuple[str, ...], str, bool], ...]] = ( @@ -90,15 +105,128 @@ 2627: (UniqueViolationError, "unique constraint violation"), 515: (NotNullViolationError, "not-null constraint violation"), 18456: (PermissionDeniedError, "permission denied"), + 229: (PermissionDeniedError, "permission denied"), + 230: (PermissionDeniedError, "permission denied"), + 262: (PermissionDeniedError, "permission denied"), 4060: (DatabaseConnectionError, "database connection error"), 53: (DatabaseConnectionError, "database connection error"), 1205: (DeadlockError, "deadlock detected"), + 3960: (SerializationConflictError, "snapshot isolation conflict"), -2: (QueryTimeoutError, "query timeout"), + 1222: (QueryTimeoutError, "lock request time out period exceeded"), + 102: (SQLParsingError, "SQL syntax error"), + 105: (SQLParsingError, "unclosed quotation mark"), + 156: (SQLParsingError, "incorrect syntax near keyword"), + 207: (SQLParsingError, "invalid column name"), + 208: (SQLParsingError, "invalid object name"), + 241: (DataError, "data conversion error"), + 242: (DataError, "datetime conversion out of range"), + 245: (DataError, "data conversion error"), + 2628: (DataError, "string or binary data would be truncated"), 8114: (DataError, "data conversion error"), + 8115: (DataError, "arithmetic overflow error"), + 8152: (DataError, "string or binary data would be truncated"), 1105: (OperationalError, "operational error"), } +def quote_bracket_identifier(identifier: str) -> str: + """Quote a T-SQL identifier with square brackets.""" + cleaned = identifier.strip() + if (cleaned.startswith("[") and cleaned.endswith("]")) or (cleaned.startswith('"') and cleaned.endswith('"')): + cleaned = cleaned[1:-1].replace("]]", "]").replace('""', '"') + return f"[{cleaned.replace(']', ']]')}]" + + +def escape_sql_literal(value: str) -> str: + """Escape single quotes inside a SQL string literal.""" + return value.replace("'", "''") + + +def split_schema_and_table(identifier: str, *, default_schema: str = "dbo") -> tuple[str, str]: + """Split a table reference into unquoted ``(schema, table)`` names.""" + cleaned = identifier.strip() + if not cleaned: + msg = "Table name must not be empty" + raise SQLSpecError(msg) + parts = split_qualified_identifier(cleaned, quote_chars='"', allow_bracket_quotes=True) + if not parts: + return default_schema, cleaned + if len(parts) == 1: + return default_schema, parts[0] + return parts[-2], parts[-1] + + +def format_identifier(identifier: str) -> str: + """Format a T-SQL identifier with bracket quoting.""" + cleaned = identifier.strip() + if not cleaned: + msg = "Table name must not be empty" + raise SQLSpecError(msg) + parts = split_qualified_identifier(cleaned, quote_chars='"', allow_bracket_quotes=True) + return ".".join(quote_bracket_identifier(part) for part in parts) + + +def build_insert_statement(table: str, columns: "list[str]") -> str: + """Build an mssql-python INSERT statement with positional qmark placeholders.""" + column_clause = ", ".join(quote_bracket_identifier(column) for column in columns) + placeholders = ", ".join("?" for _ in columns) + return f"INSERT INTO {format_identifier(table)} ({column_clause}) VALUES ({placeholders})" + + +def resolve_column_names( + description: "Sequence[Any] | None", column_name_cache: "dict[int, tuple[Any, list[str]]] | None" = None +) -> "list[str]": + """Resolve ordered column names from cursor metadata.""" + if not description: + return [] + if column_name_cache is None: + return [str(desc[0]) for desc in description] + cache_key = id(description) + cached = column_name_cache.get(cache_key) + if cached is not None and cached[0] is description: + return cached[1] + column_names = [str(desc[0]) for desc in description] + if len(column_name_cache) >= _COLUMN_CACHE_MAX_SIZE: + column_name_cache.pop(next(iter(column_name_cache))) + column_name_cache[cache_key] = (description, column_names) + return column_names + + +def collect_rows( + fetched_data: "Sequence[Any] | None", + description: "Sequence[Any] | None", + column_name_cache: "dict[int, tuple[Any, list[str]]] | None" = None, +) -> "tuple[list[tuple[Any, ...]], list[str], Literal['tuple']]": + """Collect mssql-python rows as plain tuples alongside column names.""" + column_names = resolve_column_names(description, column_name_cache) + if not fetched_data: + return [], column_names, "tuple" + return materialize_tuple_rows(fetched_data), column_names, "tuple" + + +def resolve_rowcount(cursor: Any) -> int: + """Resolve rowcount from an mssql-python cursor.""" + if not has_rowcount(cursor): + return 0 + rowcount = cursor.rowcount + if isinstance(rowcount, int) and rowcount >= 0: + return rowcount + return 0 + + +def resolve_many_rowcount(cursor: Any, parameters: Any, *, fallback_count: "int | None" = None) -> int: + """Resolve executemany rowcount using cursor metadata with payload fallback.""" + rowcount = resolve_rowcount(cursor) + if rowcount > 0: + return rowcount + if fallback_count is not None: + return fallback_count + if isinstance(parameters, Sized): + return len(parameters) + return 0 + + def extract_error_number(exc: BaseException | None) -> int | None: """Extract numeric SQL Server error code using fast attribute/string parsing before regex fallback.""" if exc is None: @@ -123,6 +251,10 @@ def extract_error_number(exc: BaseException | None) -> int | None: first_arg = exc.args[0] if isinstance(first_arg, int) and not isinstance(first_arg, bool) and first_arg != 0: return first_arg + if isinstance(first_arg, tuple) and first_arg: + nested = first_arg[0] + if isinstance(nested, int) and not isinstance(nested, bool) and nested != 0: + return nested if isinstance(first_arg, str): msg = first_arg start_idx = msg.rfind("(") @@ -144,9 +276,10 @@ def extract_error_number(exc: BaseException | None) -> int | None: return int(last_match[0] or last_match[1]) -def create_mapped_exception(error: Exception, *, logger: "Logger | None" = None) -> SQLSpecError: - """Map a mssql-python exception to SQLSpec's exception hierarchy.""" +def create_mapped_exception(error: BaseException, *, logger: "Logger | None" = None) -> SQLSpecError: + """Map a SQL Server exception to SQLSpec's exception hierarchy.""" error_number = extract_error_number(error) + if error_number == _MSSQL_CONSTRAINT_547: message = str(error) if "check constraint" in message.lower(): @@ -195,9 +328,22 @@ def apply_driver_features( statement_config: "StatementConfig", driver_features: "Mapping[str, Any] | None" ) -> "tuple[StatementConfig, dict[str, Any]]": """Merge mssql-python driver-feature defaults with caller overrides.""" - defaults: dict[str, Any] = {"use_pool": True, "json_serializer": to_json, "json_deserializer": from_json} - defaults.update(driver_features or {}) - return statement_config, defaults + features: dict[str, Any] = dict(driver_features) if driver_features else {} + features.setdefault("use_pool", True) + json_serializer = features.setdefault("json_serializer", to_json) + json_deserializer = features.setdefault("json_deserializer", from_json) + + if json_serializer is not None and ( + json_serializer is not to_json + or json_deserializer is not from_json + or statement_config.parameter_config.json_serializer is not to_json + ): + parameter_config = statement_config.parameter_config.with_json_serializers( + json_serializer, deserializer=json_deserializer + ) + statement_config = statement_config.replace(parameter_config=parameter_config) + + return statement_config, features def build_connection_config(params: dict[str, Any]) -> tuple[str, dict[str, Any]]: @@ -282,10 +428,16 @@ def build_connection_config(params: dict[str, Any]) -> tuple[str, dict[str, Any] continue parts.append(f"{option_name}={_format_connection_value(value)}") - extra = config.get("extra") + extra = config.pop("extra", None) if isinstance(extra, dict): parts.extend(f"{key}={_format_connection_value(value)}" for key, value in extra.items() if value is not None) + for remaining_key, remaining_value in list(config.items()): + if remaining_value is not None: + canonical_remaining_key = _CANONICAL_KEY_LOOKUP.get(remaining_key.lower(), remaining_key) + parts.append(f"{canonical_remaining_key}={_format_connection_value(remaining_value)}") + config.pop(remaining_key, None) + return ";".join(parts) + ";", connect_kwargs @@ -308,14 +460,19 @@ def build_profile() -> "DriverParameterProfile": ) -def build_statement_config(*, json_serializer: "Callable[[Any], str] | None" = None) -> "StatementConfig": +def build_statement_config( + *, json_serializer: "Callable[[Any], str] | None" = None, json_deserializer: "Callable[[str], Any] | None" = None +) -> "StatementConfig": """Construct the mssql-python statement configuration.""" return build_statement_config_from_profile( - driver_profile, statement_overrides={"dialect": "tsql"}, json_serializer=json_serializer or to_json + driver_profile, + statement_overrides={"dialect": "tsql"}, + json_serializer=json_serializer or to_json, + json_deserializer=json_deserializer or from_json, ) -def _constraint_exception_from_message(error: Exception) -> "SQLSpecError | None": +def _constraint_exception_from_message(error: BaseException) -> "SQLSpecError | None": """Classify SQL Server constraint messages when a driver omits the native error number.""" message = str(error) normalized = message.lower() diff --git a/sqlspec/adapters/mssql_python/driver.py b/sqlspec/adapters/mssql_python/driver.py index 8f3493de5..a4603fe26 100644 --- a/sqlspec/adapters/mssql_python/driver.py +++ b/sqlspec/adapters/mssql_python/driver.py @@ -1,6 +1,7 @@ -"""mssql-python sync and async drivers.""" +"""mssql-python sync driver.""" import contextlib +from collections.abc import Sized from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast from sqlspec.adapters.mssql_python._typing import ( @@ -8,13 +9,18 @@ MssqlPythonCursor, MssqlPythonError, MssqlPythonRawCursor, - MssqlPythonSessionContext, ) from sqlspec.adapters.mssql_python.core import ( + collect_rows, create_mapped_exception, default_statement_config, driver_profile, + format_identifier, materialize_tuple_rows, + quote_bracket_identifier, + resolve_column_names, + resolve_many_rowcount, + resolve_rowcount, ) from sqlspec.adapters.mssql_python.data_dictionary import MssqlPythonSyncDataDictionary from sqlspec.core import ( @@ -33,29 +39,21 @@ from sqlspec.exceptions import SQLSpecError from sqlspec.utils.arrow_helpers import arrow_reader_with_deferred_close from sqlspec.utils.logging import get_logger -from sqlspec.utils.module_loader import ensure_pyarrow -from sqlspec.utils.text import split_qualified_identifier +from sqlspec.utils.module_loader import ensure_pyarrow, import_optional if TYPE_CHECKING: from collections.abc import Iterable from sqlspec.builder import QueryBuilder - from sqlspec.core import SQL, ArrowResult, Statement, StatementConfig, StatementFilter - from sqlspec.driver import ExecutionResult + from sqlspec.core import SQL, ArrowResult, SQLResult, Statement, StatementConfig, StatementFilter + from sqlspec.driver import CachedQuery, ExecutionResult from sqlspec.storage import StorageBridgeJob, StorageDestination, StorageFormat, StorageTelemetry from sqlspec.typing import ArrowRecordBatchReader, ArrowReturnFormat, StatementParameters -__all__ = ( - "MssqlPythonBulkCopyResult", - "MssqlPythonCursor", - "MssqlPythonDriver", - "MssqlPythonExceptionHandler", - "MssqlPythonSessionContext", -) +__all__ = ("MssqlPythonBulkCopyResult", "MssqlPythonDriver", "MssqlPythonExceptionHandler") logger = get_logger("sqlspec.adapters.mssql_python") -_COLUMN_CACHE_MAX_SIZE = 256 class MssqlPythonBulkCopyResult(TypedDict): @@ -121,7 +119,7 @@ def fetch_chunk(self) -> "list[dict[str, Any]]": return [] column_names = self._column_names if column_names is None: - column_names = _resolve_column_names(cursor.description, self._driver._column_name_cache) + column_names = resolve_column_names(cursor.description, self._driver._column_name_cache) self._column_names = column_names return rows_to_dicts(rows, column_names) @@ -173,23 +171,26 @@ def dispatch_execute(self, cursor: "MssqlPythonRawCursor", statement: "SQL") -> _execute_cursor(cursor, sql, prepared_parameters) if statement.returns_rows(): - fetched = materialize_tuple_rows(cursor.fetchall()) - column_names = _resolve_column_names(cursor.description, self._column_name_cache) + fetched, column_names, row_format = collect_rows( + cursor.fetchall(), cursor.description, self._column_name_cache + ) return self.create_execution_result( cursor, selected_data=fetched, column_names=column_names, data_row_count=len(fetched), is_select_result=True, - row_format="tuple", + row_format=row_format, ) - return self.create_execution_result(cursor, rowcount_override=_cursor_rowcount(cursor)) + return self.create_execution_result(cursor, rowcount_override=resolve_rowcount(cursor)) def dispatch_execute_many(self, cursor: "MssqlPythonRawCursor", statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) + parameter_count = len(prepared_parameters) if isinstance(prepared_parameters, Sized) else None cursor.executemany(sql, cast("Any", prepared_parameters)) - return self.create_execution_result(cursor, rowcount_override=_cursor_rowcount(cursor), is_many_result=True) + affected_rows = resolve_many_rowcount(cursor, prepared_parameters, fallback_count=parameter_count) + return self.create_execution_result(cursor, rowcount_override=affected_rows, is_many_result=True) def dispatch_execute_script(self, cursor: "MssqlPythonRawCursor", statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) @@ -203,12 +204,12 @@ def dispatch_execute_script(self, cursor: "MssqlPythonRawCursor", statement: "SQ ) def collect_rows(self, cursor: "MssqlPythonRawCursor", fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": - column_names = _resolve_column_names(cursor.description, self._column_name_cache) + column_names = resolve_column_names(cursor.description, self._column_name_cache) rows = materialize_tuple_rows(fetched) return rows, column_names, len(rows) def resolve_rowcount(self, cursor: "MssqlPythonRawCursor") -> int: - return _cursor_rowcount(cursor) + return resolve_rowcount(cursor) def begin(self) -> None: if self._transaction_active: @@ -266,31 +267,45 @@ def rollback_to_savepoint(self, name: str) -> None: def set_migration_session_schema(self, schema: str) -> None: """Point the database user's default schema at the migration schema, remembering the prior one.""" - with self.with_cursor(self.connection) as cursor: + exc_handler = self.handle_database_exceptions() + with exc_handler, self.with_cursor(self.connection) as cursor: if self._migration_schema_restore is None: _execute_cursor(cursor, "SELECT USER_NAME() AS user_name, SCHEMA_NAME() AS schema_name;", None) row: Any = cursor.fetchone() - user_name, current_schema = row[0], row[1] + if row is None: + return + user_name, current_schema = ( + (row["user_name"], row["schema_name"]) if isinstance(row, dict) else (row[0], row[1]) + ) + resolved_schema = str(current_schema or "dbo") _execute_cursor(cursor, _alter_default_schema_sql(str(user_name), schema), None) - self._migration_schema_restore = (str(user_name), str(current_schema)) - return - _execute_cursor(cursor, _alter_default_schema_sql(self._migration_schema_restore[0], schema), None) + self._migration_schema_restore = (str(user_name), resolved_schema) + else: + _execute_cursor(cursor, _alter_default_schema_sql(self._migration_schema_restore[0], schema), None) + self._check_pending_exception(exc_handler) def reset_migration_session_schema(self) -> None: """Restore the user's default schema captured by set_migration_session_schema and commit it.""" if self._migration_schema_restore is None: return user_name, previous_schema = self._migration_schema_restore - with self.with_cursor(self.connection) as cursor: - _execute_cursor(cursor, _alter_default_schema_sql(user_name, previous_schema), None) - self.connection.commit() + exc_handler = self.handle_database_exceptions() + with exc_handler: + with self.with_cursor(self.connection) as cursor: + _execute_cursor(cursor, _alter_default_schema_sql(user_name, previous_schema), None) + self.connection.commit() + self._check_pending_exception(exc_handler) self._migration_schema_restore = None def has_schema(self, schema: str) -> bool: """Return whether the specified schema exists.""" - with self.with_cursor(self.connection) as cursor: + result = False + exc_handler = self.handle_database_exceptions() + with exc_handler, self.with_cursor(self.connection) as cursor: _execute_cursor(cursor, "SELECT 1 FROM sys.schemas WHERE name = ?", (schema,)) - return cursor.fetchone() is not None + result = cursor.fetchone() is not None + self._check_pending_exception(exc_handler) + return result def select_to_arrow( self, @@ -436,7 +451,7 @@ def load_from_arrow( """ self._require_capability("arrow_import_enabled") ensure_pyarrow() - import pyarrow as pa + pa = cast("Any", import_optional("pyarrow")) if batch_size < 0 or timeout < 0: msg = "batch_size and timeout must be non-negative" @@ -527,6 +542,16 @@ def load_from_storage( arrow_table, inbound = self._read_storage_arrow(source, file_format=file_format) return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound) + def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + if not params: + statement = self._cached_statement( + sql, params, cached, None, params_are_simple=True, compiled_sql=cached.compiled_sql + ) + return self._execute_cached_statement(statement) + return super()._execute_cache_hit(sql, params, cached) + def _connection_in_transaction(self) -> bool: return self._transaction_active @@ -549,35 +574,16 @@ def _quote_mssql_table(table: str) -> str: setting is, unlike double quotes which parse as a string literal when it is off. """ - return ".".join(_quote_tsql_identifier(part) for part in split_qualified_identifier(table)) + return format_identifier(table) def _execute_cursor(cursor: "MssqlPythonRawCursor", sql: str, parameters: Any) -> None: - if parameters is None: + if parameters is None or parameters == (): cursor.execute(sql) else: cursor.execute(sql, parameters) -def _cursor_rowcount(cursor: "MssqlPythonRawCursor") -> int: - rowcount = getattr(cursor, "rowcount", 0) - return rowcount if isinstance(rowcount, int) and rowcount > 0 else 0 - - -def _resolve_column_names(description: Any, cache: "dict[int, tuple[Any, list[str]]]") -> list[str]: - if not description: - return [] - cache_key = id(description) - cached = cache.get(cache_key) - if cached is not None and cached[0] is description: - return cached[1] - column_names = [desc[0] for desc in description] - if len(cache) >= _COLUMN_CACHE_MAX_SIZE: - cache.pop(next(iter(cache))) - cache[cache_key] = (description, column_names) - return column_names - - def _cursor_arrow_reader( cursor: "MssqlPythonRawCursor", arrow_kwargs: "dict[str, int]" ) -> "ArrowRecordBatchReader | None": @@ -590,16 +596,11 @@ def _cursor_arrow_reader( def _coerce_bulk_copy_result(result: Any, cursor: "MssqlPythonRawCursor") -> MssqlPythonBulkCopyResult: if isinstance(result, dict): return cast("MssqlPythonBulkCopyResult", dict(result)) - return {"rows_copied": _cursor_rowcount(cursor)} - - -def _quote_tsql_identifier(identifier: str) -> str: - """Bracket-quote an identifier so the statement is valid regardless of the session's QUOTED_IDENTIFIER setting.""" - return f"[{identifier.replace(']', ']]')}]" + return {"rows_copied": resolve_rowcount(cursor)} def _alter_default_schema_sql(user_name: str, schema: str) -> str: - return f"ALTER USER {_quote_tsql_identifier(user_name)} WITH DEFAULT_SCHEMA = {_quote_tsql_identifier(schema)};" + return f"ALTER USER {quote_bracket_identifier(user_name)} WITH DEFAULT_SCHEMA = {quote_bracket_identifier(schema)};" -register_driver_profile("mssql_python", driver_profile, allow_override=True) +register_driver_profile("mssql_python", driver_profile) diff --git a/sqlspec/adapters/mssql_python/events/store.py b/sqlspec/adapters/mssql_python/events/store.py index b3d273603..5e2517c16 100644 --- a/sqlspec/adapters/mssql_python/events/store.py +++ b/sqlspec/adapters/mssql_python/events/store.py @@ -1,13 +1,12 @@ """mssql-python event queue store with T-SQL-specific DDL.""" from sqlspec.adapters.mssql_python.config import MssqlPythonConfig +from sqlspec.adapters.mssql_python.core import escape_sql_literal, quote_bracket_identifier, split_schema_and_table from sqlspec.extensions.events import BaseEventQueueStore -from sqlspec.utils.text import split_qualified_identifier __all__ = ("MssqlPythonEventQueueStore",) _NVARCHAR_MAX_THRESHOLD = 4000 -_QUALIFIED_IDENTIFIER_MIN_PARTS = 2 class MssqlPythonEventQueueStore(BaseEventQueueStore[MssqlPythonConfig]): @@ -30,28 +29,19 @@ def _timestamp_default(self) -> str: return "SYSUTCDATETIME()" def _wrap_create_statement(self, statement: str, object_type: str) -> str: + object_name = escape_sql_literal(_object_name(self.table_name)) if object_type == "table": - return f"IF OBJECT_ID(N'{_object_name(self.table_name)}', N'U') IS NULL BEGIN {statement}; END" + return f"IF OBJECT_ID(N'{object_name}', N'U') IS NULL BEGIN {statement}; END" if object_type == "index": - return f"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'{self._index_name()}' AND object_id = OBJECT_ID(N'{_object_name(self.table_name)}')) BEGIN {statement}; END" + index_name = escape_sql_literal(self._index_name()) + return f"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'{index_name}' AND object_id = OBJECT_ID(N'{object_name}')) BEGIN {statement}; END" return statement def _wrap_drop_statement(self, statement: str) -> str: - return f"IF OBJECT_ID(N'{_object_name(self.table_name)}', N'U') IS NOT NULL {statement};" - - -def _split_table_name(table_name: str) -> tuple[str, str]: - parts = split_qualified_identifier(table_name, quote_chars='"') - if len(parts) < _QUALIFIED_IDENTIFIER_MIN_PARTS: - return "dbo", parts[0] if parts else table_name - schema_name = ".".join(parts[:-1]) - return schema_name or "dbo", parts[-1] + object_name = escape_sql_literal(_object_name(self.table_name)) + return f"IF OBJECT_ID(N'{object_name}', N'U') IS NOT NULL {statement};" def _object_name(table_name: str) -> str: - schema_name, bare_table_name = _split_table_name(table_name) - return f"{_quote_bracket_identifier(schema_name)}.{_quote_bracket_identifier(bare_table_name)}" - - -def _quote_bracket_identifier(identifier: str) -> str: - return f"[{identifier.replace(']', ']]')}]" + schema_name, bare_table_name = split_schema_and_table(table_name) + return f"{quote_bracket_identifier(schema_name)}.{quote_bracket_identifier(bare_table_name)}" diff --git a/sqlspec/adapters/mssql_python/litestar/store.py b/sqlspec/adapters/mssql_python/litestar/store.py index 0e9373ef6..33c7a9261 100644 --- a/sqlspec/adapters/mssql_python/litestar/store.py +++ b/sqlspec/adapters/mssql_python/litestar/store.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any from sqlspec.adapters.mssql_python._typing import MssqlPythonCursor +from sqlspec.adapters.mssql_python.core import escape_sql_literal, quote_bracket_identifier, split_schema_and_table from sqlspec.extensions.litestar.store import BaseSQLSpecStore from sqlspec.utils.sync_tools import async_ @@ -59,15 +60,18 @@ async def delete_expired(self) -> int: def _table_ddl(self) -> str: """Get SQL Server CREATE TABLE SQL with idempotent guards.""" + schema_name, table_name = split_schema_and_table(self._table_name) + table_ref = _table_ref(self._table_name) + index_name = quote_bracket_identifier(f"IX_{table_name}_expires_at") return f""" IF NOT EXISTS ( SELECT 1 FROM sys.tables - WHERE name = N'{self._table_name}' - AND schema_id = SCHEMA_ID(N'dbo') + WHERE name = N'{escape_sql_literal(table_name)}' + AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}') ) BEGIN - CREATE TABLE {self._table_name} ( + CREATE TABLE {table_ref} ( session_id NVARCHAR(255) PRIMARY KEY, data VARBINARY(MAX) NOT NULL, expires_at DATETIME2(6) NULL, @@ -75,15 +79,16 @@ def _table_ddl(self) -> str: updated_at DATETIME2(6) NOT NULL DEFAULT SYSUTCDATETIME() ); - CREATE INDEX IX_{self._table_name}_expires_at - ON {self._table_name}(expires_at) + CREATE INDEX {index_name} + ON {table_ref}(expires_at) WHERE expires_at IS NOT NULL; END; """ def _drop_table_sql(self) -> "list[str]": """Get SQL Server DROP TABLE statements.""" - return [f"IF OBJECT_ID(N'dbo.{self._table_name}', N'U') IS NOT NULL DROP TABLE dbo.{self._table_name};"] + table_ref = _table_ref(self._table_name) + return [f"IF OBJECT_ID(N'{escape_sql_literal(table_ref)}', N'U') IS NOT NULL DROP TABLE {table_ref};"] def _create_table(self) -> None: with self._config.provide_session() as driver: @@ -92,8 +97,9 @@ def _create_table(self) -> None: self._log_table_created() def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": + table_ref = _table_ref(self._table_name) sql = f""" - SELECT data, expires_at FROM {self._table_name} + SELECT data, expires_at FROM {table_ref} WHERE session_id = ? AND (expires_at IS NULL OR expires_at > SYSUTCDATETIME()) """ @@ -112,7 +118,7 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | with MssqlPythonCursor(conn) as update_cursor: update_cursor.execute( f""" - UPDATE {self._table_name} + UPDATE {table_ref} SET expires_at = ?, updated_at = SYSUTCDATETIME() WHERE session_id = ? """, @@ -125,8 +131,9 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None: data = self._value_to_bytes(value) expires_at = self._calculate_expires_at(expires_in) + table_ref = _table_ref(self._table_name) sql = f""" - MERGE INTO {self._table_name} AS target + MERGE INTO {table_ref} AS target USING (SELECT ? AS session_id, CONVERT(VARBINARY(MAX), ?) AS data, ? AS expires_at) AS src ON target.session_id = src.session_id WHEN MATCHED THEN @@ -143,20 +150,23 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No conn.commit() def _delete(self, key: str) -> None: + table_ref = _table_ref(self._table_name) with self._config.provide_connection() as conn, MssqlPythonCursor(conn) as cursor: - cursor.execute(f"DELETE FROM {self._table_name} WHERE session_id = ?", (key,)) + cursor.execute(f"DELETE FROM {table_ref} WHERE session_id = ?", (key,)) conn.commit() def _delete_all(self) -> None: + table_ref = _table_ref(self._table_name) with self._config.provide_connection() as conn, MssqlPythonCursor(conn) as cursor: - cursor.execute(f"TRUNCATE TABLE {self._table_name}") + cursor.execute(f"TRUNCATE TABLE {table_ref}") conn.commit() self._log_delete_all() def _exists(self, key: str) -> bool: + table_ref = _table_ref(self._table_name) sql = f""" SELECT 1 - FROM {self._table_name} + FROM {table_ref} WHERE session_id = ? AND (expires_at IS NULL OR expires_at > SYSUTCDATETIME()) """ @@ -165,8 +175,9 @@ def _exists(self, key: str) -> bool: return cursor.fetchone() is not None def _expires_in(self, key: str) -> "int | None": + table_ref = _table_ref(self._table_name) with self._config.provide_connection() as conn, MssqlPythonCursor(conn) as cursor: - cursor.execute(f"SELECT expires_at FROM {self._table_name} WHERE session_id = ?", (key,)) + cursor.execute(f"SELECT expires_at FROM {table_ref} WHERE session_id = ?", (key,)) row = cursor.fetchone() if row is None: @@ -178,8 +189,9 @@ def _expires_in(self, key: str) -> "int | None": return max(0, int(remaining.total_seconds())) def _delete_expired(self) -> int: + table_ref = _table_ref(self._table_name) sql = f""" - DELETE FROM {self._table_name} + DELETE FROM {table_ref} WHERE expires_at IS NOT NULL AND expires_at < SYSUTCDATETIME() """ @@ -192,6 +204,11 @@ def _delete_expired(self) -> int: return count +def _table_ref(table_name: str) -> str: + schema_name, bare_table_name = split_schema_and_table(table_name) + return f"{quote_bracket_identifier(schema_name)}.{quote_bracket_identifier(bare_table_name)}" + + def _row_value(row: object, key: str, index: int) -> Any: """Return a value from dict-like or sequence-like driver rows.""" if isinstance(row, dict): diff --git a/sqlspec/adapters/mssql_python/migrations.py b/sqlspec/adapters/mssql_python/migrations.py index 0e4351ff5..c2e31d5d0 100644 --- a/sqlspec/adapters/mssql_python/migrations.py +++ b/sqlspec/adapters/mssql_python/migrations.py @@ -5,12 +5,17 @@ from contextlib import suppress from typing import TYPE_CHECKING +from sqlspec.adapters.mssql_python.core import ( + escape_sql_literal, + format_identifier, + quote_bracket_identifier, + split_schema_and_table, +) from sqlspec.builder import CreateTable, sql from sqlspec.migrations.tracker import SyncMigrationTracker from sqlspec.migrations.version import parse_version from sqlspec.observability import resolve_db_system from sqlspec.utils.logging import get_logger, log_with_context -from sqlspec.utils.text import split_qualified_identifier if TYPE_CHECKING: from sqlspec.driver import SyncDriverAdapterBase @@ -18,7 +23,6 @@ __all__ = ("MssqlPythonSyncMigrationTracker",) logger = get_logger("sqlspec.migrations.mssql_python") -_QUALIFIED_IDENTIFIER_MIN_PARTS = 2 class MssqlPythonMigrationTrackerMixin: @@ -46,9 +50,9 @@ def _tracking_table_ddl(self) -> CreateTable: def _idempotent_tracking_table_ddl_text(self) -> str: """Wrap CREATE TABLE in a T-SQL sys.tables existence probe.""" - schema_name, table_name = _split_schema_table(self.version_table) + schema_name, table_name = split_schema_and_table(self.version_table) create_sql = self._tracking_table_ddl_text().rstrip().rstrip(";") - return f"IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = '{_escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID('{_escape_sql_literal(schema_name)}')) BEGIN {create_sql}; END;" + return f"IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = '{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID('{escape_sql_literal(schema_name)}')) BEGIN {create_sql}; END;" def _tracking_table_ddl_text(self) -> str: """Render CREATE TABLE text without routing SQL Server types through sqlglot.""" @@ -58,19 +62,19 @@ def _tracking_table_ddl_text(self) -> str: not_null_clause = " NOT NULL" if column_def.not_null else "" primary_key_clause = " PRIMARY KEY" if column_def.primary_key else "" column_lines.append( - f" {column_def.name} {column_def.dtype}{primary_key_clause}{default_clause}{not_null_clause}" + f" {quote_bracket_identifier(column_def.name)} {column_def.dtype}{primary_key_clause}{default_clause}{not_null_clause}" ) - return f"CREATE TABLE {self.version_table} (\n" + ",\n".join(column_lines) + "\n)" + return f"CREATE TABLE {format_identifier(self.version_table)} (\n" + ",\n".join(column_lines) + "\n)" def _existing_columns_query(self) -> str: """Return T-SQL query text for migration tracking table columns.""" - schema_name, table_name = _split_schema_table(self.version_table) + schema_name, table_name = split_schema_and_table(self.version_table) return f""" SELECT c.name AS column_name FROM sys.columns c INNER JOIN sys.tables t ON c.object_id = t.object_id - WHERE t.name = '{_escape_sql_literal(table_name)}' - AND t.schema_id = SCHEMA_ID('{_escape_sql_literal(schema_name)}') + WHERE t.name = '{escape_sql_literal(table_name)}' + AND t.schema_id = SCHEMA_ID('{escape_sql_literal(schema_name)}') """ def _add_column_statement_text(self, column_name: str) -> str | None: @@ -80,8 +84,8 @@ def _add_column_statement_text(self, column_name: str) -> str | None: if column_def is None: return None default_clause = f" DEFAULT {column_def.default}" if column_def.default else "" - nullable_clause = " NOT NULL" if column_def.not_null else " NULL" - return f"ALTER TABLE {self.version_table} ADD {column_def.name} {column_def.dtype}{default_clause}{nullable_clause};" + nullable_clause = " NOT NULL" if (column_def.not_null and column_def.default is not None) else " NULL" + return f"ALTER TABLE {format_identifier(self.version_table)} ADD {quote_bracket_identifier(column_def.name)} {column_def.dtype}{default_clause}{nullable_clause};" class MssqlPythonSyncMigrationTracker(MssqlPythonMigrationTrackerMixin, SyncMigrationTracker): @@ -90,11 +94,18 @@ class MssqlPythonSyncMigrationTracker(MssqlPythonMigrationTrackerMixin, SyncMigr def ensure_tracking_table(self, driver: "SyncDriverAdapterBase") -> None: """Create the migration tracking table if it does not exist.""" driver.execute_script(self._idempotent_tracking_table_ddl_text()) - driver.commit() + self._commit_if_needed(driver) self._migrate_schema_if_needed(driver) def record_migration( - self, driver: "SyncDriverAdapterBase", version: str, description: str, execution_time_ms: int, checksum: str + self, + driver: "SyncDriverAdapterBase", + version: str, + description: str, + execution_time_ms: int, + checksum: str, + *, + commit: bool = True, ) -> None: """Record a successfully applied migration with T-SQL-compatible metadata.""" parsed_version = parse_version(version) @@ -112,6 +123,13 @@ def record_migration( os.environ.get("USER", "unknown"), ) ) + if commit: + self._commit_if_needed(driver) + + def _commit_if_needed(self, driver: "SyncDriverAdapterBase") -> None: + if hasattr(driver, "driver_features"): + self._safe_commit(driver) + return driver.commit() def _migrate_schema_if_needed(self, driver: "SyncDriverAdapterBase") -> None: @@ -124,7 +142,7 @@ def _migrate_schema_if_needed(self, driver: "SyncDriverAdapterBase") -> None: return for column_name in sorted(missing_columns): self._add_column(driver, column_name) - driver.commit() + self._commit_if_needed(driver) except Exception as exc: with suppress(Exception): driver.rollback() @@ -145,17 +163,3 @@ def _add_column(self, driver: "SyncDriverAdapterBase", column_name: str) -> None if add_column_sql is None: return driver.execute_script(add_column_sql) - - -def _escape_sql_literal(value: str) -> str: - """Escape a string for inclusion in a T-SQL string literal.""" - return value.replace("'", "''") - - -def _split_schema_table(table_name: str) -> tuple[str, str]: - """Split a schema-qualified table name into schema and table parts.""" - parts = split_qualified_identifier(table_name, quote_chars='"') - if len(parts) < _QUALIFIED_IDENTIFIER_MIN_PARTS: - return "dbo", parts[0] if parts else table_name - schema_name = ".".join(parts[:-1]) - return schema_name or "dbo", parts[-1] diff --git a/sqlspec/adapters/mssql_python/pool.py b/sqlspec/adapters/mssql_python/pool.py index 9d9a15faa..27a84199d 100644 --- a/sqlspec/adapters/mssql_python/pool.py +++ b/sqlspec/adapters/mssql_python/pool.py @@ -1,5 +1,6 @@ """mssql-python pool facade.""" +import threading import warnings from typing import TYPE_CHECKING, Any, cast @@ -11,6 +12,7 @@ __all__ = ("MssqlPythonConnectionPool",) _POOLING_PARAMS: "tuple[int, int, bool] | None" = None +_POOL_LOCK = threading.Lock() class MssqlPythonConnectionPool: @@ -43,17 +45,20 @@ def __init__( self.enabled = enabled self.on_connection_create = on_connection_create self._closed = False + if not enabled: + return global _POOLING_PARAMS - new_params = (max_size, idle_timeout, enabled) - if _POOLING_PARAMS is not None and new_params != _POOLING_PARAMS: - warnings.warn( - f"mssql-python pooling config already set to {_POOLING_PARAMS}; " - f"overwriting with {new_params}. Only one pool config per process is supported.", - stacklevel=2, - ) - if _POOLING_PARAMS is None or new_params != _POOLING_PARAMS: - MSSQL_PYTHON_MODULE.pooling(max_size=max_size, idle_timeout=idle_timeout, enabled=enabled) - _POOLING_PARAMS = new_params + new_params = (max_size, idle_timeout, True) + with _POOL_LOCK: + if _POOLING_PARAMS is not None and new_params != _POOLING_PARAMS: + warnings.warn( + f"mssql-python pooling config already set to {_POOLING_PARAMS}; " + f"overwriting with {new_params}. Only one pool config per process is supported.", + stacklevel=2, + ) + if _POOLING_PARAMS is None or new_params != _POOLING_PARAMS: + MSSQL_PYTHON_MODULE.pooling(max_size=max_size, idle_timeout=idle_timeout, enabled=True) + _POOLING_PARAMS = new_params def acquire(self) -> "MssqlPythonConnection": if self._closed: diff --git a/sqlspec/adapters/mssql_python/type_converter.py b/sqlspec/adapters/mssql_python/type_converter.py index 7099e801a..e5fc68363 100644 --- a/sqlspec/adapters/mssql_python/type_converter.py +++ b/sqlspec/adapters/mssql_python/type_converter.py @@ -3,13 +3,14 @@ from typing import TYPE_CHECKING, Any, Final, cast from uuid import UUID -from sqlspec.utils.module_loader import ensure_pyarrow +from sqlspec.utils.module_loader import ensure_pyarrow, import_optional from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: from collections.abc import Callable - import pyarrow as pa + from pyarrow import DataType + __all__ = ("MssqlPythonTypeConverter", "mssql_type_to_arrow") @@ -74,7 +75,7 @@ def coerce_read_value(self, value: "Any") -> "Any": return value -def mssql_type_to_arrow(sql_type: str, *, precision: int | None = None, scale: int | None = None) -> "pa.DataType": +def mssql_type_to_arrow(sql_type: str, *, precision: int | None = None, scale: int | None = None) -> "DataType": """Resolve a T-SQL type name to an Arrow data type.""" normalized_type = sql_type.lower().split("(", 1)[0].strip() if normalized_type in {"decimal", "numeric"} and precision is not None and scale is not None: @@ -86,8 +87,7 @@ def mssql_type_to_arrow(sql_type: str, *, precision: int | None = None, scale: i return _arrow_type(name, args, kwargs) -def _arrow_type(name: str, args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None) -> "pa.DataType": +def _arrow_type(name: str, args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None) -> "DataType": ensure_pyarrow() - import pyarrow as pa - - return cast("pa.DataType", getattr(pa, name)(*args, **(kwargs or {}))) + pyarrow_module = import_optional("pyarrow") + return cast("DataType", getattr(pyarrow_module, name)(*args, **(kwargs or {}))) diff --git a/sqlspec/adapters/mysql_common.py b/sqlspec/adapters/mysql_common.py index fc32353d0..4b0844adb 100644 --- a/sqlspec/adapters/mysql_common.py +++ b/sqlspec/adapters/mysql_common.py @@ -1,6 +1,7 @@ """Shared MySQL-family adapter helpers, constants, and error mappings.""" from collections.abc import Callable, Sequence, Sized +from datetime import timedelta from typing import TYPE_CHECKING, Any, Final, Literal from sqlspec.driver import rows_to_dicts @@ -36,6 +37,7 @@ "MYSQL_CR_SERVER_LOST", "MYSQL_CR_SSL_CONNECTION_ERROR", "MYSQL_CR_UNKNOWN_HOST", + "MYSQL_DATA_ERROR_DISPATCH", "MYSQL_ER_ACCESS_DENIED", "MYSQL_ER_CHECK_CONSTRAINT_VIOLATED", "MYSQL_ER_DBACCESS_DENIED", @@ -53,6 +55,7 @@ "bool_to_int", "build_insert_statement", "build_load_data_statement", + "can_use_local_infile_records", "collect_rows", "collect_stream_rows", "create_mapped_exception", @@ -105,6 +108,8 @@ } MYSQL_CONSTRAINT_ERROR_DISPATCH: Final[dict[int, tuple[type[SQLSpecError], str]]] = { MYSQL_ER_DUP_ENTRY: (UniqueViolationError, "unique constraint violation"), + 1169: (UniqueViolationError, "unique constraint violation"), + 1586: (UniqueViolationError, "unique constraint violation"), 1216: (ForeignKeyViolationError, "foreign key constraint violation"), 1217: (ForeignKeyViolationError, "foreign key constraint violation"), 1451: (ForeignKeyViolationError, "foreign key constraint violation"), @@ -112,6 +117,7 @@ 1048: (NotNullViolationError, "not-null constraint violation"), MYSQL_ER_NO_DEFAULT_FOR_FIELD: (NotNullViolationError, "not-null constraint violation"), MYSQL_ER_CHECK_CONSTRAINT_VIOLATED: (CheckViolationError, "check constraint violation"), + 4025: (CheckViolationError, "check constraint violation"), } MYSQL_ACCESS_ERROR_DISPATCH: Final[dict[int, tuple[type[SQLSpecError], str]]] = { MYSQL_ER_DBACCESS_DENIED: (PermissionDeniedError, "access denied"), @@ -121,14 +127,32 @@ MYSQL_TRANSACTION_ERROR_DISPATCH: Final[dict[int, tuple[type[SQLSpecError], str]]] = { MYSQL_ER_LOCK_DEADLOCK: (DeadlockError, "deadlock detected"), MYSQL_ER_LOCK_WAIT_TIMEOUT: (QueryTimeoutError, "lock wait timeout"), + 1317: (QueryTimeoutError, "query execution interrupted"), + 1969: (QueryTimeoutError, "query execution timeout"), + 3024: (QueryTimeoutError, "query execution timeout"), } MYSQL_CONNECTION_ERROR_DISPATCH: Final[dict[int, tuple[type[SQLSpecError], str]]] = { MYSQL_CR_SERVER_LOST: (ConnectionTimeoutError, "connection lost"), MYSQL_CR_CONNECTION_ERROR: (DatabaseConnectionError, "connection error"), MYSQL_CR_CONN_HOST_ERROR: (DatabaseConnectionError, "connection error"), + 2004: (DatabaseConnectionError, "connection error"), MYSQL_CR_UNKNOWN_HOST: (DatabaseConnectionError, "connection error"), MYSQL_CR_SERVER_GONE_ERROR: (DatabaseConnectionError, "connection error"), MYSQL_CR_SSL_CONNECTION_ERROR: (DatabaseConnectionError, "ssl connection error"), + 2055: (DatabaseConnectionError, "connection error"), + 4031: (DatabaseConnectionError, "connection error"), +} +MYSQL_DATA_ERROR_DISPATCH: Final[dict[int, tuple[type[SQLSpecError], str]]] = { + 1264: (DataError, "data error"), + 1265: (DataError, "data error"), + 1292: (DataError, "data error"), + 1366: (DataError, "data error"), + 1406: (DataError, "data error"), + 1411: (DataError, "data error"), + 3140: (DataError, "data error"), + 3141: (DataError, "data error"), + 3142: (DataError, "data error"), + 3143: (DataError, "data error"), } @@ -150,6 +174,38 @@ def build_insert_statement(table: str, columns: "list[str]") -> str: return f"INSERT INTO {format_identifier(table)} ({column_clause}) VALUES ({placeholders})" +def can_use_local_infile_records(arrow_table: Any, records: "Sequence[Sequence[Any]]") -> bool: + """Return whether Arrow records can be safely serialized via MySQL LOCAL INFILE TSV.""" + schema = getattr(arrow_table, "schema", None) + if schema is not None: + all_safe_types = True + for field in schema: + field_type = getattr(field, "type", None) + type_str = str(field_type).lower() if field_type is not None else "" + if "binary" in type_str or "duration" in type_str: + return False + if not type_str or not type_str.startswith(( + "int", + "uint", + "float", + "double", + "halffloat", + "bool", + "string", + "large_string", + "utf8", + "large_utf8", + "date", + "timestamp", + "time", + "decimal", + )): + all_safe_types = False + if all_safe_types: + return True + return not any(isinstance(value, (bytes, bytearray, memoryview, timedelta)) for row in records for value in row) + + def encode_records_for_local_infile(records: "list[tuple[Any, ...]]") -> bytes: """Encode row tuples into MySQL LOCAL INFILE TSV byte payload.""" lines: list[str] = [] @@ -264,78 +320,6 @@ def detect_json_columns( return detect_json_columns_from_description(description, json_type_codes) -def _deserialize_json_value(value: Any, deserializer: "Callable[[Any], Any]", *, logger: Any | None = None) -> Any: - """Safely decode JSON column values using the specified deserializer.""" - if value is None: - return None - if isinstance(value, (dict, list)): - return value - if isinstance(value, (bytes, bytearray, memoryview)): - try: - raw_bytes = bytes(value) if isinstance(value, (bytearray, memoryview)) else value - return deserializer(raw_bytes) - except Exception: - if logger is not None: - logger.debug("Failed to deserialize JSON column", exc_info=True) - return value - if isinstance(value, str): - try: - return deserializer(value) - except Exception: - if logger is not None: - logger.debug("Failed to deserialize JSON column", exc_info=True) - return value - return value - - -def _deserialize_json_dict_rows( - column_names: "Sequence[str]", - rows: "Sequence[dict[str, Any]]", - json_indexes: "list[int]", - deserializer: "Callable[[Any], Any]", - *, - logger: Any | None = None, -) -> "list[dict[str, Any]]": - """Deserialize JSON fields in dictionary rows in-place.""" - if not json_indexes: - return rows if isinstance(rows, list) else list(rows) - json_columns = [column_names[idx] for idx in json_indexes if idx < len(column_names)] - for row in rows: - for column in json_columns: - if column in row and row[column] is not None: - row[column] = _deserialize_json_value(row[column], deserializer, logger=logger) - return rows if isinstance(rows, list) else list(rows) - - -def _deserialize_json_tuple_rows( - rows: "Sequence[tuple[Any, ...]]", - json_indexes: "list[int]", - deserializer: "Callable[[Any], Any]", - *, - logger: Any | None = None, -) -> "list[tuple[Any, ...]]": - """Deserialize JSON fields in tuple rows using sparse index iteration and lazy row mutation.""" - if not json_indexes: - return rows if isinstance(rows, list) else list(rows) - result: list[tuple[Any, ...]] = [] - for row in rows: - row_len = len(row) - row_list: list[Any] | None = None - for idx in json_indexes: - if idx >= row_len: - continue - raw_val = row[idx] - if raw_val is None: - continue - decoded = _deserialize_json_value(raw_val, deserializer, logger=logger) - if decoded is not raw_val: - if row_list is None: - row_list = list(row) - row_list[idx] = decoded - result.append(tuple(row_list) if row_list is not None else row) - return result - - def collect_stream_rows( rows: "Sequence[Any]", row_plan: "tuple[list[str], list[int] | None]", @@ -422,17 +406,6 @@ def bool_to_int(value: bool) -> int: return int(value) -def _create_mysql_error( - error: Any, sqlstate: str | None, code: int | None, error_class: type[SQLSpecError], description: str -) -> SQLSpecError: - """Create a MySQL error instance without raising it.""" - code_str = f"[{sqlstate or code}]" if sqlstate or code else "" - msg = f"MySQL {description} {code_str}: {error}" if code_str else f"MySQL {description}: {error}" - exc = error_class(msg) - exc.__cause__ = error - return exc - - def create_mapped_exception(error: Any, *, logger: Any | None = None) -> "SQLSpecError | bool": """Map MySQL driver exceptions to SQLSpec errors.""" error_code = getattr(error, "errno", None) @@ -480,14 +453,17 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> "SQLSpe if isinstance(error_code, int) and MYSQL_SYNTAX_ERROR_MIN <= error_code < MYSQL_SYNTAX_ERROR_MAX_EXCLUSIVE: return _create_mysql_error(error, sqlstate, error_code, SQLParsingError, "SQL syntax error") + dispatch = MYSQL_CONNECTION_ERROR_DISPATCH.get(error_code) if error_code is not None else None + if dispatch is not None: + return _create_mysql_error(error, sqlstate, error_code, dispatch[0], dispatch[1]) if sqlstate_prefix == "08": dispatch = MYSQL_SQLSTATE_PREFIX_DISPATCH["08"] return _create_mysql_error(error, sqlstate, error_code, dispatch[0], dispatch[1]) - dispatch = MYSQL_CONNECTION_ERROR_DISPATCH.get(error_code) if error_code is not None else None + + dispatch = MYSQL_DATA_ERROR_DISPATCH.get(error_code) if error_code is not None else None if dispatch is not None: return _create_mysql_error(error, sqlstate, error_code, dispatch[0], dispatch[1]) - - if sqlstate_prefix == "22": + if sqlstate_prefix == "22" or type(error).__name__ == "DataError": dispatch = MYSQL_SQLSTATE_PREFIX_DISPATCH["22"] return _create_mysql_error(error, sqlstate, error_code, dispatch[0], dispatch[1]) @@ -500,3 +476,86 @@ def escape_literal_percent(sql: str, parameters: Any, validator: "ParameterValid return sql keep = {info.position for info in validator.extract_parameters(sql)} return "".join("%%" if char == "%" and index not in keep else char for index, char in enumerate(sql)) + + +def _deserialize_json_value(value: Any, deserializer: "Callable[[Any], Any]", *, logger: Any | None = None) -> Any: + """Safely decode JSON column values using the specified deserializer.""" + if value is None: + return None + if isinstance(value, (dict, list)): + return value + if isinstance(value, (bytes, bytearray, memoryview)): + try: + raw_bytes = bytes(value) if isinstance(value, (bytearray, memoryview)) else value + return deserializer(raw_bytes) + except Exception: + if logger is not None: + logger.debug("Failed to deserialize JSON column", exc_info=True) + return value + if isinstance(value, str): + try: + return deserializer(value) + except Exception: + if logger is not None: + logger.debug("Failed to deserialize JSON column", exc_info=True) + return value + return value + + +def _deserialize_json_dict_rows( + column_names: "Sequence[str]", + rows: "Sequence[dict[str, Any]]", + json_indexes: "list[int]", + deserializer: "Callable[[Any], Any]", + *, + logger: Any | None = None, +) -> "list[dict[str, Any]]": + """Deserialize JSON fields in dictionary rows in-place.""" + if not json_indexes: + return rows if isinstance(rows, list) else list(rows) + json_columns = [column_names[idx] for idx in json_indexes if idx < len(column_names)] + for row in rows: + for column in json_columns: + if column in row and row[column] is not None: + row[column] = _deserialize_json_value(row[column], deserializer, logger=logger) + return rows if isinstance(rows, list) else list(rows) + + +def _deserialize_json_tuple_rows( + rows: "Sequence[tuple[Any, ...]]", + json_indexes: "list[int]", + deserializer: "Callable[[Any], Any]", + *, + logger: Any | None = None, +) -> "list[tuple[Any, ...]]": + """Deserialize JSON fields in tuple rows using sparse index iteration and lazy row mutation.""" + if not json_indexes: + return rows if isinstance(rows, list) else list(rows) + result: list[tuple[Any, ...]] = [] + for row in rows: + row_len = len(row) + row_list: list[Any] | None = None + for idx in json_indexes: + if idx >= row_len: + continue + raw_val = row[idx] + if raw_val is None: + continue + decoded = _deserialize_json_value(raw_val, deserializer, logger=logger) + if decoded is not raw_val: + if row_list is None: + row_list = list(row) + row_list[idx] = decoded + result.append(tuple(row_list) if row_list is not None else row) + return result + + +def _create_mysql_error( + error: Any, sqlstate: str | None, code: int | None, error_class: type[SQLSpecError], description: str +) -> SQLSpecError: + """Create a MySQL error instance without raising it.""" + code_str = f"[{sqlstate or code}]" if sqlstate or code else "" + msg = f"MySQL {description} {code_str}: {error}" if code_str else f"MySQL {description}: {error}" + exc = error_class(msg) + exc.__cause__ = error + return exc diff --git a/sqlspec/adapters/mysqlconnector/__init__.py b/sqlspec/adapters/mysqlconnector/__init__.py index 40d724716..0cc10efad 100644 --- a/sqlspec/adapters/mysqlconnector/__init__.py +++ b/sqlspec/adapters/mysqlconnector/__init__.py @@ -1,3 +1,5 @@ +"""MysqlConnector adapter for SQLSpec.""" + from sqlspec.adapters.mysqlconnector._typing import ( MysqlConnectorAsyncConnection, MysqlConnectorAsyncCursor, diff --git a/sqlspec/adapters/mysqlconnector/_typing.py b/sqlspec/adapters/mysqlconnector/_typing.py index 1ad16a74d..054fea63f 100644 --- a/sqlspec/adapters/mysqlconnector/_typing.py +++ b/sqlspec/adapters/mysqlconnector/_typing.py @@ -12,8 +12,6 @@ from mysql.connector import MySQLConnection as _MysqlConnectorSyncConnection from mysql.connector import aio as _mysql_connector_aio from mysql.connector import pooling as _mysql_connector_pooling -from mysql.connector.aio import MySQLConnection as _MysqlConnectorAsyncConnection # pyright: ignore[reportMissingImports] -from mysql.connector.aio.cursor import MySQLCursor as _MysqlConnectorAsyncRawCursor # pyright: ignore[reportMissingImports] from mysql.connector.constants import FieldType as _MysqlConnectorFieldType from mysql.connector.cursor import MySQLCursor as _MysqlConnectorSyncRawCursor @@ -29,7 +27,28 @@ from sqlspec.adapters.mysqlconnector.driver import MysqlConnectorAsyncDriver, MysqlConnectorSyncDriver from sqlspec.core import StatementConfig + class MysqlConnectorAsyncRawCursorProtocol(Protocol): + description: Any + rowcount: int + lastrowid: Any + with_rows: bool + + async def execute(self, operation: Any, params: Any = None, multi: bool = False) -> Any: ... + + async def executemany(self, operation: Any, seq_params: Any) -> Any: ... + + async def fetchone(self) -> Any: ... + + async def fetchmany(self, size: int | None = None) -> list[Any]: ... + + async def fetchall(self) -> list[Any]: ... + + async def close(self) -> Any: ... + class MysqlConnectorAsyncConnectionProtocol(Protocol): + in_transaction: bool + unread_result: bool + def cursor(self, **kwargs: Any) -> "Awaitable[MysqlConnectorAsyncRawCursor]": ... async def commit(self) -> object: ... @@ -40,6 +59,8 @@ async def close(self) -> object: ... async def set_autocommit(self, value: bool) -> object: ... + async def consume_results(self) -> object: ... + class MysqlConnectorAioModuleProtocol(Protocol): def connect(self, *args: Any, **kwargs: Any) -> "Awaitable[MysqlConnectorAsyncConnection]": ... @@ -60,19 +81,19 @@ class MysqlConnectorMysqlModuleProtocol(Protocol): MysqlConnectorError: TypeAlias = _mysql_connector.Error MysqlConnectorFieldType: TypeAlias = MysqlConnectorFieldTypeProtocol MysqlConnectorMysqlModule: TypeAlias = MysqlConnectorMysqlModuleProtocol - MysqlConnectorAsyncRawCursor: TypeAlias = _MysqlConnectorAsyncRawCursor + MysqlConnectorAsyncRawCursor: TypeAlias = MysqlConnectorAsyncRawCursorProtocol if not TYPE_CHECKING: MysqlConnectorAsyncPool = import_optional_attr("mysql.connector.aio.pooling", "MySQLConnectionPool") MysqlConnectorAio = _mysql_connector_aio MysqlConnectorSyncConnection = _MysqlConnectorSyncConnection - MysqlConnectorAsyncConnection = _MysqlConnectorAsyncConnection + MysqlConnectorAsyncConnection = import_optional_attr("mysql.connector.aio", "MySQLConnection") MysqlConnectorConnectionPool = _mysql_connector_pooling.MySQLConnectionPool MysqlConnectorError = _mysql_connector.Error MysqlConnectorFieldType = _MysqlConnectorFieldType MysqlConnectorMysqlModule = _mysql MysqlConnectorSyncRawCursor = _MysqlConnectorSyncRawCursor - MysqlConnectorAsyncRawCursor = _MysqlConnectorAsyncRawCursor + MysqlConnectorAsyncRawCursor = import_optional_attr("mysql.connector.aio.cursor", "MySQLCursor") __all__ = ( "MysqlConnectorAio", @@ -91,6 +112,9 @@ class MysqlConnectorMysqlModuleProtocol(Protocol): "MysqlConnectorSyncSessionContext", ) +_resolved_sync_driver_type: "type[MysqlConnectorSyncDriver] | None" = None +_resolved_async_driver_type: "type[MysqlConnectorAsyncDriver] | None" = None + class MysqlConnectorSyncCursor: """Context manager for mysql-connector sync cursor operations.""" @@ -110,6 +134,10 @@ def __enter__(self) -> "MysqlConnectorSyncRawCursor": def __exit__(self, *_: object) -> None: if self.cursor is not None: + raw_connection = getattr(self.connection, "_cnx", None) or self.connection + with contextlib.suppress(Exception): + if getattr(raw_connection, "unread_result", False): + raw_connection.consume_results() with contextlib.suppress(Exception): self.cursor.close() @@ -132,6 +160,10 @@ async def __aenter__(self) -> "MysqlConnectorAsyncRawCursor": async def __aexit__(self, *_: object) -> None: if self.cursor is not None: + raw_connection = getattr(self.connection, "_cnx", None) or self.connection + with contextlib.suppress(Exception): + if getattr(raw_connection, "unread_result", False): + await raw_connection.consume_results() with contextlib.suppress(Exception): await self.cursor.close() @@ -166,10 +198,9 @@ def __init__( self._driver: MysqlConnectorSyncDriver | None = None def __enter__(self) -> "MysqlConnectorSyncDriver": - from sqlspec.adapters.mysqlconnector.driver import MysqlConnectorSyncDriver - + driver_cls = _get_mysqlconnector_sync_driver_type() self._connection = self._acquire_connection() - self._driver = MysqlConnectorSyncDriver( + self._driver = driver_cls( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -213,10 +244,9 @@ def __init__( self._driver: MysqlConnectorAsyncDriver | None = None async def __aenter__(self) -> "MysqlConnectorAsyncDriver": - from sqlspec.adapters.mysqlconnector.driver import MysqlConnectorAsyncDriver - + driver_cls = _get_mysqlconnector_async_driver_type() self._connection = await self._acquire_connection() - self._driver = MysqlConnectorAsyncDriver( + self._driver = driver_cls( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -228,3 +258,23 @@ async def __aexit__( await self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) self._connection = None return None + + +def _get_mysqlconnector_sync_driver_type() -> "type[MysqlConnectorSyncDriver]": + """Resolve and cache MysqlConnectorSyncDriver lazily to avoid circular imports.""" + global _resolved_sync_driver_type + driver_cls = _resolved_sync_driver_type + if driver_cls is None: + driver_cls = import_optional_attr("sqlspec.adapters.mysqlconnector.driver", "MysqlConnectorSyncDriver") + _resolved_sync_driver_type = driver_cls + return driver_cls + + +def _get_mysqlconnector_async_driver_type() -> "type[MysqlConnectorAsyncDriver]": + """Resolve and cache MysqlConnectorAsyncDriver lazily to avoid circular imports.""" + global _resolved_async_driver_type + driver_cls = _resolved_async_driver_type + if driver_cls is None: + driver_cls = import_optional_attr("sqlspec.adapters.mysqlconnector.driver", "MysqlConnectorAsyncDriver") + _resolved_async_driver_type = driver_cls + return driver_cls diff --git a/sqlspec/adapters/mysqlconnector/adk/store.py b/sqlspec/adapters/mysqlconnector/adk/store.py index 57c1b2e70..b6ef15019 100644 --- a/sqlspec/adapters/mysqlconnector/adk/store.py +++ b/sqlspec/adapters/mysqlconnector/adk/store.py @@ -1,7 +1,7 @@ """MysqlConnector ADK store for Google Agent Development Kit session/event storage.""" import re -from collections.abc import Mapping +from collections.abc import Callable, Mapping from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, cast from sqlspec.adapters.mysqlconnector._typing import MysqlConnectorError @@ -166,10 +166,20 @@ class MysqlConnectorADKConfig(ADKConfig): class MysqlConnectorAsyncADKStore(BaseAsyncADKStore["MysqlConnectorAsyncConfig"]): """MySQL/MariaDB ADK store using mysql-connector async driver.""" - __slots__ = () + __slots__ = ("_json_deserializer", "_json_serializer") def __init__(self, config: "MysqlConnectorAsyncConfig") -> None: super().__init__(config) + driver_features = getattr(config, "driver_features", None) + features = driver_features if isinstance(driver_features, Mapping) else {} + serializer = features.get("json_serializer") + deserializer = features.get("json_deserializer") + self._json_serializer: Callable[[Any], str] = ( + cast("Callable[[Any], str]", serializer) if callable(serializer) else to_json + ) + self._json_deserializer: Callable[[Any], Any] = ( + cast("Callable[[Any], Any]", deserializer) if callable(deserializer) else from_json + ) async def create_tables(self) -> None: if not self.create_schema_enabled: @@ -182,23 +192,25 @@ async def create_tables(self) -> None: await driver.execute_script(await self._app_states_table_ddl()) await driver.execute_script(await self._user_states_table_ddl()) await driver.execute_script(await self._metadata_table_ddl()) + await self.reconcile_schema(assume_existing=True) async def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: params: tuple[Any, ...] + state_json = self._json_serializer(state) if self._owner_id_column_name: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, {self._owner_id_column_name}, state, create_time, update_time) VALUES (%s, %s, %s, %s, %s, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) """ - params = (session_id, app_name, user_id, owner_id, to_json(state)) + params = (session_id, app_name, user_id, owner_id, state_json) else: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time) VALUES (%s, %s, %s, %s, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) """ - params = (session_id, app_name, user_id, to_json(state)) + params = (session_id, app_name, user_id, state_json) async with self._config.provide_connection() as conn: cursor = await conn.cursor() @@ -245,7 +257,7 @@ async def get_session( finally: await cursor.close() - return _session_record_from_row(row) if row is not None else None + return _session_record_from_row(row, deserializer=self._json_deserializer) if row is not None else None except MysqlConnectorError as exc: if _is_mysql_table_missing(exc): return None @@ -260,7 +272,7 @@ async def update_session_state(self, app_name: str, user_id: str, session_id: st async with self._config.provide_connection() as conn: cursor = await conn.cursor() try: - await cursor.execute(sql, (to_json(state), app_name, user_id, session_id)) + await cursor.execute(sql, (self._json_serializer(state), app_name, user_id, session_id)) finally: await cursor.close() await conn.commit() @@ -291,7 +303,7 @@ async def list_sessions( rows = await cursor.fetchall() finally: await cursor.close() - return [_session_record_from_row(row) for row in rows] + return [_session_record_from_row(row, deserializer=self._json_deserializer) for row in rows] except MysqlConnectorError as exc: if _is_mysql_table_missing(exc): return [] @@ -316,7 +328,7 @@ async def append_event(self, event_record: StoredEvent) -> None: async with self._config.provide_connection() as conn: cursor = await conn.cursor() try: - await cursor.execute(sql, _event_insert_params(event_record)) + await cursor.execute(sql, _event_insert_params(event_record, serializer=self._json_serializer)) finally: await cursor.close() await conn.commit() @@ -350,7 +362,7 @@ async def append_event_and_update_state( async with self._config.provide_connection() as conn: cursor = await conn.cursor() try: - await cursor.execute(update_sql, (to_json(state), app_name, user_id, session_id)) + await cursor.execute(update_sql, (self._json_serializer(state), app_name, user_id, session_id)) await cursor.execute(select_sql, (app_name, user_id, session_id)) row = await cursor.fetchone() if row is None: @@ -364,16 +376,17 @@ async def append_event_and_update_state( session_id, event_record["invocation_id"], event_record["timestamp"], - _json_for_storage(event_record["event_data"]), + _json_for_storage(event_record["event_data"], serializer=self._json_serializer), ), ) if app_state is not None: await cursor.execute( - _mysql_upsert_app_state_sql(self._app_state_table), (app_name, to_json(app_state)) + _mysql_upsert_app_state_sql(self._app_state_table), (app_name, self._json_serializer(app_state)) ) if user_state is not None: await cursor.execute( - _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, to_json(user_state)) + _mysql_upsert_user_state_sql(self._user_state_table), + (app_name, user_id, self._json_serializer(user_state)), ) await conn.commit() except Exception: @@ -381,7 +394,7 @@ async def append_event_and_update_state( raise finally: await cursor.close() - return _session_record_from_row(row) + return _session_record_from_row(row, deserializer=self._json_deserializer) async def get_events( self, @@ -420,7 +433,7 @@ async def get_events( rows = await cursor.fetchall() finally: await cursor.close() - return [_event_record_from_row(row) for row in rows] + return [_event_record_from_row(row, deserializer=self._json_deserializer) for row in rows] except MysqlConnectorError as exc: if _is_mysql_table_missing(exc): return [] @@ -443,12 +456,14 @@ async def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | async def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: await _async_execute_commit( - self, _mysql_upsert_app_state_sql(self._app_state_table), (app_name, to_json(state)) + self, _mysql_upsert_app_state_sql(self._app_state_table), (app_name, self._json_serializer(state)) ) async def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: await _async_execute_commit( - self, _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, to_json(state)) + self, + _mysql_upsert_user_state_sql(self._user_state_table), + (app_name, user_id, self._json_serializer(state)), ) async def get_metadata(self, key: str) -> "str | None": @@ -514,10 +529,20 @@ def _drop_tables_sql(self) -> "list[str]": class MysqlConnectorSyncADKStore(BaseSyncADKStore["MysqlConnectorSyncConfig"]): """MySQL/MariaDB ADK store using mysql-connector sync driver.""" - __slots__ = () + __slots__ = ("_json_deserializer", "_json_serializer") def __init__(self, config: "MysqlConnectorSyncConfig") -> None: super().__init__(config) + driver_features = getattr(config, "driver_features", None) + features = driver_features if isinstance(driver_features, Mapping) else {} + serializer = features.get("json_serializer") + deserializer = features.get("json_deserializer") + self._json_serializer: Callable[[Any], str] = ( + cast("Callable[[Any], str]", serializer) if callable(serializer) else to_json + ) + self._json_deserializer: Callable[[Any], Any] = ( + cast("Callable[[Any], Any]", deserializer) if callable(deserializer) else from_json + ) def create_tables(self) -> None: """Create tables if they don't exist.""" @@ -531,24 +556,26 @@ def create_tables(self) -> None: driver.execute_script(self._app_states_table_ddl()) driver.execute_script(self._user_states_table_ddl()) driver.execute_script(self._metadata_table_ddl()) + self.reconcile_schema(assume_existing=True) def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: """Create a new session.""" params: tuple[Any, ...] + state_json = self._json_serializer(state) if self._owner_id_column_name: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, {self._owner_id_column_name}, state, create_time, update_time) VALUES (%s, %s, %s, %s, %s, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) """ - params = (session_id, app_name, user_id, owner_id, to_json(state)) + params = (session_id, app_name, user_id, owner_id, state_json) else: sql = f""" INSERT INTO {self._session_table} (id, app_name, user_id, state, create_time, update_time) VALUES (%s, %s, %s, %s, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) """ - params = (session_id, app_name, user_id, to_json(state)) + params = (session_id, app_name, user_id, state_json) with self._config.provide_connection() as conn: cursor = conn.cursor() @@ -595,7 +622,7 @@ def get_session( row = cursor.fetchone() finally: cursor.close() - return _session_record_from_row(row) if row is not None else None + return _session_record_from_row(row, deserializer=self._json_deserializer) if row is not None else None except MysqlConnectorError as exc: if _is_mysql_table_missing(exc): return None @@ -611,7 +638,7 @@ def update_session_state(self, app_name: str, user_id: str, session_id: str, sta with self._config.provide_connection() as conn: cursor = conn.cursor() try: - cursor.execute(sql, (to_json(state), app_name, user_id, session_id)) + cursor.execute(sql, (self._json_serializer(state), app_name, user_id, session_id)) finally: cursor.close() conn.commit() @@ -643,7 +670,7 @@ def list_sessions( rows = cursor.fetchall() finally: cursor.close() - return [_session_record_from_row(row) for row in rows] + return [_session_record_from_row(row, deserializer=self._json_deserializer) for row in rows] except MysqlConnectorError as exc: if _is_mysql_table_missing(exc): return [] @@ -670,7 +697,7 @@ def append_event(self, event_record: StoredEvent) -> None: with self._config.provide_connection() as conn: cursor = conn.cursor() try: - cursor.execute(sql, _event_insert_params(event_record)) + cursor.execute(sql, _event_insert_params(event_record, serializer=self._json_serializer)) finally: cursor.close() conn.commit() @@ -706,7 +733,7 @@ def append_event_and_update_state( with self._config.provide_connection() as conn: cursor = conn.cursor() try: - cursor.execute(update_sql, (to_json(state), app_name, user_id, session_id)) + cursor.execute(update_sql, (self._json_serializer(state), app_name, user_id, session_id)) cursor.execute(select_sql, (app_name, user_id, session_id)) row = cursor.fetchone() if row is None: @@ -720,14 +747,17 @@ def append_event_and_update_state( session_id, event_record["invocation_id"], event_record["timestamp"], - _json_for_storage(event_record["event_data"]), + _json_for_storage(event_record["event_data"], serializer=self._json_serializer), ), ) if app_state is not None: - cursor.execute(_mysql_upsert_app_state_sql(self._app_state_table), (app_name, to_json(app_state))) + cursor.execute( + _mysql_upsert_app_state_sql(self._app_state_table), (app_name, self._json_serializer(app_state)) + ) if user_state is not None: cursor.execute( - _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, to_json(user_state)) + _mysql_upsert_user_state_sql(self._user_state_table), + (app_name, user_id, self._json_serializer(user_state)), ) except Exception: conn.rollback() @@ -736,7 +766,7 @@ def append_event_and_update_state( cursor.close() conn.commit() - return _session_record_from_row(row) + return _session_record_from_row(row, deserializer=self._json_deserializer) def get_events( self, @@ -776,7 +806,7 @@ def get_events( rows = cursor.fetchall() finally: cursor.close() - return [_event_record_from_row(row) for row in rows] + return [_event_record_from_row(row, deserializer=self._json_deserializer) for row in rows] except MysqlConnectorError as exc: if _is_mysql_table_missing(exc): return [] @@ -804,12 +834,16 @@ def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None" def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: """Insert or replace app-scoped state for an application.""" - _sync_execute_commit(self, _mysql_upsert_app_state_sql(self._app_state_table), (app_name, to_json(state))) + _sync_execute_commit( + self, _mysql_upsert_app_state_sql(self._app_state_table), (app_name, self._json_serializer(state)) + ) def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: """Insert or replace user-scoped state for an application user.""" _sync_execute_commit( - self, _mysql_upsert_user_state_sql(self._user_state_table), (app_name, user_id, to_json(state)) + self, + _mysql_upsert_user_state_sql(self._user_state_table), + (app_name, user_id, self._json_serializer(state)), ) def get_metadata(self, key: str) -> "str | None": @@ -877,10 +911,20 @@ def _drop_tables_sql(self) -> "list[str]": class MysqlConnectorAsyncADKMemoryStore(BaseAsyncADKMemoryStore["MysqlConnectorAsyncConfig"]): """MySQL/MariaDB ADK memory store using mysql-connector async driver.""" - __slots__ = () + __slots__ = ("_json_deserializer", "_json_serializer") def __init__(self, config: "MysqlConnectorAsyncConfig") -> None: super().__init__(config) + driver_features = getattr(config, "driver_features", None) + features = driver_features if isinstance(driver_features, Mapping) else {} + serializer = features.get("json_serializer") + deserializer = features.get("json_deserializer") + self._json_serializer: Callable[[Any], str] = ( + cast("Callable[[Any], str]", serializer) if callable(serializer) else to_json + ) + self._json_deserializer: Callable[[Any], Any] = ( + cast("Callable[[Any], Any]", deserializer) if callable(deserializer) else from_json + ) async def create_tables(self) -> None: if not self.create_schema_enabled: @@ -892,6 +936,7 @@ async def create_tables(self) -> None: async with self._config.provide_session() as driver: await driver.execute_script(await self._memory_table_ddl()) + await self.reconcile_schema(assume_existing=True) async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: if not self._enabled: @@ -923,6 +968,9 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " try: for entry in entries: params: tuple[Any, ...] + content_json = self._json_serializer(entry["content_json"]) + metadata_val = entry.get("metadata_json") + metadata_json = self._json_serializer(metadata_val) if metadata_val is not None else None if self._owner_id_column_name: params = ( entry["id"], @@ -934,9 +982,9 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " entry.get("author"), owner_id, entry["timestamp"], - to_json(entry["content_json"]), + content_json, entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + metadata_json, entry["inserted_at"], ) else: @@ -949,13 +997,13 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " entry["event_id"], entry.get("author"), entry["timestamp"], - to_json(entry["content_json"]), + content_json, entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + metadata_json, entry["inserted_at"], ) await cursor.execute(sql, params) - inserted_count += cursor.rowcount + inserted_count += max(0, int(cursor.rowcount or 0)) finally: await cursor.close() await conn.commit() @@ -1009,9 +1057,11 @@ async def search_entries( records: list[StoredMemory] = [] for row in rows: rec = cast("StoredMemory", dict(zip(columns, row, strict=False))) - rec["content_json"] = _json_dict(rec.get("content_json")) + rec["content_json"] = _json_dict(rec.get("content_json"), deserializer=self._json_deserializer) metadata_val = rec.get("metadata_json") - rec["metadata_json"] = _json_dict(metadata_val) if metadata_val is not None else None + rec["metadata_json"] = ( + _json_dict(metadata_val, deserializer=self._json_deserializer) if metadata_val is not None else None + ) rec["embedding"] = None records.append(rec) return records @@ -1068,17 +1118,18 @@ async def _memory_table_ddl(self) -> str: if fk_def: fk_constraint = _ADK_MEMORY_TABLE_DDL_TEMPLATE.format(fk_def) + safe_index_table = self._memory_table.replace(".", "_") fts_index = "" if self._use_fts: - fts_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE_2.format(self._memory_table) + fts_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE_2.format(safe_index_table) table_options = _mysql_table_options(adk_config, "memory_table_options") return _ADK_MEMORY_TABLE_DDL_TEMPLATE_3.format( self._memory_table, owner_id_line, - self._memory_table, - self._memory_table, - self._memory_table, + safe_index_table, + safe_index_table, + safe_index_table, fts_index, fk_constraint, table_options, @@ -1091,10 +1142,20 @@ def _drop_memory_table_sql(self) -> "list[str]": class MysqlConnectorSyncADKMemoryStore(BaseSyncADKMemoryStore["MysqlConnectorSyncConfig"]): """MySQL/MariaDB ADK memory store using mysql-connector sync driver.""" - __slots__ = () + __slots__ = ("_json_deserializer", "_json_serializer") def __init__(self, config: "MysqlConnectorSyncConfig") -> None: super().__init__(config) + driver_features = getattr(config, "driver_features", None) + features = driver_features if isinstance(driver_features, Mapping) else {} + serializer = features.get("json_serializer") + deserializer = features.get("json_deserializer") + self._json_serializer: Callable[[Any], str] = ( + cast("Callable[[Any], str]", serializer) if callable(serializer) else to_json + ) + self._json_deserializer: Callable[[Any], Any] = ( + cast("Callable[[Any], Any]", deserializer) if callable(deserializer) else from_json + ) def create_tables(self) -> None: """Create tables if they don't exist.""" @@ -1107,6 +1168,7 @@ def create_tables(self) -> None: with self._config.provide_session() as driver: driver.execute_script(self._memory_table_ddl()) + self.reconcile_schema(assume_existing=True) def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Bulk insert memory entries with deduplication.""" @@ -1139,6 +1201,9 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object try: for entry in entries: params: tuple[Any, ...] + content_json = self._json_serializer(entry["content_json"]) + metadata_val = entry.get("metadata_json") + metadata_json = self._json_serializer(metadata_val) if metadata_val is not None else None if self._owner_id_column_name: params = ( entry["id"], @@ -1150,9 +1215,9 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object entry.get("author"), owner_id, entry["timestamp"], - to_json(entry["content_json"]), + content_json, entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + metadata_json, entry["inserted_at"], ) else: @@ -1165,13 +1230,13 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object entry["event_id"], entry.get("author"), entry["timestamp"], - to_json(entry["content_json"]), + content_json, entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + metadata_json, entry["inserted_at"], ) cursor.execute(sql, cast("tuple[Any, ...]", params)) - inserted_count += cursor.rowcount + inserted_count += max(0, int(cursor.rowcount or 0)) finally: cursor.close() conn.commit() @@ -1226,9 +1291,11 @@ def search_entries( records: list[StoredMemory] = [] for row in rows: rec = cast("StoredMemory", dict(zip(columns, row, strict=False))) - rec["content_json"] = _json_dict(rec.get("content_json")) + rec["content_json"] = _json_dict(rec.get("content_json"), deserializer=self._json_deserializer) metadata_val = rec.get("metadata_json") - rec["metadata_json"] = _json_dict(metadata_val) if metadata_val is not None else None + rec["metadata_json"] = ( + _json_dict(metadata_val, deserializer=self._json_deserializer) if metadata_val is not None else None + ) rec["embedding"] = None records.append(rec) return records @@ -1285,17 +1352,18 @@ def _memory_table_ddl(self) -> str: if fk_def: fk_constraint = _ADK_MEMORY_TABLE_DDL_TEMPLATE.format(fk_def) + safe_index_table = self._memory_table.replace(".", "_") fts_index = "" if self._use_fts: - fts_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE_2.format(self._memory_table) + fts_index = _ADK_MEMORY_TABLE_DDL_TEMPLATE_2.format(safe_index_table) table_options = _mysql_table_options(adk_config, "memory_table_options") return _ADK_MEMORY_TABLE_DDL_TEMPLATE_3.format( self._memory_table, owner_id_line, - self._memory_table, - self._memory_table, - self._memory_table, + safe_index_table, + safe_index_table, + safe_index_table, fts_index, fk_constraint, table_options, @@ -1347,25 +1415,30 @@ def _is_mysql_table_missing(exc: BaseException) -> bool: ) -def _json_for_storage(value: Any) -> str: - return value if isinstance(value, str) else to_json(value) +def _json_for_storage(value: Any, *, serializer: Callable[[Any], str] = to_json) -> str: + return value if isinstance(value, str) else serializer(value) -def _json_dict(value: Any) -> "dict[str, Any]": +def _json_dict(value: Any, *, deserializer: Callable[[Any], Any] = from_json) -> "dict[str, Any]": if isinstance(value, bytearray): value = bytes(value) if isinstance(value, (bytes, str)): - return cast("dict[str, Any]", from_json(value)) + return cast("dict[str, Any]", deserializer(value)) return cast("dict[str, Any]", value) -def _session_record_from_row(row: Any) -> StoredSession: +def _session_record_from_row(row: Any, *, deserializer: Callable[[Any], Any] = from_json) -> StoredSession: return StoredSession( - id=row[0], app_name=row[1], user_id=row[2], state=_json_dict(row[3]), create_time=row[4], update_time=row[5] + id=row[0], + app_name=row[1], + user_id=row[2], + state=_json_dict(row[3], deserializer=deserializer), + create_time=row[4], + update_time=row[5], ) -def _event_record_from_row(row: Any) -> StoredEvent: +def _event_record_from_row(row: Any, *, deserializer: Callable[[Any], Any] = from_json) -> StoredEvent: return StoredEvent( id=row[0], app_name=row[1], @@ -1373,11 +1446,11 @@ def _event_record_from_row(row: Any) -> StoredEvent: session_id=row[3], invocation_id=row[4], timestamp=row[5], - event_data=_json_dict(row[6]), + event_data=_json_dict(row[6], deserializer=deserializer), ) -def _event_insert_params(event_record: StoredEvent) -> "tuple[Any, ...]": +def _event_insert_params(event_record: StoredEvent, *, serializer: Callable[[Any], str] = to_json) -> "tuple[Any, ...]": return ( event_record["id"], event_record["app_name"], @@ -1385,7 +1458,7 @@ def _event_insert_params(event_record: StoredEvent) -> "tuple[Any, ...]": event_record["session_id"], event_record["invocation_id"], event_record["timestamp"], - _json_for_storage(event_record["event_data"]), + _json_for_storage(event_record["event_data"], serializer=serializer), ) @@ -1437,7 +1510,7 @@ async def _async_state( row = await cursor.fetchone() finally: await cursor.close() - return _json_dict(row[0]) if row is not None else None + return _json_dict(row[0], deserializer=store._json_deserializer) if row is not None else None except MysqlConnectorError as exc: if _is_mysql_table_missing(exc): return None @@ -1497,7 +1570,7 @@ def _sync_state( row = cursor.fetchone() finally: cursor.close() - return _json_dict(row[0]) if row is not None else None + return _json_dict(row[0], deserializer=store._json_deserializer) if row is not None else None except MysqlConnectorError as exc: if _is_mysql_table_missing(exc): return None @@ -1523,20 +1596,22 @@ def _mysql_sessions_ddl(session_table: str, owner_id_column_ddl: "str | None", t if fk_def: fk_constraint = _ADK_MEMORY_TABLE_DDL_TEMPLATE.format(fk_def) + safe_index_table = session_table.replace(".", "_") return _ADK_MYSQL_SESSIONS_DDL_TEMPLATE_2.format( - session_table, owner_id_line, session_table, session_table, fk_constraint, table_options + session_table, owner_id_line, safe_index_table, safe_index_table, fk_constraint, table_options ) def _mysql_events_ddl(events_table: str, session_table: str, adk_config: Mapping[str, Any] | None = None) -> str: adk_config = adk_config or {} + safe_index_table = events_table.replace(".", "_") generated_columns = "" generated_indexes = "" if adk_config.get("enable_event_generated_columns", False): generated_columns = """, author_gc VARCHAR(256) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.author'))) STORED, node_path_gc VARCHAR(512) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(event_data, '$.node_info.path'))) STORED""" - generated_indexes = _ADK_MYSQL_EVENTS_DDL_TEMPLATE.format(events_table, events_table) + generated_indexes = _ADK_MYSQL_EVENTS_DDL_TEMPLATE.format(safe_index_table, safe_index_table) covering_column = ", invocation_id" if adk_config.get("enable_covering_indexes", False) else "" table_options = _mysql_table_options(adk_config, "events_table_options") @@ -1545,13 +1620,13 @@ def _mysql_events_ddl(events_table: str, session_table: str, adk_config: Mapping events_table, generated_columns, session_table, - events_table, + safe_index_table, covering_column, - events_table, + safe_index_table, covering_column, generated_indexes, table_options, - events_table, + safe_index_table, ) diff --git a/sqlspec/adapters/mysqlconnector/config.py b/sqlspec/adapters/mysqlconnector/config.py index 009c14799..6be631b04 100644 --- a/sqlspec/adapters/mysqlconnector/config.py +++ b/sqlspec/adapters/mysqlconnector/config.py @@ -149,6 +149,8 @@ class _MysqlConnectorBaseConnectionParams(TypedDict): allow_local_infile_in_path: NotRequired[str] use_pure: NotRequired[bool] dsn: NotRequired[str] + url: NotRequired[str] + connection_string: NotRequired[str] extra: NotRequired["dict[str, Any]"] @@ -223,16 +225,12 @@ def build_connection_config( config.setdefault(key, value) config.setdefault("host", "localhost") config.setdefault("port", 3306) + config.setdefault("charset", "utf8mb4") local_infile = bool(config.pop("local_infile", False)) config["allow_local_infile"] = bool(config.get("allow_local_infile", False) or local_infile) return config -def _normalize_local_infile(connection_config: "Mapping[str, Any] | None") -> "dict[str, Any]": - """Normalize mysql-connector local-infile consent and connection config.""" - return build_connection_config(connection_config) - - class MysqlConnectorSyncConnectionContext(SyncPoolConnectionContext): """Context manager for mysql-connector sync connections.""" @@ -250,8 +248,14 @@ def __exit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> bool | None: if self._connection is not None: - self._connection.close() + conn = self._connection self._connection = None + if not self._config.connection_config.get("pool_reset_session", True) and getattr( + conn, "in_transaction", False + ): + with contextlib.suppress(Exception): + conn.rollback() + conn.close() return None @@ -269,8 +273,14 @@ def acquire_connection(self) -> MysqlConnectorSyncConnection: def release_connection(self, _conn: MysqlConnectorSyncConnection, **kwargs: Any) -> None: if self._connection is None: return - self._connection.close() + conn = self._connection self._connection = None + if not self._config.connection_config.get("pool_reset_session", True) and getattr( + conn, "in_transaction", False + ): + with contextlib.suppress(Exception): + conn.rollback() + conn.close() class MysqlConnectorAsyncConnectionContext(AsyncPoolConnectionContext): @@ -286,8 +296,14 @@ async def __aexit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> bool | None: if self._connection is not None: - await self._connection.close() + conn = self._connection self._connection = None + if not self._config.connection_config.get("pool_reset_session", True) and getattr( + conn, "in_transaction", False + ): + with contextlib.suppress(Exception): + await conn.rollback() + await conn.close() return None @@ -301,8 +317,14 @@ async def acquire_connection(self) -> MysqlConnectorAsyncConnection: async def release_connection(self, _conn: MysqlConnectorAsyncConnection, **kwargs: Any) -> None: if self._connection is None: return - await self._connection.close() + conn = self._connection self._connection = None + if not self._config.connection_config.get("pool_reset_session", True) and getattr( + conn, "in_transaction", False + ): + with contextlib.suppress(Exception): + await conn.rollback() + await conn.close() _POOL_ONLY_CONFIG_KEYS: Final[frozenset[str]] = frozenset({"pool_name", "pool_size", "pool_reset_session"}) @@ -373,38 +395,6 @@ def __init__( **kwargs, ) - def _ensure_connection(self, connection: "MysqlConnectorSyncConnection") -> None: - """Initialize connection state after creation or a native session reset.""" - if self._user_connection_hook is None: - return - underlying = getattr(connection, "_cnx", None) or connection - if underlying not in self._initialized_connections: - self._user_connection_hook(connection) - self._initialized_connections.add(underlying) - - def _acquire_sync_connection(self) -> MysqlConnectorSyncConnection: - """Acquire and initialize a sync mysql-connector connection.""" - pool = self.provide_pool() - connection = cast("MysqlConnectorSyncConnection", pool.get_connection()) - self._ensure_connection(connection) - return connection - - def _create_pool(self) -> "MysqlConnectorConnectionPool": - config = dict(self.connection_config) - pool_name = config.pop("pool_name", None) - pool_size = config.pop("pool_size", None) - pool_reset = config.pop("pool_reset_session", True) - return MysqlConnectorConnectionPool( - pool_name=pool_name, - pool_size=5 if pool_size is None else pool_size, - pool_reset_session=pool_reset, - **config, - ) - - def _close_pool(self) -> None: - if self.connection_instance is not None: - self.connection_instance = None - def create_connection(self) -> MysqlConnectorSyncConnection: """Open a standalone connection owned by the caller. @@ -417,10 +407,15 @@ def create_connection(self) -> MysqlConnectorSyncConnection: """ config = {key: value for key, value in self.connection_config.items() if key not in _POOL_ONLY_CONFIG_KEYS} connection = mysql.connector.connect(**config) - autocommit = config.get("autocommit") - if autocommit is not None: - connection.autocommit = bool(autocommit) - self._ensure_connection(connection) + try: + autocommit = config.get("autocommit") + if autocommit is not None: + connection.autocommit = bool(autocommit) + self._ensure_connection(connection) + except BaseException: + with contextlib.suppress(Exception): + connection.close() + raise return connection def get_signature_namespace(self) -> "dict[str, Any]": @@ -439,6 +434,54 @@ def get_signature_namespace(self) -> "dict[str, Any]": def get_event_runtime_hints(self) -> "EventRuntimeHints": return EventRuntimeHints(poll_interval=0.25, lease_seconds=5, select_for_update=True, skip_locked=True) + def _ensure_connection(self, connection: "MysqlConnectorSyncConnection") -> None: + """Initialize connection state after creation or a native session reset.""" + if self._user_connection_hook is None: + return + underlying = getattr(connection, "_cnx", None) + if underlying is not None and self.connection_config.get("pool_reset_session", True): + self._user_connection_hook(connection) + return + underlying = underlying or connection + if underlying not in self._initialized_connections: + self._user_connection_hook(connection) + self._initialized_connections.add(underlying) + + def _acquire_sync_connection(self) -> MysqlConnectorSyncConnection: + """Acquire and initialize a sync mysql-connector connection.""" + pool = self.provide_pool() + connection = cast("MysqlConnectorSyncConnection", pool.get_connection()) + try: + self._ensure_connection(connection) + except BaseException: + with contextlib.suppress(Exception): + connection.close() + raise + return connection + + def _create_pool(self) -> "MysqlConnectorConnectionPool": + config = dict(self.connection_config) + pool_name = config.pop("pool_name", None) + pool_size = config.pop("pool_size", None) + pool_reset = config.pop("pool_reset_session", True) + return MysqlConnectorConnectionPool( + pool_name=pool_name, + pool_size=5 if pool_size is None else pool_size, + pool_reset_session=pool_reset, + **config, + ) + + def _close_pool(self) -> None: + pool = self.connection_instance + if pool is not None: + cnx_queue = getattr(pool, "_cnx_queue", None) + if cnx_queue is not None: + while not cnx_queue.empty(): + with contextlib.suppress(Exception): + conn = cnx_queue.get_nowait() + conn.close() + self.connection_instance = None + class MysqlConnectorAsyncConfig( AsyncDatabaseConfig[MysqlConnectorAsyncConnection, "MysqlConnectorAsyncPool", MysqlConnectorAsyncDriver] @@ -506,16 +549,67 @@ def __init__( **kwargs, ) + async def create_connection(self) -> MysqlConnectorAsyncConnection: + """Open and initialize a standalone connection owned by the caller.""" + config = {key: value for key, value in self.connection_config.items() if key not in _POOL_ONLY_CONFIG_KEYS} + connection = await mysqlconnector_aio.connect(**config) + try: + autocommit = self.connection_config.get("autocommit") + if autocommit is not None: + await connection.set_autocommit(bool(autocommit)) + await self._ensure_connection(connection) + except BaseException: + with contextlib.suppress(Exception): + await connection.close() + raise + return connection + + def provide_connection(self, *args: Any, **kwargs: Any) -> "MysqlConnectorAsyncConnectionContext": + return MysqlConnectorAsyncConnectionContext(self) + + def provide_session( + self, *_args: Any, statement_config: "StatementConfig | None" = None, **_kwargs: Any + ) -> "MysqlConnectorAsyncSessionContext": + statement_config = statement_config or self.statement_config or default_statement_config + handler = _MysqlConnectorAsyncSessionConnectionHandler(self) + + return MysqlConnectorAsyncSessionContext( + acquire_connection=handler.acquire_connection, + release_connection=handler.release_connection, + statement_config=statement_config, + driver_features=self.driver_features, + prepare_driver=self._prepare_driver, + ) + + def get_signature_namespace(self) -> "dict[str, Any]": + namespace = super().get_signature_namespace() + namespace.update({ + "MysqlConnectorAsyncConfig": MysqlConnectorAsyncConfig, + "MysqlConnectorAsyncConnection": MysqlConnectorAsyncConnection, + "MysqlConnectorAsyncConnectionParams": MysqlConnectorAsyncConnectionParams, + "MysqlConnectorAsyncCursor": MysqlConnectorAsyncCursor, + "MysqlConnectorAsyncDriver": MysqlConnectorAsyncDriver, + "MysqlConnectorAsyncExceptionHandler": MysqlConnectorAsyncExceptionHandler, + "MysqlConnectorAsyncSessionContext": MysqlConnectorAsyncSessionContext, + }) + return namespace + + def get_event_runtime_hints(self) -> "EventRuntimeHints": + return EventRuntimeHints(poll_interval=0.25, lease_seconds=5, select_for_update=True, skip_locked=True) + async def _create_pool(self) -> "MysqlConnectorAsyncPool": if MysqlConnectorAsyncPool is None: msg = "Async connection pooling requires mysql-connector-python 9.4 or later" raise ImproperConfigurationError(msg) config = dict(self.connection_config) pool_name = config.pop("pool_name", None) - pool_size = config.pop("pool_size", 5) + pool_size = config.pop("pool_size", None) pool_reset = config.pop("pool_reset_session", True) pool = MysqlConnectorAsyncPool( - pool_name=pool_name, pool_size=pool_size, pool_reset_session=pool_reset, **config + pool_name=pool_name, + pool_size=5 if pool_size is None else pool_size, + pool_reset_session=pool_reset, + **config, ) try: await pool.initialize_pool() @@ -560,52 +654,3 @@ async def _acquire_async_connection(self) -> MysqlConnectorAsyncConnection: await connection.close() raise return connection - - async def create_connection(self) -> MysqlConnectorAsyncConnection: - """Open and initialize a standalone connection owned by the caller.""" - config = {key: value for key, value in self.connection_config.items() if key not in _POOL_ONLY_CONFIG_KEYS} - connection = await mysqlconnector_aio.connect(**config) - try: - autocommit = self.connection_config.get("autocommit") - if autocommit is not None: - await connection.set_autocommit(bool(autocommit)) - if self._user_connection_hook is not None: - await self._user_connection_hook(connection) - except BaseException: - with contextlib.suppress(Exception): - await connection.close() - raise - return connection - - def provide_connection(self, *args: Any, **kwargs: Any) -> "MysqlConnectorAsyncConnectionContext": - return MysqlConnectorAsyncConnectionContext(self) - - def provide_session( - self, *_args: Any, statement_config: "StatementConfig | None" = None, **_kwargs: Any - ) -> "MysqlConnectorAsyncSessionContext": - statement_config = statement_config or self.statement_config or default_statement_config - handler = _MysqlConnectorAsyncSessionConnectionHandler(self) - - return MysqlConnectorAsyncSessionContext( - acquire_connection=handler.acquire_connection, - release_connection=handler.release_connection, - statement_config=statement_config, - driver_features=self.driver_features, - prepare_driver=self._prepare_driver, - ) - - def get_signature_namespace(self) -> "dict[str, Any]": - namespace = super().get_signature_namespace() - namespace.update({ - "MysqlConnectorAsyncConfig": MysqlConnectorAsyncConfig, - "MysqlConnectorAsyncConnection": MysqlConnectorAsyncConnection, - "MysqlConnectorAsyncConnectionParams": MysqlConnectorAsyncConnectionParams, - "MysqlConnectorAsyncCursor": MysqlConnectorAsyncCursor, - "MysqlConnectorAsyncDriver": MysqlConnectorAsyncDriver, - "MysqlConnectorAsyncExceptionHandler": MysqlConnectorAsyncExceptionHandler, - "MysqlConnectorAsyncSessionContext": MysqlConnectorAsyncSessionContext, - }) - return namespace - - def get_event_runtime_hints(self) -> "EventRuntimeHints": - return EventRuntimeHints(poll_interval=0.25, lease_seconds=5, select_for_update=True, skip_locked=True) diff --git a/sqlspec/adapters/mysqlconnector/core.py b/sqlspec/adapters/mysqlconnector/core.py index 8d92d0a3c..ff4e6068d 100644 --- a/sqlspec/adapters/mysqlconnector/core.py +++ b/sqlspec/adapters/mysqlconnector/core.py @@ -8,6 +8,7 @@ bool_to_int, build_insert_statement, build_load_data_statement, + can_use_local_infile_records, collect_rows, collect_stream_rows, create_mapped_exception, @@ -39,6 +40,7 @@ "build_load_data_statement", "build_profile", "build_statement_config", + "can_use_local_infile_records", "collect_rows", "collect_stream_rows", "create_mapped_exception", @@ -133,8 +135,9 @@ def close(self, error: bool = False) -> None: connection = self._driver.connection raw_connection = getattr(connection, "_cnx", None) or connection try: - if getattr(raw_connection, "unread_result", False): - raw_connection.consume_results() + with contextlib.suppress(Exception): + if getattr(raw_connection, "unread_result", False): + raw_connection.consume_results() finally: if cursor is not None: with contextlib.suppress(Exception): @@ -179,16 +182,6 @@ async def start(self) -> None: await self._driver._run_with_exception_handler(handler, self._start) self._driver._check_pending_exception(handler) - async def _start(self) -> None: - try: - cursor = await self._driver.connection.cursor(**self._cursor_options) - self._cursor = cursor - await cursor.execute(self._sql, normalize_execute_parameters(self._parameters)) - self._row_plan = resolve_row_plan(self._cursor.description, self._json_type_codes) - except BaseException: - await self.close(error=True) - raise - async def fetch_chunk(self) -> "list[dict[str, Any]]": handler = self._driver.handle_database_exceptions() rows = await self._driver._run_with_exception_handler(handler, self._cursor.fetchmany, self._chunk_size) @@ -210,13 +203,24 @@ async def close(self, error: bool = False) -> None: connection = self._driver.connection raw_connection = getattr(connection, "_cnx", None) or connection try: - if getattr(raw_connection, "unread_result", False): - await raw_connection.consume_results() + with contextlib.suppress(Exception): + if getattr(raw_connection, "unread_result", False): + await raw_connection.consume_results() finally: if cursor is not None: with contextlib.suppress(Exception): await cursor.close() + async def _start(self) -> None: + try: + cursor = await self._driver.connection.cursor(**self._cursor_options) + self._cursor = cursor + await cursor.execute(self._sql, normalize_execute_parameters(self._parameters)) + self._row_plan = resolve_row_plan(self._cursor.description, self._json_type_codes) + except BaseException: + await self.close(error=True) + raise + def build_profile() -> "DriverParameterProfile": """Create the mysql-connector driver parameter profile.""" diff --git a/sqlspec/adapters/mysqlconnector/driver.py b/sqlspec/adapters/mysqlconnector/driver.py index 871918ea0..a005f3de7 100644 --- a/sqlspec/adapters/mysqlconnector/driver.py +++ b/sqlspec/adapters/mysqlconnector/driver.py @@ -4,14 +4,13 @@ type coercion, error handling, and transaction management. """ +import asyncio import os import tempfile from collections.abc import Callable, Sized from pathlib import Path from typing import TYPE_CHECKING, Any, Final, cast -import anyio - from sqlspec.adapters.mysqlconnector._typing import ( MysqlConnectorAsyncCursor, MysqlConnectorAsyncSessionContext, @@ -25,6 +24,7 @@ MysqlConnectorSyncStreamSource, build_insert_statement, build_load_data_statement, + can_use_local_infile_records, collect_rows, create_mapped_exception, default_statement_config, @@ -51,7 +51,6 @@ SyncDriverAdapterBase, SyncRowStream, ) -from sqlspec.exceptions import SQLSpecError from sqlspec.utils.logging import get_logger from sqlspec.utils.serializers import from_json from sqlspec.utils.type_guards import supports_json_type @@ -137,6 +136,12 @@ def __init__( "Callable[[Any], Any]", self.driver_features.get("json_deserializer", from_json) ) + @property + def data_dictionary(self) -> "MysqlConnectorSyncDataDictionary": + if self._data_dictionary is None: + self._data_dictionary = MysqlConnectorSyncDataDictionary() + return self._data_dictionary + def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) cursor.execute(sql, normalize_execute_parameters(prepared_parameters)) @@ -182,6 +187,8 @@ def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "ExecutionRe for stmt in statements: cursor.execute(stmt, normalize_execute_parameters(prepared_parameters)) + if getattr(cursor, "with_rows", False): + cursor.fetchall() successful_count += 1 return self.create_execution_result( @@ -193,22 +200,28 @@ def begin(self) -> None: with MysqlConnectorSyncCursor(self.connection) as cursor: cursor.execute("BEGIN") except MysqlConnectorError as e: - msg = f"Failed to begin MySQL transaction: {e}" - raise SQLSpecError(msg) from e + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, bool): + return + raise mapped from e def commit(self) -> None: try: self.connection.commit() except MysqlConnectorError as e: - msg = f"Failed to commit MySQL transaction: {e}" - raise SQLSpecError(msg) from e + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, bool): + return + raise mapped from e def rollback(self) -> None: try: self.connection.rollback() except MysqlConnectorError as e: - msg = f"Failed to rollback MySQL transaction: {e}" - raise SQLSpecError(msg) from e + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, bool): + return + raise mapped from e def with_cursor(self, connection: "MysqlConnectorSyncConnection") -> "MysqlConnectorSyncCursor": cursor_options = cast("dict[str, Any]", self.driver_features.get("cursor_options") or {}) @@ -272,7 +285,11 @@ def load_from_arrow( columns, records = self._arrow_table_to_rows(arrow_table) if records: needs_preparation = self._arrow_rows_need_preparation(arrow_table) - use_infile = bool(self.driver_features.get("enable_local_infile_bulk_load")) and not needs_preparation + use_infile = ( + bool(self.driver_features.get("enable_local_infile_bulk_load")) + and not needs_preparation + and can_use_local_infile_records(arrow_table, records) + ) if use_infile: payload = encode_records_for_local_infile(records) fd, tmp_name = tempfile.mkstemp(suffix=".tsv") @@ -317,12 +334,6 @@ def load_from_storage( arrow_table, inbound = self._read_storage_arrow(source, file_format=file_format) return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound) - @property - def data_dictionary(self) -> "MysqlConnectorSyncDataDictionary": - if self._data_dictionary is None: - self._data_dictionary = MysqlConnectorSyncDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect mysql-connector sync rows for the direct execution path.""" description = cursor.description or None @@ -380,6 +391,12 @@ def __init__( "Callable[[Any], Any]", self.driver_features.get("json_deserializer", from_json) ) + @property + def data_dictionary(self) -> "MysqlConnectorAsyncDataDictionary": + if self._data_dictionary is None: + self._data_dictionary = MysqlConnectorAsyncDataDictionary() + return self._data_dictionary + async def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) await cursor.execute(sql, normalize_execute_parameters(prepared_parameters)) @@ -425,6 +442,8 @@ async def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "Execu for stmt in statements: await cursor.execute(stmt, normalize_execute_parameters(prepared_parameters)) + if getattr(cursor, "with_rows", False): + await cursor.fetchall() successful_count += 1 return self.create_execution_result( @@ -436,22 +455,28 @@ async def begin(self) -> None: async with MysqlConnectorAsyncCursor(self.connection) as cursor: await cursor.execute("BEGIN") except MysqlConnectorError as e: - msg = f"Failed to begin MySQL transaction: {e}" - raise SQLSpecError(msg) from e + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, bool): + return + raise mapped from e async def commit(self) -> None: try: await self.connection.commit() except MysqlConnectorError as e: - msg = f"Failed to commit MySQL transaction: {e}" - raise SQLSpecError(msg) from e + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, bool): + return + raise mapped from e async def rollback(self) -> None: try: await self.connection.rollback() except MysqlConnectorError as e: - msg = f"Failed to rollback MySQL transaction: {e}" - raise SQLSpecError(msg) from e + mapped = create_mapped_exception(e, logger=logger) + if isinstance(mapped, bool): + return + raise mapped from e def with_cursor(self, connection: "MysqlConnectorAsyncConnection") -> "MysqlConnectorAsyncCursor": cursor_options = cast("dict[str, Any]", self.driver_features.get("cursor_options") or {}) @@ -515,7 +540,11 @@ async def load_from_arrow( columns, records = self._arrow_table_to_rows(arrow_table) if records: needs_preparation = self._arrow_rows_need_preparation(arrow_table) - use_infile = bool(self.driver_features.get("enable_local_infile_bulk_load")) and not needs_preparation + use_infile = ( + bool(self.driver_features.get("enable_local_infile_bulk_load")) + and not needs_preparation + and can_use_local_infile_records(arrow_table, records) + ) if use_infile: payload = encode_records_for_local_infile(records) fd, tmp_name = tempfile.mkstemp(suffix=".tsv") @@ -529,7 +558,7 @@ async def load_from_arrow( if exc_handler.pending_exception is not None: raise exc_handler.pending_exception from None finally: - await anyio.Path(tmp_name).unlink(missing_ok=True) + await asyncio.to_thread(Path(tmp_name).unlink, True) else: insert_sql = build_insert_statement(table, columns) prepared_records = ( @@ -562,12 +591,6 @@ async def load_from_storage( table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound ) - @property - def data_dictionary(self) -> "MysqlConnectorAsyncDataDictionary": - if self._data_dictionary is None: - self._data_dictionary = MysqlConnectorAsyncDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect mysql-connector async rows for the direct execution path.""" description = cursor.description or None diff --git a/sqlspec/adapters/mysqlconnector/litestar/store.py b/sqlspec/adapters/mysqlconnector/litestar/store.py index ecc4d2018..77961f2e1 100644 --- a/sqlspec/adapters/mysqlconnector/litestar/store.py +++ b/sqlspec/adapters/mysqlconnector/litestar/store.py @@ -70,32 +70,27 @@ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "by try: await cursor.execute(sql, (key,)) row = await cursor.fetchone() + if row is None: + return None + + data_value: Any = row[0] + expires_at: Any = row[1] + + if renew_for is not None and expires_at is not None: + new_expires_at = self._calculate_expires_at(renew_for) + if new_expires_at is not None: + naive_expires_at = new_expires_at.replace(tzinfo=None) + update_sql = f""" + UPDATE {self._table_name} + SET expires_at = %s, updated_at = UTC_TIMESTAMP(6) + WHERE session_id = %s + """ + await cursor.execute(update_sql, (naive_expires_at, key)) + await conn.commit() + + return bytes(cast("bytes", data_value)) finally: await cursor.close() - - if row is None: - return None - - data_value: Any = row[0] - expires_at: Any = row[1] - - if renew_for is not None and expires_at is not None: - new_expires_at = self._calculate_expires_at(renew_for) - if new_expires_at is not None: - naive_expires_at = new_expires_at.replace(tzinfo=None) - update_sql = f""" - UPDATE {self._table_name} - SET expires_at = %s, updated_at = UTC_TIMESTAMP(6) - WHERE session_id = %s - """ - update_cursor = await conn.cursor() - try: - await update_cursor.execute(update_sql, (naive_expires_at, key)) - finally: - await update_cursor.close() - await conn.commit() - - return bytes(cast("bytes", data_value)) except MysqlConnectorError as exc: if "doesn't exist" in str(exc) or getattr(exc, "errno", None) == MYSQL_TABLE_NOT_FOUND_ERROR: return None @@ -181,43 +176,55 @@ async def expires_in(self, key: str) -> "int | None": WHERE session_id = %s """ - async with self._config.provide_connection() as conn: - cursor = await conn.cursor() - try: - await cursor.execute(sql, (key,)) - row = await cursor.fetchone() - finally: - await cursor.close() + try: + async with self._config.provide_connection() as conn: + cursor = await conn.cursor() + try: + await cursor.execute(sql, (key,)) + row = await cursor.fetchone() + finally: + await cursor.close() - if row is None or row[0] is None: - return None + if row is None or row[0] is None: + return None - expires_at_naive: datetime = cast("datetime", row[0]) - expires_at_utc = expires_at_naive.replace(tzinfo=UTC) - now = datetime.now(UTC) + expires_at_naive: datetime = cast("datetime", row[0]) + expires_at_utc = expires_at_naive.replace(tzinfo=UTC) + now = datetime.now(UTC) - if expires_at_utc <= now: - return 0 + if expires_at_utc <= now: + return 0 - delta = expires_at_utc - now - return int(delta.total_seconds()) + delta = expires_at_utc - now + return int(delta.total_seconds()) + except MysqlConnectorError as exc: + if "doesn't exist" in str(exc) or getattr(exc, "errno", None) == MYSQL_TABLE_NOT_FOUND_ERROR: + return None + raise async def delete_expired(self) -> int: sql = f"DELETE FROM {self._table_name} WHERE expires_at <= UTC_TIMESTAMP(6)" - async with self._config.provide_connection() as conn: - cursor = await conn.cursor() - try: - await cursor.execute(sql) - await conn.commit() - count: int = cursor.rowcount - finally: - await cursor.close() - if count > 0: - self._log_delete_expired(count) - return count + try: + async with self._config.provide_connection() as conn: + cursor = await conn.cursor() + try: + await cursor.execute(sql) + await conn.commit() + raw_count = cursor.rowcount + count: int = raw_count if raw_count and raw_count > 0 else 0 + finally: + await cursor.close() + if count > 0: + self._log_delete_expired(count) + return count + except MysqlConnectorError as exc: + if "doesn't exist" in str(exc) or getattr(exc, "errno", None) == MYSQL_TABLE_NOT_FOUND_ERROR: + return 0 + raise def _table_ddl(self) -> str: + safe_index_table = self._table_name.replace(".", "_") return f""" CREATE TABLE IF NOT EXISTS {self._table_name} ( session_id VARCHAR(255) PRIMARY KEY, @@ -225,13 +232,14 @@ def _table_ddl(self) -> str: expires_at DATETIME(6), created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), - INDEX idx_{self._table_name}_expires_at (expires_at){self._index_options} + INDEX idx_{safe_index_table}_expires_at (expires_at){self._index_options} ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci{self._table_options} """ def _drop_table_sql(self) -> "list[str]": + safe_index_table = self._table_name.replace(".", "_") return [ - f"DROP INDEX idx_{self._table_name}_expires_at ON {self._table_name}", + f"DROP INDEX idx_{safe_index_table}_expires_at ON {self._table_name}", f"DROP TABLE IF EXISTS {self._table_name}", ] @@ -277,6 +285,7 @@ async def delete_expired(self) -> int: return await async_(self._delete_expired)() def _table_ddl(self) -> str: + safe_index_table = self._table_name.replace(".", "_") return f""" CREATE TABLE IF NOT EXISTS {self._table_name} ( session_id VARCHAR(255) PRIMARY KEY, @@ -284,13 +293,14 @@ def _table_ddl(self) -> str: expires_at DATETIME(6), created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), - INDEX idx_{self._table_name}_expires_at (expires_at){self._index_options} + INDEX idx_{safe_index_table}_expires_at (expires_at){self._index_options} ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci{self._table_options} """ def _drop_table_sql(self) -> "list[str]": + safe_index_table = self._table_name.replace(".", "_") return [ - f"DROP INDEX idx_{self._table_name}_expires_at ON {self._table_name}", + f"DROP INDEX idx_{safe_index_table}_expires_at ON {self._table_name}", f"DROP TABLE IF EXISTS {self._table_name}", ] @@ -315,32 +325,27 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | try: cursor.execute(sql, (key,)) row = cursor.fetchone() + if row is None: + return None + + data_value: Any = row[0] + expires_at: Any = row[1] + + if renew_for is not None and expires_at is not None: + new_expires_at = self._calculate_expires_at(renew_for) + if new_expires_at is not None: + naive_expires_at = new_expires_at.replace(tzinfo=None) + update_sql = f""" + UPDATE {self._table_name} + SET expires_at = %s, updated_at = UTC_TIMESTAMP(6) + WHERE session_id = %s + """ + cursor.execute(update_sql, (naive_expires_at, key)) + conn.commit() + + return bytes(cast("bytes", data_value)) finally: cursor.close() - - if row is None: - return None - - data_value: Any = row[0] - expires_at: Any = row[1] - - if renew_for is not None and expires_at is not None: - new_expires_at = self._calculate_expires_at(renew_for) - if new_expires_at is not None: - naive_expires_at = new_expires_at.replace(tzinfo=None) - update_sql = f""" - UPDATE {self._table_name} - SET expires_at = %s, updated_at = UTC_TIMESTAMP(6) - WHERE session_id = %s - """ - update_cursor = conn.cursor() - try: - update_cursor.execute(update_sql, (naive_expires_at, key)) - finally: - update_cursor.close() - conn.commit() - - return bytes(cast("bytes", data_value)) except MysqlConnectorError as exc: if "doesn't exist" in str(exc) or getattr(exc, "errno", None) == MYSQL_TABLE_NOT_FOUND_ERROR: return None @@ -426,41 +431,52 @@ def _expires_in(self, key: str) -> "int | None": WHERE session_id = %s """ - with self._config.provide_connection() as conn: - cursor = conn.cursor() - try: - cursor.execute(sql, (key,)) - row = cursor.fetchone() - finally: - cursor.close() + try: + with self._config.provide_connection() as conn: + cursor = conn.cursor() + try: + cursor.execute(sql, (key,)) + row = cursor.fetchone() + finally: + cursor.close() - if row is None or row[0] is None: - return None + if row is None or row[0] is None: + return None - expires_at_naive: datetime = cast("datetime", row[0]) - expires_at_utc = expires_at_naive.replace(tzinfo=UTC) - now = datetime.now(UTC) + expires_at_naive: datetime = cast("datetime", row[0]) + expires_at_utc = expires_at_naive.replace(tzinfo=UTC) + now = datetime.now(UTC) - if expires_at_utc <= now: - return 0 + if expires_at_utc <= now: + return 0 - delta = expires_at_utc - now - return int(delta.total_seconds()) + delta = expires_at_utc - now + return int(delta.total_seconds()) + except MysqlConnectorError as exc: + if "doesn't exist" in str(exc) or getattr(exc, "errno", None) == MYSQL_TABLE_NOT_FOUND_ERROR: + return None + raise def _delete_expired(self) -> int: sql = f"DELETE FROM {self._table_name} WHERE expires_at <= UTC_TIMESTAMP(6)" - with self._config.provide_connection() as conn: - cursor = conn.cursor() - try: - cursor.execute(sql) - conn.commit() - count: int = cursor.rowcount - finally: - cursor.close() - if count > 0: - self._log_delete_expired(count) - return count + try: + with self._config.provide_connection() as conn: + cursor = conn.cursor() + try: + cursor.execute(sql) + conn.commit() + raw_count = cursor.rowcount + count: int = raw_count if raw_count and raw_count > 0 else 0 + finally: + cursor.close() + if count > 0: + self._log_delete_expired(count) + return count + except MysqlConnectorError as exc: + if "doesn't exist" in str(exc) or getattr(exc, "errno", None) == MYSQL_TABLE_NOT_FOUND_ERROR: + return 0 + raise def _mysql_table_options(litestar_config: "dict[str, Any]") -> str: diff --git a/sqlspec/adapters/oracledb/__init__.py b/sqlspec/adapters/oracledb/__init__.py index 83c91aa9e..3d2c43c6f 100644 --- a/sqlspec/adapters/oracledb/__init__.py +++ b/sqlspec/adapters/oracledb/__init__.py @@ -1,3 +1,5 @@ +"""OracleDB database adapter for SQLSpec.""" + from sqlspec.adapters.oracledb.config import ( OracleAsyncConfig, OracleConnectionParams, diff --git a/sqlspec/adapters/oracledb/_json_handlers.py b/sqlspec/adapters/oracledb/_json_handlers.py index a8a959b5c..8d4243046 100644 --- a/sqlspec/adapters/oracledb/_json_handlers.py +++ b/sqlspec/adapters/oracledb/_json_handlers.py @@ -48,12 +48,11 @@ DB_TYPE_NVARCHAR, DB_TYPE_VARCHAR, ) -from sqlspec.adapters.oracledb.data_dictionary import resolve_oracle_connection_major -from sqlspec.data_dictionary.dialects.oracle import ( - ORACLE_JSON_STORAGE_BLOB_JSON, - ORACLE_JSON_STORAGE_NATIVE, - resolve_oracle_json_storage, +from sqlspec.adapters.oracledb.data_dictionary import ( + resolve_oracle_connection_compatible_major, + resolve_oracle_connection_major, ) +from sqlspec.data_dictionary.dialects.oracle import ORACLE_JSON_STORAGE_NATIVE, resolve_oracle_json_storage from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: @@ -132,19 +131,6 @@ def json_output_type_handler(cursor: "Cursor | AsyncCursor", metadata: Any) -> A return _output_type_handler(cursor, metadata) -def _has_handler(handler: Any, target_inner: Any, chain_function: Any) -> bool: - current = handler - while current is not None: - if current is target_inner: - return True - if not isinstance(current, partial) or current.func is not chain_function: - return False - inner, current = current.args - if inner is target_inner: - return True - return False - - def register_json_handlers(connection: "Connection | AsyncConnection") -> None: """Register JSON type handlers on an Oracle connection idempotently. @@ -185,13 +171,10 @@ def chain_output_handler(inner: Any, fallback: "Any | None") -> Any: def is_json_payload(value: Any) -> bool: """Return True if the value should be claimed by the JSON input handler. - ``OracleJson``, ``dict``, and ``tuple``/``list`` of dicts are claimed. - Unwrapped sequences whose first element is a number are NOT claimed — those - are vector embeddings and belong to the vector handler. - - An unwrapped empty sequence is ambiguous (could be empty vector or empty - list) and defers to the next handler in the chain. Sequences of numbers - (vector embeddings) are rejected unless wrapped in ``OracleJson``. + ``OracleJson``, ``dict``, empty ``list``/``tuple``, and ``list``/``tuple`` + containing any non-numeric element are claimed. Non-empty sequences where + all elements are ``int``/``float`` (excluding ``bool``) are reserved for + the vector handler unless wrapped in ``OracleJson``. """ if isinstance(value, OracleJson): return True @@ -199,9 +182,21 @@ def is_json_payload(value: Any) -> bool: return True if isinstance(value, (list, tuple)): if not value: + return True + return not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value) + return False + + +def _has_handler(handler: Any, target_inner: Any, chain_function: Any) -> bool: + current = handler + while current is not None: + if current is target_inner: + return True + if not isinstance(current, partial) or current.func is not chain_function: return False - first = value[0] - return not (isinstance(first, (int, float)) and not isinstance(first, bool)) + inner, current = current.args + if inner is target_inner: + return True return False @@ -217,14 +212,13 @@ def _input_type_handler(cursor: "Cursor | AsyncCursor", value: Any, arraysize: i return cursor.var(DB_TYPE_JSON, arraysize=arraysize, inconverter=json_converter_in_native) return cursor.var(DB_TYPE_JSON, arraysize=arraysize) - storage = resolve_oracle_json_storage(server_major) + compatible_major = resolve_oracle_connection_compatible_major(cursor.connection) + storage = resolve_oracle_json_storage(server_major, compatible_major=compatible_major) if storage == ORACLE_JSON_STORAGE_NATIVE: if isinstance(value, OracleJson): return cursor.var(DB_TYPE_JSON, arraysize=arraysize, inconverter=json_converter_in_native) return cursor.var(DB_TYPE_JSON, arraysize=arraysize) - if storage == ORACLE_JSON_STORAGE_BLOB_JSON: - return cursor.var(DB_TYPE_BLOB, arraysize=arraysize, inconverter=json_converter_in_blob) - return cursor.var(DB_TYPE_CLOB, arraysize=arraysize, inconverter=json_converter_in_clob) + return cursor.var(DB_TYPE_BLOB, arraysize=arraysize, inconverter=json_converter_in_blob) def _output_type_handler(cursor: "Cursor | AsyncCursor", metadata: Any) -> Any: diff --git a/sqlspec/adapters/oracledb/_storage.py b/sqlspec/adapters/oracledb/_storage.py index 603581320..7700ba7cf 100644 --- a/sqlspec/adapters/oracledb/_storage.py +++ b/sqlspec/adapters/oracledb/_storage.py @@ -97,6 +97,13 @@ def oracle_table_feature_report( return {"applied": tuple(applied), "clause": clause} +def validate_oracle_identifier(value: str, field_name: str) -> str: + if not value or not value.replace("_", "").isalnum() or value[0].isdigit(): + msg = f"Oracle {field_name} must be a simple SQL identifier" + raise ValueError(msg) + return value + + def _oracle_partition_clause( partitioning: Mapping[str, Any], table_kind: str, hash_partition_key: str, range_partition_key: str ) -> str: @@ -126,10 +133,3 @@ def _oracle_partition_clause( return f"PARTITION BY RANGE ({partition_key}) INTERVAL ({interval_sql}) (PARTITION p_initial VALUES LESS THAN ({initial_less_than}))" msg = f"Unsupported Oracle partitioning strategy {strategy!r}. Supported values: hash, range" raise ValueError(msg) - - -def validate_oracle_identifier(value: str, field_name: str) -> str: - if not value or not value.replace("_", "").isalnum() or value[0].isdigit(): - msg = f"Oracle {field_name} must be a simple SQL identifier" - raise ValueError(msg) - return value diff --git a/sqlspec/adapters/oracledb/_typing.py b/sqlspec/adapters/oracledb/_typing.py index 9ac2e396e..353dbc5e2 100644 --- a/sqlspec/adapters/oracledb/_typing.py +++ b/sqlspec/adapters/oracledb/_typing.py @@ -5,7 +5,8 @@ """ import contextlib -from typing import TYPE_CHECKING, Any +import importlib +from typing import TYPE_CHECKING, Any, cast import oracledb as oracledb_module from oracledb import ( @@ -44,20 +45,12 @@ from sqlspec.adapters.oracledb.driver import OracleAsyncDriver, OracleSyncDriver from sqlspec.core import StatementConfig - OracleSyncConnection: TypeAlias = Connection - OracleAsyncConnection: TypeAlias = AsyncConnection - OracleSyncConnectionPool: TypeAlias = ConnectionPool - OracleAsyncConnectionPool: TypeAlias = AsyncConnectionPool - OracleSyncRawCursor: TypeAlias = Cursor - OracleAsyncRawCursor: TypeAlias = AsyncCursor - -if not TYPE_CHECKING: - OracleSyncConnection = Connection - OracleAsyncConnection = AsyncConnection - OracleSyncConnectionPool = ConnectionPool - OracleAsyncConnectionPool = AsyncConnectionPool - OracleSyncRawCursor = Cursor - OracleAsyncRawCursor = AsyncCursor +OracleSyncConnection: "TypeAlias" = Connection +OracleAsyncConnection: "TypeAlias" = AsyncConnection +OracleSyncConnectionPool: "TypeAlias" = ConnectionPool +OracleAsyncConnectionPool: "TypeAlias" = AsyncConnectionPool +OracleSyncRawCursor: "TypeAlias" = Cursor +OracleAsyncRawCursor: "TypeAlias" = AsyncCursor __all__ = ( "DB_TYPE_BLOB", @@ -186,10 +179,9 @@ def __init__( self._driver: OracleSyncDriver | None = None def __enter__(self) -> "OracleSyncDriver": - from sqlspec.adapters.oracledb.driver import OracleSyncDriver - + driver_cls = _resolve_sync_driver_type() self._connection = self._acquire_connection() - self._driver = OracleSyncDriver( + self._driver = driver_cls( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -241,10 +233,9 @@ def __init__( self._driver: OracleAsyncDriver | None = None async def __aenter__(self) -> "OracleAsyncDriver": - from sqlspec.adapters.oracledb.driver import OracleAsyncDriver - + driver_cls = _resolve_async_driver_type() self._connection = await self._acquire_connection() - self._driver = OracleAsyncDriver( + self._driver = driver_cls( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -256,3 +247,18 @@ async def __aexit__( await self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) self._connection = None return None + + +def _resolve_sync_driver_type() -> "Callable[..., OracleSyncDriver]": + """Resolve OracleSyncDriver without a function-scoped import.""" + return cast( + "Callable[..., OracleSyncDriver]", importlib.import_module("sqlspec.adapters.oracledb.driver").OracleSyncDriver + ) + + +def _resolve_async_driver_type() -> "Callable[..., OracleAsyncDriver]": + """Resolve OracleAsyncDriver without a function-scoped import.""" + return cast( + "Callable[..., OracleAsyncDriver]", + importlib.import_module("sqlspec.adapters.oracledb.driver").OracleAsyncDriver, + ) diff --git a/sqlspec/adapters/oracledb/_vector_handlers.py b/sqlspec/adapters/oracledb/_vector_handlers.py index d94a00bea..7f0856ae2 100644 --- a/sqlspec/adapters/oracledb/_vector_handlers.py +++ b/sqlspec/adapters/oracledb/_vector_handlers.py @@ -15,8 +15,10 @@ from sqlspec.adapters.oracledb._json_handlers import chain_input_handler, chain_output_handler from sqlspec.adapters.oracledb._typing import DB_TYPE_VECTOR, OracleSparseVector +from sqlspec.adapters.oracledb.data_dictionary import resolve_oracle_connection_major from sqlspec.typing import NUMPY_INSTALLED from sqlspec.utils.logging import get_logger +from sqlspec.utils.module_loader import import_optional if TYPE_CHECKING: from sqlspec.adapters.oracledb._typing import OracleAsyncConnection as AsyncConnection @@ -25,6 +27,7 @@ from sqlspec.adapters.oracledb._typing import OracleSyncRawCursor as Cursor SPARSE_VECTOR_TYPE = OracleSparseVector +np: Any = import_optional("numpy") __all__ = ( "DTYPE_TO_ARRAY_CODE", @@ -49,6 +52,7 @@ _INT8_MIN = -128 _INT8_MAX = 127 +_ORACLE_VECTOR_MIN_MAJOR = 23 _VECTOR_RETURN_NUMPY = "numpy" _VECTOR_RETURN_LIST = "list" @@ -111,8 +115,6 @@ def numpy_converter_out(value: "array.array[Any]") -> Any: if not NUMPY_INSTALLED: return value - import numpy as np - return np.array(value, copy=True, dtype=value.typecode) @@ -162,16 +164,10 @@ def _is_vector_payload(value: Any) -> bool: return False if isinstance(value, array.array): return True - if NUMPY_INSTALLED: - import numpy as np - - if isinstance(value, np.ndarray): - return True + if NUMPY_INSTALLED and isinstance(value, np.ndarray): + return True if isinstance(value, (list, tuple)) and value: - first = value[0] - if isinstance(first, bool): - return False - return isinstance(first, (int, float)) + return all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in value) return False @@ -206,11 +202,15 @@ def _input_type_handler(cursor: "Cursor | AsyncCursor", value: Any, arraysize: i if not _is_vector_payload(value): return None - if NUMPY_INSTALLED: - import numpy as np + connection = getattr(cursor, "connection", None) + if getattr(connection, "_sqlspec_enable_numpy_vectors", True) is False: + return None + server_major = resolve_oracle_connection_major(connection) + if server_major is not None and server_major < _ORACLE_VECTOR_MIN_MAJOR: + return None - if isinstance(value, np.ndarray): - return cursor.var(DB_TYPE_VECTOR, arraysize=arraysize, inconverter=numpy_converter_in) + if NUMPY_INSTALLED and isinstance(value, np.ndarray): + return cursor.var(DB_TYPE_VECTOR, arraysize=arraysize, inconverter=numpy_converter_in) if isinstance(value, array.array): return cursor.var(DB_TYPE_VECTOR, arraysize=arraysize) diff --git a/sqlspec/adapters/oracledb/adk/store.py b/sqlspec/adapters/oracledb/adk/store.py index bb39f3cb4..030a0b580 100644 --- a/sqlspec/adapters/oracledb/adk/store.py +++ b/sqlspec/adapters/oracledb/adk/store.py @@ -8,6 +8,7 @@ from sqlspec import SQL from sqlspec.adapters.oracledb._storage import oracle_table_feature_report, validate_oracle_identifier from sqlspec.adapters.oracledb._typing import DatabaseError as OracleDatabaseError +from sqlspec.adapters.oracledb.core import coerce_large_parameters_async, coerce_large_parameters_sync from sqlspec.adapters.oracledb.data_dictionary import JSONStorageType, OracleVersionInfo, storage_type_from_version from sqlspec.config import ADKConfig from sqlspec.extensions.adk import ( @@ -60,6 +61,11 @@ " key VARCHAR2(128) PRIMARY KEY,\n" " value VARCHAR2(512) NOT NULL\n" " )';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" " " ) @@ -78,16 +84,31 @@ " create_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,\n" " update_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL{2}\n" " ){3}';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" "\n" " BEGIN\n" " EXECUTE IMMEDIATE 'CREATE INDEX idx_{4}_app_user\n" " ON {5}(app_name, user_id)';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" "\n" " BEGIN\n" " EXECUTE IMMEDIATE 'CREATE INDEX idx_{6}_update_time\n" " ON {7}(update_time DESC)';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" " " ) @@ -106,26 +127,51 @@ " CONSTRAINT fk_{2}_session FOREIGN KEY (session_id)\n" " REFERENCES {3}(id) ON DELETE CASCADE\n" " ){4}';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" "\n" " BEGIN\n" " EXECUTE IMMEDIATE 'CREATE INDEX idx_{5}_session\n" " ON {6}(session_id, timestamp ASC)';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" "\n" " BEGIN\n" " EXECUTE IMMEDIATE 'CREATE INDEX idx_{7}_invocation\n" " ON {8}(invocation_id)';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" "\n" " BEGIN\n" " EXECUTE IMMEDIATE 'CREATE INDEX idx_{9}_timestamp\n" " ON {10}(timestamp ASC)';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" "\n" " BEGIN\n" " EXECUTE IMMEDIATE 'CREATE INDEX idx_{11}_app_timestamp\n" " ON {12}(app_name, timestamp ASC)';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" " " ) @@ -138,6 +184,11 @@ " {1},\n" " update_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL\n" " ){2}';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" " " ) @@ -152,6 +203,11 @@ " update_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,\n" " PRIMARY KEY (app_name, user_id)\n" " ){2}';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" " " ) @@ -163,6 +219,11 @@ " BEGIN\n" " EXECUTE IMMEDIATE 'CREATE INDEX idx_{0}_fts\n" " ON {1}(content_text) INDEXTYPE IS CTXSYS.CONTEXT';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" " " ) @@ -183,21 +244,41 @@ " content_text CLOB NOT NULL,\n" " inserted_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL\n" " ){3}';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" "\n" " BEGIN\n" " EXECUTE IMMEDIATE 'CREATE INDEX idx_{4}_app_scope_user_time\n" " ON {5}(app_name, scope, user_id, timestamp DESC)';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" "\n" " BEGIN\n" " EXECUTE IMMEDIATE 'CREATE INDEX idx_{6}_scope\n" " ON {7}(app_name, scope)';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" "\n" " BEGIN\n" " EXECUTE IMMEDIATE 'CREATE INDEX idx_{8}_session\n" " ON {9}(session_id)';\n" + " EXCEPTION\n" + " WHEN OTHERS THEN\n" + " IF SQLCODE != -955 THEN\n" + " RAISE;\n" + " END IF;\n" " END;\n" " {10}\n" " " @@ -406,8 +487,9 @@ async def create_session( params = {"id": session_id, "app_name": app_name, "user_id": user_id, "state": state_data} async with self._config.provide_connection() as conn: + coerced_params = await coerce_large_parameters_async(conn, params) with conn.cursor() as cursor: - await cursor.execute(sql, params) + await cursor.execute(sql, coerced_params) await conn.commit() result = await self.get_session(app_name, user_id, session_id) @@ -499,10 +581,11 @@ async def update_session_state(self, app_name: str, user_id: str, session_id: st """ async with self._config.provide_connection() as conn: + coerced_params = await coerce_large_parameters_async( + conn, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id} + ) with conn.cursor() as cursor: - await cursor.execute( - sql, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id} - ) + await cursor.execute(sql, coerced_params) await conn.commit() async def list_sessions( @@ -600,19 +683,20 @@ async def append_event(self, event_record: StoredEvent) -> None: """ async with self._config.provide_connection() as conn: + coerced_params = await coerce_large_parameters_async( + conn, + { + "id": event_record["id"], + "app_name": event_record["app_name"], + "user_id": event_record["user_id"], + "session_id": event_record["session_id"], + "invocation_id": event_record["invocation_id"], + "timestamp": event_record["timestamp"], + "event_data": await self._serialize_event_data(event_record["event_data"]), + }, + ) with conn.cursor() as cursor: - await cursor.execute( - sql, - { - "id": event_record["id"], - "app_name": event_record["app_name"], - "user_id": event_record["user_id"], - "session_id": event_record["session_id"], - "invocation_id": event_record["invocation_id"], - "timestamp": event_record["timestamp"], - "event_data": await self._serialize_event_data(event_record["event_data"]), - }, - ) + await cursor.execute(sql, coerced_params) await conn.commit() async def append_event_and_update_state( @@ -654,38 +738,39 @@ async def append_event_and_update_state( app_upsert_sql = f""" MERGE INTO {self._app_state_table} target - USING (SELECT :app_name AS app_name, :state AS state FROM DUAL) source + USING (SELECT :app_name AS app_name FROM DUAL) source ON (target.app_name = source.app_name) WHEN MATCHED THEN - UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP + UPDATE SET target.state = :state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, state, update_time) - VALUES (source.app_name, source.state, SYSTIMESTAMP) + VALUES (source.app_name, :state, SYSTIMESTAMP) """ user_upsert_sql = f""" MERGE INTO {self._user_state_table} target - USING (SELECT :app_name AS app_name, :user_id AS user_id, :state AS state FROM DUAL) source + USING (SELECT :app_name AS app_name, :user_id AS user_id FROM DUAL) source ON (target.app_name = source.app_name AND target.user_id = source.user_id) WHEN MATCHED THEN - UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP + UPDATE SET target.state = :state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, user_id, state, update_time) - VALUES (source.app_name, source.user_id, source.state, SYSTIMESTAMP) + VALUES (source.app_name, source.user_id, :state, SYSTIMESTAMP) """ async with self._config.provide_connection() as conn: with conn.cursor() as cursor: try: - await cursor.execute( - update_sql, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id} + update_params = await coerce_large_parameters_async( + conn, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id} ) + await cursor.execute(update_sql, update_params) await cursor.execute(select_sql, {"app_name": app_name, "user_id": user_id, "id": session_id}) row = await cursor.fetchone() if row is None: _raise_session_not_found(session_id) - await cursor.execute( - insert_sql, + insert_params = await coerce_large_parameters_async( + conn, { "id": event_record["id"], "app_name": event_record["app_name"], @@ -696,19 +781,22 @@ async def append_event_and_update_state( "event_data": await self._serialize_event_data(event_record["event_data"]), }, ) + await cursor.execute(insert_sql, insert_params) if app_state is not None: - await cursor.execute( - app_upsert_sql, {"app_name": app_name, "state": await self._serialize_state(app_state)} + app_params = await coerce_large_parameters_async( + conn, {"app_name": app_name, "state": await self._serialize_state(app_state)} ) + await cursor.execute(app_upsert_sql, app_params) if user_state is not None: - await cursor.execute( - user_upsert_sql, + user_params = await coerce_large_parameters_async( + conn, { "app_name": app_name, "user_id": user_id, "state": await self._serialize_state(user_state), }, ) + await cursor.execute(user_upsert_sql, user_params) await conn.commit() except Exception: await conn.rollback() @@ -889,38 +977,42 @@ async def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None """Insert or replace app-scoped state for an application.""" sql = f""" MERGE INTO {self._app_state_table} target - USING (SELECT :app_name AS app_name, :state AS state FROM DUAL) source + USING (SELECT :app_name AS app_name FROM DUAL) source ON (target.app_name = source.app_name) WHEN MATCHED THEN - UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP + UPDATE SET target.state = :state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, state, update_time) - VALUES (source.app_name, source.state, SYSTIMESTAMP) + VALUES (source.app_name, :state, SYSTIMESTAMP) """ async with self._config.provide_connection() as conn: + coerced_params = await coerce_large_parameters_async( + conn, {"app_name": app_name, "state": await self._serialize_state(state)} + ) with conn.cursor() as cursor: - await cursor.execute(sql, {"app_name": app_name, "state": await self._serialize_state(state)}) + await cursor.execute(sql, coerced_params) await conn.commit() async def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: """Insert or replace user-scoped state for an application user.""" sql = f""" MERGE INTO {self._user_state_table} target - USING (SELECT :app_name AS app_name, :user_id AS user_id, :state AS state FROM DUAL) source + USING (SELECT :app_name AS app_name, :user_id AS user_id FROM DUAL) source ON (target.app_name = source.app_name AND target.user_id = source.user_id) WHEN MATCHED THEN - UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP + UPDATE SET target.state = :state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, user_id, state, update_time) - VALUES (source.app_name, source.user_id, source.state, SYSTIMESTAMP) + VALUES (source.app_name, source.user_id, :state, SYSTIMESTAMP) """ async with self._config.provide_connection() as conn: + coerced_params = await coerce_large_parameters_async( + conn, {"app_name": app_name, "user_id": user_id, "state": await self._serialize_state(state)} + ) with conn.cursor() as cursor: - await cursor.execute( - sql, {"app_name": app_name, "user_id": user_id, "state": await self._serialize_state(state)} - ) + await cursor.execute(sql, coerced_params) await conn.commit() async def get_metadata(self, key: str) -> "str | None": @@ -1110,7 +1202,7 @@ def _sessions_table_ddl_for_type(self, storage_type: JSONStorageType) -> str: state_column = "state BLOB NOT NULL" owner_id_column_sql = ( - _ADK_SESSIONS_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._owner_id_column_ddl) + _ADK_SESSIONS_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._owner_id_column_ddl.replace("'", "''")) if self._owner_id_column_ddl else "" ) @@ -1416,7 +1508,8 @@ def create_session( params = {"id": session_id, "app_name": app_name, "user_id": user_id, "state": state_data} with self._config.provide_connection() as conn, conn.cursor() as cursor: - cursor.execute(sql, params) + coerced_params = coerce_large_parameters_sync(conn, params) + cursor.execute(sql, coerced_params) conn.commit() result = self.get_session(app_name, user_id, session_id) @@ -1506,7 +1599,10 @@ def update_session_state(self, app_name: str, user_id: str, session_id: str, sta """ with self._config.provide_connection() as conn, conn.cursor() as cursor: - cursor.execute(sql, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id}) + params = coerce_large_parameters_sync( + conn, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id} + ) + cursor.execute(sql, params) conn.commit() def list_sessions( @@ -1598,8 +1694,8 @@ def append_event(self, event_record: StoredEvent) -> None: """ with self._config.provide_connection() as conn, conn.cursor() as cursor: - cursor.execute( - sql, + params = coerce_large_parameters_sync( + conn, { "id": event_record["id"], "app_name": event_record["app_name"], @@ -1610,6 +1706,7 @@ def append_event(self, event_record: StoredEvent) -> None: "event_data": self._serialize_event_data(event_record["event_data"]), }, ) + cursor.execute(sql, params) conn.commit() def append_event_and_update_state( @@ -1647,37 +1744,38 @@ def append_event_and_update_state( app_upsert_sql = f""" MERGE INTO {self._app_state_table} target - USING (SELECT :app_name AS app_name, :state AS state FROM DUAL) source + USING (SELECT :app_name AS app_name FROM DUAL) source ON (target.app_name = source.app_name) WHEN MATCHED THEN - UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP + UPDATE SET target.state = :state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, state, update_time) - VALUES (source.app_name, source.state, SYSTIMESTAMP) + VALUES (source.app_name, :state, SYSTIMESTAMP) """ user_upsert_sql = f""" MERGE INTO {self._user_state_table} target - USING (SELECT :app_name AS app_name, :user_id AS user_id, :state AS state FROM DUAL) source + USING (SELECT :app_name AS app_name, :user_id AS user_id FROM DUAL) source ON (target.app_name = source.app_name AND target.user_id = source.user_id) WHEN MATCHED THEN - UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP + UPDATE SET target.state = :state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, user_id, state, update_time) - VALUES (source.app_name, source.user_id, source.state, SYSTIMESTAMP) + VALUES (source.app_name, source.user_id, :state, SYSTIMESTAMP) """ with self._config.provide_connection() as conn, conn.cursor() as cursor: try: - cursor.execute( - update_sql, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id} + update_params = coerce_large_parameters_sync( + conn, {"state": state_data, "app_name": app_name, "user_id": user_id, "id": session_id} ) + cursor.execute(update_sql, update_params) cursor.execute(select_sql, {"app_name": app_name, "user_id": user_id, "id": session_id}) row = cursor.fetchone() if row is None: _raise_session_not_found(session_id) - cursor.execute( - insert_sql, + insert_params = coerce_large_parameters_sync( + conn, { "id": event_record["id"], "app_name": event_record["app_name"], @@ -1688,13 +1786,17 @@ def append_event_and_update_state( "event_data": self._serialize_event_data(event_record["event_data"]), }, ) + cursor.execute(insert_sql, insert_params) if app_state is not None: - cursor.execute(app_upsert_sql, {"app_name": app_name, "state": self._serialize_state(app_state)}) + app_params = coerce_large_parameters_sync( + conn, {"app_name": app_name, "state": self._serialize_state(app_state)} + ) + cursor.execute(app_upsert_sql, app_params) if user_state is not None: - cursor.execute( - user_upsert_sql, - {"app_name": app_name, "user_id": user_id, "state": self._serialize_state(user_state)}, + user_params = coerce_large_parameters_sync( + conn, {"app_name": app_name, "user_id": user_id, "state": self._serialize_state(user_state)} ) + cursor.execute(user_upsert_sql, user_params) conn.commit() except Exception: conn.rollback() @@ -1869,34 +1971,38 @@ def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: """Insert or replace app-scoped state for an application.""" sql = f""" MERGE INTO {self._app_state_table} target - USING (SELECT :app_name AS app_name, :state AS state FROM DUAL) source + USING (SELECT :app_name AS app_name FROM DUAL) source ON (target.app_name = source.app_name) WHEN MATCHED THEN - UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP + UPDATE SET target.state = :state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, state, update_time) - VALUES (source.app_name, source.state, SYSTIMESTAMP) + VALUES (source.app_name, :state, SYSTIMESTAMP) """ with self._config.provide_connection() as conn, conn.cursor() as cursor: - cursor.execute(sql, {"app_name": app_name, "state": self._serialize_state(state)}) + params = coerce_large_parameters_sync(conn, {"app_name": app_name, "state": self._serialize_state(state)}) + cursor.execute(sql, params) conn.commit() def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: """Insert or replace user-scoped state for an application user.""" sql = f""" MERGE INTO {self._user_state_table} target - USING (SELECT :app_name AS app_name, :user_id AS user_id, :state AS state FROM DUAL) source + USING (SELECT :app_name AS app_name, :user_id AS user_id FROM DUAL) source ON (target.app_name = source.app_name AND target.user_id = source.user_id) WHEN MATCHED THEN - UPDATE SET target.state = source.state, target.update_time = SYSTIMESTAMP + UPDATE SET target.state = :state, target.update_time = SYSTIMESTAMP WHEN NOT MATCHED THEN INSERT (app_name, user_id, state, update_time) - VALUES (source.app_name, source.user_id, source.state, SYSTIMESTAMP) + VALUES (source.app_name, source.user_id, :state, SYSTIMESTAMP) """ with self._config.provide_connection() as conn, conn.cursor() as cursor: - cursor.execute(sql, {"app_name": app_name, "user_id": user_id, "state": self._serialize_state(state)}) + params = coerce_large_parameters_sync( + conn, {"app_name": app_name, "user_id": user_id, "state": self._serialize_state(state)} + ) + cursor.execute(sql, params) conn.commit() def get_metadata(self, key: str) -> "str | None": @@ -2079,10 +2185,9 @@ def _sessions_table_ddl_for_type(self, storage_type: JSONStorageType) -> str: else: state_column = "state BLOB NOT NULL" + escaped_owner_id_ddl = self._owner_id_column_ddl.replace("'", "''") if self._owner_id_column_ddl else "" owner_id_column_sql = ( - _ADK_SESSIONS_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._owner_id_column_ddl) - if self._owner_id_column_ddl - else "" + _ADK_SESSIONS_TABLE_DDL_FOR_TYPE_TEMPLATE.format(escaped_owner_id_ddl) if escaped_owner_id_ddl else "" ) table_clauses = _adk_table_feature_clause( self._config, @@ -2276,6 +2381,7 @@ class OracleAsyncADKMemoryStore(BaseAsyncADKMemoryStore["OracleAsyncConfig"]): def __init__(self, config: "OracleAsyncConfig") -> None: super().__init__(config) + validate_oracle_identifier(self._memory_table, "table name") adk_config = _adk_config(config) self._in_memory: bool = bool(adk_config.get("in_memory", False)) @@ -2318,7 +2424,7 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " for entry in entries: content_json = await self._serialize_json_field(entry["content_json"]) metadata_json = await self._serialize_json_field(entry["metadata_json"]) - params = { + params: dict[str, Any] = { "id": entry["id"], "session_id": entry["session_id"], "app_name": entry["app_name"], @@ -2333,7 +2439,8 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " "inserted_at": entry["inserted_at"], } if self._owner_id_column_name: - params["owner_id"] = str(owner_id) if owner_id is not None else None + params["owner_id"] = owner_id + params = await coerce_large_parameters_async(conn, params) if await self._execute_insert_entry(cursor, sql, params): inserted_count += 1 await conn.commit() @@ -2448,10 +2555,9 @@ def _memory_table_ddl_for_type(self, storage_type: "JSONStorageType") -> str: metadata_json BLOB """ + escaped_owner_id_ddl = self._owner_id_column_ddl.replace("'", "''") if self._owner_id_column_ddl else "" owner_id_line = ( - _ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._owner_id_column_ddl) - if self._owner_id_column_ddl - else "" + _ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE.format(escaped_owner_id_ddl) if escaped_owner_id_ddl else "" ) table_clauses = _adk_table_feature_clause( self._config, @@ -2480,7 +2586,7 @@ def _memory_table_ddl_for_type(self, storage_type: "JSONStorageType") -> str: ) def _drop_memory_table_sql(self) -> "list[str]": - return [ + statements = [ f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_session'; @@ -2493,7 +2599,7 @@ def _drop_memory_table_sql(self) -> "list[str]": """, f""" BEGIN - EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_app_user_time'; + EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_app_scope_user_time'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN @@ -2503,15 +2609,41 @@ def _drop_memory_table_sql(self) -> "list[str]": """, f""" BEGIN - EXECUTE IMMEDIATE 'DROP TABLE {self._memory_table}'; + EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_scope'; EXCEPTION WHEN OTHERS THEN - IF SQLCODE != -942 THEN + IF SQLCODE != -1418 THEN RAISE; END IF; END; """, ] + if self._use_fts: + statements.append( + f""" + BEGIN + EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_fts'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -1418 THEN + RAISE; + END IF; + END; + """ + ) + statements.append( + f""" + BEGIN + EXECUTE IMMEDIATE 'DROP TABLE {self._memory_table}'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -942 THEN + RAISE; + END IF; + END; + """ + ) + return statements async def _execute_insert_entry(self, cursor: Any, sql: str, params: "dict[str, Any]") -> bool: """Execute an insert and skip duplicate key errors.""" @@ -2607,6 +2739,7 @@ class OracleSyncADKMemoryStore(BaseSyncADKMemoryStore["OracleSyncConfig"]): def __init__(self, config: "OracleSyncConfig") -> None: super().__init__(config) + validate_oracle_identifier(self._memory_table, "table name") adk_config = _adk_config(config) self._in_memory = bool(adk_config.get("in_memory", False)) @@ -2650,7 +2783,7 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object for entry in entries: content_json = self._serialize_json_field(entry["content_json"]) metadata_json = self._serialize_json_field(entry["metadata_json"]) - params = { + params: dict[str, Any] = { "id": entry["id"], "session_id": entry["session_id"], "app_name": entry["app_name"], @@ -2665,7 +2798,8 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object "inserted_at": entry["inserted_at"], } if self._owner_id_column_name: - params["owner_id"] = str(owner_id) if owner_id is not None else None + params["owner_id"] = owner_id + params = coerce_large_parameters_sync(conn, params) if self._execute_insert_entry(cursor, sql, params): inserted_count += 1 conn.commit() @@ -2779,10 +2913,9 @@ def _memory_table_ddl_for_type(self, storage_type: "JSONStorageType") -> str: metadata_json BLOB """ + escaped_owner_id_ddl = self._owner_id_column_ddl.replace("'", "''") if self._owner_id_column_ddl else "" owner_id_line = ( - _ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE.format(self._owner_id_column_ddl) - if self._owner_id_column_ddl - else "" + _ADK_MEMORY_TABLE_DDL_FOR_TYPE_TEMPLATE.format(escaped_owner_id_ddl) if escaped_owner_id_ddl else "" ) table_clauses = _adk_table_feature_clause( self._config, @@ -2811,7 +2944,7 @@ def _memory_table_ddl_for_type(self, storage_type: "JSONStorageType") -> str: ) def _drop_memory_table_sql(self) -> "list[str]": - return [ + statements = [ f""" BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_session'; @@ -2824,7 +2957,7 @@ def _drop_memory_table_sql(self) -> "list[str]": """, f""" BEGIN - EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_app_user_time'; + EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_app_scope_user_time'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN @@ -2834,15 +2967,41 @@ def _drop_memory_table_sql(self) -> "list[str]": """, f""" BEGIN - EXECUTE IMMEDIATE 'DROP TABLE {self._memory_table}'; + EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_scope'; EXCEPTION WHEN OTHERS THEN - IF SQLCODE != -942 THEN + IF SQLCODE != -1418 THEN RAISE; END IF; END; """, ] + if self._use_fts: + statements.append( + f""" + BEGIN + EXECUTE IMMEDIATE 'DROP INDEX idx_{self._memory_table}_fts'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -1418 THEN + RAISE; + END IF; + END; + """ + ) + statements.append( + f""" + BEGIN + EXECUTE IMMEDIATE 'DROP TABLE {self._memory_table}'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -942 THEN + RAISE; + END IF; + END; + """ + ) + return statements def _execute_insert_entry(self, cursor: Any, sql: str, params: "dict[str, Any]") -> bool: """Execute an insert and skip duplicate key errors.""" diff --git a/sqlspec/adapters/oracledb/config.py b/sqlspec/adapters/oracledb/config.py index a7954938e..ff43a94bf 100644 --- a/sqlspec/adapters/oracledb/config.py +++ b/sqlspec/adapters/oracledb/config.py @@ -24,7 +24,11 @@ from sqlspec.adapters.oracledb._uuid_handlers import register_uuid_handlers from sqlspec.adapters.oracledb._vector_handlers import register_numpy_handlers from sqlspec.adapters.oracledb.core import apply_driver_features, build_connection_config, default_statement_config -from sqlspec.adapters.oracledb.data_dictionary import OracleVersionCache, resolve_oracle_connection_major +from sqlspec.adapters.oracledb.data_dictionary import ( + OracleVersionCache, + resolve_oracle_connection_compatible_major, + resolve_oracle_connection_major, +) from sqlspec.adapters.oracledb.driver import ( OracleAsyncDriver, OracleAsyncExceptionHandler, @@ -66,6 +70,7 @@ OraclePoolBoundary = Literal["statement", "transaction"] OracleVectorReturnFormat = Literal["array", "list", "numpy"] OracleEventsBackend = Literal["aq", "poll_queue", "txeventq"] +_ORACLE_VECTOR_MIN_MAJOR = 23 class OracleConnectionParams(TypedDict): @@ -426,30 +431,31 @@ def _create_pool(self) -> "OracleSyncConnectionPool": def _init_connection(self, connection: "OracleSyncConnection", tag: str) -> None: """Initialize connection with type handlers and cached server metadata. - Registers vector, JSON, and UUID handlers. Vector and JSON handlers run - unconditionally — both gate any optional dependencies (NumPy in the - vector case) internally. UUID registration remains gated for - backwards compatibility with existing user configurations. - - Caches major version and vector return format on the connection when - dynamic attributes are supported. - Args: connection: Oracle connection to initialize. tag: Connection tag for session state. """ - register_numpy_handlers(connection) - - register_json_handlers(connection) - - if self.driver_features.get("enable_uuid_binary", False): - register_uuid_handlers(connection) + server_major = resolve_oracle_connection_major(connection, self._oracle_version_cache) + compatible_major = resolve_oracle_connection_compatible_major(connection, self._oracle_version_cache) + enable_vectors = bool(self.driver_features.get("enable_numpy_vectors", True)) conn_any = cast("Any", connection) with contextlib.suppress(AttributeError): - conn_any._sqlspec_oracle_major = resolve_oracle_connection_major(connection, self._oracle_version_cache) + conn_any._sqlspec_oracle_major = server_major + with contextlib.suppress(AttributeError): + conn_any._sqlspec_oracle_compatible_major = compatible_major with contextlib.suppress(AttributeError): conn_any._sqlspec_vector_return_format = self.driver_features.get("vector_return_format") + with contextlib.suppress(AttributeError): + conn_any._sqlspec_enable_numpy_vectors = enable_vectors + + if enable_vectors and (server_major is None or server_major >= _ORACLE_VECTOR_MIN_MAJOR): + register_numpy_handlers(connection) + + register_json_handlers(connection) + + if self.driver_features.get("enable_uuid_binary", False): + register_uuid_handlers(connection) if self._pool_session_callback is not None: self._pool_session_callback(connection, tag) @@ -623,27 +629,31 @@ async def _create_pool(self) -> "OracleAsyncConnectionPool": async def _init_connection(self, connection: "OracleAsyncConnection", tag: str) -> None: """Initialize async connection with type handlers and cached server metadata. - Registers vector, JSON, and UUID handlers. Vector and JSON registration - is unconditional — both gate any optional dependencies (NumPy in the - vector case) internally. Caches major version and vector return format - on the connection when dynamic attributes are supported. - Args: connection: Oracle async connection to initialize. tag: Connection tag for session state. """ - register_numpy_handlers(connection) - - register_json_handlers(connection) - - if self.driver_features.get("enable_uuid_binary", False): - register_uuid_handlers(connection) + server_major = resolve_oracle_connection_major(connection, self._oracle_version_cache) + compatible_major = resolve_oracle_connection_compatible_major(connection, self._oracle_version_cache) + enable_vectors = bool(self.driver_features.get("enable_numpy_vectors", True)) conn_any = cast("Any", connection) with contextlib.suppress(AttributeError): - conn_any._sqlspec_oracle_major = resolve_oracle_connection_major(connection, self._oracle_version_cache) + conn_any._sqlspec_oracle_major = server_major + with contextlib.suppress(AttributeError): + conn_any._sqlspec_oracle_compatible_major = compatible_major with contextlib.suppress(AttributeError): conn_any._sqlspec_vector_return_format = self.driver_features.get("vector_return_format") + with contextlib.suppress(AttributeError): + conn_any._sqlspec_enable_numpy_vectors = enable_vectors + + if enable_vectors and (server_major is None or server_major >= _ORACLE_VECTOR_MIN_MAJOR): + register_numpy_handlers(connection) + + register_json_handlers(connection) + + if self.driver_features.get("enable_uuid_binary", False): + register_uuid_handlers(connection) if self._pool_session_callback is not None: session_callback_result = self._pool_session_callback(connection, tag) diff --git a/sqlspec/adapters/oracledb/core.py b/sqlspec/adapters/oracledb/core.py index 704e57c51..b8df3ddfe 100644 --- a/sqlspec/adapters/oracledb/core.py +++ b/sqlspec/adapters/oracledb/core.py @@ -8,7 +8,10 @@ from sqlspec.adapters.oracledb._json_handlers import is_json_payload from sqlspec.adapters.oracledb._param_types import OracleBlob, OracleClob, OracleJson from sqlspec.adapters.oracledb._typing import DB_TYPE_BLOB, DB_TYPE_CLOB, oracledb_module -from sqlspec.adapters.oracledb.data_dictionary import resolve_oracle_connection_major +from sqlspec.adapters.oracledb.data_dictionary import ( + resolve_oracle_connection_compatible_major, + resolve_oracle_connection_major, +) from sqlspec.adapters.oracledb.type_converter import OracleOutputConverter from sqlspec.core import ( DriverParameterProfile, @@ -18,7 +21,7 @@ build_statement_config_from_profile, create_sql_result, ) -from sqlspec.data_dictionary.dialects.oracle import ORACLE_JSON_STORAGE_BLOB_JSON, resolve_oracle_json_storage +from sqlspec.data_dictionary.dialects.oracle import ORACLE_JSON_STORAGE_NATIVE, resolve_oracle_json_storage from sqlspec.driver import rows_to_dicts from sqlspec.exceptions import ( CheckViolationError, @@ -40,7 +43,7 @@ from sqlspec.typing import NUMPY_INSTALLED from sqlspec.utils.serializers import to_json from sqlspec.utils.type_converters import build_uuid_coercions -from sqlspec.utils.type_guards import has_rowcount, is_readable +from sqlspec.utils.type_guards import has_rowcount, is_async_readable, is_readable if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Mapping @@ -59,6 +62,7 @@ "DB_TYPE_BLOB", "DB_TYPE_CLOB", "ORACLEDB_SUPPORTS_SPARSE_VECTORS", + "ORACLEDB_VERSION", "SPARSE_VECTOR_MIN_DATABASE_MAJOR", "OracleAsyncStreamSource", "OracleBlob", @@ -95,6 +99,117 @@ IMPLICIT_UPPER_COLUMN_PATTERN: "re.Pattern[str]" = re.compile(r"^(?!\d)(?:[A-Z0-9_]+)$") +_UNQUOTED_ORACLE_IDENTIFIER_PATTERN: "re.Pattern[str]" = re.compile(r"^[A-Za-z][A-Za-z0-9_$#]*$") +_ORACLE_RESERVED_COLUMN_NAMES: "frozenset[str]" = frozenset({ + "ACCESS", + "ADD", + "ALL", + "ALTER", + "AND", + "ANY", + "AS", + "ASC", + "AUDIT", + "BETWEEN", + "BY", + "CHAR", + "CHECK", + "CLUSTER", + "COLUMN", + "COMMENT", + "COMPRESS", + "CONNECT", + "CREATE", + "CURRENT", + "DATE", + "DECIMAL", + "DEFAULT", + "DELETE", + "DESC", + "DISTINCT", + "DROP", + "ELSE", + "EXCLUSIVE", + "EXISTS", + "FILE", + "FLOAT", + "FOR", + "FROM", + "GRANT", + "GROUP", + "HAVING", + "IDENTIFIED", + "IMMEDIATE", + "IN", + "INCREMENT", + "INDEX", + "INITIAL", + "INSERT", + "INTEGER", + "INTERSECT", + "INTO", + "IS", + "LEVEL", + "LIKE", + "LOCK", + "LONG", + "MAXEXTENTS", + "MINUS", + "MLSLABEL", + "MODE", + "MODIFY", + "NOAUDIT", + "NOCOMPRESS", + "NOT", + "NOWAIT", + "NULL", + "NUMBER", + "OF", + "OFFLINE", + "ON", + "ONLINE", + "OPTION", + "OR", + "ORDER", + "PCTFREE", + "PRIOR", + "PUBLIC", + "RAW", + "RENAME", + "RESOURCE", + "REVOKE", + "ROW", + "ROWID", + "ROWNUM", + "ROWS", + "SELECT", + "SESSION", + "SET", + "SHARE", + "SIZE", + "SMALLINT", + "START", + "SUCCESSFUL", + "SYNONYM", + "SYSDATE", + "TABLE", + "THEN", + "TO", + "TRIGGER", + "UID", + "UNION", + "UNIQUE", + "UPDATE", + "USER", + "VALIDATE", + "VALUES", + "VARCHAR", + "VARCHAR2", + "VIEW", + "WHENEVER", + "WHERE", + "WITH", +}) _VERSION_COMPONENTS: int = 3 TYPE_CONVERTER = OracleOutputConverter() _LOB_TYPE_NAME_MARKERS: "tuple[str, ...]" = ("LOB", "BFILE") @@ -111,21 +226,30 @@ _ERROR_CODE_MAPPING: "dict[int, tuple[type[SQLSpecError], str]]" = { 1: (UniqueViolationError, "unique constraint violation"), + 28: (DatabaseConnectionError, "session has been killed"), 54: (OperationalError, "resource busy and acquire with NOWAIT specified or timeout expired"), 60: (DeadlockError, "deadlock detected"), - 942: (PermissionDeniedError, "table or view does not exist"), + 942: (SQLParsingError, "table or view does not exist"), 1013: (OperationCancelledError, "user requested cancel"), 1017: (PermissionDeniedError, "invalid username/password"), 1031: (PermissionDeniedError, "insufficient privileges"), 1400: (NotNullViolationError, "not-null constraint violation"), 1407: (NotNullViolationError, "not-null constraint violation"), + 1438: (DataError, "numeric precision overflow"), 1652: (OperationalError, "tablespace full"), 1722: (DataError, "invalid number"), + 1830: (DataError, "date format picture ends before converting entire input string"), 1840: (DataError, "data conversion error"), + 1843: (DataError, "not a valid month"), 1858: (DataError, "invalid character"), + 1861: (DataError, "literal does not match format string"), 2291: (ForeignKeyViolationError, "foreign key constraint violation"), 2292: (ForeignKeyViolationError, "foreign key constraint violation"), ORA_CHECK_CONSTRAINT: (CheckViolationError, "check constraint violation"), + 3113: (DatabaseConnectionError, "end-of-file on communication channel"), + 3114: (DatabaseConnectionError, "not connected to ORACLE"), + 3135: (DatabaseConnectionError, "connection lost contact"), + 4068: (OperationalError, "existing state of packages has been discarded"), 8176: (TransactionError, "consistent read failure"), 8177: (TransactionError, "cannot serialize access"), 12154: (DatabaseConnectionError, "TNS resolution failed"), @@ -134,18 +258,23 @@ 12514: (DatabaseConnectionError, "service not known"), 12541: (ConnectionTimeoutError, "no listener"), 12545: (ConnectionTimeoutError, "connect failed"), + 12899: (DataError, "value too large for column"), + 28000: (PermissionDeniedError, "the account is locked"), + 28001: (PermissionDeniedError, "the password has expired"), + 30006: (OperationalError, "resource busy; acquire with WAIT timeout expired"), } - -def _parse_version_tuple(version: str) -> "tuple[int, int, int]": - parts = [int(part) for part in version.split(".") if part.isdigit()] - while len(parts) < _VERSION_COMPONENTS: - parts.append(0) - return parts[0], parts[1], parts[2] +_DPY_ERROR_CODE_MAPPING: "dict[str, tuple[type[SQLSpecError], str]]" = { + "DPY-1001": (DatabaseConnectionError, "not connected to database"), + "DPY-4011": (DatabaseConnectionError, "connection was closed"), + "DPY-6000": (DatabaseConnectionError, "cannot connect to database"), + "DPY-6001": (DatabaseConnectionError, "cannot connect to database"), + "DPY-6005": (ConnectionTimeoutError, "cannot connect to database"), +} -ORACLEDB_VERSION: "tuple[int, int, int]" = _parse_version_tuple(oracledb_module.__version__) SPARSE_VECTOR_MIN_DATABASE_MAJOR: int = 23 + ORACLEDB_SUPPORTS_SPARSE_VECTORS: bool = True @@ -202,14 +331,7 @@ def build_fetch_kwargs(driver_features: "dict[str, Any]") -> "dict[str, object]" def build_arrow_fetch_kwargs(driver_features: "dict[str, Any]") -> "dict[str, object]": """Build Oracle Arrow/DataFrame fetch keyword arguments.""" - fetch_kwargs: dict[str, object] = {} - fetch_lobs = driver_features.get("fetch_lobs") - if fetch_lobs is not None: - fetch_kwargs["fetch_lobs"] = fetch_lobs - fetch_decimals = driver_features.get("fetch_decimals") - if fetch_decimals is not None: - fetch_kwargs["fetch_decimals"] = fetch_decimals - return fetch_kwargs + return build_fetch_kwargs(driver_features) def normalize_column_names(column_names: "list[str]", driver_features: "dict[str, Any]") -> "list[str]": @@ -248,9 +370,7 @@ def normalize_execute_many_parameters_async(parameters: Any) -> Any: Returns: Normalized parameters payload. """ - if isinstance(parameters, tuple): - return list(parameters) - return parameters + return normalize_execute_many_parameters_sync(parameters) class _OracleJsonBindingState: @@ -268,10 +388,12 @@ def uses_blob(self) -> bool: """Return the operation-scoped JSON storage decision.""" if not self._resolved: server_major = resolve_oracle_connection_major(self._connection, self._version_cache) + compatible_major = resolve_oracle_connection_compatible_major(self._connection, self._version_cache) self._use_blob = ( isinstance(server_major, int) and not isinstance(server_major, bool) - and resolve_oracle_json_storage(server_major) == ORACLE_JSON_STORAGE_BLOB_JSON + and resolve_oracle_json_storage(server_major, compatible_major=compatible_major) + != ORACLE_JSON_STORAGE_NATIVE ) self._resolved = True return self._use_blob @@ -281,10 +403,10 @@ def coerce_large_parameters_sync( connection: Any, parameters: Any, *, - clob_type: Any, - blob_type: Any, - varchar2_byte_limit: int, - raw_byte_limit: int, + clob_type: Any = DB_TYPE_CLOB, + blob_type: Any = DB_TYPE_BLOB, + varchar2_byte_limit: int = 4000, + raw_byte_limit: int = 2000, version_cache: Any = None, ) -> Any: """Coerce large string/bytes parameters into CLOBs/BLOBs with wrapper-aware routing. @@ -328,10 +450,10 @@ async def coerce_large_parameters_async( connection: Any, parameters: Any, *, - clob_type: Any, - blob_type: Any, - varchar2_byte_limit: int, - raw_byte_limit: int, + clob_type: Any = DB_TYPE_CLOB, + blob_type: Any = DB_TYPE_BLOB, + varchar2_byte_limit: int = 4000, + raw_byte_limit: int = 2000, version_cache: Any = None, ) -> Any: """Async mirror of :func:`coerce_large_parameters_sync`. @@ -356,10 +478,10 @@ def coerce_many_parameters_sync( connection: Any, parameters: Any, *, - clob_type: Any, - blob_type: Any, - varchar2_byte_limit: int, - raw_byte_limit: int, + clob_type: Any = DB_TYPE_CLOB, + blob_type: Any = DB_TYPE_BLOB, + varchar2_byte_limit: int = 4000, + raw_byte_limit: int = 2000, version_cache: Any = None, ) -> Any: """Coerce every parameter row prepared for synchronous ``executemany``.""" @@ -390,10 +512,10 @@ async def coerce_many_parameters_async( connection: Any, parameters: Any, *, - clob_type: Any, - blob_type: Any, - varchar2_byte_limit: int, - raw_byte_limit: int, + clob_type: Any = DB_TYPE_CLOB, + blob_type: Any = DB_TYPE_BLOB, + varchar2_byte_limit: int = 4000, + raw_byte_limit: int = 2000, version_cache: Any = None, ) -> Any: """Coerce every parameter row prepared for asynchronous ``executemany``.""" @@ -421,7 +543,7 @@ async def coerce_many_parameters_async( def build_insert_statement(table: str, columns: "list[str]") -> str: - column_list = ", ".join(columns) + column_list = ", ".join(_format_insert_column(col) for col in columns) placeholders = ", ".join(f":{idx + 1}" for idx in range(len(columns))) return f"INSERT INTO {table} ({column_list}) VALUES ({placeholders})" @@ -708,6 +830,13 @@ def fetch_chunk(self) -> "list[dict[str, Any]]": ) return rows_to_dicts(cast("list[Any]", coerced_rows), column_names) + def close(self, error: bool = False) -> None: + cursor = self._cursor + self._cursor = None + if cursor is not None: + with contextlib.suppress(Exception): + cursor.close() + def _caller_wants_locators(self) -> bool: """Report whether the caller asked to receive LOB locators. @@ -720,13 +849,6 @@ def _caller_wants_locators(self) -> bool: fetch_lobs = self._driver.driver_features.get("fetch_lobs") return bool(fetch_lobs) - def close(self, error: bool = False) -> None: - cursor = self._cursor - self._cursor = None - if cursor is not None: - with contextlib.suppress(Exception): - cursor.close() - class OracleAsyncStreamSource: """Compiled async chunk source streaming dict rows from an oracledb cursor via ``fetchmany``.""" @@ -764,29 +886,6 @@ async def start(self) -> None: await self._driver._run_with_exception_handler(handler, self._start) self._driver._check_pending_exception(handler) - async def _start(self) -> None: - try: - cursor = self._driver.connection.cursor() - self._cursor = cursor - cursor.arraysize = self._chunk_size - cursor.prefetchrows = self._chunk_size - fetch_kwargs = build_fetch_kwargs(self._driver.driver_features) - if self._fetch_lobs is not None: - fetch_kwargs["fetch_lobs"] = self._fetch_lobs - parameters = await coerce_large_parameters_async( - self._driver.connection, - self._parameters, - clob_type=DB_TYPE_CLOB, - blob_type=DB_TYPE_BLOB, - varchar2_byte_limit=self._driver.driver_features.get("oracle_varchar2_byte_limit", 4000), - raw_byte_limit=self._driver.driver_features.get("oracle_raw_byte_limit", 2000), - version_cache=getattr(self._driver, "_oracle_version_cache", None), - ) - await cast("Any", cursor).execute(self._sql, parameters or {}, **fetch_kwargs) - except BaseException: - await self.close(error=True) - raise - async def fetch_chunk(self) -> "list[dict[str, Any]]": handler = self._driver.handle_database_exceptions() cursor = self._cursor @@ -812,6 +911,36 @@ async def fetch_chunk(self) -> "list[dict[str, Any]]": ) return rows_to_dicts(cast("list[Any]", coerced_rows), column_names) + async def close(self, error: bool = False) -> None: + cursor = self._cursor + self._cursor = None + if cursor is not None: + with contextlib.suppress(Exception): + cursor.close() + + async def _start(self) -> None: + try: + cursor = self._driver.connection.cursor() + self._cursor = cursor + cursor.arraysize = self._chunk_size + cursor.prefetchrows = self._chunk_size + fetch_kwargs = build_fetch_kwargs(self._driver.driver_features) + if self._fetch_lobs is not None: + fetch_kwargs["fetch_lobs"] = self._fetch_lobs + parameters = await coerce_large_parameters_async( + self._driver.connection, + self._parameters, + clob_type=DB_TYPE_CLOB, + blob_type=DB_TYPE_BLOB, + varchar2_byte_limit=self._driver.driver_features.get("oracle_varchar2_byte_limit", 4000), + raw_byte_limit=self._driver.driver_features.get("oracle_raw_byte_limit", 2000), + version_cache=getattr(self._driver, "_oracle_version_cache", None), + ) + await cast("Any", cursor).execute(self._sql, parameters or {}, **fetch_kwargs) + except BaseException: + await self.close(error=True) + raise + def _caller_wants_locators(self) -> bool: """Report whether the caller asked to receive LOB locators. @@ -824,13 +953,6 @@ def _caller_wants_locators(self) -> bool: fetch_lobs = self._driver.driver_features.get("fetch_lobs") return bool(fetch_lobs) - async def close(self, error: bool = False) -> None: - cursor = self._cursor - self._cursor = None - if cursor is not None: - with contextlib.suppress(Exception): - cursor.close() - def collect_sync_rows( fetched_data: "list[Any] | None", @@ -953,11 +1075,29 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec if not error_obj: return _create_oracle_error(error, None, SQLSpecError, "database error") + full_code = getattr(error_obj, "full_code", None) + if isinstance(full_code, str): + dpy_mapping = _DPY_ERROR_CODE_MAPPING.get(full_code) + if dpy_mapping: + error_class, error_desc = dpy_mapping + return _create_oracle_error(error, None, error_class, f"{error_desc} [{full_code}]") + try: error_code = error_obj.code except AttributeError: error_code = None - if not error_code: + + if isinstance(error_code, str): + dpy_mapping = _DPY_ERROR_CODE_MAPPING.get(error_code) + if dpy_mapping: + error_class, error_desc = dpy_mapping + return _create_oracle_error(error, None, error_class, f"{error_desc} [{error_code}]") + + if not isinstance(error_code, int) or isinstance(error_code, bool) or not error_code: + error_text = str(error_obj) + for dpy_code, (error_class, error_desc) in _DPY_ERROR_CODE_MAPPING.items(): + if error_text.startswith(dpy_code): + return _create_oracle_error(error, None, error_class, f"{error_desc} [{dpy_code}]") return _create_oracle_error(error, None, SQLSpecError, "database error") mapping = _ERROR_CODE_MAPPING.get(error_code) @@ -1032,6 +1172,44 @@ async def _run_with_exception_handler( def _resolve_row_metadata(self, description: object) -> tuple[list[str], bool]: ... +driver_profile = build_profile() + +default_statement_config = build_statement_config() + + +def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": + """Build a normalized connection configuration dictionary. + + Args: + connection_config: Raw connection configuration mapping. + + Returns: + Normalized connection parameters. + """ + config = dict(connection_config) + if "dsn" not in config: + dsn = config.pop("url", None) or config.pop("connection_string", None) + if dsn is not None: + config["dsn"] = dsn + else: + config.pop("url", None) + config.pop("connection_string", None) + user_alias = config.pop("username", None) + if "user" not in config and user_alias is not None: + config["user"] = user_alias + return config + + +def _parse_version_tuple(version: str) -> "tuple[int, int, int]": + parts = [int(part) for part in version.split(".") if part.isdigit()] + while len(parts) < _VERSION_COMPONENTS: + parts.append(0) + return parts[0], parts[1], parts[2] + + +ORACLEDB_VERSION: "tuple[int, int, int]" = _parse_version_tuple(oracledb_module.__version__) + + def _parameter_values_need_coercion( values: "Iterable[Any]", *, @@ -1330,13 +1508,21 @@ def _description_requires_lob_coercion(description: "list[Any]") -> bool: return False +def _format_insert_column(column: str) -> str: + """Format a column identifier for INSERT statements, quoting special names.""" + if _UNQUOTED_ORACLE_IDENTIFIER_PATTERN.fullmatch(column) and column.upper() not in _ORACLE_RESERVED_COLUMN_NAMES: + return column + escaped = column.replace('"', '""') + return f'"{escaped}"' + + def _row_requires_lob_coercion(row: "tuple[Any, ...]") -> bool: """Return True when a row contains readable values that need LOB coercion.""" for value in row: value_type = type(value) if value_type in _SCALAR_PASSTHROUGH_TYPES: continue - if is_readable(value): + if is_readable(value) or is_async_readable(value): return True return False @@ -1363,13 +1549,7 @@ def _coerce_sync_row_values(row: "tuple[Any, ...]") -> "tuple[Any, ...]": continue if is_readable(value): - try: - processed_value = value.read() - except Exception: - if coerced_values is not None: - coerced_values.append(value) - continue - + processed_value = value.read() if coerced_values is None: coerced_values = list(row[:index]) coerced_values.append(processed_value) @@ -1404,14 +1584,8 @@ async def _coerce_async_row_values(row: "tuple[Any, ...]") -> "tuple[Any, ...]": coerced_values.append(value) continue - if is_readable(value): - try: - processed_value = await TYPE_CONVERTER.process_lob(value) - except Exception: - if coerced_values is not None: - coerced_values.append(value) - continue - + if is_readable(value) or is_async_readable(value): + processed_value = await TYPE_CONVERTER.process_lob(value) if coerced_values is None: if processed_value is value: continue @@ -1445,31 +1619,3 @@ def _create_oracle_error( exc = error_class(msg) exc.__cause__ = error return exc - - -driver_profile = build_profile() - -default_statement_config = build_statement_config() - - -def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": - """Build a normalized connection configuration dictionary. - - Args: - connection_config: Raw connection configuration mapping. - - Returns: - Normalized connection parameters. - """ - config = dict(connection_config) - if "dsn" not in config: - dsn = config.pop("url", None) or config.pop("connection_string", None) - if dsn is not None: - config["dsn"] = dsn - else: - config.pop("url", None) - config.pop("connection_string", None) - user_alias = config.pop("username", None) - if "user" not in config and user_alias is not None: - config["user"] = user_alias - return config diff --git a/sqlspec/adapters/oracledb/data_dictionary.py b/sqlspec/adapters/oracledb/data_dictionary.py index c6d8db4d6..415ef42f3 100644 --- a/sqlspec/adapters/oracledb/data_dictionary.py +++ b/sqlspec/adapters/oracledb/data_dictionary.py @@ -57,6 +57,7 @@ "OracleVersionInfo", "OracledbAsyncDataDictionary", "OracledbSyncDataDictionary", + "resolve_oracle_connection_compatible_major", "resolve_oracle_connection_major", "storage_type_from_version", ) @@ -124,6 +125,15 @@ def __init__( self.compatible = compatible self.is_autonomous = is_autonomous + def __str__(self) -> str: + """String representation of version info.""" + version_str = f"{self.major}.{self.minor}.{self.patch}" + if self.compatible: + version_str += f" (compatible={self.compatible})" + if self.is_autonomous: + version_str += " [Autonomous]" + return version_str + @property def compatible_major(self) -> "int | None": """Get major version from compatible parameter.""" @@ -141,15 +151,6 @@ def supports_json_blob(self) -> bool: """Check if database supports BLOB with JSON validation.""" return oracle_supports_json_blob(self.major) - def __str__(self) -> str: - """String representation of version info.""" - version_str = f"{self.major}.{self.minor}.{self.patch}" - if self.compatible: - version_str += f" (compatible={self.compatible})" - if self.is_autonomous: - version_str += " [Autonomous]" - return version_str - class JSONStorageType(StrEnum): """Oracle JSON storage rung selected from the server version. @@ -215,6 +216,18 @@ def resolve_oracle_connection_major(connection: Any, version_cache: "OracleVersi return components[0] if components is not None else None +def resolve_oracle_connection_compatible_major( + connection: Any, version_cache: "OracleVersionCache | None" = None +) -> "int | None": + """Resolve an Oracle server compatible major version without issuing a metadata query.""" + if version_cache is not None and version_cache.resolved and version_cache.version is not None: + return version_cache.version.compatible_major + cached_compatible = getattr(connection, "_sqlspec_oracle_compatible_major", None) + if isinstance(cached_compatible, int) and not isinstance(cached_compatible, bool): + return cached_compatible + return None + + @mypyc_attr(allow_interpreted_subclasses=True, native_class=False) class OracledbSyncDataDictionary(SyncDataDictionaryBase): """Oracle-specific sync data dictionary.""" diff --git a/sqlspec/adapters/oracledb/driver.py b/sqlspec/adapters/oracledb/driver.py index 70a064b4e..288fd433e 100644 --- a/sqlspec/adapters/oracledb/driver.py +++ b/sqlspec/adapters/oracledb/driver.py @@ -1,3 +1,5 @@ +"""Oracle Database driver implementation.""" + import logging from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, cast, overload @@ -69,7 +71,7 @@ ) from sqlspec.exceptions import ImproperConfigurationError, SQLSpecError, StackExecutionError from sqlspec.utils.logging import get_logger, log_with_context -from sqlspec.utils.module_loader import ensure_pyarrow +from sqlspec.utils.module_loader import ensure_pyarrow, import_optional from sqlspec.utils.text import normalize_identifier, quote_identifier, split_qualified_identifier from sqlspec.utils.type_guards import has_pipeline_capability, is_async_readable from sqlspec.utils.uuids import uuid4 @@ -95,6 +97,7 @@ logger = get_logger(__name__) +pa: Any = import_optional("pyarrow") PLAN_STATEMENT_ID_MAX_LENGTH: Final[int] = 30 """Width of the PLAN_TABLE STATEMENT_ID column.""" @@ -198,7 +201,8 @@ def _prepare_pipeline_operation(self, operation: "StackOperation") -> "_Compiled sql=compiled_sql, parameters=prepared_parameters, method=operation.method, - returns_rows=sql_statement.returns_rows(), + returns_rows=sql_statement.returns_rows() + and sql_statement.operation_type not in {"INSERT", "UPDATE", "DELETE"}, summary=summary, ) @@ -349,6 +353,17 @@ def __init__( self._row_metadata_cache: dict[int, tuple[Any, list[str], bool]] = {} self._transaction_active = False + @property + def data_dictionary(self) -> "OracledbSyncDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = OracledbSyncDataDictionary() + return self._data_dictionary + def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": """Execute single SQL statement with Oracle data handling. @@ -385,7 +400,7 @@ def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": is_select_like = statement.returns_rows() or self._should_force_select(statement, cursor) - if is_select_like: + if is_select_like and cursor.description is not None: fetched_data = cursor.fetchall() column_names, requires_lob_coercion = self._resolve_row_metadata(cursor.description) data, column_names = collect_sync_rows( @@ -469,6 +484,15 @@ def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "ExecutionRe Execution result containing statement count and success information """ sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) + prepared_parameters = coerce_large_parameters_sync( + self.connection, + prepared_parameters, + clob_type=DB_TYPE_CLOB, + blob_type=DB_TYPE_BLOB, + varchar2_byte_limit=self.driver_features.get("oracle_varchar2_byte_limit", 4000), + raw_byte_limit=self.driver_features.get("oracle_raw_byte_limit", 2000), + version_cache=self._oracle_version_cache, + ) prepared_parameters = cast("list[Any] | tuple[Any, ...] | dict[Any, Any] | None", prepared_parameters) statements = self.split_script_statements(sql, statement.statement_config, strip_trailing_semicolon=True) @@ -476,7 +500,7 @@ def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "ExecutionRe last_cursor = cursor for stmt in statements: - cursor.execute(stmt, prepared_parameters or {}) + cursor.execute(stmt, _filter_script_parameters(stmt, prepared_parameters)) successful_count += 1 return self.create_execution_result( @@ -501,8 +525,7 @@ def commit(self) -> None: try: self.connection.commit() except OracleError as e: - msg = f"Failed to commit Oracle transaction: {e}" - raise SQLSpecError(msg) from e + raise create_mapped_exception(e) from e self._transaction_active = False def rollback(self) -> None: @@ -515,8 +538,7 @@ def rollback(self) -> None: try: self.connection.rollback() except OracleError as e: - msg = f"Failed to rollback Oracle transaction: {e}" - raise SQLSpecError(msg) from e + raise create_mapped_exception(e) from e finally: self._transaction_active = False @@ -565,7 +587,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "SyncRowStream[SchemaT]": ... - @overload def select_stream( self, @@ -578,7 +599,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "SyncRowStream[dict[str, Any]]": ... - def select_stream( self, statement: "SQL | Statement | QueryBuilder", @@ -658,8 +678,6 @@ def select_to_arrow( """ ensure_pyarrow() - import pyarrow as pa - config = statement_config or self.statement_config prepared_statement = self.prepare_statement(statement, parameters, statement_config=config, kwargs=kwargs) sql, prepared_parameters = self._compiled_sql(prepared_statement, config) @@ -677,8 +695,6 @@ def select_to_arrow( try: if return_format in {"batches", "reader"} and supports_df_batches(self.connection): - import pyarrow as pa - record_batches, batch_schema = self._fetch_arrow_record_batches( sql, prepared_parameters, batch_size, arrow_schema ) @@ -731,9 +747,7 @@ def execute_stack(self, stack: "StatementStack", *, continue_on_error: bool = Fa if not self._pipeline_native_supported(): self._log_pipeline_skip(self._pipeline_support_reason or "database_version", stack) - return super().execute_stack(stack, continue_on_error=continue_on_error) - - return self._execute_stack_native(stack, continue_on_error=continue_on_error) + return super().execute_stack(stack, continue_on_error=continue_on_error) def select_to_storage( self, @@ -805,7 +819,16 @@ def load_from_arrow( with self.with_cursor(self.connection) as cursor, exc_handler: for batch in arrow_table.to_batches(max_chunksize=5000): batch_rows = [tuple(row[col] for col in columns) for row in batch.to_pylist()] - cursor.executemany(statement, batch_rows) + coerced_rows = coerce_many_parameters_sync( + self.connection, + batch_rows, + clob_type=DB_TYPE_CLOB, + blob_type=DB_TYPE_BLOB, + varchar2_byte_limit=self.driver_features.get("oracle_varchar2_byte_limit", 4000), + raw_byte_limit=self.driver_features.get("oracle_raw_byte_limit", 2000), + version_cache=self._oracle_version_cache, + ) + cursor.executemany(statement, coerced_rows) if exc_handler.pending_exception is not None: raise exc_handler.pending_exception from None telemetry_payload = self._ingest_telemetry(arrow_table) @@ -826,17 +849,6 @@ def load_from_storage( arrow_table, inbound = self._read_storage_arrow(source, file_format=file_format) return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound) - @property - def data_dictionary(self) -> "OracledbSyncDataDictionary": - """Get the data dictionary for this driver. - - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = OracledbSyncDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect Oracle sync rows for the direct execution path.""" column_names, requires_lob_coercion = self._resolve_row_metadata(cursor.description) @@ -949,8 +961,6 @@ def _fetch_arrow_record_batches( batch_size: int | None, arrow_schema: "ArrowSchema | None", ) -> "tuple[list[ArrowRecordBatch], ArrowSchema | None]": - import pyarrow as pa - params = parameters if parameters is not None else [] record_batches: list[ArrowRecordBatch] = [] batch_schema: ArrowSchema | None = None @@ -978,40 +988,6 @@ def _fetch_arrow_record_batches( record_batches.extend(batch_table.to_batches()) return record_batches, batch_schema - def _execute_stack_native(self, stack: "StatementStack", *, continue_on_error: bool) -> "tuple[StackResult, ...]": - - compiled_operations = [self._prepare_pipeline_operation(op) for op in stack.operations] - pipeline = create_oracle_pipeline() - for compiled in compiled_operations: - self._add_pipeline_operation(pipeline, compiled) - - results: list[StackResult] = [] - owns_transaction = not self._connection_in_transaction() - - with StackExecutionObserver(self, stack, continue_on_error, native_pipeline=True) as observer: - try: - if owns_transaction and not continue_on_error: - self.begin() - - pipeline_results = self.connection.run_pipeline(pipeline, continue_on_error=True) - results = self._build_stack_results_from_pipeline( - compiled_operations, pipeline_results, continue_on_error, observer - ) - - if owns_transaction: - self.commit() - except Exception as exc: - if owns_transaction: - try: - self.rollback() - except Exception as rollback_error: # pragma: no cover - logger.debug("Rollback after pipeline failure failed: %s", rollback_error) - if isinstance(exc, StackExecutionError): - raise - raise self._wrap_pipeline_error(exc, stack, continue_on_error) from exc - - return tuple(results) - def _pipeline_native_supported(self) -> bool: if self._pipeline_support is not None: return self._pipeline_support @@ -1062,6 +1038,17 @@ def __init__( self._row_metadata_cache: dict[int, tuple[Any, list[str], bool]] = {} self._transaction_active = False + @property + def data_dictionary(self) -> "OracledbAsyncDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = OracledbAsyncDataDictionary() + return self._data_dictionary + async def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": """Execute single SQL statement with Oracle data handling. @@ -1098,7 +1085,7 @@ async def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionRes is_select_like = statement.returns_rows() or self._should_force_select(statement, cursor) - if is_select_like: + if is_select_like and cursor.description is not None: fetched_data = await cursor.fetchall() column_names, requires_lob_coercion = self._resolve_row_metadata(cursor.description) data, column_names = await collect_async_rows( @@ -1184,14 +1171,23 @@ async def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "Execu Execution result containing statement count and success information """ sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) + prepared_parameters = await coerce_large_parameters_async( + self.connection, + prepared_parameters, + clob_type=DB_TYPE_CLOB, + blob_type=DB_TYPE_BLOB, + varchar2_byte_limit=self.driver_features.get("oracle_varchar2_byte_limit", 4000), + raw_byte_limit=self.driver_features.get("oracle_raw_byte_limit", 2000), + version_cache=self._oracle_version_cache, + ) + prepared_parameters = cast("list[Any] | tuple[Any, ...] | dict[Any, Any] | None", prepared_parameters) statements = self.split_script_statements(sql, statement.statement_config, strip_trailing_semicolon=True) - script_params = cast("dict[str, Any]", prepared_parameters or {}) successful_count = 0 last_cursor = cursor for stmt in statements: - await cursor.execute(stmt, script_params) + await cursor.execute(stmt, _filter_script_parameters(stmt, prepared_parameters)) successful_count += 1 return self.create_execution_result( @@ -1216,8 +1212,7 @@ async def commit(self) -> None: try: await self.connection.commit() except OracleError as e: - msg = f"Failed to commit Oracle transaction: {e}" - raise SQLSpecError(msg) from e + raise create_mapped_exception(e) from e self._transaction_active = False async def rollback(self) -> None: @@ -1230,8 +1225,7 @@ async def rollback(self) -> None: try: await self.connection.rollback() except OracleError as e: - msg = f"Failed to rollback Oracle transaction: {e}" - raise SQLSpecError(msg) from e + raise create_mapped_exception(e) from e finally: self._transaction_active = False @@ -1283,7 +1277,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "AsyncRowStream[SchemaT]": ... - @overload def select_stream( self, @@ -1296,7 +1289,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "AsyncRowStream[dict[str, Any]]": ... - def select_stream( self, statement: "SQL | Statement | QueryBuilder", @@ -1376,8 +1368,6 @@ async def select_to_arrow( """ ensure_pyarrow() - import pyarrow as pa - config = statement_config or self.statement_config prepared_statement = self.prepare_statement(statement, parameters, statement_config=config, kwargs=kwargs) sql, prepared_parameters = self._compiled_sql(prepared_statement, config) @@ -1395,8 +1385,6 @@ async def select_to_arrow( try: if return_format in {"batches", "reader"} and supports_df_batches(self.connection): - import pyarrow as pa - record_batches, batch_schema = await self._fetch_arrow_record_batches( sql, prepared_parameters, batch_size, arrow_schema ) @@ -1521,7 +1509,16 @@ async def load_from_arrow( async with self.with_cursor(self.connection) as cursor, exc_handler: for batch in arrow_table.to_batches(max_chunksize=5000): batch_rows = [tuple(row[col] for col in columns) for row in batch.to_pylist()] - await cursor.executemany(statement, batch_rows) + coerced_rows = await coerce_many_parameters_async( + self.connection, + batch_rows, + clob_type=DB_TYPE_CLOB, + blob_type=DB_TYPE_BLOB, + varchar2_byte_limit=self.driver_features.get("oracle_varchar2_byte_limit", 4000), + raw_byte_limit=self.driver_features.get("oracle_raw_byte_limit", 2000), + version_cache=self._oracle_version_cache, + ) + await cursor.executemany(statement, coerced_rows) if exc_handler.pending_exception is not None: raise exc_handler.pending_exception from None telemetry_payload = self._ingest_telemetry(arrow_table) @@ -1544,17 +1541,6 @@ async def load_from_storage( table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound ) - @property - def data_dictionary(self) -> "OracledbAsyncDataDictionary": - """Get the data dictionary for this driver. - - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = OracledbAsyncDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect Oracle async rows for the direct execution path. @@ -1676,8 +1662,6 @@ async def _fetch_arrow_record_batches( batch_size: int | None, arrow_schema: "ArrowSchema | None", ) -> "tuple[list[ArrowRecordBatch], ArrowSchema | None]": - import pyarrow as pa - params = parameters if parameters is not None else [] record_batches: list[ArrowRecordBatch] = [] batch_schema: ArrowSchema | None = None @@ -1709,10 +1693,35 @@ async def _execute_stack_native( self, stack: "StatementStack", *, continue_on_error: bool ) -> "tuple[StackResult, ...]": - compiled_operations = [self._prepare_pipeline_operation(op) for op in stack.operations] + raw_operations = [self._prepare_pipeline_operation(op) for op in stack.operations] + compiled_operations: list[_CompiledStackOperation] = [] pipeline = create_oracle_pipeline() - for compiled in compiled_operations: - self._add_pipeline_operation(pipeline, compiled) + varchar2_limit = self.driver_features.get("oracle_varchar2_byte_limit", 4000) + raw_limit = self.driver_features.get("oracle_raw_byte_limit", 2000) + for compiled in raw_operations: + if compiled.method == "execute_many": + coerced_params: Any = await coerce_many_parameters_async( + self.connection, + compiled.parameters, + clob_type=DB_TYPE_CLOB, + blob_type=DB_TYPE_BLOB, + varchar2_byte_limit=varchar2_limit, + raw_byte_limit=raw_limit, + version_cache=self._oracle_version_cache, + ) + else: + coerced_params = await coerce_large_parameters_async( + self.connection, + compiled.parameters, + clob_type=DB_TYPE_CLOB, + blob_type=DB_TYPE_BLOB, + varchar2_byte_limit=varchar2_limit, + raw_byte_limit=raw_limit, + version_cache=self._oracle_version_cache, + ) + coerced_op = compiled._replace(parameters=coerced_params) + compiled_operations.append(coerced_op) + self._add_pipeline_operation(pipeline, coerced_op) results: list[StackResult] = [] owns_transaction = not self._connection_in_transaction() @@ -1802,7 +1811,6 @@ def _arrow_schema_supports_direct_path(arrow_table: Any) -> bool: True when every column type is known to be convertible. """ ensure_pyarrow() - import pyarrow as pa for field in arrow_table.schema: data_type = field.type @@ -1899,3 +1907,30 @@ def _retry_arrow_without_fetch_lobs(exc: TypeError, fetch_kwargs: "dict[str, obj retry_kwargs = dict(fetch_kwargs) retry_kwargs.pop("fetch_lobs", None) return retry_kwargs + + +def _statement_contains_bind(statement: str, name: str) -> bool: + """Return whether a SQL statement references a named Oracle bind placeholder.""" + lowered_sql = statement.lower() + target = f":{name.lower()}" + start = 0 + while True: + idx = lowered_sql.find(target, start) + if idx == -1: + return False + prev_ok = idx == 0 or lowered_sql[idx - 1] != ":" + end_idx = idx + len(target) + next_ch = lowered_sql[end_idx] if end_idx < len(lowered_sql) else "" + next_ok = not (next_ch.isalnum() or next_ch in {"_", "$", "#"}) + if prev_ok and next_ok: + return True + start = idx + 1 + + +def _filter_script_parameters(statement: str, parameters: Any) -> Any: + """Filter dictionary script parameters to the bind placeholders present in statement.""" + if not parameters: + return {} + if isinstance(parameters, dict): + return {key: val for key, val in parameters.items() if _statement_contains_bind(statement, str(key))} + return parameters diff --git a/sqlspec/adapters/oracledb/events/_hub.py b/sqlspec/adapters/oracledb/events/_hub.py index f432df545..50f748568 100644 --- a/sqlspec/adapters/oracledb/events/_hub.py +++ b/sqlspec/adapters/oracledb/events/_hub.py @@ -99,6 +99,13 @@ def dequeue(self, channel: str, poll_interval: float) -> "Any | None": ) with contextlib.suppress(Exception): driver.rollback() + session_cm = self._session_cm + self._queues.clear() + self._session_cm = None + self._session_driver = None + if session_cm is not None: + with contextlib.suppress(Exception): + session_cm.__exit__(None, None, None) return None if message is None: with contextlib.suppress(Exception): @@ -226,6 +233,13 @@ async def dequeue(self, channel: str, poll_interval: float) -> "Any | None": ) with contextlib.suppress(Exception): await driver.rollback() + session_cm = self._session_cm + self._queues.clear() + self._session_cm = None + self._session_driver = None + if session_cm is not None: + with contextlib.suppress(Exception): + await session_cm.__aexit__(None, None, None) return None if message is None: with contextlib.suppress(Exception): @@ -278,7 +292,22 @@ def _register_pool_destroying(self) -> None: self._pool_destroying_registered = True def _pool_destroying_hook(self, _context: "dict[str, Any]") -> "Any": - return self.shutdown() + session_cm = self._session_cm + self._queues.clear() + self._session_cm = None + self._session_driver = None + if session_cm is not None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return None + return loop.create_task(self._close_session_cm(session_cm)) + return None + + @staticmethod + async def _close_session_cm(session_cm: Any) -> None: + with contextlib.suppress(Exception): + await session_cm.__aexit__(None, None, None) def _apply_deq_options( diff --git a/sqlspec/adapters/oracledb/litestar/store.py b/sqlspec/adapters/oracledb/litestar/store.py index 28a5ec0cb..080146d6a 100644 --- a/sqlspec/adapters/oracledb/litestar/store.py +++ b/sqlspec/adapters/oracledb/litestar/store.py @@ -323,6 +323,7 @@ def _table_ddl(self) -> str: SQL statement to create the sessions table with proper indexes. """ table_clause = _litestar_table_feature_clause(self._config, self._in_memory) + index_name = f"idx_{self._table_name.replace('.', '_')}_expires_at"[:30] return f""" BEGIN EXECUTE IMMEDIATE 'CREATE TABLE {self._table_name} ( @@ -340,7 +341,7 @@ def _table_ddl(self) -> str: END; BEGIN - EXECUTE IMMEDIATE 'CREATE INDEX idx_{self._table_name}_expires_at + EXECUTE IMMEDIATE 'CREATE INDEX {index_name} ON {self._table_name}(expires_at)'; EXCEPTION WHEN OTHERS THEN @@ -356,10 +357,11 @@ def _drop_table_sql(self) -> "list[str]": Returns: List of SQL statements with exception handling for non-existent objects. """ + index_name = f"idx_{self._table_name.replace('.', '_')}_expires_at"[:30] return [ f""" BEGIN - EXECUTE IMMEDIATE 'DROP INDEX idx_{self._table_name}_expires_at'; + EXECUTE IMMEDIATE 'DROP INDEX {index_name}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN @@ -496,6 +498,7 @@ def _table_ddl(self) -> str: SQL statement to create the sessions table with proper indexes. """ table_clause = _litestar_table_feature_clause(self._config, self._in_memory) + index_name = f"idx_{self._table_name.replace('.', '_')}_expires_at"[:30] return f""" BEGIN EXECUTE IMMEDIATE 'CREATE TABLE {self._table_name} ( @@ -513,7 +516,7 @@ def _table_ddl(self) -> str: END; BEGIN - EXECUTE IMMEDIATE 'CREATE INDEX idx_{self._table_name}_expires_at + EXECUTE IMMEDIATE 'CREATE INDEX {index_name} ON {self._table_name}(expires_at)'; EXCEPTION WHEN OTHERS THEN @@ -529,10 +532,11 @@ def _drop_table_sql(self) -> "list[str]": Returns: List of SQL statements with exception handling for non-existent objects. """ + index_name = f"idx_{self._table_name.replace('.', '_')}_expires_at"[:30] return [ f""" BEGIN - EXECUTE IMMEDIATE 'DROP INDEX idx_{self._table_name}_expires_at'; + EXECUTE IMMEDIATE 'DROP INDEX {index_name}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN diff --git a/sqlspec/adapters/oracledb/migrations.py b/sqlspec/adapters/oracledb/migrations.py index 5437b0633..850c09b86 100644 --- a/sqlspec/adapters/oracledb/migrations.py +++ b/sqlspec/adapters/oracledb/migrations.py @@ -162,70 +162,6 @@ def _detect_missing_columns(self, existing_columns: "set[str]") -> "set[str]": class OracleSyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTracker["SyncDriverAdapterBase"]): """Oracle-specific sync migration tracker.""" - __slots__ = () - - def _migrate_schema_if_needed(self, driver: "SyncDriverAdapterBase") -> None: - """Check for and add any missing columns to the tracking table. - - Uses the driver's data dictionary to query existing columns from Oracle's - USER_TAB_COLUMNS metadata table. - - Args: - driver: The database driver to use. - """ - try: - if self.version_table_schema: - columns_data = driver.data_dictionary.get_columns( - driver, self.version_table_name, schema=self.version_table_schema - ) - else: - columns_data = driver.data_dictionary.get_columns(driver, self.version_table_name) - existing_columns = {str(row["column_name"]).upper() for row in columns_data} - missing_columns = self._detect_missing_columns(existing_columns) - - if not missing_columns: - logger.debug("Migration tracking table schema is up-to-date") - return - - if self._should_echo(): - console.print( - f"[cyan]Migrating tracking table schema, adding columns: {', '.join(sorted(missing_columns))}[/]" - ) - - for col_name in sorted(missing_columns): - self._add_column(driver, col_name) - - driver.commit() - if self._should_echo(): - console.print("[green]Migration tracking table schema updated successfully[/]") - - except Exception as e: - logger.warning("Could not check or migrate tracking table schema: %s", e) - - def _add_column(self, driver: "SyncDriverAdapterBase", column_name: str) -> None: - """Add a single column to the tracking table. - - Args: - driver: The database driver to use. - column_name: Name of the column to add (lowercase). - """ - target_create = self._tracking_table_ddl() - column_def = next((col for col in target_create.columns if col.name.lower() == column_name), None) - - if not column_def: - return - - default_clause = f" DEFAULT {column_def.default}" if column_def.default else "" - not_null_clause = " NOT NULL" if column_def.not_null else "" - - alter_sql = f""" - ALTER TABLE {self.version_table} - ADD {column_def.name} {column_def.dtype}{default_clause}{not_null_clause} - """ - - driver.execute(alter_sql) - logger.debug("Added column %s to tracking table", column_name) - def ensure_tracking_table(self, driver: "SyncDriverAdapterBase") -> None: """Create the migration tracking table if it doesn't exist. @@ -288,7 +224,14 @@ def get_applied_migrations(self, driver: "SyncDriverAdapterBase") -> "list[Appli return cast("list[AppliedMigrationRecord]", result.get_data()) def record_migration( - self, driver: "SyncDriverAdapterBase", version: str, description: str, execution_time_ms: int, checksum: str + self, + driver: "SyncDriverAdapterBase", + version: str, + description: str, + execution_time_ms: int, + checksum: str, + *, + commit: bool = True, ) -> None: """Record a successfully applied migration. @@ -298,6 +241,7 @@ def record_migration( description: Description of the migration. execution_time_ms: Execution time in milliseconds. checksum: MD5 checksum of the migration content. + commit: Whether to commit the transaction after recording. """ applied_by = getpass.getuser() parsed_version = parse_version(version) @@ -311,18 +255,21 @@ def record_migration( version, version_type, execution_sequence, description, execution_time_ms, checksum, applied_by ) driver.execute(record_sql) - driver.commit() + if commit: + driver.commit() - def remove_migration(self, driver: "SyncDriverAdapterBase", version: str) -> None: + def remove_migration(self, driver: "SyncDriverAdapterBase", version: str, *, commit: bool = True) -> None: """Remove a migration record. Args: driver: The database driver to use. version: Version number to remove. + commit: Whether to commit the transaction after removal. """ remove_sql = self._remove_migration_statement(version) driver.execute(remove_sql) - driver.commit() + if commit: + driver.commit() def update_version_record( self, @@ -369,13 +316,7 @@ def update_version_record( driver.commit() - -class OracleAsyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTracker["AsyncDriverAdapterBase"]): - """Oracle-specific async migration tracker.""" - - __slots__ = () - - async def _migrate_schema_if_needed(self, driver: "AsyncDriverAdapterBase") -> None: + def _migrate_schema_if_needed(self, driver: "SyncDriverAdapterBase") -> None: """Check for and add any missing columns to the tracking table. Uses the driver's data dictionary to query existing columns from Oracle's @@ -386,11 +327,11 @@ async def _migrate_schema_if_needed(self, driver: "AsyncDriverAdapterBase") -> N """ try: if self.version_table_schema: - columns_data = await driver.data_dictionary.get_columns( + columns_data = driver.data_dictionary.get_columns( driver, self.version_table_name, schema=self.version_table_schema ) else: - columns_data = await driver.data_dictionary.get_columns(driver, self.version_table_name) + columns_data = driver.data_dictionary.get_columns(driver, self.version_table_name) existing_columns = {str(row["column_name"]).upper() for row in columns_data} missing_columns = self._detect_missing_columns(existing_columns) @@ -404,16 +345,16 @@ async def _migrate_schema_if_needed(self, driver: "AsyncDriverAdapterBase") -> N ) for col_name in sorted(missing_columns): - await self._add_column(driver, col_name) + self._add_column(driver, col_name) - await driver.commit() + driver.commit() if self._should_echo(): console.print("[green]Migration tracking table schema updated successfully[/]") except Exception as e: logger.warning("Could not check or migrate tracking table schema: %s", e) - async def _add_column(self, driver: "AsyncDriverAdapterBase", column_name: str) -> None: + def _add_column(self, driver: "SyncDriverAdapterBase", column_name: str) -> None: """Add a single column to the tracking table. Args: @@ -434,9 +375,13 @@ async def _add_column(self, driver: "AsyncDriverAdapterBase", column_name: str) ADD {column_def.name} {column_def.dtype}{default_clause}{not_null_clause} """ - await driver.execute(alter_sql) + driver.execute(alter_sql) logger.debug("Added column %s to tracking table", column_name) + +class OracleAsyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTracker["AsyncDriverAdapterBase"]): + """Oracle-specific async migration tracker.""" + async def ensure_tracking_table(self, driver: "AsyncDriverAdapterBase") -> None: """Create the migration tracking table if it doesn't exist. @@ -499,7 +444,14 @@ async def get_applied_migrations(self, driver: "AsyncDriverAdapterBase") -> "lis return cast("list[AppliedMigrationRecord]", result.get_data()) async def record_migration( - self, driver: "AsyncDriverAdapterBase", version: str, description: str, execution_time_ms: int, checksum: str + self, + driver: "AsyncDriverAdapterBase", + version: str, + description: str, + execution_time_ms: int, + checksum: str, + *, + commit: bool = True, ) -> None: """Record a successfully applied migration. @@ -509,6 +461,7 @@ async def record_migration( description: Description of the migration. execution_time_ms: Execution time in milliseconds. checksum: MD5 checksum of the migration content. + commit: Whether to commit the transaction after recording. """ applied_by = getpass.getuser() @@ -523,18 +476,21 @@ async def record_migration( version, version_type, execution_sequence, description, execution_time_ms, checksum, applied_by ) await driver.execute(record_sql) - await driver.commit() + if commit: + await driver.commit() - async def remove_migration(self, driver: "AsyncDriverAdapterBase", version: str) -> None: + async def remove_migration(self, driver: "AsyncDriverAdapterBase", version: str, *, commit: bool = True) -> None: """Remove a migration record. Args: driver: The database driver to use. version: Version number to remove. + commit: Whether to commit the transaction after removal. """ remove_sql = self._remove_migration_statement(version) await driver.execute(remove_sql) - await driver.commit() + if commit: + await driver.commit() async def update_version_record( self, @@ -580,3 +536,65 @@ async def update_version_record( raise ValueError(msg) await driver.commit() + + async def _migrate_schema_if_needed(self, driver: "AsyncDriverAdapterBase") -> None: + """Check for and add any missing columns to the tracking table. + + Uses the driver's data dictionary to query existing columns from Oracle's + USER_TAB_COLUMNS metadata table. + + Args: + driver: The database driver to use. + """ + try: + if self.version_table_schema: + columns_data = await driver.data_dictionary.get_columns( + driver, self.version_table_name, schema=self.version_table_schema + ) + else: + columns_data = await driver.data_dictionary.get_columns(driver, self.version_table_name) + existing_columns = {str(row["column_name"]).upper() for row in columns_data} + missing_columns = self._detect_missing_columns(existing_columns) + + if not missing_columns: + logger.debug("Migration tracking table schema is up-to-date") + return + + if self._should_echo(): + console.print( + f"[cyan]Migrating tracking table schema, adding columns: {', '.join(sorted(missing_columns))}[/]" + ) + + for col_name in sorted(missing_columns): + await self._add_column(driver, col_name) + + await driver.commit() + if self._should_echo(): + console.print("[green]Migration tracking table schema updated successfully[/]") + + except Exception as e: + logger.warning("Could not check or migrate tracking table schema: %s", e) + + async def _add_column(self, driver: "AsyncDriverAdapterBase", column_name: str) -> None: + """Add a single column to the tracking table. + + Args: + driver: The database driver to use. + column_name: Name of the column to add (lowercase). + """ + target_create = self._tracking_table_ddl() + column_def = next((col for col in target_create.columns if col.name.lower() == column_name), None) + + if not column_def: + return + + default_clause = f" DEFAULT {column_def.default}" if column_def.default else "" + not_null_clause = " NOT NULL" if column_def.not_null else "" + + alter_sql = f""" + ALTER TABLE {self.version_table} + ADD {column_def.name} {column_def.dtype}{default_clause}{not_null_clause} + """ + + await driver.execute(alter_sql) + logger.debug("Added column %s to tracking table", column_name) diff --git a/sqlspec/adapters/oracledb/type_converter.py b/sqlspec/adapters/oracledb/type_converter.py index d2639a388..1011a3c60 100644 --- a/sqlspec/adapters/oracledb/type_converter.py +++ b/sqlspec/adapters/oracledb/type_converter.py @@ -13,7 +13,7 @@ ) from sqlspec.typing import NUMPY_INSTALLED from sqlspec.utils.sync_tools import ensure_async_ -from sqlspec.utils.type_guards import is_readable +from sqlspec.utils.type_guards import is_async_readable, is_readable try: import numpy as np @@ -40,6 +40,8 @@ async def process_lob(self, value: Any) -> Any: Returns: LOB content if value is a LOB, original value otherwise. """ + if is_async_readable(value): + return await value.read() if not is_readable(value): return value diff --git a/sqlspec/adapters/psqlpy/_typing.py b/sqlspec/adapters/psqlpy/_typing.py index 8e2423cbb..7be4219bf 100644 --- a/sqlspec/adapters/psqlpy/_typing.py +++ b/sqlspec/adapters/psqlpy/_typing.py @@ -4,6 +4,8 @@ compilation to avoid ABI boundary issues. """ +import contextlib +import importlib from typing import TYPE_CHECKING, Any from sqlspec.typing import import_optional_attr @@ -45,6 +47,7 @@ class _PsqlpyUnavailableError(Exception): else: + PSQLPY_JSONB = import_optional_attr("psqlpy.extra_types", "JSONB") PsqlpyConnection = import_optional_attr("psqlpy", "Connection") or Any PsqlpyConnectionPool = import_optional_attr("psqlpy", "ConnectionPool") or Any PsqlpyDataError = import_optional_attr("psqlpy.exceptions", "DataError") or _PsqlpyUnavailableError @@ -143,11 +146,10 @@ def __init__( self._driver: PsqlpyDriver | None = None async def __aenter__(self) -> "PsqlpyDriver": - from sqlspec.adapters.psqlpy.driver import PsqlpyDriver - + driver_cls = _resolve_psqlpy_driver() self._connection = await self._acquire_connection() statement_config = self._statement_config() if callable(self._statement_config) else self._statement_config - self._driver = PsqlpyDriver( + self._driver = driver_cls( connection=self._connection, statement_config=statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -155,27 +157,17 @@ async def __aenter__(self) -> "PsqlpyDriver": async def __aexit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> "bool | None": + driver = self._driver + self._driver = None + if driver is not None and getattr(driver, "_transaction_active", False): + with contextlib.suppress(Exception): + await driver.rollback() if self._connection is not None: await self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) self._connection = None return None -_LAZY_DRIVER_EXPORTS: dict[str, tuple[str, str]] = { - "PSQLPY_JSONB": ("psqlpy.extra_types", "JSONB"), - "PsqlpyConnectionPool": ("psqlpy", "ConnectionPool"), -} - - -def __getattr__(name: str) -> Any: - """Resolve optional driver symbols only when a consumer requests them.""" - target = _LAZY_DRIVER_EXPORTS.get(name) - if target is None: - msg = f"module {__name__!r} has no attribute {name!r}" - raise AttributeError(msg) - module_name, attribute = target - value = import_optional_attr(module_name, attribute) - if value is None: - msg = f"Cannot import {attribute!r} from {module_name!r}" - raise ImportError(msg) - return value +def _resolve_psqlpy_driver() -> "type[PsqlpyDriver]": + module = importlib.import_module("sqlspec.adapters.psqlpy.driver") + return module.PsqlpyDriver # type: ignore[no-any-return] diff --git a/sqlspec/adapters/psqlpy/adk/store.py b/sqlspec/adapters/psqlpy/adk/store.py index 5350fcf1b..f721da380 100644 --- a/sqlspec/adapters/psqlpy/adk/store.py +++ b/sqlspec/adapters/psqlpy/adk/store.py @@ -47,6 +47,21 @@ class PsqlpyADKConfig(ADKConfig): autovacuum_analyze_scale_factor: NotRequired[float] """Optional event-table autovacuum analyze scale factor.""" + enable_bm25: NotRequired[bool] + """Create BM25 index on content_text using pg_textsearch extension.""" + + vector_dimensions: NotRequired[int] + """Vector embedding dimension size. Defaults to 1536.""" + + vector_index_type: NotRequired[Literal["hnsw", "ivfflat", "scann", "none"]] + """Type of vector index to create. Defaults to 'hnsw'.""" + + scann_num_leaves: NotRequired[int] + """Number of leaves for AlloyDB ScaNN index. Defaults to 500.""" + + scann_quantizer: NotRequired[Literal["SQ8", "FP32"]] + """Quantizer for AlloyDB ScaNN index. Defaults to 'SQ8'.""" + class PsqlpyADKStore(BaseAsyncADKStore["PsqlpyConfig"]): """PostgreSQL ADK store using Psqlpy driver. @@ -383,21 +398,25 @@ async def get_events( async def delete_expired_events(self, before: "datetime", app_name: "str | None" = None) -> int: if app_name is not None: - count_sql = f"SELECT COUNT(*) AS count FROM {self._events_table} WHERE timestamp < $1 AND app_name = $2" - delete_sql = f"DELETE FROM {self._events_table} WHERE timestamp < $1 AND app_name = $2" + sql = ( + f"WITH deleted AS (DELETE FROM {self._events_table} " + f"WHERE timestamp < $1 AND app_name = $2 RETURNING 1) " + "SELECT COUNT(*) AS count FROM deleted" + ) params: list[Any] = [before, app_name] else: - count_sql = f"SELECT COUNT(*) AS count FROM {self._events_table} WHERE timestamp < $1" - delete_sql = f"DELETE FROM {self._events_table} WHERE timestamp < $1" + sql = ( + f"WITH deleted AS (DELETE FROM {self._events_table} " + f"WHERE timestamp < $1 RETURNING 1) " + "SELECT COUNT(*) AS count FROM deleted" + ) params = [before] try: async with self._config.provide_connection() as conn: - count_result = await conn.fetch(count_sql, params) - count_rows: list[dict[str, Any]] = count_result.result() if count_result else [] - count = int(count_rows[0]["count"]) if count_rows else 0 - await conn.execute(delete_sql, params) - return count + result = await conn.fetch(sql, params) + rows: list[dict[str, Any]] = result.result() if result else [] + return int(rows[0]["count"]) if rows else 0 except Exception as e: if _is_table_missing_error(e): return 0 @@ -405,21 +424,25 @@ async def delete_expired_events(self, before: "datetime", app_name: "str | None" async def delete_idle_sessions(self, updated_before: "datetime", app_name: "str | None" = None) -> int: if app_name is not None: - count_sql = f"SELECT COUNT(*) AS count FROM {self._session_table} WHERE update_time < $1 AND app_name = $2" - delete_sql = f"DELETE FROM {self._session_table} WHERE update_time < $1 AND app_name = $2" + sql = ( + f"WITH deleted AS (DELETE FROM {self._session_table} " + f"WHERE update_time < $1 AND app_name = $2 RETURNING 1) " + "SELECT COUNT(*) AS count FROM deleted" + ) params: list[Any] = [updated_before, app_name] else: - count_sql = f"SELECT COUNT(*) AS count FROM {self._session_table} WHERE update_time < $1" - delete_sql = f"DELETE FROM {self._session_table} WHERE update_time < $1" + sql = ( + f"WITH deleted AS (DELETE FROM {self._session_table} " + f"WHERE update_time < $1 RETURNING 1) " + "SELECT COUNT(*) AS count FROM deleted" + ) params = [updated_before] try: async with self._config.provide_connection() as conn: - count_result = await conn.fetch(count_sql, params) - count_rows: list[dict[str, Any]] = count_result.result() if count_result else [] - count = int(count_rows[0]["count"]) if count_rows else 0 - await conn.execute(delete_sql, params) - return count + result = await conn.fetch(sql, params) + rows: list[dict[str, Any]] = result.result() if result else [] + return int(rows[0]["count"]) if rows else 0 except Exception as e: if _is_table_missing_error(e): return 0 @@ -427,23 +450,25 @@ async def delete_idle_sessions(self, updated_before: "datetime", app_name: "str async def delete_idle_user_states(self, updated_before: "datetime", app_name: "str | None" = None) -> int: if app_name is not None: - count_sql = ( - f"SELECT COUNT(*) AS count FROM {self._user_state_table} WHERE update_time < $1 AND app_name = $2" + sql = ( + f"WITH deleted AS (DELETE FROM {self._user_state_table} " + f"WHERE update_time < $1 AND app_name = $2 RETURNING 1) " + "SELECT COUNT(*) AS count FROM deleted" ) - delete_sql = f"DELETE FROM {self._user_state_table} WHERE update_time < $1 AND app_name = $2" params: list[Any] = [updated_before, app_name] else: - count_sql = f"SELECT COUNT(*) AS count FROM {self._user_state_table} WHERE update_time < $1" - delete_sql = f"DELETE FROM {self._user_state_table} WHERE update_time < $1" + sql = ( + f"WITH deleted AS (DELETE FROM {self._user_state_table} " + f"WHERE update_time < $1 RETURNING 1) " + "SELECT COUNT(*) AS count FROM deleted" + ) params = [updated_before] try: async with self._config.provide_connection() as conn: - count_result = await conn.fetch(count_sql, params) - count_rows: list[dict[str, Any]] = count_result.result() if count_result else [] - count = int(count_rows[0]["count"]) if count_rows else 0 - await conn.execute(delete_sql, params) - return count + result = await conn.fetch(sql, params) + rows: list[dict[str, Any]] = result.result() if result else [] + return int(rows[0]["count"]) if rows else 0 except Exception as e: if _is_table_missing_error(e): return 0 @@ -638,14 +663,16 @@ def __init__(self, config: "PsqlpyConfig") -> None: async def create_tables(self) -> None: """Create the memory table and indexes if they don't exist.""" - if not self.create_schema_enabled: - await self.reconcile_schema() + if not self._enabled: return - if not self._enabled: + if not self.create_schema_enabled: + await self.reconcile_schema() return async with self._config.provide_session() as driver: + if self._enable_bm25: + self._config._ensure_pg_textsearch_available() await driver.execute_script(await self._memory_table_ddl()) async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: @@ -662,10 +689,10 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " sql = f""" INSERT INTO {self._memory_table} ( id, session_id, app_name, user_id, scope, event_id, author, - {self._owner_id_column_name}, timestamp, content_json, + {self._owner_id_column_name}, timestamp, embedding, content_json, content_text, metadata_json, inserted_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::float8[]::vector, $11, $12, $13, $14 ) ON CONFLICT (event_id) DO NOTHING """ @@ -673,9 +700,9 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " sql = f""" INSERT INTO {self._memory_table} ( id, session_id, app_name, user_id, scope, event_id, author, - timestamp, content_json, content_text, metadata_json, inserted_at + timestamp, embedding, content_json, content_text, metadata_json, inserted_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 + $1, $2, $3, $4, $5, $6, $7, $8, $9::float8[]::vector, $10, $11, $12, $13 ) ON CONFLICT (event_id) DO NOTHING """ @@ -690,12 +717,13 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " entry["user_id"], entry.get("scope", "user"), entry["event_id"], - entry["author"], + entry.get("author"), owner_id, entry["timestamp"], + entry.get("embedding"), entry["content_json"], entry["content_text"], - entry["metadata_json"], + entry.get("metadata_json"), entry["inserted_at"], ] else: @@ -706,11 +734,12 @@ async def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: " entry["user_id"], entry.get("scope", "user"), entry["event_id"], - entry["author"], + entry.get("author"), entry["timestamp"], + entry.get("embedding"), entry["content_json"], entry["content_text"], - entry["metadata_json"], + entry.get("metadata_json"), entry["inserted_at"], ] @@ -730,57 +759,71 @@ async def search_entries( scope_filter: Literal["all", "user", "app"] = "all", embedding: "Sequence[float] | None" = None, ) -> "list[StoredMemory]": - """Search memory entries by text query.""" + """Search memory entries by text query or vector embedding.""" if not self._enabled: msg = "Memory store is disabled" raise RuntimeError(msg) - if not query or not query.strip(): + has_query = bool(query and query.strip()) + if not has_query and embedding is None: return [] effective_limit = limit if limit is not None else self._max_results try: + if embedding is not None and self._enable_bm25 and has_query: + return await self._search_entries_hybrid( + query, app_name, user_id, effective_limit, scope_filter, embedding + ) + if embedding is not None: + return await self._search_entries_vector(app_name, user_id, effective_limit, scope_filter, embedding) if self._use_fts: try: return await self._search_entries_fts( query, app_name, user_id, effective_limit, scope_filter=scope_filter ) except Exception as exc: + if _is_table_missing_error(exc): + raise logger.warning("FTS search failed; falling back to simple search: %s", exc) return await self._search_entries_simple( query, app_name, user_id, effective_limit, scope_filter=scope_filter ) except Exception as e: - if _is_psqlpy_database_error(e): - error_msg = str(e).lower() - if "does not exist" in error_msg or "relation" in error_msg: - return [] + if _is_table_missing_error(e): + return [] raise async def delete_entries_by_session(self, session_id: str) -> int: """Delete all memory entries for a specific session.""" - count_sql = f"SELECT COUNT(*) AS count FROM {self._memory_table} WHERE session_id = $1" - delete_sql = f"DELETE FROM {self._memory_table} WHERE session_id = $1" + if not self._enabled: + msg = "Memory store is disabled" + raise RuntimeError(msg) + + sql = ( + f"WITH deleted AS (DELETE FROM {self._memory_table} " + f"WHERE session_id = $1 RETURNING 1) " + "SELECT COUNT(*) AS count FROM deleted" + ) try: async with self._config.provide_connection() as conn: - count_result = await conn.fetch(count_sql, [session_id]) - count_rows: list[dict[str, Any]] = count_result.result() if count_result else [] - count = int(count_rows[0]["count"]) if count_rows else 0 - await conn.execute(delete_sql, [session_id]) - return count + result = await conn.fetch(sql, [session_id]) + rows: list[dict[str, Any]] = result.result() if result else [] + return int(rows[0]["count"]) if rows else 0 except Exception as e: - if _is_psqlpy_database_error(e): - error_msg = str(e).lower() - if "does not exist" in error_msg or "relation" in error_msg: - return 0 + if _is_table_missing_error(e): + return 0 raise async def delete_entries_older_than( self, days: int, app_name: "str | None" = None, scope: "str | None" = None ) -> int: """Delete memory entries older than specified days.""" + if not self._enabled: + msg = "Memory store is disabled" + raise RuntimeError(msg) + clauses = ["inserted_at < (CURRENT_TIMESTAMP - ($1::int * INTERVAL '1 day'))"] params: list[Any] = [days] if app_name is not None: @@ -791,27 +834,20 @@ async def delete_entries_older_than( clauses.append(f"scope = ${len(params)}") where_clause = " AND ".join(clauses) - count_sql = f""" - SELECT COUNT(*) AS count FROM {self._memory_table} - WHERE {where_clause} - """ - delete_sql = f""" - DELETE FROM {self._memory_table} - WHERE {where_clause} - """ + sql = ( + f"WITH deleted AS (DELETE FROM {self._memory_table} " + f"WHERE {where_clause} RETURNING 1) " + "SELECT COUNT(*) AS count FROM deleted" + ) try: async with self._config.provide_connection() as conn: - count_result = await conn.fetch(count_sql, params) - count_rows: list[dict[str, Any]] = count_result.result() if count_result else [] - count = int(count_rows[0]["count"]) if count_rows else 0 - await conn.execute(delete_sql, params) - return count + result = await conn.fetch(sql, params) + rows: list[dict[str, Any]] = result.result() if result else [] + return int(rows[0]["count"]) if rows else 0 except Exception as e: - if _is_psqlpy_database_error(e): - error_msg = str(e).lower() - if "does not exist" in error_msg or "relation" in error_msg: - return 0 + if _is_table_missing_error(e): + return 0 raise async def _memory_table_ddl(self) -> str: @@ -820,12 +856,14 @@ async def _memory_table_ddl(self) -> str: if self._owner_id_column_ddl: owner_id_line = f",\n {self._owner_id_column_ddl}" - fts_index = "" - if self._use_fts: - fts_index = f""" - CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_fts - ON {self._memory_table} USING GIN (to_tsvector('english', content_text)); - """ + indexes_sql = _postgres_memory_indexes( + self._memory_table, + self._use_fts, + self._enable_bm25, + self._vector_index_type, + self._scann_num_leaves, + self._scann_quantizer, + ) return f""" CREATE TABLE IF NOT EXISTS {self._memory_table} ( @@ -837,24 +875,104 @@ async def _memory_table_ddl(self) -> str: event_id VARCHAR(128) NOT NULL UNIQUE, author VARCHAR(256){owner_id_line}, timestamp TIMESTAMPTZ NOT NULL, + embedding VECTOR({self._vector_dimensions}), content_json JSONB NOT NULL, content_text TEXT NOT NULL, metadata_json JSONB, inserted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); - - CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_app_user_time - ON {self._memory_table}(app_name, user_id, timestamp DESC); - - CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_session - ON {self._memory_table}(session_id); - {fts_index} + {indexes_sql} """ def _drop_memory_table_sql(self) -> "list[str]": """Get PostgreSQL DROP TABLE SQL statements.""" return [f"DROP TABLE IF EXISTS {self._memory_table}"] + async def _search_entries_hybrid( + self, + query: str, + app_name: str, + user_id: str, + limit: int, + scope_filter: Literal["all", "user", "app"], + embedding: "Sequence[float]", + ) -> "list[StoredMemory]": + if scope_filter == "all": + where_scope = "app_name = $3 AND ((scope = 'user' AND user_id = $4) OR scope = 'app')" + scope_params: list[Any] = [list(embedding), query, app_name, user_id] + p_cand = "$5" + p_lim = "$6" + elif scope_filter == "user": + where_scope = "app_name = $3 AND scope = 'user' AND user_id = $4" + scope_params = [list(embedding), query, app_name, user_id] + p_cand = "$5" + p_lim = "$6" + else: + where_scope = "app_name = $3 AND scope = 'app'" + scope_params = [list(embedding), query, app_name] + p_cand = "$4" + p_lim = "$5" + + candidate_limit = max(limit * 2, 50) + sql = f""" + WITH vector_matches AS ( + SELECT id, RANK() OVER (ORDER BY embedding <=> $1::float8[]::vector) AS rank_vec + FROM {self._memory_table} + WHERE {where_scope} AND embedding IS NOT NULL + LIMIT {p_cand} + ), + text_matches AS ( + SELECT id, RANK() OVER (ORDER BY content_text <@> $2) AS rank_txt + FROM {self._memory_table} + WHERE {where_scope} + LIMIT {p_cand} + ) + SELECT m.*, (COALESCE(1.0 / (60 + v.rank_vec), 0.0) + COALESCE(1.0 / (60 + t.rank_txt), 0.0)) AS rrf_score + FROM {self._memory_table} m + LEFT JOIN vector_matches v ON m.id = v.id + LEFT JOIN text_matches t ON m.id = t.id + WHERE v.id IS NOT NULL OR t.id IS NOT NULL + ORDER BY rrf_score DESC, m.timestamp DESC + LIMIT {p_lim} + """ + async with self._config.provide_connection() as conn: + self._config._ensure_pg_textsearch_available() + result = await conn.fetch(sql, [*scope_params, candidate_limit, limit]) + rows: list[dict[str, Any]] = result.result() if result else [] + return _rows_to_records(rows) + + async def _search_entries_vector( + self, + app_name: str, + user_id: str, + limit: int, + scope_filter: Literal["all", "user", "app"], + embedding: "Sequence[float]", + ) -> "list[StoredMemory]": + if scope_filter == "all": + where_scope = "app_name = $2 AND ((scope = 'user' AND user_id = $3) OR scope = 'app')" + scope_params: list[Any] = [list(embedding), app_name, user_id] + p_lim = "$4" + elif scope_filter == "user": + where_scope = "app_name = $2 AND scope = 'user' AND user_id = $3" + scope_params = [list(embedding), app_name, user_id] + p_lim = "$4" + else: + where_scope = "app_name = $2 AND scope = 'app'" + scope_params = [list(embedding), app_name] + p_lim = "$3" + + sql = f""" + SELECT * FROM {self._memory_table} + WHERE {where_scope} AND embedding IS NOT NULL + ORDER BY embedding <=> $1::float8[]::vector ASC, timestamp DESC + LIMIT {p_lim} + """ + async with self._config.provide_connection() as conn: + result = await conn.fetch(sql, [*scope_params, limit]) + rows: list[dict[str, Any]] = result.result() if result else [] + return _rows_to_records(rows) + async def _search_entries_fts( self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" ) -> "list[StoredMemory]": @@ -964,7 +1082,7 @@ def _rows_to_records(rows: "list[dict[str, Any]]") -> "list[StoredMemory]": "content_text": row["content_text"], "metadata_json": row["metadata_json"], "inserted_at": row["inserted_at"], - "embedding": None, + "embedding": row.get("embedding"), } for row in rows ] @@ -978,7 +1096,7 @@ def _is_table_missing_error(exc: Exception) -> bool: if not isinstance(exc, (PsqlpyDatabaseError, PsqlpyConnectionExecuteError)): return False error_msg = str(exc).lower() - return "does not exist" in error_msg or "relation" in error_msg + return ("does not exist" in error_msg and "relation" in error_msg) or "42p01" in error_msg def _adk_config(config: Any) -> PsqlpyADKConfig: @@ -1020,6 +1138,54 @@ def _postgres_autovacuum_options(adk_config: PsqlpyADKConfig) -> "list[str]": return options +def _postgres_memory_indexes( + memory_table: str, + use_fts: bool, + enable_bm25: bool, + vector_index_type: str, + scann_num_leaves: int, + scann_quantizer: str, +) -> str: + """Return the index DDL statements for the ADK memory table.""" + + indexes: list[str] = [ + f"CREATE INDEX IF NOT EXISTS idx_{memory_table}_app_scope_user_time ON {memory_table}(app_name, scope, user_id, timestamp DESC);", + f"CREATE INDEX IF NOT EXISTS idx_{memory_table}_scope ON {memory_table}(app_name, scope);", + f"CREATE INDEX IF NOT EXISTS idx_{memory_table}_session ON {memory_table}(session_id);", + ] + + if use_fts: + indexes.append( + f"CREATE INDEX IF NOT EXISTS idx_{memory_table}_fts ON {memory_table} USING GIN (to_tsvector('english', content_text));" + ) + + if enable_bm25: + indexes.append( + f"CREATE INDEX IF NOT EXISTS idx_{memory_table}_bm25 ON {memory_table} USING bm25 (content_text) WITH (text_config='english');" + ) + + if vector_index_type == "scann": + if not isinstance(scann_num_leaves, int) or isinstance(scann_num_leaves, bool) or scann_num_leaves <= 0: + msg = "extension_config['adk']['scann_num_leaves'] must be a positive integer" + raise ValueError(msg) + if scann_quantizer not in {"SQ8", "FP32"}: + msg = "extension_config['adk']['scann_quantizer'] must be 'SQ8' or 'FP32'" + raise ValueError(msg) + indexes.append( + f"CREATE INDEX IF NOT EXISTS idx_{memory_table}_scann ON {memory_table} USING scann (embedding) WITH (num_leaves = {scann_num_leaves}, quantizer = '{scann_quantizer}');" + ) + elif vector_index_type == "ivfflat": + indexes.append( + f"CREATE INDEX IF NOT EXISTS idx_{memory_table}_ivfflat ON {memory_table} USING ivfflat (embedding vector_cosine_ops);" + ) + elif vector_index_type == "hnsw": + indexes.append( + f"CREATE INDEX IF NOT EXISTS idx_{memory_table}_hnsw ON {memory_table} USING hnsw (embedding vector_cosine_ops);" + ) + + return "\n ".join(indexes) + + def _postgres_event_ddl_options(adk_config: PsqlpyADKConfig, events_table: str) -> "tuple[str, str, str]": generated_columns = "" generated_indexes = "" diff --git a/sqlspec/adapters/psqlpy/config.py b/sqlspec/adapters/psqlpy/config.py index 95a8bd04a..f04126f40 100644 --- a/sqlspec/adapters/psqlpy/config.py +++ b/sqlspec/adapters/psqlpy/config.py @@ -1,7 +1,7 @@ """Psqlpy database configuration.""" import sys -from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, TypedDict, cast +from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NotRequired, TypedDict, cast from mypy_extensions import mypyc_attr @@ -18,6 +18,7 @@ resolve_runtime_statement_config, ) from sqlspec.driver import AsyncPoolConnectionContext, AsyncPoolSessionFactory +from sqlspec.exceptions import MissingDependencyError from sqlspec.extensions.events import EventRuntimeHints from sqlspec.utils.config_tools import normalize_connection_config @@ -29,6 +30,8 @@ __all__ = ("PsqlpyConfig", "PsqlpyConnectionParams", "PsqlpyCursor", "PsqlpyDriverFeatures", "PsqlpyPoolParams") +_MAX_INITIALIZED_CONNECTION_IDS: Final[int] = 4096 + class PsqlpyConnectionParams(TypedDict): """Psqlpy connection parameters.""" @@ -107,7 +110,7 @@ class PsqlpyDriverFeatures(TypedDict): json_deserializer: Custom JSON deserializer retained alongside the serializer for parity with asyncpg. on_connection_create: Async callback executed when a connection is acquired from pool. Receives the raw psqlpy connection for low-level driver configuration. - Called exactly once per physical connection using WeakSet tracking. + Called once per tracked connection instance. enable_events: Enable database event channel support. Defaults to True when extension_config["events"] is configured. Provides pub/sub capabilities via LISTEN/NOTIFY or table-backed fallback. @@ -190,7 +193,10 @@ async def __aexit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> bool | None: if self._ctx: - return await self._ctx.__aexit__(exc_type, exc_val, exc_tb) # type: ignore[no-any-return] + try: + return await self._ctx.__aexit__(exc_type, exc_val, exc_tb) # type: ignore[no-any-return] + finally: + self._ctx = None return None @@ -229,10 +235,6 @@ def __init__( ) -> None: """Initialize Psqlpy configuration. - Extracts the 'on_connection_create' hook from driver_features before - storing them. Initializes a set to track initialized connection IDs - because psqlpy connections do not support weak references. - Args: connection_config: Connection and pool configuration parameters. connection_instance: Existing connection pool instance to use. @@ -249,13 +251,14 @@ def __init__( statement_config, driver_features = apply_driver_features(statement_config, driver_features) features_dict = dict(driver_features) if driver_features else {} - self._user_connection_hook: Callable[[PsqlpyConnection], Awaitable[None]] | None = features_dict.pop( - "on_connection_create", None + self._user_connection_hook: Callable[[PsqlpyConnection], Awaitable[None]] | None = features_dict.get( + "on_connection_create" ) self._initialized_connection_ids: set[int] = set() self._pgvector_available: bool | None = None self._paradedb_available: bool | None = None self._pg_textsearch_available: bool | None = None + self._pg_textsearch_probe_error: Exception | None = None super().__init__( connection_config=connection_config, @@ -273,47 +276,6 @@ def pg_textsearch_available(self) -> bool: """Return True if the pg_textsearch extension is available.""" return bool(self._pg_textsearch_available) - async def _ensure_connection(self, connection: "PsqlpyConnection") -> None: - """Ensure connection callback has been called exactly once for this connection. - - Detects PostgreSQL extensions on first connection and updates dialect accordingly. - """ - if self._pgvector_available is None: - detected_extensions: set[str] = set() - extensions = build_postgres_extension_probe_names(self.driver_features) - if extensions: - try: - result = await connection.fetch( - "SELECT extname FROM pg_extension WHERE extname = ANY($1::text[])", [extensions] - ) - rows = result.result() if result else [] - detected_extensions = {r["extname"] for r in rows} - except Exception: - detected_extensions = set() - self.statement_config, self._pgvector_available, self._paradedb_available = ( - resolve_postgres_extension_state(self.statement_config, self.driver_features, detected_extensions) - ) - self._pg_textsearch_available = is_postgres_extension_active(self.driver_features, "pg_textsearch") - - conn_id = id(connection) - if conn_id not in self._initialized_connection_ids: - if self._user_connection_hook is not None: - await self._user_connection_hook(connection) - self._initialized_connection_ids.add(conn_id) - - async def _create_pool(self) -> "ConnectionPool": - """Create the actual async connection pool.""" - return ConnectionPool(**build_connection_config(self.connection_config)) - - async def _close_pool(self) -> None: - """Close the actual async connection pool.""" - if not self.connection_instance: - return - - self.connection_instance.close() - self.connection_instance = None - self._initialized_connection_ids.clear() - async def create_connection(self) -> "PsqlpyConnection": """Create a single async connection (not from pool). @@ -325,7 +287,9 @@ async def create_connection(self) -> "PsqlpyConnection": pool = await self.create_pool() self.connection_instance = pool - return await pool.connection() + connection = await pool.connection() + await self._ensure_connection(connection) + return connection def provide_session( self, *_args: Any, statement_config: "StatementConfig | None" = None, **_kwargs: Any @@ -384,3 +348,56 @@ def get_event_runtime_hints(self) -> "EventRuntimeHints": """Return LISTEN/NOTIFY defaults for Psqlpy adapters.""" return EventRuntimeHints(poll_interval=0.5, select_for_update=True, skip_locked=True) + + async def _ensure_connection(self, connection: "PsqlpyConnection") -> None: + """Ensure connection callback has been called once for this connection. + + Detects PostgreSQL extensions on first connection and updates dialect accordingly. + """ + if self._pgvector_available is None: + detected_extensions: set[str] = set() + extensions = build_postgres_extension_probe_names(self.driver_features) + probe_succeeded = True + if extensions: + try: + result = await connection.fetch( + "SELECT extname FROM pg_extension WHERE extname = ANY($1::text[])", [extensions] + ) + rows = result.result() if result else [] + detected_extensions = {r["extname"] for r in rows} + except Exception as exc: + probe_succeeded = False + self._pg_textsearch_probe_error = exc + if probe_succeeded: + self.statement_config, self._pgvector_available, self._paradedb_available = ( + resolve_postgres_extension_state(self.statement_config, self.driver_features, detected_extensions) + ) + self._pg_textsearch_available = is_postgres_extension_active(self.driver_features, "pg_textsearch") + self._pg_textsearch_probe_error = None + + conn_id = id(connection) + if conn_id not in self._initialized_connection_ids: + if self._user_connection_hook is not None: + await self._user_connection_hook(connection) + if len(self._initialized_connection_ids) >= _MAX_INITIALIZED_CONNECTION_IDS: + self._initialized_connection_ids.clear() + self._initialized_connection_ids.add(conn_id) + + def _ensure_pg_textsearch_available(self) -> None: + """Raise MissingDependencyError if pg_textsearch is required but unavailable.""" + if not self.pg_textsearch_available: + msg = "PostgreSQL extension 'pg_textsearch' is required for BM25 hybrid search but is not installed or enabled." + raise MissingDependencyError(msg) from self._pg_textsearch_probe_error + + async def _create_pool(self) -> "ConnectionPool": + """Create the actual async connection pool.""" + return ConnectionPool(**build_connection_config(self.connection_config)) + + async def _close_pool(self) -> None: + """Close the actual async connection pool.""" + if not self.connection_instance: + return + + self.connection_instance.close() + self.connection_instance = None + self._initialized_connection_ids.clear() diff --git a/sqlspec/adapters/psqlpy/core.py b/sqlspec/adapters/psqlpy/core.py index 84ba7b6e0..3185776d1 100644 --- a/sqlspec/adapters/psqlpy/core.py +++ b/sqlspec/adapters/psqlpy/core.py @@ -1,7 +1,7 @@ """psqlpy adapter compiled helpers.""" import contextlib -import datetime +import datetime as dt import decimal import re import uuid @@ -12,6 +12,7 @@ from sqlglot import exp from sqlglot.errors import ParseError +from sqlspec.adapters.psqlpy._typing import PSQLPY_JSONB as _JSONB_TYPE from sqlspec.adapters.psqlpy._typing import PsqlpyDataError, PsqlpyIntegrityError, PsqlpyOperationalError from sqlspec.adapters.psqlpy.type_converter import coerce_pgvector from sqlspec.core import ( @@ -93,20 +94,13 @@ }) _UUID_CASTS: Final[frozenset[str]] = frozenset({"UUID"}) _DECIMAL_NORMALIZER = build_nested_decimal_normalizer(mode="float") -_JSONB_TYPE: type[Any] | None = None -try: - from sqlspec.adapters.psqlpy._typing import PSQLPY_JSONB as _JSONB_IMPORTED -except ImportError: - pass -else: - _JSONB_TYPE = _JSONB_IMPORTED PSQLPY_STATUS_REGEX: "re.Pattern[str]" = re.compile(r"^([A-Z]+)(?:\s+(\d+))?\s+(\d+)$", re.IGNORECASE) _DML_COUNT_CTE_ALIAS: Final = "_sqlspec_affected" _DML_COUNT_COLUMN: Final = "_sqlspec_rows_affected" _DML_COUNT_QUERY_CACHE_SIZE: Final = 1024 logger = get_logger("sqlspec.adapters.psqlpy.core") -_NUMERIC_COERCE_TYPES: "tuple[type[Any], ...]" = (float, decimal.Decimal, list, tuple, dict) +_NUMERIC_COERCE_TYPES: "tuple[type[Any], ...]" = (float, decimal.Decimal, list, tuple) def build_profile() -> "DriverParameterProfile": @@ -210,7 +204,15 @@ def collect_rows(query_result: Any | None) -> "tuple[list[dict[str, Any]], list[ if not query_result: return [], [] - dict_rows = cast("list[dict[str, Any]]", query_result if isinstance(query_result, list) else query_result.result()) + if isinstance(query_result, list): + dict_rows = cast("list[dict[str, Any]]", query_result) + else: + try: + dict_rows = cast("list[dict[str, Any]]", query_result.result()) + except Exception as exc: + if "type: void" in str(exc): + return [], [] + raise if not dict_rows: return [], [] return dict_rows, list(dict_rows[0]) @@ -240,25 +242,6 @@ async def start(self) -> None: await self._driver._run_with_exception_handler(handler, self._start) self._driver._check_pending_exception(handler) - async def _start(self) -> None: - if self._driver._connection_in_transaction(): - cursor = self._driver.connection.cursor(self._sql, self._parameters, array_size=self._chunk_size) - await cursor.start() - self._cursor = cursor - return - transaction = self._driver.connection.transaction() - await transaction.begin() - self._transaction = transaction - try: - cursor = transaction.cursor(self._sql, self._parameters, array_size=self._chunk_size) - await cursor.start() - self._cursor = cursor - except BaseException: - self._transaction = None - with contextlib.suppress(Exception): - await transaction.rollback() - raise - async def fetch_chunk(self) -> "list[dict[str, Any]]": handler = self._driver.handle_database_exceptions() query_result = await self._driver._run_with_exception_handler(handler, self._cursor.fetchmany, self._chunk_size) @@ -285,6 +268,25 @@ async def close(self, error: bool = False) -> None: with contextlib.suppress(Exception): await transaction.rollback() + async def _start(self) -> None: + if self._driver._connection_in_transaction(): + cursor = self._driver.connection.cursor(self._sql, self._parameters, array_size=self._chunk_size) + await cursor.start() + self._cursor = cursor + return + transaction = self._driver.connection.transaction() + await transaction.begin() + self._transaction = transaction + try: + cursor = transaction.cursor(self._sql, self._parameters, array_size=self._chunk_size) + await cursor.start() + self._cursor = cursor + except BaseException: + self._transaction = None + with contextlib.suppress(Exception): + await transaction.rollback() + raise + def coerce_numeric_for_write(value: Any) -> Any: if isinstance(value, float): @@ -311,21 +313,15 @@ def coerce_numeric_for_write(value: Any) -> Any: coerced_tuple = list(value[:index]) coerced_tuple.append(coerced_item) return value if coerced_tuple is None else tuple(coerced_tuple) - if isinstance(value, dict): - coerced_dict: dict[Any, Any] | None = None - for key, item in value.items(): - coerced_item = coerce_numeric_for_write(item) - if coerced_dict is None: - if coerced_item is item: - continue - coerced_dict = dict(value) - coerced_dict[key] = coerced_item - return value if coerced_dict is None else coerced_dict return value def coerce_json_columns( - records: "list[tuple[Any, ...]]", columns: "list[str]", json_columns: "set[str]" + records: "list[tuple[Any, ...]]", + columns: "list[str]", + json_columns: "set[str]", + *, + deserializer: "Callable[[str], Any]" = from_json, ) -> "list[tuple[Any, ...]]": """Parse JSON text into objects for destination columns typed json or jsonb. @@ -338,6 +334,7 @@ def coerce_json_columns( records: Row tuples in column order. columns: Destination column names, positionally aligned with the rows. json_columns: Names of destination columns typed json or jsonb. + deserializer: JSON decoder callable. Returns: The rows, with JSON column values decoded. @@ -351,7 +348,7 @@ def coerce_json_columns( for record in records: row = list(record) for index in indexes: - row[index] = _coerce_json_text_for_execute_many(row[index]) + row[index] = _coerce_json_text_for_execute_many(row[index], deserializer=deserializer) coerced.append(tuple(row)) return coerced @@ -438,9 +435,10 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec Mapped Exceptions: * Integrity constraint violations (UniqueViolationError, ForeignKeyViolationError, etc.) * Transaction/serialization errors (DeadlockError, SerializationConflictError) + * ConnectionTimeoutError / DatabaseConnectionError: connection errors * QueryTimeoutError and OperationCancelledError * PermissionDeniedError: permission denied, authentication failed - * ConnectionTimeoutError / DatabaseConnectionError: connection errors + * DataError: data conversion and formatting errors * SQLParsingError: syntax/parse errors Args: @@ -469,24 +467,36 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec if "serialization failure" in error_msg or "could not serialize" in error_msg: return _create_postgres_error(error, SerializationConflictError, "serialization failure") + if ( + "connection" in error_msg + or "could not connect" in error_msg + or "connection refused" in error_msg + or "connection reset" in error_msg + or "broken pipe" in error_msg + or "server closed the connection" in error_msg + ): + if "timeout" in error_msg or "timed out" in error_msg: + return _create_postgres_error(error, ConnectionTimeoutError, "connection timeout") + return _create_postgres_error(error, DatabaseConnectionError, "connection error") + if error_class := _classify_timeout_or_cancellation(error_msg): return _create_postgres_error(error, error_class, "query terminated") if "permission denied" in error_msg or "insufficient privilege" in error_msg: return _create_postgres_error(error, PermissionDeniedError, "permission denied") - if "authentication failed" in error_msg or "password" in error_msg: + if ( + "authentication failed" in error_msg + or "password authentication failed" in error_msg + or "no password supplied" in error_msg + ): return _create_postgres_error(error, PermissionDeniedError, "authentication error") - if "connection" in error_msg or "could not connect" in error_msg: - if "timeout" in error_msg: - return _create_postgres_error(error, ConnectionTimeoutError, "connection timeout") - return _create_postgres_error(error, DatabaseConnectionError, "connection error") + if isinstance(error, PsqlpyDataError): + return _create_postgres_error(error, DataError, "data error") if "syntax error" in error_msg or "parse" in error_msg: return _create_postgres_error(error, SQLParsingError, "SQL syntax error") - if isinstance(error, PsqlpyDataError): - return _create_postgres_error(error, DataError, "data error") if isinstance(error, PsqlpyOperationalError): return _create_postgres_error(error, OperationalError, "operational error") if isinstance(error, PsqlpyIntegrityError): @@ -546,6 +556,15 @@ def _coerce_json_parameter(value: Any, cast_type: str, serializer: "Callable[[An return jsonb_type(value) if isinstance(value, (list, tuple)): return jsonb_type(list(value)) + if isinstance(value, str): + stripped = value.strip() + if stripped.startswith(("{", "[")): + with contextlib.suppress(Exception): + parsed = from_json(value) + if isinstance(parsed, dict): + return jsonb_type(parsed) + if isinstance(parsed, (list, tuple)): + return jsonb_type(list(parsed)) if isinstance(value, tuple): return list(value) if jsonb_type is not None and isinstance(value, jsonb_type): @@ -575,14 +594,14 @@ def _coerce_uuid_parameter(value: Any) -> Any: def _coerce_timestamp_parameter(value: Any) -> Any: - """Convert ISO-formatted timestamp strings to ``datetime.datetime``.""" + """Convert ISO-formatted timestamp strings to ``dt.datetime``.""" - if isinstance(value, datetime.datetime): + if isinstance(value, dt.datetime): return value if isinstance(value, str): normalized_value = value[:-1] + "+00:00" if value.endswith("Z") else value try: - return datetime.datetime.fromisoformat(normalized_value) + return dt.datetime.fromisoformat(normalized_value) except ValueError as error: msg = "Invalid ISO timestamp parameter for psqlpy." raise SQLSpecError(msg) from error @@ -610,11 +629,15 @@ def _prepare_dict_parameter(value: "dict[str, Any]") -> "dict[str, Any]": def _prepare_list_parameter(value: "list[Any]") -> "list[Any]": - return [_DECIMAL_NORMALIZER(item) for item in value] + normalized = _DECIMAL_NORMALIZER(value) + return normalized if isinstance(normalized, list) else value def _prepare_tuple_parameter(value: "tuple[Any, ...]") -> "tuple[Any, ...]": - return tuple(_DECIMAL_NORMALIZER(item) for item in value) + normalized = _DECIMAL_NORMALIZER(value) + if isinstance(normalized, list): + return tuple(normalized) + return normalized if isinstance(normalized, tuple) else value def _parameter_config(base_config: "ParameterStyleConfig") -> "ParameterStyleConfig": @@ -665,11 +688,11 @@ def _dml_count_query(sql: str) -> str | None: except ParseError: return None - if ( - not isinstance(expression, (exp.Insert, exp.Update, exp.Delete)) - or expression.args.get("returning") - or expression.args.get("with_") - ): + if not isinstance(expression, (exp.Insert, exp.Update, exp.Delete)) or expression.args.get("returning"): + return None + + existing_with = expression.args.get("with_") + if existing_with is not None and existing_with.args.get("recursive"): return None used_aliases = {cte.alias_or_name for cte in expression.find_all(exp.CTE)} @@ -679,9 +702,14 @@ def _dml_count_query(sql: str) -> str | None: cte_alias = f"{_DML_COUNT_CTE_ALIAS}_{suffix}" suffix += 1 + if existing_with is not None: + expression.set("with_", None) + expression = expression.returning("1", dialect="postgres", copy=False) count_expression = exp.alias_(exp.Count(this=exp.Star()), _DML_COUNT_COLUMN) count_query = exp.select(count_expression).from_(cte_alias, copy=False) + if existing_with is not None: + count_query.set("with_", existing_with) count_query.with_(cte_alias, as_=expression, copy=False) return count_query.sql(dialect="postgres", copy=False) @@ -741,14 +769,14 @@ def _format_execute_many_param_set(param_set: Any, *, coerce_numeric: bool) -> " return [param_set] -def _coerce_json_text_for_execute_many(value: Any) -> Any: +def _coerce_json_text_for_execute_many(value: Any, *, deserializer: "Callable[[str], Any]" = from_json) -> Any: if not isinstance(value, str): return value stripped = value.lstrip() if not stripped or stripped[0] not in "{[": return value try: - decoded = from_json(value) + decoded = deserializer(value) except Exception: return value return decoded if isinstance(decoded, (dict, list, tuple)) else value diff --git a/sqlspec/adapters/psqlpy/data_dictionary.py b/sqlspec/adapters/psqlpy/data_dictionary.py index 05baa87d9..41e0d060f 100644 --- a/sqlspec/adapters/psqlpy/data_dictionary.py +++ b/sqlspec/adapters/psqlpy/data_dictionary.py @@ -91,15 +91,6 @@ async def get_system_metadata_capabilities( requested_domains = tuple(_POSTGRES_SYSTEM_METADATA_QUERIES) if domains is None else tuple(domains) return tuple(_postgres_system_metadata_capability(domain) for domain in requested_domains) - async def _select_domain( - self, driver: "PsqlpyDriver", domain: str, query_name: str, **parameters: Any - ) -> MetadataResult: - query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) - if not query.is_supported or query.sql is None: - return MetadataResult(domain, capability=query.capability, warnings=query.warnings) - rows = await driver.select(query.sql, **parameters) - return _metadata_result(domain, _postgres_metadata_capability(domain), rows) - async def get_schemas(self, driver: "PsqlpyDriver") -> MetadataResult: """Get schema metadata.""" return await self._select_domain(driver, "schemas", "list") @@ -212,7 +203,7 @@ async def get_version(self, driver: "PsqlpyDriver") -> "VersionInfo | None": Performs an inline cache check first to avoid a cross-module method call that causes mypyc segfaults. If not cached, fetches from the database. """ - driver_id = id(driver) + driver_id = id(getattr(driver, "connection", None) or driver) if driver_id in self._version_fetch_attempted: return self._version_cache.get(driver_id) @@ -324,6 +315,15 @@ async def get_foreign_keys( schema_type=ForeignKeyMetadata, ) + async def _select_domain( + self, driver: "PsqlpyDriver", domain: str, query_name: str, **parameters: Any + ) -> MetadataResult: + query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) + if not query.is_supported or query.sql is None: + return MetadataResult(domain, capability=query.capability, warnings=query.warnings) + rows = await driver.select(query.sql, **parameters) + return _metadata_result(domain, _postgres_metadata_capability(domain), rows) + def _postgres_metadata_capability(domain: str) -> MetadataCapability: if domain == "system": diff --git a/sqlspec/adapters/psqlpy/driver.py b/sqlspec/adapters/psqlpy/driver.py index 69b9f88c0..ce015f55d 100644 --- a/sqlspec/adapters/psqlpy/driver.py +++ b/sqlspec/adapters/psqlpy/driver.py @@ -6,7 +6,13 @@ from typing import TYPE_CHECKING, Any, cast -from sqlspec.adapters.psqlpy._typing import PsqlpyCursor, PsqlpyDatabaseError, PsqlpyError, PsqlpySessionContext +from sqlspec.adapters.psqlpy._typing import ( + PsqlpyConnectionExecuteError, + PsqlpyCursor, + PsqlpyDatabaseError, + PsqlpyError, + PsqlpySessionContext, +) from sqlspec.adapters.psqlpy.core import ( _DML_COUNT_COLUMN, PsqlpyStreamSource, @@ -28,10 +34,11 @@ from sqlspec.core import SQL, StatementConfig, get_cache_config, register_driver_profile from sqlspec.driver import AsyncDriverAdapterBase, AsyncRowStream, BaseAsyncExceptionHandler from sqlspec.exceptions import SQLSpecError +from sqlspec.utils.serializers import from_json from sqlspec.utils.text import normalize_identifier, quote_identifier if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Callable, Mapping, Sequence from sqlspec.adapters.psqlpy._typing import PsqlpyConnection from sqlspec.core import ArrowResult, SQLResult @@ -40,6 +47,8 @@ __all__ = ("PsqlpyCursor", "PsqlpyDriver", "PsqlpyExceptionHandler", "PsqlpySessionContext") +_CAST_DIALECTS = frozenset({"postgres", "postgresql", "pgvector", "paradedb", "pg_textsearch"}) + class PsqlpyExceptionHandler(BaseAsyncExceptionHandler): """Async context manager for handling psqlpy database exceptions. @@ -58,7 +67,7 @@ def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "Ba if exc_type is None: return False - if issubclass(exc_type, (PsqlpyDatabaseError, PsqlpyError)): + if issubclass(exc_type, (PsqlpyDatabaseError, PsqlpyConnectionExecuteError, PsqlpyError)): self.pending_exception = create_mapped_exception(exc_val) return True return False @@ -89,6 +98,17 @@ def __init__( self._data_dictionary: PsqlpyDataDictionary | None = None self._transaction_active = False + @property + def data_dictionary(self) -> "PsqlpyDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = PsqlpyDataDictionary() + return self._data_dictionary + async def dispatch_execute(self, cursor: "PsqlpyConnection", statement: SQL) -> "ExecutionResult": """Execute single SQL statement. @@ -175,9 +195,11 @@ async def dispatch_execute_script(self, cursor: "PsqlpyConnection", statement: S successful_count = 0 last_result = None params = prepared_parameters or [] + is_single = len(statements) == 1 for stmt in statements: - last_result = await cursor.execute(stmt, params) + stmt_params = params if (is_single or "$" in stmt) else [] + last_result = await cursor.execute(stmt, stmt_params) successful_count += 1 return self.create_execution_result( @@ -186,10 +208,12 @@ async def dispatch_execute_script(self, cursor: "PsqlpyConnection", statement: S async def begin(self) -> None: """Begin a database transaction.""" + if self._transaction_active: + return try: await self.connection.execute("BEGIN") - except PsqlpyDatabaseError as e: + except (PsqlpyDatabaseError, PsqlpyConnectionExecuteError, PsqlpyError) as e: msg = f"Failed to begin psqlpy transaction: {e}" raise SQLSpecError(msg) from e self._transaction_active = True @@ -199,7 +223,7 @@ async def commit(self) -> None: try: await self.connection.execute("COMMIT") - except PsqlpyDatabaseError as e: + except (PsqlpyDatabaseError, PsqlpyConnectionExecuteError, PsqlpyError) as e: msg = f"Failed to commit psqlpy transaction: {e}" raise SQLSpecError(msg) from e finally: @@ -210,7 +234,7 @@ async def rollback(self) -> None: try: await self.connection.execute("ROLLBACK") - except PsqlpyDatabaseError as e: + except (PsqlpyDatabaseError, PsqlpyConnectionExecuteError, PsqlpyError) as e: msg = f"Failed to rollback psqlpy transaction: {e}" raise SQLSpecError(msg) from e finally: @@ -232,37 +256,6 @@ async def reset_migration_session_schema(self) -> None: """Reset the PostgreSQL search path after non-transactional migration SQL.""" await self.connection.execute("RESET search_path") - async def _resolve_json_columns(self, schema_name: "str | None", table_name: str) -> "set[str]": - """Return the destination columns the database reports as json or jsonb. - - Args: - schema_name: Destination schema, or None for the current search path. - table_name: Destination table name. - - The relation is resolved the way the server would resolve it in a query, - so a table shadowed on the search path contributes no columns, and only - the one relation it names is inspected. A column typed as a domain is - read through to the type the domain is built on. - - Returns: - Names of columns typed json or jsonb. - """ - qualified = quote_identifier(table_name) - if schema_name is not None: - qualified = f"{quote_identifier(schema_name)}.{qualified}" - rows = await self.connection.fetch( - "SELECT a.attname AS column_name " - "FROM pg_attribute a " - "JOIN pg_type t ON t.oid = a.atttypid " - "LEFT JOIN pg_type b ON b.oid = t.typbasetype " - "WHERE a.attrelid = to_regclass($1) " - "AND a.attnum > 0 AND NOT a.attisdropped " - "AND COALESCE(b.typname, t.typname) IN ('json', 'jsonb')", - [qualified], - ) - data, _ = collect_rows(rows) - return {str(row["column_name"]) for row in data} - async def has_schema(self, schema: str) -> bool: """Return whether a PostgreSQL schema exists.""" normalized_schema = normalize_identifier(schema, "postgres") @@ -352,8 +345,11 @@ async def load_from_arrow( if schema_name: copy_kwargs["schema_name"] = schema_name json_columns = await self._resolve_json_columns(schema_name, table_name) + deserializer = cast("Callable[[str], Any]", self.driver_features.get("json_deserializer", from_json)) await cursor.copy_records_to_table( - table_name, coerce_json_columns(records, columns, json_columns), **copy_kwargs + table_name, + coerce_json_columns(records, columns, json_columns, deserializer=deserializer), + **copy_kwargs, ) if exc_handler.pending_exception is not None: raise exc_handler.pending_exception from None @@ -398,18 +394,27 @@ def prepare_driver_parameters( Parameters with cast-aware type coercion applied """ enable_cast_detection = self.driver_features.get("enable_cast_detection", True) + active_dialect = statement_config.dialect or self.dialect - if enable_cast_detection and prepared_statement and self.dialect in {"postgres", "postgresql"} and not is_many: + prepared: Any + if enable_cast_detection and prepared_statement and active_dialect in _CAST_DIALECTS: parameter_casts = get_parameter_casts(prepared_statement) - prepared = prepare_parameters_with_casts(parameters, parameter_casts, statement_config) + if is_many: + if isinstance(parameters, (list, tuple)): + prepared = [ + prepare_parameters_with_casts(param_set, parameter_casts, statement_config) + for param_set in parameters + ] + else: + prepared = super().prepare_driver_parameters( + parameters, statement_config, is_many, prepared_statement + ) + else: + prepared = prepare_parameters_with_casts(parameters, parameter_casts, statement_config) else: prepared = super().prepare_driver_parameters(parameters, statement_config, is_many, prepared_statement) - if not is_many: - operation_type = getattr(prepared_statement, "operation_type", None) - if operation_type != "SELECT": - prepared = coerce_numeric_for_write(prepared) - elif getattr(prepared_statement, "operation_type", None) != "SELECT": + if getattr(prepared_statement, "operation_type", None) != "SELECT": prepared = coerce_numeric_for_write(prepared) if not is_many and isinstance(prepared, list): @@ -417,17 +422,6 @@ def prepare_driver_parameters( return prepared - @property - def data_dictionary(self) -> "PsqlpyDataDictionary": - """Get the data dictionary for this driver. - - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = PsqlpyDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: "PsqlpyConnection", fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect psqlpy rows for the direct execution path. @@ -440,6 +434,37 @@ def resolve_rowcount(self, cursor: "PsqlpyConnection") -> int: """Resolve rowcount from psqlpy result for the direct execution path.""" return extract_rows_affected(cursor) + async def _resolve_json_columns(self, schema_name: "str | None", table_name: str) -> "set[str]": + """Return the destination columns the database reports as json or jsonb. + + Args: + schema_name: Destination schema, or None for the current search path. + table_name: Destination table name. + + The relation is resolved the way the server would resolve it in a query, + so a table shadowed on the search path contributes no columns, and only + the one relation it names is inspected. A column typed as a domain is + read through to the type the domain is built on. + + Returns: + Names of columns typed json or jsonb. + """ + qualified = quote_identifier(table_name) + if schema_name is not None: + qualified = f"{quote_identifier(schema_name)}.{qualified}" + rows = await self.connection.fetch( + "SELECT a.attname AS column_name " + "FROM pg_attribute a " + "JOIN pg_type t ON t.oid = a.atttypid " + "LEFT JOIN pg_type b ON b.oid = t.typbasetype " + "WHERE a.attrelid = to_regclass($1) " + "AND a.attnum > 0 AND NOT a.attisdropped " + "AND COALESCE(b.typname, t.typname) IN ('json', 'jsonb')", + [qualified], + ) + data, _ = collect_rows(rows) + return {str(row["column_name"]) for row in data} + async def _execute_cache_hit( self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: Any ) -> "SQLResult": diff --git a/sqlspec/adapters/psqlpy/events/_hub.py b/sqlspec/adapters/psqlpy/events/_hub.py index d28e50ebb..86ff29853 100644 --- a/sqlspec/adapters/psqlpy/events/_hub.py +++ b/sqlspec/adapters/psqlpy/events/_hub.py @@ -207,15 +207,17 @@ async def _subscribe_locked( self._callbacks[channel] = callback try: await listener.add_callback(channel=channel, callback=callback.__call__) - except Exception: + if self._listener_started: + listener.abort_listen() + listener.listen() + self._listener_started = True + await self._wait_until_ready(channel, timeout=ready_timeout) + except BaseException: self._queues.pop(channel, None) self._callbacks.pop(channel, None) + with contextlib.suppress(Exception): + await listener.clear_channel_callbacks(channel=channel) raise - if self._listener_started: - listener.abort_listen() - listener.listen() - self._listener_started = True - await self._wait_until_ready(channel, timeout=ready_timeout) return consumer_queue async def _ensure_listener_locked(self) -> "PsqlpyListener": diff --git a/sqlspec/adapters/psqlpy/events/backend.py b/sqlspec/adapters/psqlpy/events/backend.py index 3924557f6..cf49e89a4 100644 --- a/sqlspec/adapters/psqlpy/events/backend.py +++ b/sqlspec/adapters/psqlpy/events/backend.py @@ -257,6 +257,23 @@ async def dequeue(self, channel: str, poll_interval: float) -> EventMessage | No return event return await self._dequeue_hinted(channel) + async def ack(self, event_id: str) -> None: + await self._queue.ack(event_id) + self._runtime.increment_metric("events.ack") + + async def nack(self, event_id: str) -> None: + await self._queue.nack(event_id) + self._runtime.increment_metric("events.nack") + + async def shutdown(self) -> None: + hub = self._hub + try: + if hub is not None: + self._hub = None + await hub.shutdown() + finally: + self._marker_state.reset() + async def _dequeue_hinted(self, channel: str) -> EventMessage | None: event = await self._queue.dequeue(channel) if event is None: @@ -278,23 +295,6 @@ async def _dequeue_reconciled(self, channel: str) -> EventMessage | None: _record_dequeue_result(self._runtime, event) return event - async def ack(self, event_id: str) -> None: - await self._queue.ack(event_id) - self._runtime.increment_metric("events.ack") - - async def nack(self, event_id: str) -> None: - await self._queue.nack(event_id) - self._runtime.increment_metric("events.nack") - - async def shutdown(self) -> None: - hub = self._hub - try: - if hub is not None: - self._hub = None - await hub.shutdown() - finally: - self._marker_state.reset() - def _ensure_hub(self) -> PsqlpyListenerHub: if self._hub is None: self._hub = PsqlpyListenerHub(self._config, self.backend_name) @@ -305,25 +305,30 @@ async def _publish_durable( ) -> None: now = datetime.now(UTC) async with self._config.provide_session() as driver: - await driver.execute( - SQL( - self._queue._insert_statement, # pyright: ignore[reportPrivateUsage] - { - "event_id": event_id, - "channel": channel, - "payload_json": payload, - "metadata_json": metadata, - "status": "pending", - "available_at": now, - "lease_expires_at": None, - "attempts": 0, - "created_at": now, - }, - statement_config=self._queue._statement_config, # pyright: ignore[reportPrivateUsage] + await driver.begin() + try: + await driver.execute( + SQL( + self._queue._insert_statement, # pyright: ignore[reportPrivateUsage] + { + "event_id": event_id, + "channel": channel, + "payload_json": payload, + "metadata_json": metadata, + "status": "pending", + "available_at": now, + "lease_expires_at": None, + "attempts": 0, + "created_at": now, + }, + statement_config=self._queue._statement_config, # pyright: ignore[reportPrivateUsage] + ) ) - ) - await driver.execute_script(SQL("SELECT pg_notify($1, $2)", channel, to_json({"event_id": event_id}))) - await driver.commit() + await driver.execute_script(SQL("SELECT pg_notify($1, $2)", channel, to_json({"event_id": event_id}))) + await driver.commit() + except BaseException: + await driver.rollback() + raise def create_event_backend( diff --git a/sqlspec/adapters/psycopg/__init__.py b/sqlspec/adapters/psycopg/__init__.py index a193bdbf1..c963104cc 100644 --- a/sqlspec/adapters/psycopg/__init__.py +++ b/sqlspec/adapters/psycopg/__init__.py @@ -1,3 +1,5 @@ +"""Psycopg synchronous and asynchronous PostgreSQL database adapter.""" + from sqlspec.adapters.psycopg._typing import ( PsycopgAsyncConnection, PsycopgAsyncCursor, diff --git a/sqlspec/adapters/psycopg/_typing.py b/sqlspec/adapters/psycopg/_typing.py index 9f3ffb2f8..9a74fe490 100644 --- a/sqlspec/adapters/psycopg/_typing.py +++ b/sqlspec/adapters/psycopg/_typing.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Any, Protocol import psycopg as psycopg_module -from psycopg import AsyncConnection, AsyncCursor, Connection, Cursor from psycopg import AsyncConnection as PsycopgNativeAsyncConnection from psycopg import AsyncCursor as PsycopgNativeAsyncCursor from psycopg import Connection as PsycopgConnection @@ -47,15 +46,15 @@ from sqlspec.builder import QueryBuilder from sqlspec.core import SQL, Statement, StatementConfig - PsycopgSyncConnection: TypeAlias = Connection[PsycopgDictRow] - PsycopgAsyncConnection: TypeAlias = AsyncConnection[PsycopgDictRow] - PsycopgSyncRawCursor: TypeAlias = Cursor[PsycopgDictRow] - PsycopgAsyncRawCursor: TypeAlias = AsyncCursor[PsycopgDictRow] + PsycopgSyncConnection: TypeAlias = PsycopgConnection[PsycopgDictRow] + PsycopgAsyncConnection: TypeAlias = PsycopgNativeAsyncConnection[PsycopgDictRow] + PsycopgSyncRawCursor: TypeAlias = PsycopgCursor[PsycopgDictRow] + PsycopgAsyncRawCursor: TypeAlias = PsycopgNativeAsyncCursor[PsycopgDictRow] else: - PsycopgSyncConnection = Connection - PsycopgAsyncConnection = AsyncConnection - PsycopgSyncRawCursor = Cursor - PsycopgAsyncRawCursor = AsyncCursor + PsycopgSyncConnection = PsycopgConnection + PsycopgAsyncConnection = PsycopgNativeAsyncConnection + PsycopgSyncRawCursor = PsycopgCursor + PsycopgAsyncRawCursor = PsycopgNativeAsyncCursor __all__ = ( @@ -96,6 +95,9 @@ "psycopg_sql", ) +_psycopg_sync_driver_cls: "type[PsycopgSyncDriver] | None" = None +_psycopg_async_driver_cls: "type[PsycopgAsyncDriver] | None" = None + class PsycopgSyncCursor: """Context manager for PostgreSQL psycopg cursor management.""" @@ -201,11 +203,10 @@ def __init__( self._driver: PsycopgSyncDriver | None = None def __enter__(self) -> "PsycopgSyncDriver": - from sqlspec.adapters.psycopg.driver import PsycopgSyncDriver - + driver_cls = _resolve_psycopg_sync_driver() self._connection = self._acquire_connection() statement_config = self._statement_config() if callable(self._statement_config) else self._statement_config - self._driver = PsycopgSyncDriver( + self._driver = driver_cls( connection=self._connection, statement_config=statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -213,9 +214,15 @@ def __enter__(self) -> "PsycopgSyncDriver": def __exit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> "bool | None": - if self._connection is not None: - self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) - self._connection = None + try: + if self._driver is not None and getattr(self._driver, "_transaction_active", False): + with contextlib.suppress(Exception): + self._driver.rollback() + finally: + self._driver = None + if self._connection is not None: + self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) + self._connection = None return None @@ -257,11 +264,10 @@ def __init__( self._driver: PsycopgAsyncDriver | None = None async def __aenter__(self) -> "PsycopgAsyncDriver": - from sqlspec.adapters.psycopg.driver import PsycopgAsyncDriver - + driver_cls = _resolve_psycopg_async_driver() self._connection = await self._acquire_connection() statement_config = self._statement_config() if callable(self._statement_config) else self._statement_config - self._driver = PsycopgAsyncDriver( + self._driver = driver_cls( connection=self._connection, statement_config=statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -269,9 +275,15 @@ async def __aenter__(self) -> "PsycopgAsyncDriver": async def __aexit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> "bool | None": - if self._connection is not None: - await self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) - self._connection = None + try: + if self._driver is not None and getattr(self._driver, "_transaction_active", False): + with contextlib.suppress(Exception): + await self._driver.rollback() + finally: + self._driver = None + if self._connection is not None: + await self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) + self._connection = None return None @@ -292,3 +304,23 @@ def __getattr__(name: str) -> Any: msg = f"Cannot import {attribute!r} from {module_name!r}" raise ImportError(msg) return value + + +def _resolve_psycopg_sync_driver() -> "type[PsycopgSyncDriver]": + global _psycopg_sync_driver_cls + cls = _psycopg_sync_driver_cls + if cls is None: + cls = import_optional_attr("sqlspec.adapters.psycopg.driver", "PsycopgSyncDriver") + assert cls is not None + _psycopg_sync_driver_cls = cls + return cls + + +def _resolve_psycopg_async_driver() -> "type[PsycopgAsyncDriver]": + global _psycopg_async_driver_cls + cls = _psycopg_async_driver_cls + if cls is None: + cls = import_optional_attr("sqlspec.adapters.psycopg.driver", "PsycopgAsyncDriver") + assert cls is not None + _psycopg_async_driver_cls = cls + return cls diff --git a/sqlspec/adapters/psycopg/adk/store.py b/sqlspec/adapters/psycopg/adk/store.py index 8ea955ee3..423be4691 100644 --- a/sqlspec/adapters/psycopg/adk/store.py +++ b/sqlspec/adapters/psycopg/adk/store.py @@ -233,6 +233,7 @@ async def create_session( query = pg_sql.SQL(""" INSERT INTO {table} (id, app_name, user_id, {owner_id_col}, state, create_time, update_time) VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id, app_name, user_id, state, create_time, update_time """).format( table=pg_sql.Identifier(self._session_table), owner_id_col=pg_sql.Identifier(self._owner_id_column_name) ) @@ -241,13 +242,27 @@ async def create_session( query = pg_sql.SQL(""" INSERT INTO {table} (id, app_name, user_id, state, create_time, update_time) VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id, app_name, user_id, state, create_time, update_time """).format(table=pg_sql.Identifier(self._session_table)) params = (session_id, app_name, user_id, Jsonb(state)) + row: dict[str, Any] | None = None async with self._config.provide_connection() as conn, conn.cursor(row_factory=dict_row) as cur: await cur.execute(query, params) + if hasattr(cur, "fetchone"): + row = await cur.fetchone() await conn.commit() + if row is not None: + return StoredSession( + id=row["id"], + app_name=row["app_name"], + user_id=row["user_id"], + state=row["state"], + create_time=row["create_time"], + update_time=row["update_time"], + ) + result = await self.get_session(app_name, user_id, session_id) if result is None: msg = "Failed to fetch created session" @@ -420,32 +435,58 @@ async def append_event_and_update_state( event_data_value = event_record["event_data"] jsonb_value = Jsonb(event_data_value) if isinstance(event_data_value, dict) else event_data_value - async with self._config.provide_connection() as conn, conn.cursor(row_factory=dict_row) as cur: - try: - await cur.execute( - insert_query, - ( - event_record["id"], - event_record["app_name"], - event_record["user_id"], - event_record["session_id"], - event_record["invocation_id"], - event_record["timestamp"], - jsonb_value, - ), - ) - await cur.execute(update_query, (Jsonb(state), app_name, user_id, session_id)) - row = await cur.fetchone() - if row is None: - _raise_missing_session(session_id) - if app_state is not None: - await cur.execute(app_upsert_query, (app_name, Jsonb(app_state))) - if user_state is not None: - await cur.execute(user_upsert_query, (app_name, user_id, Jsonb(user_state))) - except Exception: - await conn.rollback() - raise - await conn.commit() + async with self._config.provide_connection() as conn: + tx_factory = getattr(conn, "transaction", None) + if callable(tx_factory) and getattr(conn, "autocommit", False): + tx_ctx: Any = tx_factory() + async with tx_ctx, conn.cursor(row_factory=dict_row) as cur: + await cur.execute( + insert_query, + ( + event_record["id"], + event_record["app_name"], + event_record["user_id"], + event_record["session_id"], + event_record["invocation_id"], + event_record["timestamp"], + jsonb_value, + ), + ) + await cur.execute(update_query, (Jsonb(state), app_name, user_id, session_id)) + row = await cur.fetchone() + if row is None: + _raise_missing_session(session_id) + if app_state is not None: + await cur.execute(app_upsert_query, (app_name, Jsonb(app_state))) + if user_state is not None: + await cur.execute(user_upsert_query, (app_name, user_id, Jsonb(user_state))) + else: + async with conn.cursor(row_factory=dict_row) as cur: + try: + await cur.execute( + insert_query, + ( + event_record["id"], + event_record["app_name"], + event_record["user_id"], + event_record["session_id"], + event_record["invocation_id"], + event_record["timestamp"], + jsonb_value, + ), + ) + await cur.execute(update_query, (Jsonb(state), app_name, user_id, session_id)) + row = await cur.fetchone() + if row is None: + _raise_missing_session(session_id) + if app_state is not None: + await cur.execute(app_upsert_query, (app_name, Jsonb(app_state))) + if user_state is not None: + await cur.execute(user_upsert_query, (app_name, user_id, Jsonb(user_state))) + except Exception: + await conn.rollback() + raise + await conn.commit() return StoredSession( id=row["id"], @@ -748,6 +789,7 @@ def create_session( query = pg_sql.SQL(""" INSERT INTO {table} (id, app_name, user_id, {owner_id_col}, state, create_time, update_time) VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id, app_name, user_id, state, create_time, update_time """).format( table=pg_sql.Identifier(self._session_table), owner_id_col=pg_sql.Identifier(self._owner_id_column_name) ) @@ -756,13 +798,27 @@ def create_session( query = pg_sql.SQL(""" INSERT INTO {table} (id, app_name, user_id, state, create_time, update_time) VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id, app_name, user_id, state, create_time, update_time """).format(table=pg_sql.Identifier(self._session_table)) params = (session_id, app_name, user_id, Jsonb(state)) + row: dict[str, Any] | None = None with self._config.provide_connection() as conn, conn.cursor(row_factory=dict_row) as cur: cur.execute(query, params) + if hasattr(cur, "fetchone"): + row = cur.fetchone() conn.commit() + if row is not None: + return StoredSession( + id=row["id"], + app_name=row["app_name"], + user_id=row["user_id"], + state=row["state"], + create_time=row["create_time"], + update_time=row["update_time"], + ) + result = self.get_session(app_name, user_id, session_id) if result is None: msg = "Failed to fetch created session" @@ -919,32 +975,58 @@ def append_event_and_update_state( event_data_value = event_record["event_data"] jsonb_value = Jsonb(event_data_value) if isinstance(event_data_value, dict) else event_data_value - with self._config.provide_connection() as conn, conn.cursor(row_factory=dict_row) as cur: - try: - cur.execute( - insert_query, - ( - event_record["id"], - event_record["app_name"], - event_record["user_id"], - event_record["session_id"], - event_record["invocation_id"], - event_record["timestamp"], - jsonb_value, - ), - ) - cur.execute(update_query, (Jsonb(state), app_name, user_id, session_id)) - row = cur.fetchone() - if row is None: - _raise_missing_session(session_id) - if app_state is not None: - cur.execute(app_upsert_query, (app_name, Jsonb(app_state))) - if user_state is not None: - cur.execute(user_upsert_query, (app_name, user_id, Jsonb(user_state))) - except Exception: - conn.rollback() - raise - conn.commit() + with self._config.provide_connection() as conn: + tx_factory = getattr(conn, "transaction", None) + if callable(tx_factory) and getattr(conn, "autocommit", False): + tx_ctx: Any = tx_factory() + with tx_ctx, conn.cursor(row_factory=dict_row) as cur: + cur.execute( + insert_query, + ( + event_record["id"], + event_record["app_name"], + event_record["user_id"], + event_record["session_id"], + event_record["invocation_id"], + event_record["timestamp"], + jsonb_value, + ), + ) + cur.execute(update_query, (Jsonb(state), app_name, user_id, session_id)) + row = cur.fetchone() + if row is None: + _raise_missing_session(session_id) + if app_state is not None: + cur.execute(app_upsert_query, (app_name, Jsonb(app_state))) + if user_state is not None: + cur.execute(user_upsert_query, (app_name, user_id, Jsonb(user_state))) + else: + with conn.cursor(row_factory=dict_row) as cur: + try: + cur.execute( + insert_query, + ( + event_record["id"], + event_record["app_name"], + event_record["user_id"], + event_record["session_id"], + event_record["invocation_id"], + event_record["timestamp"], + jsonb_value, + ), + ) + cur.execute(update_query, (Jsonb(state), app_name, user_id, session_id)) + row = cur.fetchone() + if row is None: + _raise_missing_session(session_id) + if app_state is not None: + cur.execute(app_upsert_query, (app_name, Jsonb(app_state))) + if user_state is not None: + cur.execute(user_upsert_query, (app_name, user_id, Jsonb(user_state))) + except Exception: + conn.rollback() + raise + conn.commit() return StoredSession( id=row["id"], @@ -1351,6 +1433,8 @@ async def search_entries( if self._use_fts: try: return await self._search_entries_fts(query, app_name, user_id, effective_limit, scope_filter) + except errors.UndefinedTable: + raise except Exception as exc: # pragma: no cover logger.warning("FTS search failed; falling back to simple search: %s", exc) return await self._search_entries_simple(query, app_name, user_id, effective_limit, scope_filter) @@ -1629,6 +1713,8 @@ def search_entries( if self._use_fts: try: return self._search_entries_fts(query, app_name, user_id, effective_limit, scope_filter) + except errors.UndefinedTable: + raise except Exception as exc: # pragma: no cover logger.warning("FTS search failed; falling back to simple search: %s", exc) return self._search_entries_simple(query, app_name, user_id, effective_limit, scope_filter) @@ -1935,6 +2021,12 @@ def _postgres_memory_indexes( ) if vector_index_type == "scann": + if not isinstance(scann_num_leaves, int) or isinstance(scann_num_leaves, bool) or scann_num_leaves <= 0: + msg = "extension_config['adk']['scann_num_leaves'] must be a positive integer" + raise ValueError(msg) + if scann_quantizer not in {"SQ8", "FP32"}: + msg = "extension_config['adk']['scann_quantizer'] must be 'SQ8' or 'FP32'" + raise ValueError(msg) indexes.append( f"CREATE INDEX IF NOT EXISTS idx_{memory_table}_scann ON {memory_table} USING scann (embedding) WITH (num_leaves = {scann_num_leaves}, quantizer = '{scann_quantizer}');" ) diff --git a/sqlspec/adapters/psycopg/config.py b/sqlspec/adapters/psycopg/config.py index 46c9cbeb0..c7a39db80 100644 --- a/sqlspec/adapters/psycopg/config.py +++ b/sqlspec/adapters/psycopg/config.py @@ -1,5 +1,6 @@ """Psycopg database configuration with direct field-based configuration.""" +from collections.abc import Mapping from contextlib import suppress from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NotRequired, Self, TypedDict, cast @@ -50,7 +51,7 @@ from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: - from collections.abc import Awaitable, Callable, Mapping + from collections.abc import Awaitable, Callable from types import TracebackType from sqlspec.adapters.psycopg._typing import PsycopgAdaptContext as AdaptContext @@ -250,7 +251,10 @@ def __exit__( ) -> bool | None: if self._ctx is None: return None - return cast("bool | None", self._ctx.__exit__(exc_type, exc_val, exc_tb)) + try: + return cast("bool | None", self._ctx.__exit__(exc_type, exc_val, exc_tb)) + finally: + self._ctx = None class _PsycopgSyncSessionConnectionHandler(SyncPoolSessionFactory): @@ -319,8 +323,8 @@ def __init__( statement_config, driver_features = apply_driver_features(statement_config, driver_features) features_dict = dict(driver_features) if driver_features else {} - self._user_connection_hook: Callable[[PsycopgSyncConnection], None] | None = features_dict.pop( - "on_connection_create", None + self._user_connection_hook: Callable[[PsycopgSyncConnection], None] | None = features_dict.get( + "on_connection_create" ) features_dict.setdefault("enable_alloydb", False) features_dict.setdefault("enable_alloydb_iam_auth", False) @@ -344,6 +348,94 @@ def __init__( self._validate_alloydb_config() + @property + def pg_textsearch_available(self) -> bool: + """Return True if the pg_textsearch extension is available.""" + return bool(self._pg_textsearch_available) + + def create_connection(self) -> "PsycopgSyncConnection": + """Open a standalone connection owned by the caller. + + The connection is not bound to the pool, so closing it actually closes + it and consumes no pool slot. + + Returns: + A psycopg Connection instance. + """ + connection_class, conninfo, connection_kwargs = self._connection_kwargs() + connection = connection_class.connect(conninfo, **connection_kwargs) + user_configure = self.connection_config.get("configure") + try: + self._configure_connection(cast("PsycopgSyncConnection", connection)) + if user_configure is not None and user_configure is not self._configure_connection: + user_configure(cast("PsycopgSyncConnection", connection)) + except BaseException: + with suppress(Exception): + connection.close() + raise + return cast("PsycopgSyncConnection", connection) + + def provide_session( + self, *_args: Any, statement_config: "StatementConfig | None" = None, **_kwargs: Any + ) -> "PsycopgSyncSessionContext": + """Provide a driver session context manager. + + Args: + *_args: Additional arguments. + statement_config: Optional statement configuration override. + **_kwargs: Additional keyword arguments. + + Returns: + A PsycopgSyncDriver session context manager. + """ + handler = _PsycopgSyncSessionConnectionHandler(self) + + return PsycopgSyncSessionContext( + acquire_connection=handler.acquire_connection, + release_connection=handler.release_connection, + statement_config=statement_config + or (lambda: resolve_runtime_statement_config(None, self.statement_config, default_statement_config)), + driver_features=self.driver_features, + prepare_driver=self._prepare_driver, + ) + + def provide_pool(self, *args: Any, **kwargs: Any) -> "ConnectionPool": + """Provide pool instance. + + Returns: + The connection pool. + """ + if not self.connection_instance: + self.connection_instance = self.create_pool() + return self.connection_instance + + def get_signature_namespace(self) -> "dict[str, Any]": + """Get the signature namespace for Psycopg types. + + This provides all Psycopg-specific types that Litestar needs to recognize + to avoid serialization attempts. + + Returns: + Dictionary mapping type names to types. + """ + namespace = super().get_signature_namespace() + namespace.update({ + "PsycopgConnectionParams": PsycopgConnectionParams, + "PsycopgPoolParams": PsycopgPoolParams, + "PsycopgSyncConnectionContext": PsycopgSyncConnectionContext, + "PsycopgSyncConnection": PsycopgSyncConnection, + "PsycopgSyncCursor": PsycopgSyncCursor, + "PsycopgSyncDriver": PsycopgSyncDriver, + "PsycopgSyncExceptionHandler": PsycopgSyncExceptionHandler, + "PsycopgSyncSessionContext": PsycopgSyncSessionContext, + }) + return namespace + + def get_event_runtime_hints(self) -> "EventRuntimeHints": + """Return polling defaults for PostgreSQL queue fallback.""" + + return EventRuntimeHints(poll_interval=0.5, select_for_update=True, skip_locked=True) + def _validate_alloydb_config(self) -> None: """Validate sync-only AlloyDB connector configuration.""" if not self.driver_features.get("enable_alloydb", False): @@ -415,7 +507,16 @@ def _create_pool(self) -> "ConnectionPool": "close_returns": all_config.pop("close_returns", False), } - pool_parameters["configure"] = all_config.pop("configure", self._configure_connection) + user_configure = all_config.pop("configure", None) + if user_configure is not None and user_configure is not self._configure_connection: + + def _chained_configure(conn: "PsycopgSyncConnection") -> None: + self._configure_connection(conn) + user_configure(conn) + + pool_parameters["configure"] = _chained_configure + else: + pool_parameters["configure"] = self._configure_connection pool_parameters = {k: v for k, v in pool_parameters.items() if v is not None} @@ -455,34 +556,36 @@ def _configure_connection(self, conn: "PsycopgSyncConnection") -> None: if self._pgvector_available is None: detected_extensions: set[str] = set() extensions = build_postgres_extension_probe_names(self.driver_features) + probe_succeeded = True if extensions: try: cursor = conn.execute( "SELECT extname FROM pg_extension WHERE extname = ANY(%s::text[])", (extensions,) ) results = cursor.fetchall() - detected_extensions = {r[0] for r in results} # type: ignore[index] + detected_extensions = { + cast("str", r["extname"] if isinstance(r, Mapping) else r[0]) for r in results + } except Exception as exc: - detected_extensions = set() + probe_succeeded = False self._pg_textsearch_probe_error = exc - self.statement_config, self._pgvector_available, self._paradedb_available = ( - resolve_postgres_extension_state(self.statement_config, self.driver_features, detected_extensions) - ) - self._pg_textsearch_available = is_postgres_extension_active(self.driver_features, "pg_textsearch") + if not conn.autocommit: + with suppress(Exception): + conn.rollback() + if probe_succeeded: + self.statement_config, self._pgvector_available, self._paradedb_available = ( + resolve_postgres_extension_state(self.statement_config, self.driver_features, detected_extensions) + ) + self._pg_textsearch_available = is_postgres_extension_active(self.driver_features, "pg_textsearch") if self._pgvector_available: register_pgvector_sync(conn) - if not conn.autocommit: - conn.rollback() - if self._user_connection_hook is not None: self._user_connection_hook(conn) - @property - def pg_textsearch_available(self) -> bool: - """Return True if the pg_textsearch extension is available.""" - return bool(self._pg_textsearch_available) + if not conn.autocommit: + conn.rollback() def _ensure_pg_textsearch_available(self) -> None: if self._pg_textsearch_available: @@ -520,87 +623,6 @@ def _connection_kwargs(self) -> "tuple[type[Any], str, dict[str, Any]]": conninfo = None return cast("type[Any]", connection_class), str(conninfo or ""), all_config - def create_connection(self) -> "PsycopgSyncConnection": - """Open a standalone connection owned by the caller. - - The connection is not bound to the pool, so closing it actually closes - it and consumes no pool slot. - - Returns: - A psycopg Connection instance. - """ - connection_class, conninfo, connection_kwargs = self._connection_kwargs() - connection = connection_class.connect(conninfo, **connection_kwargs) - configure = self.connection_config.get("configure", self._configure_connection) - try: - configure(cast("PsycopgSyncConnection", connection)) - except BaseException: - with suppress(Exception): - connection.close() - raise - return cast("PsycopgSyncConnection", connection) - - def provide_session( - self, *_args: Any, statement_config: "StatementConfig | None" = None, **_kwargs: Any - ) -> "PsycopgSyncSessionContext": - """Provide a driver session context manager. - - Args: - *_args: Additional arguments. - statement_config: Optional statement configuration override. - **_kwargs: Additional keyword arguments. - - Returns: - A PsycopgSyncDriver session context manager. - """ - handler = _PsycopgSyncSessionConnectionHandler(self) - - return PsycopgSyncSessionContext( - acquire_connection=handler.acquire_connection, - release_connection=handler.release_connection, - statement_config=statement_config - or (lambda: resolve_runtime_statement_config(None, self.statement_config, default_statement_config)), - driver_features=self.driver_features, - prepare_driver=self._prepare_driver, - ) - - def provide_pool(self, *args: Any, **kwargs: Any) -> "ConnectionPool": - """Provide pool instance. - - Returns: - The connection pool. - """ - if not self.connection_instance: - self.connection_instance = self.create_pool() - return self.connection_instance - - def get_signature_namespace(self) -> "dict[str, Any]": - """Get the signature namespace for Psycopg types. - - This provides all Psycopg-specific types that Litestar needs to recognize - to avoid serialization attempts. - - Returns: - Dictionary mapping type names to types. - """ - namespace = super().get_signature_namespace() - namespace.update({ - "PsycopgConnectionParams": PsycopgConnectionParams, - "PsycopgPoolParams": PsycopgPoolParams, - "PsycopgSyncConnectionContext": PsycopgSyncConnectionContext, - "PsycopgSyncConnection": PsycopgSyncConnection, - "PsycopgSyncCursor": PsycopgSyncCursor, - "PsycopgSyncDriver": PsycopgSyncDriver, - "PsycopgSyncExceptionHandler": PsycopgSyncExceptionHandler, - "PsycopgSyncSessionContext": PsycopgSyncSessionContext, - }) - return namespace - - def get_event_runtime_hints(self) -> "EventRuntimeHints": - """Return polling defaults for PostgreSQL queue fallback.""" - - return EventRuntimeHints(poll_interval=0.5, select_for_update=True, skip_locked=True) - class PsycopgAsyncConnectionContext(AsyncPoolConnectionContext): """Async context manager for Psycopg connections.""" @@ -612,19 +634,18 @@ def __init__(self, config: "PsycopgAsyncConfig") -> None: self._ctx: Any = None async def __aenter__(self) -> "PsycopgAsyncConnection": - if self._config.connection_instance is None: - self._config.connection_instance = await self._config.create_pool() - if self._config.connection_instance: - self._ctx = self._config.connection_instance.connection() - return cast("PsycopgAsyncConnection", await self._ctx.__aenter__()) - msg = "Connection pool not initialized" - raise ImproperConfigurationError(msg) + pool = await self._config.provide_pool() + self._ctx = pool.connection() + return cast("PsycopgAsyncConnection", await self._ctx.__aenter__()) async def __aexit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> bool | None: if self._ctx: - return cast("bool | None", await self._ctx.__aexit__(exc_type, exc_val, exc_tb)) + try: + return cast("bool | None", await self._ctx.__aexit__(exc_type, exc_val, exc_tb)) + finally: + self._ctx = None return None @@ -636,16 +657,17 @@ def __init__(self, config: "PsycopgAsyncConfig") -> None: self._ctx: Any = None async def acquire_connection(self) -> "PsycopgAsyncConnection": - if self._config.connection_instance is None: - self._config.connection_instance = await self._config.create_pool() - self._ctx = self._config.connection_instance.connection() + pool = await self._config.provide_pool() + self._ctx = pool.connection() return cast("PsycopgAsyncConnection", await self._ctx.__aenter__()) async def release_connection(self, _conn: "PsycopgAsyncConnection", **kwargs: Any) -> None: if self._ctx is None: return - await self._ctx.__aexit__(kwargs.get("exc_type"), kwargs.get("exc_val"), kwargs.get("exc_tb")) - self._ctx = None + try: + await self._ctx.__aexit__(kwargs.get("exc_type"), kwargs.get("exc_val"), kwargs.get("exc_tb")) + finally: + self._ctx = None @mypyc_attr(native_class=False) @@ -701,8 +723,11 @@ def __init__( statement_config, driver_features = apply_driver_features(statement_config, driver_features) features_dict = dict(driver_features) if driver_features else {} - self._user_connection_hook: Callable[[PsycopgAsyncConnection], Awaitable[None]] | None = features_dict.pop( - "on_connection_create", None + if features_dict.get("enable_alloydb", False): + msg = "Google AlloyDB connector integration (enable_alloydb) is only supported on PsycopgSyncConfig" + raise ImproperConfigurationError(msg) + self._user_connection_hook: Callable[[PsycopgAsyncConnection], Awaitable[None]] | None = features_dict.get( + "on_connection_create" ) self._pgvector_available: bool | None = None self._paradedb_available: bool | None = None @@ -720,6 +745,91 @@ def __init__( **kwargs, ) + @property + def pg_textsearch_available(self) -> bool: + """Return True if the pg_textsearch extension is available.""" + return bool(self._pg_textsearch_available) + + async def create_connection(self) -> "PsycopgAsyncConnection": # pyright: ignore + """Open a standalone connection owned by the caller. + + The connection is not bound to the pool, so closing it actually closes + it and consumes no pool slot. + + Returns: + A psycopg AsyncConnection instance. + """ + connection_class, conninfo, connection_kwargs = self._connection_kwargs() + connection = await connection_class.connect(conninfo, **connection_kwargs) + user_configure = self.connection_config.get("configure") + try: + await self._configure_async_connection(cast("PsycopgAsyncConnection", connection)) + if user_configure is not None and user_configure is not self._configure_async_connection: + await user_configure(cast("PsycopgAsyncConnection", connection)) + except BaseException: + with suppress(Exception): + await connection.close() + raise + return cast("PsycopgAsyncConnection", connection) + + def get_signature_namespace(self) -> "dict[str, Any]": + """Get the signature namespace for PsycopgAsyncConfig types. + + Returns: + Dictionary mapping type names to types. + """ + namespace = super().get_signature_namespace() + namespace.update({ + "PsycopgAsyncConnectionContext": PsycopgAsyncConnectionContext, + "PsycopgAsyncConnection": PsycopgAsyncConnection, + "PsycopgAsyncCursor": PsycopgAsyncCursor, + "PsycopgAsyncDriver": PsycopgAsyncDriver, + "PsycopgAsyncExceptionHandler": PsycopgAsyncExceptionHandler, + "PsycopgAsyncSessionContext": PsycopgAsyncSessionContext, + "PsycopgConnectionParams": PsycopgConnectionParams, + "PsycopgPoolParams": PsycopgPoolParams, + }) + return namespace + + def provide_session( + self, *_args: Any, statement_config: "StatementConfig | None" = None, **_kwargs: Any + ) -> "PsycopgAsyncSessionContext": + """Provide an async driver session context manager. + + Args: + *_args: Additional arguments. + statement_config: Optional statement configuration override. + **_kwargs: Additional keyword arguments. + + Returns: + A PsycopgAsyncDriver session context manager. + """ + handler = _PsycopgAsyncSessionConnectionHandler(self) + + return PsycopgAsyncSessionContext( + acquire_connection=handler.acquire_connection, + release_connection=handler.release_connection, + statement_config=statement_config + or (lambda: resolve_runtime_statement_config(None, self.statement_config, default_statement_config)), + driver_features=self.driver_features, + prepare_driver=self._prepare_driver, + ) + + async def provide_pool(self, *args: Any, **kwargs: Any) -> "AsyncConnectionPool": + """Provide async pool instance. + + Returns: + The async connection pool. + """ + if not self.connection_instance: + self.connection_instance = await self.create_pool() + return self.connection_instance + + def get_event_runtime_hints(self) -> "EventRuntimeHints": + """Return polling defaults for PostgreSQL queue fallback.""" + + return EventRuntimeHints(poll_interval=0.5, select_for_update=True, skip_locked=True) + async def _create_pool(self) -> "AsyncConnectionPool": """Create the actual async connection pool.""" @@ -745,7 +855,16 @@ async def _create_pool(self) -> "AsyncConnectionPool": open_pool = all_config.pop("open", True) pool_parameters["open"] = False if open_pool is True else open_pool - pool_parameters["configure"] = all_config.pop("configure", self._configure_async_connection) + user_configure = all_config.pop("configure", None) + if user_configure is not None and user_configure is not self._configure_async_connection: + + async def _chained_async_configure(conn: "PsycopgAsyncConnection") -> None: + await self._configure_async_connection(conn) + await user_configure(conn) + + pool_parameters["configure"] = _chained_async_configure + else: + pool_parameters["configure"] = self._configure_async_connection pool_parameters = {k: v for k, v in pool_parameters.items() if v is not None} @@ -782,34 +901,36 @@ async def _configure_async_connection(self, conn: "PsycopgAsyncConnection") -> N if self._pgvector_available is None: detected_extensions: set[str] = set() extensions = build_postgres_extension_probe_names(self.driver_features) + probe_succeeded = True if extensions: try: cursor = await conn.execute( "SELECT extname FROM pg_extension WHERE extname = ANY(%s::text[])", (extensions,) ) results = await cursor.fetchall() - detected_extensions = {r[0] for r in results} # type: ignore[index] + detected_extensions = { + cast("str", r["extname"] if isinstance(r, Mapping) else r[0]) for r in results + } except Exception as exc: - detected_extensions = set() + probe_succeeded = False self._pg_textsearch_probe_error = exc - self.statement_config, self._pgvector_available, self._paradedb_available = ( - resolve_postgres_extension_state(self.statement_config, self.driver_features, detected_extensions) - ) - self._pg_textsearch_available = is_postgres_extension_active(self.driver_features, "pg_textsearch") + if not conn.autocommit: + with suppress(Exception): + await conn.rollback() + if probe_succeeded: + self.statement_config, self._pgvector_available, self._paradedb_available = ( + resolve_postgres_extension_state(self.statement_config, self.driver_features, detected_extensions) + ) + self._pg_textsearch_available = is_postgres_extension_active(self.driver_features, "pg_textsearch") if self._pgvector_available: await register_pgvector_async(conn) - if not conn.autocommit: - await conn.rollback() - if self._user_connection_hook is not None: await self._user_connection_hook(conn) - @property - def pg_textsearch_available(self) -> bool: - """Return True if the pg_textsearch extension is available.""" - return bool(self._pg_textsearch_available) + if not conn.autocommit: + await conn.rollback() def _ensure_pg_textsearch_available(self) -> None: if self._pg_textsearch_available: @@ -839,84 +960,6 @@ def _connection_kwargs(self) -> "tuple[type[Any], str, dict[str, Any]]": all_config.update(all_config.pop("kwargs", {})) return cast("type[Any]", connection_class), str(conninfo or ""), all_config - async def create_connection(self) -> "PsycopgAsyncConnection": # pyright: ignore - """Open a standalone connection owned by the caller. - - The connection is not bound to the pool, so closing it actually closes - it and consumes no pool slot. - - Returns: - A psycopg AsyncConnection instance. - """ - connection_class, conninfo, connection_kwargs = self._connection_kwargs() - connection = await connection_class.connect(conninfo, **connection_kwargs) - configure = self.connection_config.get("configure", self._configure_async_connection) - try: - await configure(cast("PsycopgAsyncConnection", connection)) - except BaseException: - with suppress(Exception): - await connection.close() - raise - return cast("PsycopgAsyncConnection", connection) - - def get_signature_namespace(self) -> "dict[str, Any]": - """Get the signature namespace for PsycopgAsyncConfig types. - - Returns: - Dictionary mapping type names to types. - """ - namespace = super().get_signature_namespace() - namespace.update({ - "PsycopgAsyncConnectionContext": PsycopgAsyncConnectionContext, - "PsycopgAsyncConnection": PsycopgAsyncConnection, - "PsycopgAsyncCursor": PsycopgAsyncCursor, - "PsycopgAsyncDriver": PsycopgAsyncDriver, - "PsycopgAsyncExceptionHandler": PsycopgAsyncExceptionHandler, - "PsycopgAsyncSessionContext": PsycopgAsyncSessionContext, - "PsycopgConnectionParams": PsycopgConnectionParams, - "PsycopgPoolParams": PsycopgPoolParams, - }) - return namespace - - def provide_session( - self, *_args: Any, statement_config: "StatementConfig | None" = None, **_kwargs: Any - ) -> "PsycopgAsyncSessionContext": - """Provide an async driver session context manager. - - Args: - *_args: Additional arguments. - statement_config: Optional statement configuration override. - **_kwargs: Additional keyword arguments. - - Returns: - A PsycopgAsyncDriver session context manager. - """ - handler = _PsycopgAsyncSessionConnectionHandler(self) - - return PsycopgAsyncSessionContext( - acquire_connection=handler.acquire_connection, - release_connection=handler.release_connection, - statement_config=statement_config - or (lambda: resolve_runtime_statement_config(None, self.statement_config, default_statement_config)), - driver_features=self.driver_features, - prepare_driver=self._prepare_driver, - ) - - async def provide_pool(self, *args: Any, **kwargs: Any) -> "AsyncConnectionPool": - """Provide async pool instance. - - Returns: - The async connection pool. - """ - if not self.connection_instance: - self.connection_instance = await self.create_pool() - return self.connection_instance - - def get_event_runtime_hints(self) -> "EventRuntimeHints": - """Return polling defaults for PostgreSQL queue fallback.""" - - return EventRuntimeHints(poll_interval=0.5, select_for_update=True, skip_locked=True) - def _make_alloydb_connection_class( *, @@ -930,6 +973,8 @@ def _make_alloydb_connection_class( ) -> "type[PsycopgConnection[Any]]": """Build a psycopg connection class backed by the AlloyDB connector.""" + default_prepare_threshold = 5 + class _AlloyDBPsycopgConnection(PsycopgConnection[Any]): @classmethod def connect( @@ -937,13 +982,13 @@ def connect( conninfo: str = "", *, autocommit: bool = False, - prepare_threshold: int | None = 5, + prepare_threshold: int | None = default_prepare_threshold, context: "AdaptContext | None" = None, row_factory: "RowFactory[Any] | None" = None, cursor_factory: "type[Cursor[Any]] | None" = None, **kwargs: str | int | None, ) -> Self: - _ = (cls, conninfo, autocommit, prepare_threshold, context, row_factory, cursor_factory) + _ = (cls, conninfo, context) connector_kwargs = dict(kwargs) connector_kwargs["enable_iam_auth"] = enable_iam_auth connector_kwargs["ip_type"] = ip_type @@ -953,6 +998,15 @@ def connect( connector_kwargs["password"] = password if database is not None: connector_kwargs["db"] = database - return cast("Self", connector.connect(instance_uri, "psycopg", **connector_kwargs)) + conn = connector.connect(instance_uri, "psycopg", **connector_kwargs) + if autocommit: + conn.autocommit = autocommit + if prepare_threshold != default_prepare_threshold: + conn.prepare_threshold = prepare_threshold + if row_factory is not None: + conn.row_factory = row_factory + if cursor_factory is not None: + conn.cursor_factory = cursor_factory + return cast("Self", conn) return _AlloyDBPsycopgConnection diff --git a/sqlspec/adapters/psycopg/core.py b/sqlspec/adapters/psycopg/core.py index d49c730ea..ee9010839 100644 --- a/sqlspec/adapters/psycopg/core.py +++ b/sqlspec/adapters/psycopg/core.py @@ -1,12 +1,13 @@ """psycopg adapter compiled helpers.""" import contextlib -import datetime +import datetime as dt from collections.abc import Sized from typing import TYPE_CHECKING, Any, Final, LiteralString, NamedTuple, cast from sqlspec.adapters.psycopg._typing import PsycopgComposed, PsycopgIdentifier, PsycopgSQL from sqlspec.adapters.psycopg._typing import psycopg_errors as pg_errors +from sqlspec.adapters.psycopg._typing import psycopg_module as psycopg from sqlspec.core import ( SQL, DriverParameterProfile, @@ -52,6 +53,11 @@ from sqlspec.core import ParameterStyleConfig, StackOperation __all__ = ( + "TRANSACTION_STATUS_ACTIVE", + "TRANSACTION_STATUS_IDLE", + "TRANSACTION_STATUS_INERROR", + "TRANSACTION_STATUS_INTRANS", + "TRANSACTION_STATUS_UNKNOWN", "PipelineCursorEntry", "PreparedStackOperation", "PsycopgAsyncStreamSource", @@ -68,6 +74,7 @@ "create_mapped_exception", "default_statement_config", "driver_profile", + "escape_literal_percent", "execute_with_optional_parameters", "execute_with_optional_parameters_async", "is_postgres_extension_active", @@ -105,10 +112,8 @@ class PipelineCursorEntry(NamedTuple): def pipeline_supported() -> bool: """Return True when libpq pipeline support is available.""" try: - from sqlspec.adapters.psycopg._typing import psycopg_module as psycopg - capabilities = psycopg.capabilities - except (ImportError, AttributeError): + except AttributeError: return False try: return bool(capabilities.has_pipeline()) @@ -310,21 +315,6 @@ async def start(self) -> None: await self._driver._run_with_exception_handler(handler, self._start) self._driver._check_pending_exception(handler) - async def _start(self) -> None: - transaction = self._driver.connection.transaction() - await transaction.__aenter__() - self._transaction = transaction - try: - cursor = self._driver.connection.cursor(name=f"sqlspec_stream_{uuid4().hex}") - cursor.itersize = self._chunk_size - await execute_with_optional_parameters_async(cursor, self._sql, self._parameters) - self._cursor = cursor - except BaseException as exc: - self._transaction = None - with contextlib.suppress(Exception): - await transaction.__aexit__(type(exc), exc, exc.__traceback__) - raise - async def fetch_chunk(self) -> "list[dict[str, Any]]": handler = self._driver.handle_database_exceptions() rows = await self._driver._run_with_exception_handler(handler, self._cursor.fetchmany, self._chunk_size) @@ -350,6 +340,21 @@ async def close(self, error: bool = False) -> None: else: await transaction.__aexit__(None, None, None) + async def _start(self) -> None: + transaction = self._driver.connection.transaction() + await transaction.__aenter__() + self._transaction = transaction + try: + cursor = self._driver.connection.cursor(name=f"sqlspec_stream_{uuid4().hex}") + cursor.itersize = self._chunk_size + await execute_with_optional_parameters_async(cursor, self._sql, self._parameters) + self._cursor = cursor + except BaseException as exc: + self._transaction = None + with contextlib.suppress(Exception): + await transaction.__aexit__(type(exc), exc, exc.__traceback__) + raise + def resolve_rowcount(cursor: Any) -> int: """Resolve rowcount from a psycopg cursor. @@ -398,7 +403,11 @@ def resolve_many_rowcount(cursor: Any, parameters: Any, *, fallback_count: "int def build_pipeline_execution_result( - statement: "SQL", cursor: Any, *, column_name_resolver: "Callable[[Any], list[str]] | None" = None + statement: "SQL", + cursor: Any, + *, + column_name_resolver: "Callable[[Any], list[str]] | None" = None, + column_types_resolver: "Callable[[Any], dict[str, str] | None] | None" = None, ) -> "ExecutionResult": """Build an ExecutionResult for psycopg pipeline execution. @@ -406,17 +415,19 @@ def build_pipeline_execution_result( statement: SQL statement executed by the pipeline. cursor: Psycopg cursor holding the pipeline result. column_name_resolver: Optional cached column-name resolver. + column_types_resolver: Optional cursor column-type resolver. Returns: ExecutionResult representing the pipeline operation. """ - if statement.returns_rows(): + if statement.returns_rows() or cursor.description is not None: fetched_data = cast("list[Any] | None", cursor.fetchall()) or [] if column_name_resolver is None: fetched_data, column_names = collect_rows(fetched_data, cursor.description) else: column_names = column_name_resolver(cursor.description) + column_types = column_types_resolver(cursor.description) if column_types_resolver is not None else None row_format = resolve_row_format(fetched_data) return ExecutionResult( cursor_result=cursor, @@ -424,6 +435,7 @@ def build_pipeline_execution_result( special_data=None, selected_data=fetched_data, column_names=column_names, + column_types=column_types, data_row_count=len(fetched_data), statement_count=None, successful_statements=None, @@ -452,7 +464,11 @@ def build_pipeline_execution_result( async def build_async_pipeline_execution_result( - statement: "SQL", cursor: Any, *, column_name_resolver: "Callable[[Any], list[str]] | None" = None + statement: "SQL", + cursor: Any, + *, + column_name_resolver: "Callable[[Any], list[str]] | None" = None, + column_types_resolver: "Callable[[Any], dict[str, str] | None] | None" = None, ) -> "ExecutionResult": """Build an ExecutionResult for psycopg async pipeline execution. @@ -460,17 +476,19 @@ async def build_async_pipeline_execution_result( statement: SQL statement executed by the pipeline. cursor: Psycopg cursor holding the pipeline result. column_name_resolver: Optional cached column-name resolver. + column_types_resolver: Optional cursor column-type resolver. Returns: ExecutionResult representing the pipeline operation. """ - if statement.returns_rows(): + if statement.returns_rows() or cursor.description is not None: fetched_data = cast("list[Any] | None", await cursor.fetchall()) or [] if column_name_resolver is None: fetched_data, column_names = collect_rows(fetched_data, cursor.description) else: column_names = column_name_resolver(cursor.description) + column_types = column_types_resolver(cursor.description) if column_types_resolver is not None else None row_format = resolve_row_format(fetched_data) return ExecutionResult( cursor_result=cursor, @@ -478,6 +496,7 @@ async def build_async_pipeline_execution_result( special_data=None, selected_data=fetched_data, column_names=column_names, + column_types=column_types, data_row_count=len(fetched_data), statement_count=None, successful_statements=None, @@ -506,27 +525,8 @@ async def build_async_pipeline_execution_result( _EXCEPTION_MAPPING: Final[dict[type[Any], tuple[str, type[SQLSpecError], str]]] = {} -_EXCEPTION_MAPPING_CACHE: Final[dict[type[Any], tuple[str, type[SQLSpecError], str]]] = {} - - -def _register_exception_mappings() -> None: - _EXCEPTION_MAPPING.update({ - pg_errors.UniqueViolation: ("23505", UniqueViolationError, "unique constraint violation"), - pg_errors.ForeignKeyViolation: ("23503", ForeignKeyViolationError, "foreign key constraint violation"), - pg_errors.NotNullViolation: ("23502", NotNullViolationError, "not-null constraint violation"), - pg_errors.CheckViolation: ("23514", CheckViolationError, "check constraint violation"), - pg_errors.IntegrityError: ("23000", IntegrityError, "integrity constraint violation"), - pg_errors.DeadlockDetected: ("40P01", DeadlockError, "deadlock detected"), - pg_errors.SerializationFailure: ("40001", SerializationConflictError, "serialization failure"), - pg_errors.QueryCanceled: ("57014", OperationCancelledError, "query canceled"), - pg_errors.InsufficientPrivilege: ("42501", PermissionDeniedError, "insufficient privilege"), - pg_errors.AdminShutdown: ("57P01", ConnectionTimeoutError, "admin shutdown"), - pg_errors.CannotConnectNow: ("57P03", ConnectionTimeoutError, "cannot connect now"), - pg_errors.SyntaxError: ("42601", SQLParsingError, "SQL syntax error"), - }) - -_register_exception_mappings() +_EXCEPTION_MAPPING_CACHE: Final[dict[type[Any], tuple[str, type[SQLSpecError], str]]] = {} def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpecError: @@ -560,7 +560,6 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec return _create_postgres_error(error, mapped_error_code, termination_class, description) return _create_postgres_error(error, mapped_error_code, error_class, description) - # Priority 2: Fall back to SQLSTATE code mapping using centralized utility sqlstate_attr = error.sqlstate if has_sqlstate(error) else None error_code = sqlstate_attr if sqlstate_attr is not None else None if error_code: @@ -571,10 +570,44 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec if exc_class: return _create_postgres_error(error, error_code, exc_class, "database error") - # Priority 3: Default fallback return _create_postgres_error(error, error_code, SQLSpecError, "database error") +def escape_literal_percent(sql: str, parameters: Any, validator: "ParameterValidator") -> str: + """Escape literal percent characters before driver-side interpolation.""" + if not parameters or "%" not in sql: + return sql + keep = {info.position for info in validator.extract_parameters(sql)} + segments: list[str] = [] + index = 0 + while index < len(sql): + if sql[index : index + 2] == "%%": + segments.append("%%") + index += 2 + continue + char = sql[index] + segments.append("%%" if char == "%" and index not in keep else char) + index += 1 + return "".join(segments) + + +def _register_exception_mappings() -> None: + _EXCEPTION_MAPPING.update({ + pg_errors.UniqueViolation: ("23505", UniqueViolationError, "unique constraint violation"), + pg_errors.ForeignKeyViolation: ("23503", ForeignKeyViolationError, "foreign key constraint violation"), + pg_errors.NotNullViolation: ("23502", NotNullViolationError, "not-null constraint violation"), + pg_errors.CheckViolation: ("23514", CheckViolationError, "check constraint violation"), + pg_errors.IntegrityError: ("23000", IntegrityError, "integrity constraint violation"), + pg_errors.DeadlockDetected: ("40P01", DeadlockError, "deadlock detected"), + pg_errors.SerializationFailure: ("40001", SerializationConflictError, "serialization failure"), + pg_errors.QueryCanceled: ("57014", OperationCancelledError, "query canceled"), + pg_errors.InsufficientPrivilege: ("42501", PermissionDeniedError, "insufficient privilege"), + pg_errors.AdminShutdown: ("57P01", ConnectionTimeoutError, "admin shutdown"), + pg_errors.CannotConnectNow: ("57P03", ConnectionTimeoutError, "cannot connect now"), + pg_errors.SyntaxError: ("42601", SQLParsingError, "SQL syntax error"), + }) + + def _compose_table_identifier(table: str) -> "PsycopgComposed": parts = split_qualified_identifier(table, quote_chars='"', allow_bracket_quotes=False) if not parts: @@ -591,12 +624,7 @@ def _identity(value: Any) -> Any: def _custom_type_coercions() -> "dict[type, Callable[[Any], Any]]": """Return custom type coercions for psycopg.""" - return { - datetime.datetime: _identity, - datetime.date: _identity, - datetime.time: _identity, - **build_uuid_coercions(native=True), - } + return {dt.datetime: _identity, dt.date: _identity, dt.time: _identity, **build_uuid_coercions(native=True)} def _parameter_config( @@ -624,11 +652,6 @@ def _parameter_config( return base_config.replace(type_coercion_map=updated_type_map) -driver_profile = build_profile() - -default_statement_config = build_statement_config() - - def _create_postgres_error( error: Any, code: "str | None", error_class: type[SQLSpecError], description: str ) -> SQLSpecError: @@ -662,19 +685,8 @@ def _resolve_exception_mapping(error_type: type[Any]) -> "tuple[str, type[SQLSpe return None -def escape_literal_percent(sql: str, parameters: Any, validator: "ParameterValidator") -> str: - """Escape literal percent characters before driver-side interpolation.""" - if not parameters or "%" not in sql: - return sql - keep = {info.position for info in validator.extract_parameters(sql)} - segments: list[str] = [] - index = 0 - while index < len(sql): - if sql[index : index + 2] == "%%": - segments.append("%%") - index += 2 - continue - char = sql[index] - segments.append("%%" if char == "%" and index not in keep else char) - index += 1 - return "".join(segments) +driver_profile = build_profile() + +default_statement_config = build_statement_config() + +_register_exception_mappings() diff --git a/sqlspec/adapters/psycopg/data_dictionary.py b/sqlspec/adapters/psycopg/data_dictionary.py index 80c16accc..2bcf3ee5b 100644 --- a/sqlspec/adapters/psycopg/data_dictionary.py +++ b/sqlspec/adapters/psycopg/data_dictionary.py @@ -91,15 +91,6 @@ def get_system_metadata_capabilities( requested_domains = tuple(_POSTGRES_SYSTEM_METADATA_QUERIES) if domains is None else tuple(domains) return tuple(_postgres_system_metadata_capability(domain) for domain in requested_domains) - def _select_domain( - self, driver: "PsycopgSyncDriver", domain: str, query_name: str, **parameters: Any - ) -> MetadataResult: - query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) - if not query.is_supported or query.sql is None: - return MetadataResult(domain, capability=query.capability, warnings=query.warnings) - rows = driver.select(query.sql, **parameters) - return _metadata_result(domain, _postgres_metadata_capability(domain), rows) - def get_schemas(self, driver: "PsycopgSyncDriver") -> MetadataResult: """Get schema metadata.""" return self._select_domain(driver, "schemas", "list") @@ -209,14 +200,16 @@ def get_system_metadata( def get_version(self, driver: "PsycopgSyncDriver") -> "VersionInfo | None": """Get PostgreSQL database version information. + Uses an inline cache check to avoid cross-module method calls that can + trigger MyPyC boundary issues. + Args: driver: Sync database driver instance. Returns: PostgreSQL version information or None if detection fails. """ - driver_id = id(driver) - # Inline cache check to avoid cross-module method call that causes mypyc segfault + driver_id = id(getattr(driver, "connection", None) or driver) if driver_id in self._version_fetch_attempted: return self._version_cache.get(driver_id) @@ -344,6 +337,15 @@ def get_foreign_keys( schema_type=ForeignKeyMetadata, ) + def _select_domain( + self, driver: "PsycopgSyncDriver", domain: str, query_name: str, **parameters: Any + ) -> MetadataResult: + query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) + if not query.is_supported or query.sql is None: + return MetadataResult(domain, capability=query.capability, warnings=query.warnings) + rows = driver.select(query.sql, **parameters) + return _metadata_result(domain, _postgres_metadata_capability(domain), rows) + @mypyc_attr(allow_interpreted_subclasses=True, native_class=False) class PsycopgAsyncDataDictionary(AsyncDataDictionaryBase): @@ -368,15 +370,6 @@ async def get_system_metadata_capabilities( requested_domains = tuple(_POSTGRES_SYSTEM_METADATA_QUERIES) if domains is None else tuple(domains) return tuple(_postgres_system_metadata_capability(domain) for domain in requested_domains) - async def _select_domain( - self, driver: "PsycopgAsyncDriver", domain: str, query_name: str, **parameters: Any - ) -> MetadataResult: - query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) - if not query.is_supported or query.sql is None: - return MetadataResult(domain, capability=query.capability, warnings=query.warnings) - rows = await driver.select(query.sql, **parameters) - return _metadata_result(domain, _postgres_metadata_capability(domain), rows) - async def get_schemas(self, driver: "PsycopgAsyncDriver") -> MetadataResult: """Get schema metadata.""" return await self._select_domain(driver, "schemas", "list") @@ -488,14 +481,16 @@ async def get_system_metadata( async def get_version(self, driver: "PsycopgAsyncDriver") -> "VersionInfo | None": """Get PostgreSQL database version information. + Uses an inline cache check to avoid cross-module method calls that can + trigger MyPyC boundary issues. + Args: driver: Async database driver instance. Returns: PostgreSQL version information or None if detection fails. """ - driver_id = id(driver) - # Inline cache check to avoid cross-module method call that causes mypyc segfault + driver_id = id(getattr(driver, "connection", None) or driver) if driver_id in self._version_fetch_attempted: return self._version_cache.get(driver_id) @@ -623,6 +618,15 @@ async def get_foreign_keys( schema_type=ForeignKeyMetadata, ) + async def _select_domain( + self, driver: "PsycopgAsyncDriver", domain: str, query_name: str, **parameters: Any + ) -> MetadataResult: + query = get_data_dictionary_loader().get_domain_query(type(self).dialect, domain, query_name) + if not query.is_supported or query.sql is None: + return MetadataResult(domain, capability=query.capability, warnings=query.warnings) + rows = await driver.select(query.sql, **parameters) + return _metadata_result(domain, _postgres_metadata_capability(domain), rows) + def _postgres_metadata_capability(domain: str) -> MetadataCapability: if domain == "system": diff --git a/sqlspec/adapters/psycopg/driver.py b/sqlspec/adapters/psycopg/driver.py index 1253f9921..d501f55d2 100644 --- a/sqlspec/adapters/psycopg/driver.py +++ b/sqlspec/adapters/psycopg/driver.py @@ -16,7 +16,9 @@ ) from sqlspec.adapters.psycopg._typing import psycopg_module as psycopg from sqlspec.adapters.psycopg.core import ( - TRANSACTION_STATUS_IDLE, + TRANSACTION_STATUS_ACTIVE, + TRANSACTION_STATUS_INERROR, + TRANSACTION_STATUS_INTRANS, PipelineCursorEntry, PreparedStackOperation, PsycopgAsyncStreamSource, @@ -222,15 +224,16 @@ def __init__( self._restore_autocommit = False self._transaction_active = False - def _execute_cache_hit( - self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" - ) -> "SQLResult": - if "%" in cached.compiled_sql: - statement = self._cached_statement( - sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql - ) - return self._execute_cached_statement(statement) - return super()._execute_cache_hit(sql, params, cached) + @property + def data_dictionary(self) -> "PsycopgSyncDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = PsycopgSyncDataDictionary() + return self._data_dictionary def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": """Execute single SQL statement. @@ -306,7 +309,8 @@ def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "ExecutionRe last_cursor = cursor for stmt in statements: - execute_with_optional_parameters(cursor, stmt, prepared_parameters) + stmt_sql = escape_literal_percent(stmt, prepared_parameters, self.statement_config.parameter_validator) + execute_with_optional_parameters(cursor, stmt_sql, prepared_parameters) successful_count += 1 return self.create_execution_result( @@ -356,13 +360,16 @@ def dispatch_special_handling(self, cursor: Any, statement: "SQL") -> "SQLResult if is_copy_to_operation(operation_type): output_data: list[str] = [] with cursor.copy(sql) as copy_ctx: - output_data.extend(row.decode() if isinstance(row, bytes) else str(row) for row in copy_ctx) + output_data.extend( + bytes(row).decode("utf-8") if isinstance(row, (bytes, bytearray, memoryview)) else str(row) + for row in copy_ctx + ) exported_data = "".join(output_data) return SQLResult( data=[{"copy_output": exported_data}], - rows_affected=0, + rows_affected=max(cursor.rowcount, 0), statement=statement, metadata={"copy_operation": "TO_STDOUT"}, ) @@ -379,10 +386,15 @@ def begin(self) -> None: if self._connection_in_transaction(): return try: + if self.connection.info.transaction_status == TRANSACTION_STATUS_INERROR: + self.connection.rollback() restore_autocommit = self.connection.autocommit if restore_autocommit: self.connection.autocommit = False except psycopg.Error as e: + mapped = create_mapped_exception(e) + if type(mapped) is not SQLSpecError: + raise mapped from e msg = f"Failed to begin transaction: {e}" raise SQLSpecError(msg) from e self._restore_autocommit = restore_autocommit @@ -393,6 +405,9 @@ def commit(self) -> None: try: self.connection.commit() except psycopg.Error as e: + mapped = create_mapped_exception(e) + if type(mapped) is not SQLSpecError: + raise mapped from e msg = f"Failed to commit transaction: {e}" raise SQLSpecError(msg) from e self._transaction_active = False @@ -403,6 +418,9 @@ def rollback(self) -> None: try: self.connection.rollback() except psycopg.Error as e: + mapped = create_mapped_exception(e) + if type(mapped) is not SQLSpecError: + raise mapped from e msg = f"Failed to rollback transaction: {e}" raise SQLSpecError(msg) from e self._transaction_active = False @@ -549,17 +567,6 @@ def load_from_storage( arrow_table, inbound = self._read_storage_arrow(source, file_format=file_format) return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound) - @property - def data_dictionary(self) -> "PsycopgSyncDataDictionary": - """Get the data dictionary for this driver. - - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = PsycopgSyncDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect psycopg sync rows for the direct execution path.""" data = cast("list[Any] | None", fetched) or [] @@ -570,14 +577,20 @@ def resolve_rowcount(self, cursor: Any) -> int: """Resolve rowcount from psycopg cursor for the direct execution path.""" return resolve_rowcount(cursor) + def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + expected_percents = cached.parameter_profile.total_count if params else 0 + if cached.compiled_sql.count("%") != expected_percents: + statement = self._cached_statement( + sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql + ) + return self._execute_cached_statement(statement) + return super()._execute_cache_hit(sql, params, cached) + def _execute_stack_pipeline( self, stack: "StatementStack", prepared_ops: "list[PreparedStackOperation]" ) -> "tuple[StackResult, ...]": - def _raise_pending_exception(exception_ctx: "PsycopgSyncExceptionHandler") -> None: - if exception_ctx.pending_exception is not None: - stack_error = _stack_pipeline_sync_error(type(self).__name__, exception_ctx.pending_exception) - raise stack_error from exception_ctx.pending_exception - results: list[StackResult] = [] started_transaction = False @@ -587,15 +600,11 @@ def _raise_pending_exception(exception_ctx: "PsycopgSyncExceptionHandler") -> No self.begin() started_transaction = True - exception_handlers = [] with ExitStack() as resource_stack: pipeline = resource_stack.enter_context(self.connection.pipeline()) pending: list[PipelineCursorEntry] = [] for prepared in prepared_ops: - exception_ctx = self.handle_database_exceptions() - exception_handlers.append(exception_ctx) - resource_stack.enter_context(exception_ctx) cursor = resource_stack.enter_context(self.with_cursor(self.connection)) try: @@ -606,14 +615,7 @@ def _raise_pending_exception(exception_ctx: "PsycopgSyncExceptionHandler") -> No cursor.execute(sql) except Exception as exc: owner = _attribute_pipeline_failure(pending, prepared) - stack_error = StackExecutionError( - owner.operation_index, - describe_stack_statement(owner.operation.statement), - exc, - adapter=type(self).__name__, - mode="fail-fast", - ) - raise stack_error from exc + raise _stack_operation_error(type(self).__name__, owner, exc) from exc pending.append(PipelineCursorEntry(prepared=prepared, cursor=cursor)) @@ -625,9 +627,6 @@ def _raise_pending_exception(exception_ctx: "PsycopgSyncExceptionHandler") -> No results = self._collect_pipeline_results(pending, sync_error) - for exception_ctx in exception_handlers: - _raise_pending_exception(exception_ctx) - if started_transaction: self.commit() except Exception: @@ -652,7 +651,10 @@ def _collect_pipeline_results( raise _stack_operation_error(type(self).__name__, entry.prepared, sync_error) from sync_error try: execution_result = build_pipeline_execution_result( - statement, cursor, column_name_resolver=self._resolve_column_names + statement, + cursor, + column_name_resolver=self._resolve_column_names, + column_types_resolver=_resolve_column_types, ) except Exception as exc: raise _stack_operation_error(type(self).__name__, entry.prepared, exc) from exc @@ -671,7 +673,10 @@ def _resolve_column_names(self, description: Any) -> list[str]: def _connection_in_transaction(self) -> bool: """Check if connection is in transaction.""" - return self._transaction_active or self.connection.info.transaction_status != TRANSACTION_STATUS_IDLE + return self._transaction_active or self.connection.info.transaction_status in { + TRANSACTION_STATUS_ACTIVE, + TRANSACTION_STATUS_INTRANS, + } def _restore_original_autocommit(self) -> None: """Restore autocommit after a completed SQLSpec-owned transaction.""" @@ -737,15 +742,16 @@ def __init__( self._restore_autocommit = False self._transaction_active = False - async def _execute_cache_hit( - self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" - ) -> "SQLResult": - if "%" in cached.compiled_sql: - statement = self._cached_statement( - sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql - ) - return await self._execute_cached_statement(statement) - return await super()._execute_cache_hit(sql, params, cached) + @property + def data_dictionary(self) -> "PsycopgAsyncDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = PsycopgAsyncDataDictionary() + return self._data_dictionary async def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": """Execute single SQL statement (async). @@ -821,7 +827,8 @@ async def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "Execu last_cursor = cursor for stmt in statements: - await execute_with_optional_parameters_async(cursor, stmt, prepared_parameters) + stmt_sql = escape_literal_percent(stmt, prepared_parameters, self.statement_config.parameter_validator) + await execute_with_optional_parameters_async(cursor, stmt_sql, prepared_parameters) successful_count += 1 return self.create_execution_result( @@ -871,13 +878,17 @@ async def dispatch_special_handling(self, cursor: Any, statement: "SQL") -> "SQL if is_copy_to_operation(operation_type): output_data: list[str] = [] async with cursor.copy(sql) as copy_ctx: - output_data.extend([row.decode() if isinstance(row, bytes) else str(row) async for row in copy_ctx]) + output_data.extend([ + bytes(row).decode("utf-8") if isinstance(row, (bytes, bytearray, memoryview)) else str(row) + async for row in copy_ctx + ]) exported_data = "".join(output_data) + rows_affected = max(cursor.rowcount, 0) return SQLResult( data=[{"copy_output": exported_data}], - rows_affected=0, + rows_affected=rows_affected, statement=statement, metadata={"copy_operation": "TO_STDOUT"}, ) @@ -894,10 +905,15 @@ async def begin(self) -> None: if self._connection_in_transaction(): return try: + if self.connection.info.transaction_status == TRANSACTION_STATUS_INERROR: + await self.connection.rollback() restore_autocommit = self.connection.autocommit if restore_autocommit: await self.connection.set_autocommit(False) except psycopg.Error as e: + mapped = create_mapped_exception(e) + if type(mapped) is not SQLSpecError: + raise mapped from e msg = f"Failed to begin transaction: {e}" raise SQLSpecError(msg) from e self._restore_autocommit = restore_autocommit @@ -908,6 +924,9 @@ async def commit(self) -> None: try: await self.connection.commit() except psycopg.Error as e: + mapped = create_mapped_exception(e) + if type(mapped) is not SQLSpecError: + raise mapped from e msg = f"Failed to commit transaction: {e}" raise SQLSpecError(msg) from e self._transaction_active = False @@ -918,6 +937,9 @@ async def rollback(self) -> None: try: await self.connection.rollback() except psycopg.Error as e: + mapped = create_mapped_exception(e) + if type(mapped) is not SQLSpecError: + raise mapped from e msg = f"Failed to rollback transaction: {e}" raise SQLSpecError(msg) from e self._transaction_active = False @@ -1071,17 +1093,6 @@ async def load_from_storage( table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound ) - @property - def data_dictionary(self) -> "PsycopgAsyncDataDictionary": - """Get the data dictionary for this driver. - - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = PsycopgAsyncDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect psycopg async rows for the direct execution path.""" data = cast("list[Any] | None", fetched) or [] @@ -1092,14 +1103,20 @@ def resolve_rowcount(self, cursor: Any) -> int: """Resolve rowcount from psycopg cursor for the direct execution path.""" return resolve_rowcount(cursor) + async def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + expected_percents = cached.parameter_profile.total_count if params else 0 + if cached.compiled_sql.count("%") != expected_percents: + statement = self._cached_statement( + sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql + ) + return await self._execute_cached_statement(statement) + return await super()._execute_cache_hit(sql, params, cached) + async def _execute_stack_pipeline( self, stack: "StatementStack", prepared_ops: "list[PreparedStackOperation]" ) -> "tuple[StackResult, ...]": - def _raise_pending_exception(exception_ctx: "PsycopgAsyncExceptionHandler") -> None: - if exception_ctx.pending_exception is not None: - stack_error = _stack_pipeline_sync_error(type(self).__name__, exception_ctx.pending_exception) - raise stack_error from exception_ctx.pending_exception - results: list[StackResult] = [] started_transaction = False @@ -1109,15 +1126,11 @@ def _raise_pending_exception(exception_ctx: "PsycopgAsyncExceptionHandler") -> N await self.begin() started_transaction = True - exception_handlers = [] async with AsyncExitStack() as resource_stack: pipeline = await resource_stack.enter_async_context(self.connection.pipeline()) pending: list[PipelineCursorEntry] = [] for prepared in prepared_ops: - exception_ctx = self.handle_database_exceptions() - exception_handlers.append(exception_ctx) - await resource_stack.enter_async_context(exception_ctx) cursor = await resource_stack.enter_async_context(self.with_cursor(self.connection)) try: @@ -1128,14 +1141,7 @@ def _raise_pending_exception(exception_ctx: "PsycopgAsyncExceptionHandler") -> N await cursor.execute(sql) except Exception as exc: owner = _attribute_pipeline_failure(pending, prepared) - stack_error = StackExecutionError( - owner.operation_index, - describe_stack_statement(owner.operation.statement), - exc, - adapter=type(self).__name__, - mode="fail-fast", - ) - raise stack_error from exc + raise _stack_operation_error(type(self).__name__, owner, exc) from exc pending.append(PipelineCursorEntry(prepared=prepared, cursor=cursor)) @@ -1147,9 +1153,6 @@ def _raise_pending_exception(exception_ctx: "PsycopgAsyncExceptionHandler") -> N results = await self._collect_pipeline_results(pending, sync_error) - for exception_ctx in exception_handlers: - _raise_pending_exception(exception_ctx) - if started_transaction: await self.commit() except Exception: @@ -1174,7 +1177,10 @@ async def _collect_pipeline_results( raise _stack_operation_error(type(self).__name__, entry.prepared, sync_error) from sync_error try: execution_result = await build_async_pipeline_execution_result( - statement, cursor, column_name_resolver=self._resolve_column_names + statement, + cursor, + column_name_resolver=self._resolve_column_names, + column_types_resolver=_resolve_column_types, ) except Exception as exc: raise _stack_operation_error(type(self).__name__, entry.prepared, exc) from exc @@ -1193,7 +1199,10 @@ def _resolve_column_names(self, description: Any) -> list[str]: def _connection_in_transaction(self) -> bool: """Check if connection is in transaction.""" - return self._transaction_active or self.connection.info.transaction_status != TRANSACTION_STATUS_IDLE + return self._transaction_active or self.connection.info.transaction_status in { + TRANSACTION_STATUS_ACTIVE, + TRANSACTION_STATUS_INTRANS, + } async def _restore_original_autocommit(self) -> None: """Restore autocommit after a completed SQLSpec-owned transaction.""" @@ -1228,10 +1237,11 @@ def _stack_operation_error( adapter: str, prepared: "PreparedStackOperation", cause: "Exception" ) -> "StackExecutionError": """Build a fail-fast StackExecutionError attributed to a specific pipeline operation.""" + mapped_cause = create_mapped_exception(cause) if isinstance(cause, psycopg.Error) else cause return StackExecutionError( prepared.operation_index, describe_stack_statement(prepared.operation.statement), - cause, + mapped_cause, adapter=adapter, mode="fail-fast", native_pipeline=True, @@ -1240,8 +1250,9 @@ def _stack_operation_error( def _stack_pipeline_sync_error(adapter: str, cause: "Exception") -> "StackExecutionError": """Build a fail-fast StackExecutionError for an unattributed pipeline sync failure.""" + mapped_cause = create_mapped_exception(cause) if isinstance(cause, psycopg.Error) else cause return StackExecutionError( - -1, "psycopg pipeline sync failed", cause, adapter=adapter, mode="fail-fast", native_pipeline=True + -1, "psycopg pipeline sync failed", mapped_cause, adapter=adapter, mode="fail-fast", native_pipeline=True ) diff --git a/sqlspec/adapters/psycopg/events/_hub.py b/sqlspec/adapters/psycopg/events/_hub.py index 9d3ce7df5..a9a98d5ab 100644 --- a/sqlspec/adapters/psycopg/events/_hub.py +++ b/sqlspec/adapters/psycopg/events/_hub.py @@ -186,8 +186,15 @@ async def _ensure_connection_locked(self) -> None: await connection_cm.__aexit__(None, None, None) msg = "Psycopg async adapter did not yield a connection" raise ImproperConfigurationError(msg) - with contextlib.suppress(Exception): + try: + if not getattr(connection, "autocommit", True): + with contextlib.suppress(Exception): + await connection.rollback() await connection.set_autocommit(True) + except BaseException: + with contextlib.suppress(Exception): + await connection_cm.__aexit__(None, None, None) + raise self._connection_cm = connection_cm self._connection = connection try: @@ -247,7 +254,7 @@ def _dispatch(self, channel: str, payload: str) -> None: self._record_queue_depth() def _record_queue_depth(self) -> None: - depth = sum(queue.qsize() for queues in self._queues.values() for queue in queues.values()) + depth = sum(queue.qsize() for queues in list(self._queues.values()) for queue in list(queues.values())) self._config.get_observability_runtime().record_metric("events.listener.queue.depth", depth) @@ -285,7 +292,9 @@ def __init__(self, config: "PsycopgSyncConfig", backend_name: str = "notify") -> self._connection: Any | None = None self._stopping = threading.Event() self._shutting_down = False - self._command_queue: stdlib_queue.Queue[tuple[str, str, threading.Event]] = stdlib_queue.Queue() + self._command_queue: stdlib_queue.Queue[tuple[str, str, threading.Event, list[BaseException]]] = ( + stdlib_queue.Queue() + ) self._worker_thread: threading.Thread | None = None self._pool_destroying_registered = False @@ -336,7 +345,7 @@ def shutdown(self) -> None: channels = list(self._queues.keys()) worker_thread = self._worker_thread self._queues.clear() - self._record_queue_depth() + self._record_queue_depth_locked() self._connection_cm = None self._worker_thread = None if worker_thread is not None: @@ -403,7 +412,15 @@ def _ensure_connection_locked(self) -> None: connection_cm.__exit__(None, None, None) msg = "Psycopg sync adapter did not yield a connection" raise ImproperConfigurationError(msg) - connection.autocommit = True + try: + if not getattr(connection, "autocommit", True): + with contextlib.suppress(Exception): + connection.rollback() + connection.autocommit = True + except BaseException: + with contextlib.suppress(Exception): + connection_cm.__exit__(None, None, None) + raise self._connection_cm = connection_cm self._connection = connection self._worker_thread = threading.Thread( @@ -433,14 +450,26 @@ def _pool_destroying_hook(self, _context: "dict[str, Any]") -> None: def _submit(self, op: str, channel: str, *, timeout: "float | None" = None) -> None: done = threading.Event() - self._command_queue.put((op, channel, done)) - done.wait(timeout=timeout) + error_box: list[BaseException] = [] + self._command_queue.put((op, channel, done, error_box)) + if timeout is not None: + done.wait(timeout=timeout) + else: + while not done.wait(timeout=0.05): + worker = self._worker_thread + if worker is None or not worker.is_alive(): + break + if error_box: + raise error_box[0] def _worker( - self, stopping: threading.Event, command_queue: "stdlib_queue.Queue[tuple[str, str, threading.Event]]" + self, + stopping: threading.Event, + command_queue: "stdlib_queue.Queue[tuple[str, str, threading.Event, list[BaseException]]]", ) -> None: connection = self._connection if connection is None or not has_notifies(connection): + self._drain_commands(connection, command_queue, stopping) return while not stopping.is_set(): self._drain_commands(connection, command_queue, stopping) @@ -460,22 +489,29 @@ def _worker( def _drain_commands( self, connection: Any, - command_queue: "stdlib_queue.Queue[tuple[str, str, threading.Event]]", + command_queue: "stdlib_queue.Queue[tuple[str, str, threading.Event, list[BaseException]]]", stopping: threading.Event, ) -> None: while True: try: - op, channel, done = command_queue.get_nowait() + op, channel, done, error_box = command_queue.get_nowait() except stdlib_queue.Empty: return + if op == "listen" and connection is None: + error_box.append(RuntimeError("Psycopg sync listener connection is not available")) + done.set() + continue try: - if op == "listen": + if op == "listen" and connection is not None: connection.execute(_listen_statement(channel)) elif op == "unlisten": with contextlib.suppress(Exception): - connection.execute(_unlisten_statement(channel)) + if connection is not None: + connection.execute(_unlisten_statement(channel)) elif op == "stop": stopping.set() + except Exception as exc: + error_box.append(exc) finally: done.set() @@ -490,10 +526,14 @@ def _dispatch(self, channel: str, payload: str) -> None: self._config.get_observability_runtime().increment_metric("events.listener.queue.dropped") self._record_queue_depth() - def _record_queue_depth(self) -> None: - depth = sum(queue.qsize() for queues in self._queues.values() for queue in queues.values()) + def _record_queue_depth_locked(self) -> None: + depth = sum(queue.qsize() for queues in list(self._queues.values()) for queue in list(queues.values())) self._config.get_observability_runtime().record_metric("events.listener.queue.depth", depth) + def _record_queue_depth(self) -> None: + with self._lock: + self._record_queue_depth_locked() + def _record_listener_lifecycle( config: "PsycopgAsyncConfig | PsycopgSyncConfig", backend_name: str, status: str, *, is_async: bool diff --git a/sqlspec/adapters/psycopg/events/backend.py b/sqlspec/adapters/psycopg/events/backend.py index 1e48285d6..7a683ebb6 100644 --- a/sqlspec/adapters/psycopg/events/backend.py +++ b/sqlspec/adapters/psycopg/events/backend.py @@ -427,30 +427,39 @@ def _publish_durable( ) -> None: now = datetime.now(UTC) with self._config.provide_session() as driver: - driver.execute( - SQL( - self._queue._insert_statement, - { - "event_id": event_id, - "channel": channel, - "payload_json": to_json(payload), - "metadata_json": to_json(metadata) if metadata is not None else None, - "status": "pending", - "available_at": now, - "lease_expires_at": None, - "attempts": 0, - "created_at": now, - }, - statement_config=self._queue._statement_config, + was_autocommit = bool(getattr(driver.connection, "autocommit", False)) + driver.begin() + try: + driver.execute( + SQL( + self._queue._insert_statement, + { + "event_id": event_id, + "channel": channel, + "payload_json": to_json(payload), + "metadata_json": to_json(metadata) if metadata is not None else None, + "status": "pending", + "available_at": now, + "lease_expires_at": None, + "attempts": 0, + "created_at": now, + }, + statement_config=self._queue._statement_config, + ) ) - ) - driver.execute( - SQL( - "SELECT pg_notify(:channel, :payload)", - {"channel": channel, "payload": to_json({"event_id": event_id})}, + driver.execute( + SQL( + "SELECT pg_notify(:channel, :payload)", + {"channel": channel, "payload": to_json({"event_id": event_id})}, + ) ) - ) - driver.commit() + driver.commit() + except BaseException: + driver.rollback() + raise + finally: + if was_autocommit: + driver.connection.autocommit = True class PsycopgAsyncHybridEventsBackend: @@ -589,30 +598,39 @@ async def _publish_durable( ) -> None: now = datetime.now(UTC) async with self._config.provide_session() as driver: - await driver.execute( - SQL( - self._queue._insert_statement, - { - "event_id": event_id, - "channel": channel, - "payload_json": to_json(payload), - "metadata_json": to_json(metadata) if metadata is not None else None, - "status": "pending", - "available_at": now, - "lease_expires_at": None, - "attempts": 0, - "created_at": now, - }, - statement_config=self._queue._statement_config, + was_autocommit = bool(getattr(driver.connection, "autocommit", False)) + await driver.begin() + try: + await driver.execute( + SQL( + self._queue._insert_statement, + { + "event_id": event_id, + "channel": channel, + "payload_json": to_json(payload), + "metadata_json": to_json(metadata) if metadata is not None else None, + "status": "pending", + "available_at": now, + "lease_expires_at": None, + "attempts": 0, + "created_at": now, + }, + statement_config=self._queue._statement_config, + ) ) - ) - await driver.execute( - SQL( - "SELECT pg_notify(:channel, :payload)", - {"channel": channel, "payload": to_json({"event_id": event_id})}, + await driver.execute( + SQL( + "SELECT pg_notify(:channel, :payload)", + {"channel": channel, "payload": to_json({"event_id": event_id})}, + ) ) - ) - await driver.commit() + await driver.commit() + except BaseException: + await driver.rollback() + raise + finally: + if was_autocommit: + await driver.connection.set_autocommit(True) def create_event_backend( @@ -661,7 +679,8 @@ def create_event_backend( def _validate_listener_pool_capacity(config: "PsycopgAsyncConfig | PsycopgSyncConfig") -> None: max_size = config.connection_config.get("max_size") - if max_size is not None and max_size < _MIN_LISTENER_POOL_SIZE: + effective_max = max_size if max_size is not None else config.connection_config.get("min_size") + if effective_max is not None and effective_max < _MIN_LISTENER_POOL_SIZE: msg = f"{type(config).__name__} native event listeners require pool max_size >= {_MIN_LISTENER_POOL_SIZE}" raise ImproperConfigurationError(msg) diff --git a/sqlspec/adapters/psycopg/litestar/store.py b/sqlspec/adapters/psycopg/litestar/store.py index 1464ee038..3d7a2e7ab 100644 --- a/sqlspec/adapters/psycopg/litestar/store.py +++ b/sqlspec/adapters/psycopg/litestar/store.py @@ -95,7 +95,7 @@ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "by conn_context = self._config.provide_connection() async with conn_context as conn: async with conn.cursor(row_factory=dict_row) as cur: - await cur.execute(sql.encode(), (key,)) + await cur.execute(cast("Any", sql), (key,)) row = await cur.fetchone() if row is None: @@ -109,7 +109,7 @@ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "by SET expires_at = %s, updated_at = CURRENT_TIMESTAMP WHERE session_id = %s """ - await conn.execute(update_sql.encode(), (new_expires_at, key)) + await conn.execute(cast("Any", update_sql), (new_expires_at, key)) await conn.commit() return bytes(row["data"]) @@ -137,7 +137,7 @@ async def set(self, key: str, value: "str | bytes", expires_in: "int | timedelta conn_context = self._config.provide_connection() async with conn_context as conn: - await conn.execute(sql.encode(), (key, data, expires_at)) + await conn.execute(cast("Any", sql), (key, data, expires_at)) await conn.commit() async def delete(self, key: str) -> None: @@ -150,7 +150,7 @@ async def delete(self, key: str) -> None: conn_context = self._config.provide_connection() async with conn_context as conn: - await conn.execute(sql.encode(), (key,)) + await conn.execute(cast("Any", sql), (key,)) await conn.commit() async def delete_all(self) -> None: @@ -159,7 +159,7 @@ async def delete_all(self) -> None: conn_context = self._config.provide_connection() async with conn_context as conn: - await conn.execute(sql.encode()) + await conn.execute(cast("Any", sql)) await conn.commit() self._log_delete_all() @@ -180,7 +180,7 @@ async def exists(self, key: str) -> bool: conn_context = self._config.provide_connection() async with conn_context as conn, conn.cursor() as cur: - await cur.execute(sql.encode(), (key,)) + await cur.execute(cast("Any", sql), (key,)) result = await cur.fetchone() return result is not None @@ -201,7 +201,7 @@ async def expires_in(self, key: str) -> "int | None": conn_context = self._config.provide_connection() async with conn_context as conn: async with conn.cursor(row_factory=dict_row) as cur: - await cur.execute(sql.encode(), (key,)) + await cur.execute(cast("Any", sql), (key,)) row = await cur.fetchone() if row is None or row["expires_at"] is None: @@ -225,7 +225,7 @@ async def delete_expired(self) -> int: conn_context = self._config.provide_connection() async with conn_context as conn, conn.cursor() as cur: - await cur.execute(sql.encode()) + await cur.execute(cast("Any", sql)) await conn.commit() count = cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0 if count > 0: @@ -421,7 +421,7 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | with self._config.provide_connection() as conn: with conn.cursor(row_factory=dict_row) as cur: - cur.execute(sql.encode(), (key,)) + cur.execute(cast("Any", sql), (key,)) row = cur.fetchone() if row is None: @@ -435,7 +435,7 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | SET expires_at = %s, updated_at = CURRENT_TIMESTAMP WHERE session_id = %s """ - conn.execute(update_sql.encode(), (new_expires_at, key)) + conn.execute(cast("Any", update_sql), (new_expires_at, key)) conn.commit() return bytes(row["data"]) @@ -456,7 +456,7 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No """ with self._config.provide_connection() as conn: - conn.execute(sql.encode(), (key, data, expires_at)) + conn.execute(cast("Any", sql), (key, data, expires_at)) conn.commit() def _delete(self, key: str) -> None: @@ -464,7 +464,7 @@ def _delete(self, key: str) -> None: sql = f"DELETE FROM {self._table_name} WHERE session_id = %s" with self._config.provide_connection() as conn: - conn.execute(sql.encode(), (key,)) + conn.execute(cast("Any", sql), (key,)) conn.commit() def _delete_all(self) -> None: @@ -472,7 +472,7 @@ def _delete_all(self) -> None: sql = f"DELETE FROM {self._table_name}" with self._config.provide_connection() as conn: - conn.execute(sql.encode()) + conn.execute(cast("Any", sql)) conn.commit() self._log_delete_all() @@ -485,7 +485,7 @@ def _exists(self, key: str) -> bool: """ with self._config.provide_connection() as conn, conn.cursor() as cur: - cur.execute(sql.encode(), (key,)) + cur.execute(cast("Any", sql), (key,)) result = cur.fetchone() return result is not None @@ -498,7 +498,7 @@ def _expires_in(self, key: str) -> "int | None": with self._config.provide_connection() as conn: with conn.cursor(row_factory=dict_row) as cur: - cur.execute(sql.encode(), (key,)) + cur.execute(cast("Any", sql), (key,)) row = cur.fetchone() if row is None or row["expires_at"] is None: @@ -518,7 +518,7 @@ def _delete_expired(self) -> int: sql = f"DELETE FROM {self._table_name} WHERE expires_at <= CURRENT_TIMESTAMP" with self._config.provide_connection() as conn, conn.cursor() as cur: - cur.execute(sql.encode()) + cur.execute(cast("Any", sql)) conn.commit() count = cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0 if count > 0: diff --git a/sqlspec/adapters/pymssql/_typing.py b/sqlspec/adapters/pymssql/_typing.py index 3ae4e9fab..7a1ec10d7 100644 --- a/sqlspec/adapters/pymssql/_typing.py +++ b/sqlspec/adapters/pymssql/_typing.py @@ -12,7 +12,10 @@ from pymssql import Cursor as _PymssqlRawCursor # pyright: ignore[reportMissingTypeStubs] from pymssql import Error as PymssqlError +from sqlspec.utils.module_loader import import_optional_attr + PYMSSQL_MODULE = _pymssql +_DRIVER_CLS: Any = None if TYPE_CHECKING: from collections.abc import Callable @@ -46,14 +49,25 @@ class PymssqlCursor: """Context manager for pymssql cursor operations.""" - __slots__ = ("connection", "cursor") + __slots__ = ("_as_dict", "connection", "cursor") - def __init__(self, connection: "PymssqlConnection") -> None: + def __init__(self, connection: "PymssqlConnection", *, as_dict: bool | None = None) -> None: self.connection = connection self.cursor: PymssqlRawCursor | None = None + self._as_dict = as_dict def __enter__(self) -> "PymssqlRawCursor": - self.cursor = self.connection.cursor() + if self._as_dict is None: + self.cursor = self.connection.cursor() + else: + try: + raw_cursor: Any = ( + self.connection.cursor(as_dict=True) if self._as_dict else self.connection.cursor(as_dict=False) + ) + self.cursor = raw_cursor + except TypeError: + self.cursor = self.connection.cursor() + assert self.cursor is not None return self.cursor def __exit__(self, *_: Any) -> None: @@ -92,18 +106,24 @@ def __init__( self._driver: PymssqlDriver | None = None def __enter__(self) -> "PymssqlDriver": - from sqlspec.adapters.pymssql.driver import PymssqlDriver - + global _DRIVER_CLS + if _DRIVER_CLS is None: + _DRIVER_CLS = import_optional_attr("sqlspec.adapters.pymssql.driver", "PymssqlDriver") self._connection = self._acquire_connection() - self._driver = PymssqlDriver( + self._driver = _DRIVER_CLS( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) + assert self._driver is not None return self._prepare_driver(self._driver) def __exit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" ) -> "bool | None": + if self._driver is not None and (exc_type is not None or self._driver._connection_in_transaction()): + with contextlib.suppress(Exception): + self._driver.rollback() if self._connection is not None: self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) self._connection = None + self._driver = None return None diff --git a/sqlspec/adapters/pymssql/adk/store.py b/sqlspec/adapters/pymssql/adk/store.py index ca0169936..2a2974b59 100644 --- a/sqlspec/adapters/pymssql/adk/store.py +++ b/sqlspec/adapters/pymssql/adk/store.py @@ -4,7 +4,13 @@ from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NotRequired, cast from sqlspec.adapters.pymssql._typing import PymssqlCursor, PymssqlError -from sqlspec.adapters.pymssql.core import extract_error_number, resolve_rowcount +from sqlspec.adapters.pymssql.core import ( + escape_sql_literal, + extract_error_number, + quote_bracket_identifier, + resolve_rowcount, + split_schema_and_table, +) from sqlspec.adapters.pymssql.data_dictionary import MssqlVersionInfo from sqlspec.config import ADKConfig from sqlspec.extensions.adk import BaseSyncADKStore, StoredEvent, StoredSession, normalize_session_list_options @@ -67,19 +73,19 @@ def create_tables(self) -> None: driver.execute_script(self._app_states_table_ddl()) driver.execute_script(self._user_states_table_ddl()) driver.execute_script(self._metadata_table_ddl()) - existing_indexes = _casefold_names( - driver.data_dictionary.get_indexes(driver, schema=MSSQL_SCHEMA), "index_name" - ) - for index_name, index_table, columns in self._index_specs(): + index_specs = self._index_specs() + existing_indexes = _existing_index_names(driver, index_specs) + for index_name, index_table, columns in index_specs: if _bare_name(index_name) not in existing_indexes: driver.execute(_create_index_sql(index_table, index_name, columns)) driver.commit() + self.reconcile_schema(assume_existing=True) def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> StoredSession: """Create a new ADK session.""" - owner_column = f", {_quote_identifier(self._owner_id_column_name)}" if self._owner_id_column_name else "" + owner_column = f", {quote_bracket_identifier(self._owner_id_column_name)}" if self._owner_id_column_name else "" owner_param = ", %s" if self._owner_id_column_name else "" sql = f""" INSERT INTO {_table_ref(self._session_table)} ( @@ -196,7 +202,7 @@ def append_event_and_update_state( OUTPUT inserted.id, inserted.app_name, inserted.user_id, inserted.state, inserted.create_time, inserted.update_time WHERE app_name = %s AND user_id = %s AND id = %s """ - with self._config.provide_connection() as conn, PymssqlCursor(conn) as cursor: + with self._config.provide_connection() as conn, PymssqlCursor(conn, as_dict=False) as cursor: try: cursor.execute(update_sql, (to_json(state), app_name, user_id, session_id)) row = cursor.fetchone() @@ -285,7 +291,7 @@ def get_app_state(self, app_name: str) -> "dict[str, Any] | None": if _is_mssql_table_missing(exc): return None raise - return _json_dict(row[0]) if row is not None else None + return _json_dict(_row_value(row, "state", 0)) if row is not None else None def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None": """Return user-scoped state.""" @@ -302,7 +308,7 @@ def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None" if _is_mssql_table_missing(exc): return None raise - return _json_dict(row[0]) if row is not None else None + return _json_dict(_row_value(row, "state", 0)) if row is not None else None def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: """Insert or replace app-scoped state.""" @@ -322,7 +328,7 @@ def get_metadata(self, key: str) -> "str | None": if _is_mssql_table_missing(exc): return None raise - return str(row[0]) if row is not None else None + return str(_row_value(row, "value", 0)) if row is not None else None def set_metadata(self, key: str, value: str) -> None: """Set an ADK metadata value.""" @@ -401,7 +407,7 @@ def _json_column_type_sync(self) -> str: return self._json_column_type def _execute_fetchone(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = False) -> "Any | None": - with self._config.provide_connection() as conn, PymssqlCursor(conn) as cursor: + with self._config.provide_connection() as conn, PymssqlCursor(conn, as_dict=False) as cursor: cursor.execute(sql, params) row = cursor.fetchone() if commit: @@ -409,12 +415,12 @@ def _execute_fetchone(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: return row def _execute_fetchall(self, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": - with self._config.provide_connection() as conn, PymssqlCursor(conn) as cursor: + with self._config.provide_connection() as conn, PymssqlCursor(conn, as_dict=False) as cursor: cursor.execute(sql, params) return list(cursor.fetchall()) def _execute(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = False) -> int: - with self._config.provide_connection() as conn, PymssqlCursor(conn) as cursor: + with self._config.provide_connection() as conn, PymssqlCursor(conn, as_dict=False) as cursor: cursor.execute(sql, params) rowcount = resolve_rowcount(cursor) if commit: @@ -425,10 +431,14 @@ def _execute(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = F class PymssqlADKMemoryStore(BaseSyncADKMemoryStore["PymssqlConfig"]): """SQL Server ADK memory store using pymssql.""" - __slots__ = () + __slots__ = ("_json_column_type", "_native_json") def __init__(self, config: "PymssqlConfig") -> None: super().__init__(config) + adk_config = _adk_config(config) + native_json = adk_config.get("native_json") + self._native_json: bool | None = native_json if isinstance(native_json, bool) else None + self._json_column_type: str | None = None def create_tables(self) -> None: """Create the memory table (idempotent T-SQL) and DD-gated indexes.""" @@ -439,14 +449,19 @@ def create_tables(self) -> None: if not self._enabled: return with self._config.provide_session() as driver: + if self._json_column_type is None: + configured = _configured_json_column_type(self._native_json) + self._json_column_type = ( + configured if configured is not None else _json_column_type_from_sync_driver(driver) + ) driver.execute_script(self._memory_table_ddl()) - existing_indexes = _casefold_names( - driver.data_dictionary.get_indexes(driver, schema=MSSQL_SCHEMA), "index_name" - ) - for index_name, index_table, columns in self._memory_index_specs(): + index_specs = self._memory_index_specs() + existing_indexes = _existing_index_names(driver, index_specs) + for index_name, index_table, columns in index_specs: if _bare_name(index_name) not in existing_indexes: driver.execute(_create_index_sql(index_table, index_name, columns)) driver.commit() + self.reconcile_schema(assume_existing=True) def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Bulk insert memory entries with event-id deduplication.""" @@ -456,7 +471,7 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object if not entries: return 0 - owner_column = f", {_quote_identifier(self._owner_id_column_name)}" if self._owner_id_column_name else "" + owner_column = f", {quote_bracket_identifier(self._owner_id_column_name)}" if self._owner_id_column_name else "" owner_value = ", %s" if self._owner_id_column_name else "" sql = f""" INSERT INTO {_table_ref(self._memory_table)} ( @@ -470,25 +485,29 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object ); """ inserted = 0 - with self._config.provide_connection() as conn, PymssqlCursor(conn) as cursor: - for entry in entries: - params: tuple[Any, ...] = ( - entry["id"], - entry["session_id"], - entry["app_name"], - entry["user_id"], - entry.get("scope", "user"), - entry["event_id"], - entry.get("author"), - entry["timestamp"], - to_json(entry["content_json"]), - entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, - ) - if self._owner_id_column_name: - params = (*params, owner_id) - cursor.execute(sql, (*params, entry["event_id"])) - inserted += resolve_rowcount(cursor) + with self._config.provide_connection() as conn, PymssqlCursor(conn, as_dict=False) as cursor: + try: + for entry in entries: + params: tuple[Any, ...] = ( + entry["id"], + entry["session_id"], + entry["app_name"], + entry["user_id"], + entry.get("scope", "user"), + entry["event_id"], + entry.get("author"), + entry["timestamp"], + to_json(entry["content_json"]), + entry["content_text"], + to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + ) + if self._owner_id_column_name: + params = (*params, owner_id) + cursor.execute(sql, (*params, entry["event_id"])) + inserted += resolve_rowcount(cursor) + except Exception: + conn.rollback() + raise conn.commit() return inserted @@ -507,15 +526,16 @@ def search_entries( raise RuntimeError(msg) limit_value = limit or self._max_results where_scope, scope_params = _build_mssql_scope_where(app_name, user_id, scope_filter) + escaped_query = _escape_like_pattern(query) sql = f""" SELECT TOP (%s) id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at FROM {_table_ref(self._memory_table)} - WHERE {where_scope} AND content_text LIKE %s + WHERE {where_scope} AND content_text LIKE %s ESCAPE N'\\' ORDER BY timestamp DESC """ - rows = self._execute_fetchall(sql, (limit_value, *scope_params, f"%{query}%")) + rows = self._execute_fetchall(sql, (limit_value, *scope_params, f"%{escaped_query}%")) return [_memory_record_from_row(row) for row in rows] def delete_entries_by_session(self, session_id: str) -> int: @@ -541,9 +561,11 @@ def delete_entries_older_than(self, days: int, app_name: "str | None" = None, sc def _memory_table_ddl(self) -> str: owner_line = f",\n {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" + json_column_type = self._json_column_type_sync() + schema_name, table_name = split_schema_and_table(self._memory_table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(self._memory_table)}' - AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' + AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(self._memory_table)} ( id NVARCHAR(128) NOT NULL, @@ -554,9 +576,9 @@ def _memory_table_ddl(self) -> str: event_id NVARCHAR(128) NOT NULL, author NVARCHAR(256) NULL, timestamp DATETIME2(6) NOT NULL, - content_json NVARCHAR(MAX) NOT NULL, + content_json {json_column_type} NOT NULL, content_text NVARCHAR(MAX) NOT NULL, - metadata_json NVARCHAR(MAX) NULL, + metadata_json {json_column_type} NULL, inserted_at DATETIME2(6) NOT NULL CONSTRAINT {_constraint_ref("df", self._memory_table, "inserted_at")} DEFAULT SYSUTCDATETIME(){owner_line}, CONSTRAINT {_constraint_ref("pk", self._memory_table, "id")} PRIMARY KEY (id), @@ -567,27 +589,38 @@ def _memory_table_ddl(self) -> str: def _memory_index_specs(self) -> "list[tuple[str, str, str]]": """Return ``(index_name, table, columns)`` specs for memory-table indexes.""" + _, table_name = split_schema_and_table(self._memory_table) return [ - ( - f"idx_{self._memory_table}_app_scope_user_time", - self._memory_table, - "app_name, scope, user_id, timestamp DESC", - ), - (f"idx_{self._memory_table}_scope", self._memory_table, "app_name, scope"), - (f"idx_{self._memory_table}_session", self._memory_table, "session_id"), - (f"idx_{self._memory_table}_timestamp", self._memory_table, "timestamp DESC"), + (f"idx_{table_name}_app_scope_user_time", self._memory_table, "app_name, scope, user_id, timestamp DESC"), + (f"idx_{table_name}_scope", self._memory_table, "app_name, scope"), + (f"idx_{table_name}_session", self._memory_table, "session_id"), + (f"idx_{table_name}_timestamp", self._memory_table, "timestamp DESC"), ] def _drop_memory_table_sql(self) -> "list[str]": return [f"DROP TABLE IF EXISTS {_table_ref(self._memory_table)}"] + def _json_column_type_sync(self) -> str: + if self._json_column_type is not None: + return self._json_column_type + configured = _configured_json_column_type(self._native_json) + if configured is not None: + self._json_column_type = configured + return configured + try: + with self._config.provide_session() as driver: + self._json_column_type = _json_column_type_from_sync_driver(driver) + except Exception: + return JSON_FALLBACK_COLUMN_TYPE + return self._json_column_type + def _execute_fetchall(self, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": - with self._config.provide_connection() as conn, PymssqlCursor(conn) as cursor: + with self._config.provide_connection() as conn, PymssqlCursor(conn, as_dict=False) as cursor: cursor.execute(sql, params) return list(cursor.fetchall()) def _execute(self, sql: str, params: "tuple[Any, ...]" = (), *, commit: bool = False) -> int: - with self._config.provide_connection() as conn, PymssqlCursor(conn) as cursor: + with self._config.provide_connection() as conn, PymssqlCursor(conn, as_dict=False) as cursor: cursor.execute(sql, params) rowcount = resolve_rowcount(cursor) if commit: @@ -620,10 +653,19 @@ def _json_column_type_from_sync_driver(driver: "PymssqlDriver") -> str: return JSON_FALLBACK_COLUMN_TYPE +def _existing_index_names(driver: "PymssqlDriver", index_specs: "list[tuple[str, str, str]]") -> "set[str]": + schemas = {split_schema_and_table(index_table)[0] for _, index_table, _ in index_specs} or {MSSQL_SCHEMA} + existing: set[str] = set() + for schema_name in schemas: + existing.update(_casefold_names(driver.data_dictionary.get_indexes(driver, schema=schema_name), "index_name")) + return existing + + def _sessions_table_ddl(table: str, json_column_type: str, owner_id_column_ddl: "str | None") -> str: owner_line = f",\n {owner_id_column_ddl}" if owner_id_column_ddl else "" + schema_name, table_name = split_schema_and_table(table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(table)}' AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(table)} ( row_id UNIQUEIDENTIFIER NOT NULL CONSTRAINT {_constraint_ref("df", table, "row_id")} DEFAULT NEWSEQUENTIALID(), @@ -641,15 +683,17 @@ def _sessions_table_ddl(table: str, json_column_type: str, owner_id_column_ddl: def _sessions_index_specs(table: str) -> "list[tuple[str, str, str]]": + _, table_name = split_schema_and_table(table) return [ - (f"idx_{table}_app_user", table, "app_name, user_id"), - (f"idx_{table}_update_time", table, "update_time DESC"), + (f"idx_{table_name}_app_user", table, "app_name, user_id"), + (f"idx_{table_name}_update_time", table, "update_time DESC"), ] def _events_table_ddl(table: str, session_table: str, json_column_type: str) -> str: + schema_name, table_name = split_schema_and_table(table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(table)}' AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(table)} ( row_id UNIQUEIDENTIFIER NOT NULL CONSTRAINT {_constraint_ref("df", table, "row_id")} DEFAULT NEWSEQUENTIALID(), @@ -670,18 +714,20 @@ def _events_table_ddl(table: str, session_table: str, json_column_type: str) -> def _events_index_specs(table: str) -> "list[tuple[str, str, str]]": + _, table_name = split_schema_and_table(table) return [ - (f"idx_{table}_scope", table, "app_name, user_id, session_id, timestamp ASC"), - (f"idx_{table}_session", table, "session_id, timestamp ASC"), - (f"idx_{table}_invocation", table, "invocation_id"), - (f"idx_{table}_timestamp", table, "timestamp ASC"), - (f"idx_{table}_app_timestamp", table, "app_name, timestamp ASC"), + (f"idx_{table_name}_scope", table, "app_name, user_id, session_id, timestamp ASC"), + (f"idx_{table_name}_session", table, "session_id, timestamp ASC"), + (f"idx_{table_name}_invocation", table, "invocation_id"), + (f"idx_{table_name}_timestamp", table, "timestamp ASC"), + (f"idx_{table_name}_app_timestamp", table, "app_name, timestamp ASC"), ] def _app_states_table_ddl(table: str, json_column_type: str) -> str: + schema_name, table_name = split_schema_and_table(table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(table)}' AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(table)} ( app_name NVARCHAR(128) NOT NULL, @@ -694,8 +740,9 @@ def _app_states_table_ddl(table: str, json_column_type: str) -> str: def _user_states_table_ddl(table: str, json_column_type: str) -> str: + schema_name, table_name = split_schema_and_table(table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(table)}' AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(table)} ( app_name NVARCHAR(128) NOT NULL, @@ -709,8 +756,9 @@ def _user_states_table_ddl(table: str, json_column_type: str) -> str: def _metadata_table_ddl(table: str) -> str: + schema_name, table_name = split_schema_and_table(table) return f""" -IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{_escape_sql_literal(table)}' AND schema_id = SCHEMA_ID(N'dbo')) +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}')) BEGIN CREATE TABLE {_table_ref(table)} ( [key] NVARCHAR(128) NOT NULL, @@ -722,7 +770,7 @@ def _metadata_table_ddl(table: str) -> str: def _create_index_sql(table: str, index_name: str, columns: str) -> str: - return f"CREATE INDEX {_quote_identifier(index_name)} ON {_table_ref(table)} ({columns})" + return f"CREATE INDEX {quote_bracket_identifier(index_name)} ON {_table_ref(table)} ({columns})" def _casefold_names(rows: "list[Any]", key: str) -> "set[str]": @@ -746,13 +794,14 @@ def _insert_event_sql(table: str) -> str: def _upsert_state_sql(table: str, key_columns: "tuple[str, ...]", key_params: "tuple[str, ...]") -> str: source_columns = ", ".join( - f"{param} AS {_quote_identifier(column)}" for column, param in zip(key_columns, key_params, strict=False) + f"{param} AS {quote_bracket_identifier(column)}" for column, param in zip(key_columns, key_params, strict=False) ) source_columns = f"{source_columns}, %s AS state" - insert_columns = ", ".join(_quote_identifier(column) for column in (*key_columns, "state", "update_time")) - insert_values = ", ".join(f"source.{_quote_identifier(column)}" for column in (*key_columns, "state")) + insert_columns = ", ".join(quote_bracket_identifier(column) for column in (*key_columns, "state", "update_time")) + insert_values = ", ".join(f"source.{quote_bracket_identifier(column)}" for column in (*key_columns, "state")) match_clause = " AND ".join( - f"target.{_quote_identifier(column)} = source.{_quote_identifier(column)}" for column in key_columns + f"target.{quote_bracket_identifier(column)} = source.{quote_bracket_identifier(column)}" + for column in key_columns ) return f""" MERGE INTO {_table_ref(table)} WITH (HOLDLOCK) AS target @@ -810,40 +859,59 @@ def _event_insert_params(event_record: StoredEvent) -> "tuple[Any, ...]": ) +def _row_value(row: object, key: str, index: int) -> Any: + if isinstance(row, dict): + if key in row: + return row[key] + upper_key = key.upper() + if upper_key in row: + return row[upper_key] + return None + if isinstance(row, (list, tuple)) and len(row) > index: + return row[index] + return getattr(row, key, None) + + def _session_record_from_row(row: Any) -> StoredSession: return StoredSession( - id=row[0], app_name=row[1], user_id=row[2], state=_json_dict(row[3]), create_time=row[4], update_time=row[5] + id=_row_value(row, "id", 0), + app_name=_row_value(row, "app_name", 1), + user_id=_row_value(row, "user_id", 2), + state=_json_dict(_row_value(row, "state", 3)), + create_time=_row_value(row, "create_time", 4), + update_time=_row_value(row, "update_time", 5), ) def _event_record_from_row(row: Any) -> StoredEvent: return StoredEvent( - id=row[0], - app_name=row[1], - user_id=row[2], - session_id=row[3], - invocation_id=row[4], - timestamp=row[5], - event_data=_json_dict(row[6]), + id=_row_value(row, "id", 0), + app_name=_row_value(row, "app_name", 1), + user_id=_row_value(row, "user_id", 2), + session_id=_row_value(row, "session_id", 3), + invocation_id=_row_value(row, "invocation_id", 4), + timestamp=_row_value(row, "timestamp", 5), + event_data=_json_dict(_row_value(row, "event_data", 6)), ) def _memory_record_from_row(row: Any) -> "StoredMemory": + metadata_raw = _row_value(row, "metadata_json", 10) return cast( "StoredMemory", { - "id": row[0], - "session_id": row[1], - "app_name": row[2], - "user_id": row[3], - "scope": row[4], - "event_id": row[5], - "author": row[6], - "timestamp": row[7], - "content_json": _json_dict(row[8]), - "content_text": row[9], - "metadata_json": _json_dict(row[10]) if row[10] is not None else None, - "inserted_at": row[11], + "id": _row_value(row, "id", 0), + "session_id": _row_value(row, "session_id", 1), + "app_name": _row_value(row, "app_name", 2), + "user_id": _row_value(row, "user_id", 3), + "scope": _row_value(row, "scope", 4), + "event_id": _row_value(row, "event_id", 5), + "author": _row_value(row, "author", 6), + "timestamp": _row_value(row, "timestamp", 7), + "content_json": _json_dict(_row_value(row, "content_json", 8)), + "content_text": _row_value(row, "content_text", 9), + "metadata_json": _json_dict(metadata_raw) if metadata_raw is not None else None, + "inserted_at": _row_value(row, "inserted_at", 11), "embedding": None, }, ) @@ -866,20 +934,14 @@ def _is_mssql_table_missing(exc: BaseException) -> bool: return "invalid object name" in text or extract_error_number(exc) == MSSQL_TABLE_NOT_FOUND_ERROR -def _quote_identifier(identifier: str) -> str: - return f"[{identifier.replace(']', ']]')}]" - - def _table_ref(table: str) -> str: - return f"{_quote_identifier(MSSQL_SCHEMA)}.{_quote_identifier(table)}" + schema_name, table_name = split_schema_and_table(table) + return f"{quote_bracket_identifier(schema_name)}.{quote_bracket_identifier(table_name)}" def _constraint_ref(prefix: str, table: str, suffix: str) -> str: - return _quote_identifier(f"{prefix}_{table}_{suffix}") - - -def _escape_sql_literal(value: str) -> str: - return value.replace("'", "''") + _, table_name = split_schema_and_table(table) + return quote_bracket_identifier(f"{prefix}_{table_name}_{suffix}") def _raise_session_not_found(session_id: str) -> None: @@ -897,6 +959,10 @@ def _build_mssql_scope_where( return "app_name = %s AND scope = 'app'", (app_name,) +def _escape_like_pattern(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_").replace("[", "\\[") + + def _session_list_query( session_table: str, app_name: str, diff --git a/sqlspec/adapters/pymssql/config.py b/sqlspec/adapters/pymssql/config.py index 40fbb3fc7..0aa3a2e50 100644 --- a/sqlspec/adapters/pymssql/config.py +++ b/sqlspec/adapters/pymssql/config.py @@ -95,10 +95,10 @@ class PymssqlConfig(SyncDatabaseConfig[PymssqlConnection, PymssqlConnectionPool, migration_tracker_type: "ClassVar[type[PymssqlSyncMigrationTracker]]" = PymssqlSyncMigrationTracker supports_transactional_ddl: "ClassVar[bool]" = True supports_migration_schemas: "ClassVar[bool]" = True - supports_native_arrow_export: "ClassVar[bool]" = False - supports_native_arrow_import: "ClassVar[bool]" = False - supports_native_parquet_export: "ClassVar[bool]" = False - supports_native_parquet_import: "ClassVar[bool]" = False + supports_native_arrow_export: "ClassVar[bool]" = True + supports_native_arrow_import: "ClassVar[bool]" = True + supports_native_parquet_export: "ClassVar[bool]" = True + supports_native_parquet_import: "ClassVar[bool]" = True supports_native_row_streaming: "ClassVar[bool]" = True type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" @@ -143,22 +143,6 @@ def __init__( **kwargs, ) - def _create_pool(self) -> "PymssqlConnectionPool": - config = dict(self.connection_config) - pool_recycle = config.pop("pool_recycle_seconds", 86400) - health_check = config.pop("health_check_interval", 30.0) - return PymssqlConnectionPool( - config, - recycle_seconds=pool_recycle, - health_check_interval=health_check, - on_connection_create=self._user_connection_hook, - ) - - def _close_pool(self) -> None: - if self.connection_instance: - self.connection_instance.close() - self.connection_instance = None - def create_connection(self) -> "PymssqlConnection": """Open a standalone connection owned by the caller. @@ -192,3 +176,19 @@ def get_signature_namespace(self) -> "dict[str, Any]": def get_event_runtime_hints(self) -> "EventRuntimeHints": """Return runtime hints for pymssql event channels.""" return EventRuntimeHints(poll_interval=0.25, lease_seconds=5) + + def _create_pool(self) -> "PymssqlConnectionPool": + config = dict(self.connection_config) + pool_recycle = config.pop("pool_recycle_seconds", 86400) + health_check = config.pop("health_check_interval", 30.0) + return PymssqlConnectionPool( + config, + recycle_seconds=pool_recycle, + health_check_interval=health_check, + on_connection_create=self._user_connection_hook, + ) + + def _close_pool(self) -> None: + if self.connection_instance: + self.connection_instance.close() + self.connection_instance = None diff --git a/sqlspec/adapters/pymssql/core.py b/sqlspec/adapters/pymssql/core.py index bccd94c81..f6aafdfde 100644 --- a/sqlspec/adapters/pymssql/core.py +++ b/sqlspec/adapters/pymssql/core.py @@ -1,7 +1,7 @@ """pymssql adapter compiled helpers.""" import re -from collections.abc import Callable, Sized +from collections.abc import Callable, Mapping, Sized from typing import TYPE_CHECKING, Any, Final, Literal from sqlspec.core import DriverParameterProfile, ParameterStyle, StatementConfig, build_statement_config_from_profile @@ -16,6 +16,8 @@ OperationalError, PermissionDeniedError, QueryTimeoutError, + SerializationConflictError, + SQLParsingError, SQLSpecError, UniqueViolationError, ) @@ -25,9 +27,11 @@ from sqlspec.utils.type_guards import has_rowcount if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Sequence from logging import Logger + from sqlspec.core.parameters import ParameterValidator + __all__ = ( "apply_driver_features", "build_connection_config", @@ -38,13 +42,17 @@ "create_mapped_exception", "default_statement_config", "driver_profile", + "escape_literal_percent", + "escape_sql_literal", "extract_error_number", "format_identifier", "normalize_execute_many_parameters", "normalize_execute_parameters", + "quote_bracket_identifier", "resolve_column_names", "resolve_many_rowcount", "resolve_rowcount", + "split_schema_and_table", ) _ERROR_NUMBER_PATTERN: Final[re.Pattern[str]] = re.compile(r"(?:\(([-]?\d+)(?:,|\))|\bMsg\s+([-]?\d+)\b)") @@ -55,23 +63,58 @@ 2627: (UniqueViolationError, "unique constraint violation"), 515: (NotNullViolationError, "not-null constraint violation"), 18456: (PermissionDeniedError, "permission denied"), + 229: (PermissionDeniedError, "permission denied"), + 230: (PermissionDeniedError, "permission denied"), + 262: (PermissionDeniedError, "permission denied"), 4060: (DatabaseConnectionError, "database connection error"), 53: (DatabaseConnectionError, "database connection error"), 1205: (DeadlockError, "deadlock detected"), + 3960: (SerializationConflictError, "snapshot isolation conflict"), -2: (QueryTimeoutError, "query timeout"), + 1222: (QueryTimeoutError, "lock request time out period exceeded"), + 102: (SQLParsingError, "SQL syntax error"), + 105: (SQLParsingError, "unclosed quotation mark"), + 156: (SQLParsingError, "incorrect syntax near keyword"), + 207: (SQLParsingError, "invalid column name"), + 208: (SQLParsingError, "invalid object name"), + 241: (DataError, "data conversion error"), + 242: (DataError, "datetime conversion out of range"), + 245: (DataError, "data conversion error"), + 2628: (DataError, "string or binary data would be truncated"), 8114: (DataError, "data conversion error"), + 8115: (DataError, "arithmetic overflow error"), + 8152: (DataError, "string or binary data would be truncated"), 1105: (OperationalError, "operational error"), } -def _quote_bracket_identifier(identifier: str) -> str: +def quote_bracket_identifier(identifier: str) -> str: """Quote a T-SQL identifier with square brackets.""" cleaned = identifier.strip() - if cleaned.startswith("[") and cleaned.endswith("]"): - cleaned = cleaned[1:-1].replace("]]", "]") + if (cleaned.startswith("[") and cleaned.endswith("]")) or (cleaned.startswith('"') and cleaned.endswith('"')): + cleaned = cleaned[1:-1].replace("]]", "]").replace('""', '"') return f"[{cleaned.replace(']', ']]')}]" +def escape_sql_literal(value: str) -> str: + """Escape single quotes inside a SQL string literal.""" + return value.replace("'", "''") + + +def split_schema_and_table(identifier: str, *, default_schema: str = "dbo") -> tuple[str, str]: + """Split a table reference into unquoted ``(schema, table)`` names.""" + cleaned = identifier.strip() + if not cleaned: + msg = "Table name must not be empty" + raise SQLSpecError(msg) + parts = split_qualified_identifier(cleaned, quote_chars='"', allow_bracket_quotes=True) + if not parts: + return default_schema, cleaned + if len(parts) == 1: + return default_schema, parts[0] + return parts[-2], parts[-1] + + def format_identifier(identifier: str) -> str: """Format a T-SQL identifier with bracket quoting.""" cleaned = identifier.strip() @@ -79,16 +122,24 @@ def format_identifier(identifier: str) -> str: msg = "Table name must not be empty" raise SQLSpecError(msg) parts = split_qualified_identifier(cleaned, quote_chars='"', allow_bracket_quotes=True) - return ".".join(_quote_bracket_identifier(part) for part in parts) + return ".".join(quote_bracket_identifier(part) for part in parts) def build_insert_statement(table: str, columns: "list[str]") -> str: """Build a pymssql-compatible INSERT statement.""" - column_clause = ", ".join(_quote_bracket_identifier(column) for column in columns) + column_clause = ", ".join(quote_bracket_identifier(column) for column in columns) placeholders = ", ".join("%s" for _ in columns) return f"INSERT INTO {format_identifier(table)} ({column_clause}) VALUES ({placeholders})" +def escape_literal_percent(sql: str, parameters: Any, validator: "ParameterValidator") -> str: + """Escape literal percent characters before driver-side interpolation.""" + if not parameters or "%" not in sql: + return sql + keep = {info.position for info in validator.extract_parameters(sql)} + return "".join("%%" if char == "%" and index not in keep else char for index, char in enumerate(sql)) + + def normalize_execute_parameters(parameters: Any) -> Any: """Normalize parameters for pymssql execute calls.""" if parameters is None: @@ -142,7 +193,11 @@ def apply_driver_features( json_serializer = features.setdefault("json_serializer", to_json) json_deserializer = features.setdefault("json_deserializer", from_json) - if json_serializer is not None: + if json_serializer is not None and ( + json_serializer is not to_json + or json_deserializer is not from_json + or statement_config.parameter_config.json_serializer is not to_json + ): parameter_config = statement_config.parameter_config.with_json_serializers( json_serializer, deserializer=json_deserializer ) @@ -151,9 +206,10 @@ def apply_driver_features( return statement_config, features -def create_mapped_exception(error: Exception, *, logger: "Logger | None" = None) -> SQLSpecError: - """Map a pymssql exception to SQLSpec's exception hierarchy.""" +def create_mapped_exception(error: BaseException, *, logger: "Logger | None" = None) -> SQLSpecError: + """Map a SQL Server exception to SQLSpec's exception hierarchy.""" error_number = extract_error_number(error) + if error_number == _MSSQL_CONSTRAINT_547: message = str(error) if "check constraint" in message.lower(): @@ -239,32 +295,6 @@ def resolve_many_rowcount(cursor: Any, parameters: Any, *, fallback_count: "int return 0 -def _bool_to_int(value: bool) -> int: - return int(value) - - -def _constraint_exception_from_message(error: Exception) -> "SQLSpecError | None": - """Classify SQL Server constraint messages when a driver omits the native error number.""" - message = str(error) - normalized = message.lower() - if "unique key constraint" in normalized or "duplicate key" in normalized: - return UniqueViolationError(f"SQL Server unique constraint violation. Original error: {message}") - if "cannot insert the value null" in normalized or "does not allow nulls" in normalized: - return NotNullViolationError(f"SQL Server not-null constraint violation. Original error: {message}") - if "check constraint" in normalized: - return CheckViolationError(f"SQL Server check constraint violation. Original error: {message}") - if "foreign key constraint" in normalized: - return ForeignKeyViolationError(f"SQL Server foreign key constraint violation. Original error: {message}") - return None - - -def _custom_type_coercions() -> dict[type, Callable[[Any], Any]]: - """Return custom type coercions for pymssql.""" - coercions: dict[type, Callable[[Any], Any]] = {bool: _bool_to_int} - coercions.update(build_uuid_coercions()) - return coercions - - def extract_error_number(exc: BaseException | None) -> int | None: """Extract integer SQL Server error code from an exception if present. @@ -280,6 +310,10 @@ def extract_error_number(exc: BaseException | None) -> int | None: first = exc.args[0] if isinstance(first, int) and not isinstance(first, bool) and first != 0: return first + if isinstance(first, tuple) and first: + nested = first[0] + if isinstance(nested, int) and not isinstance(nested, bool) and nested != 0: + return nested matches = _ERROR_NUMBER_PATTERN.findall(str(exc)) if not matches: return None @@ -287,10 +321,6 @@ def extract_error_number(exc: BaseException | None) -> int | None: return int(last_match[0] or last_match[1]) -driver_profile = build_profile() -default_statement_config = build_statement_config() - - def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str, Any]": """Build a normalized connection configuration dictionary. @@ -301,6 +331,11 @@ def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str Normalized connection parameters. """ config = dict(connection_config) + extra = config.pop("extra", None) + if isinstance(extra, Mapping): + for extra_key, extra_value in extra.items(): + if extra_value is not None: + config.setdefault(str(extra_key), extra_value) config.setdefault("server", config.pop("host", "localhost")) config.setdefault("port", 1433) db_alias = config.pop("db", None) @@ -310,3 +345,33 @@ def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str if "user" not in config and user_alias is not None: config["user"] = user_alias return config + + +def _bool_to_int(value: bool) -> int: + return int(value) + + +def _constraint_exception_from_message(error: BaseException) -> "SQLSpecError | None": + """Classify SQL Server constraint messages when a driver omits the native error number.""" + message = str(error) + normalized = message.lower() + if "unique key constraint" in normalized or "duplicate key" in normalized: + return UniqueViolationError(f"SQL Server unique constraint violation. Original error: {message}") + if "cannot insert the value null" in normalized or "does not allow nulls" in normalized: + return NotNullViolationError(f"SQL Server not-null constraint violation. Original error: {message}") + if "check constraint" in normalized: + return CheckViolationError(f"SQL Server check constraint violation. Original error: {message}") + if "foreign key constraint" in normalized: + return ForeignKeyViolationError(f"SQL Server foreign key constraint violation. Original error: {message}") + return None + + +def _custom_type_coercions() -> dict[type, Callable[[Any], Any]]: + """Return custom type coercions for pymssql.""" + coercions: dict[type, Callable[[Any], Any]] = {bool: _bool_to_int} + coercions.update(build_uuid_coercions()) + return coercions + + +driver_profile = build_profile() +default_statement_config = build_statement_config() diff --git a/sqlspec/adapters/pymssql/driver.py b/sqlspec/adapters/pymssql/driver.py index ccf488b90..4fe7d29da 100644 --- a/sqlspec/adapters/pymssql/driver.py +++ b/sqlspec/adapters/pymssql/driver.py @@ -12,12 +12,16 @@ PymssqlSessionContext, ) from sqlspec.adapters.pymssql.core import ( + build_insert_statement, collect_rows, create_mapped_exception, default_statement_config, driver_profile, + escape_literal_percent, + format_identifier, normalize_execute_many_parameters, normalize_execute_parameters, + quote_bracket_identifier, resolve_column_names, resolve_many_rowcount, resolve_rowcount, @@ -39,6 +43,11 @@ from collections.abc import Sequence from sqlspec.adapters.pymssql._typing import PymssqlQueryParams as QueryParams + from sqlspec.builder import QueryBuilder + from sqlspec.core import ArrowResult, SQLResult, Statement, StatementFilter + from sqlspec.driver import CachedQuery + from sqlspec.storage import StorageBridgeJob, StorageDestination, StorageFormat, StorageTelemetry + from sqlspec.typing import StatementParameters __all__ = ("PymssqlCursor", "PymssqlDriver", "PymssqlExceptionHandler", "PymssqlSessionContext") @@ -78,7 +87,11 @@ def start(self) -> None: cursor = cursor_manager.__enter__() handler = self._driver.handle_database_exceptions() with handler: - cursor.execute(self._sql, normalize_execute_parameters(self._parameters)) + normalized_params = normalize_execute_parameters(self._parameters) + sql = escape_literal_percent( + self._sql, normalized_params, self._driver.statement_config.parameter_validator + ) + cursor.execute(sql, normalized_params) self._driver._check_pending_exception(handler) except BaseException: with contextlib.suppress(Exception): @@ -142,9 +155,17 @@ def __init__( self._transaction_active = False self._explicit_transaction = False + @property + def data_dictionary(self) -> "PymssqlSyncDataDictionary": + if self._data_dictionary is None: + self._data_dictionary = PymssqlSyncDataDictionary() + return self._data_dictionary + def dispatch_execute(self, cursor: "PymssqlRawCursor", statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) - cursor.execute(sql, normalize_execute_parameters(prepared_parameters)) + normalized_params = normalize_execute_parameters(prepared_parameters) + sql = escape_literal_percent(sql, normalized_params, self.statement_config.parameter_validator) + cursor.execute(sql, normalized_params) if statement.returns_rows(): fetched_data = cursor.fetchall() @@ -165,6 +186,7 @@ def dispatch_execute_many(self, cursor: "PymssqlRawCursor", statement: "SQL") -> sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) prepared_parameters = normalize_execute_many_parameters(prepared_parameters) + sql = escape_literal_percent(sql, prepared_parameters, self.statement_config.parameter_validator) parameter_count = len(prepared_parameters) if isinstance(prepared_parameters, Sized) else None cursor.executemany(sql, cast("Sequence[QueryParams]", prepared_parameters)) @@ -174,10 +196,12 @@ def dispatch_execute_many(self, cursor: "PymssqlRawCursor", statement: "SQL") -> def dispatch_execute_script(self, cursor: "PymssqlRawCursor", statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) statements = self.split_script_statements(sql, statement.statement_config, strip_trailing_semicolon=True) + normalized_params = normalize_execute_parameters(prepared_parameters) successful_count = 0 for stmt in statements: - cursor.execute(stmt, normalize_execute_parameters(prepared_parameters)) + stmt_sql = escape_literal_percent(stmt, normalized_params, self.statement_config.parameter_validator) + cursor.execute(stmt_sql, normalized_params) successful_count += 1 return self.create_execution_result( cursor, statement_count=len(statements), successful_statements=successful_count, is_script_result=True @@ -253,59 +277,158 @@ def rollback_to_savepoint(self, name: str) -> None: def set_migration_session_schema(self, schema: str) -> None: """Point the database user's default schema at the migration schema, remembering the prior one.""" - with self.with_cursor(self.connection) as cursor: + exc_handler = self.handle_database_exceptions() + with exc_handler, self.with_cursor(self.connection) as cursor: if self._migration_schema_restore is None: cursor.execute("SELECT USER_NAME() AS user_name, SCHEMA_NAME() AS schema_name;") row: Any = cursor.fetchone() + if row is None: + return user_name, current_schema = ( (row["user_name"], row["schema_name"]) if isinstance(row, dict) else (row[0], row[1]) ) + resolved_schema = str(current_schema or "dbo") cursor.execute(_alter_default_schema_sql(str(user_name), schema)) - self._migration_schema_restore = (str(user_name), str(current_schema)) - return - cursor.execute(_alter_default_schema_sql(self._migration_schema_restore[0], schema)) + self._migration_schema_restore = (str(user_name), resolved_schema) + else: + cursor.execute(_alter_default_schema_sql(self._migration_schema_restore[0], schema)) + self._check_pending_exception(exc_handler) def reset_migration_session_schema(self) -> None: """Restore the user's default schema captured by set_migration_session_schema and commit it.""" if self._migration_schema_restore is None: return user_name, previous_schema = self._migration_schema_restore - with self.with_cursor(self.connection) as cursor: - cursor.execute(_alter_default_schema_sql(user_name, previous_schema)) - self.connection.commit() + exc_handler = self.handle_database_exceptions() + with exc_handler: + with self.with_cursor(self.connection) as cursor: + cursor.execute(_alter_default_schema_sql(user_name, previous_schema)) + self.connection.commit() + self._check_pending_exception(exc_handler) self._migration_schema_restore = None def has_schema(self, schema: str) -> bool: """Return whether the specified schema exists.""" - with self.with_cursor(self.connection) as cursor: + result = False + exc_handler = self.handle_database_exceptions() + with exc_handler, self.with_cursor(self.connection) as cursor: cursor.execute("SELECT 1 FROM sys.schemas WHERE name = %s", (schema,)) - return cursor.fetchone() is not None + result = cursor.fetchone() is not None + self._check_pending_exception(exc_handler) + return result - @property - def data_dictionary(self) -> "PymssqlSyncDataDictionary": - if self._data_dictionary is None: - self._data_dictionary = PymssqlSyncDataDictionary() - return self._data_dictionary + def select_to_storage( + self, + statement: "Statement | QueryBuilder | SQL | str", + destination: "StorageDestination", + /, + *parameters: "StatementParameters | StatementFilter", + statement_config: "StatementConfig | None" = None, + partitioner: "dict[str, object] | None" = None, + format_hint: "StorageFormat | None" = None, + telemetry: "StorageTelemetry | None" = None, + **kwargs: Any, + ) -> "StorageBridgeJob": + """Execute a query and write the Arrow result to storage.""" + self._require_capability("arrow_export_enabled") + arrow_result = self.select_to_arrow(statement, *parameters, statement_config=statement_config, **kwargs) + pipeline = self._storage_pipeline() + telemetry_payload = self._write_storage_result( + arrow_result, destination, format_hint=format_hint, pipeline=pipeline + ) + self._attach_partition_telemetry(telemetry_payload, partitioner) + return self._storage_job(telemetry_payload, telemetry) + + def load_from_arrow( + self, + table: str, + source: "ArrowResult | Any", + *, + partitioner: "dict[str, object] | None" = None, + overwrite: bool = False, + telemetry: "StorageTelemetry | None" = None, + ) -> "StorageBridgeJob": + """Load Arrow data into the target table via executemany.""" + self._require_capability("arrow_import_enabled") + arrow_table = self._coerce_arrow_table(source) + if overwrite: + statement = f"DELETE FROM {format_identifier(table)}" + exc_handler = self.handle_database_exceptions() + with exc_handler, self.with_cursor(self.connection) as cursor: + cursor.execute(statement) + self._check_pending_exception(exc_handler) + + columns, records = self._arrow_table_to_rows(arrow_table) + if records: + insert_sql = escape_literal_percent( + build_insert_statement(table, columns), records, self.statement_config.parameter_validator + ) + needs_preparation = self._arrow_rows_need_preparation(arrow_table) + prepared_records = ( + self.prepare_driver_parameters(records, self.statement_config, is_many=True) + if needs_preparation + else records + ) + normalized_records = normalize_execute_many_parameters(prepared_records) + exc_handler = self.handle_database_exceptions() + with exc_handler, self.with_cursor(self.connection) as cursor: + cursor.executemany(insert_sql, cast("Sequence[QueryParams]", normalized_records)) + self._check_pending_exception(exc_handler) + + telemetry_payload = self._ingest_telemetry(arrow_table) + telemetry_payload["destination"] = table + self._attach_partition_telemetry(telemetry_payload, partitioner) + return self._storage_job(telemetry_payload, telemetry) + + def load_from_storage( + self, + table: str, + source: "StorageDestination", + *, + file_format: "StorageFormat", + partitioner: "dict[str, object] | None" = None, + overwrite: bool = False, + ) -> "StorageBridgeJob": + """Load staged artifacts from storage into the target SQL Server table.""" + arrow_table, inbound = self._read_storage_arrow(source, file_format=file_format) + return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound) def collect_rows(self, cursor: "PymssqlRawCursor", fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": - column_names = resolve_column_names(cursor.description or None, self._column_name_cache) - return fetched, column_names, len(fetched) + rows, column_names, _row_format = collect_rows(fetched, cursor.description or None, self._column_name_cache) + return rows, column_names, len(rows) def resolve_rowcount(self, cursor: "PymssqlRawCursor") -> int: return resolve_rowcount(cursor) + def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + normalized_params = normalize_execute_parameters(params) if isinstance(params, list) else params + if not normalized_params or ( + "%" in cached.compiled_sql + and escape_literal_percent( + cached.compiled_sql, normalized_params, self.statement_config.parameter_validator + ) + != cached.compiled_sql + ): + statement = self._cached_statement( + sql, + normalized_params, + cached, + normalized_params or None, + params_are_simple=True, + compiled_sql=cached.compiled_sql, + ) + return self._execute_cached_statement(statement) + return super()._execute_cache_hit(sql, normalized_params, cached) + def _connection_in_transaction(self) -> bool: """Return whether a transaction opened by this driver remains active.""" return self._transaction_active -def _quote_tsql_identifier(identifier: str) -> str: - """Bracket-quote an identifier so the statement is valid regardless of the session's QUOTED_IDENTIFIER setting.""" - return f"[{identifier.replace(']', ']]')}]" - - def _alter_default_schema_sql(user_name: str, schema: str) -> str: - return f"ALTER USER {_quote_tsql_identifier(user_name)} WITH DEFAULT_SCHEMA = {_quote_tsql_identifier(schema)};" + return f"ALTER USER {quote_bracket_identifier(user_name)} WITH DEFAULT_SCHEMA = {quote_bracket_identifier(schema)};" register_driver_profile("pymssql", driver_profile) diff --git a/sqlspec/adapters/pymssql/events/store.py b/sqlspec/adapters/pymssql/events/store.py index ccdd2383e..aecc55761 100644 --- a/sqlspec/adapters/pymssql/events/store.py +++ b/sqlspec/adapters/pymssql/events/store.py @@ -1,13 +1,12 @@ """pymssql event queue store with T-SQL-specific DDL.""" from sqlspec.adapters.pymssql.config import PymssqlConfig +from sqlspec.adapters.pymssql.core import escape_sql_literal, quote_bracket_identifier, split_schema_and_table from sqlspec.extensions.events import BaseEventQueueStore -from sqlspec.utils.text import split_qualified_identifier __all__ = ("PymssqlEventQueueStore",) _NVARCHAR_MAX_THRESHOLD = 4000 -_QUALIFIED_IDENTIFIER_MIN_PARTS = 2 class PymssqlEventQueueStore(BaseEventQueueStore[PymssqlConfig]): @@ -30,28 +29,19 @@ def _timestamp_default(self) -> str: return "SYSUTCDATETIME()" def _wrap_create_statement(self, statement: str, object_type: str) -> str: + object_name = escape_sql_literal(_object_name(self.table_name)) if object_type == "table": - return f"IF OBJECT_ID(N'{_object_name(self.table_name)}', N'U') IS NULL BEGIN {statement}; END" + return f"IF OBJECT_ID(N'{object_name}', N'U') IS NULL BEGIN {statement}; END" if object_type == "index": - return f"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'{self._index_name()}' AND object_id = OBJECT_ID(N'{_object_name(self.table_name)}')) BEGIN {statement}; END" + index_name = escape_sql_literal(self._index_name()) + return f"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'{index_name}' AND object_id = OBJECT_ID(N'{object_name}')) BEGIN {statement}; END" return statement def _wrap_drop_statement(self, statement: str) -> str: - return f"IF OBJECT_ID(N'{_object_name(self.table_name)}', N'U') IS NOT NULL {statement};" - - -def _split_table_name(table_name: str) -> tuple[str, str]: - parts = split_qualified_identifier(table_name, quote_chars='"') - if len(parts) < _QUALIFIED_IDENTIFIER_MIN_PARTS: - return "dbo", parts[0] if parts else table_name - schema_name = ".".join(parts[:-1]) - return schema_name or "dbo", parts[-1] + object_name = escape_sql_literal(_object_name(self.table_name)) + return f"IF OBJECT_ID(N'{object_name}', N'U') IS NOT NULL {statement};" def _object_name(table_name: str) -> str: - schema_name, bare_table_name = _split_table_name(table_name) - return f"{_quote_bracket_identifier(schema_name)}.{_quote_bracket_identifier(bare_table_name)}" - - -def _quote_bracket_identifier(identifier: str) -> str: - return f"[{identifier.replace(']', ']]')}]" + schema_name, bare_table_name = split_schema_and_table(table_name) + return f"{quote_bracket_identifier(schema_name)}.{quote_bracket_identifier(bare_table_name)}" diff --git a/sqlspec/adapters/pymssql/litestar/store.py b/sqlspec/adapters/pymssql/litestar/store.py index 96e950fe0..f4360a99c 100644 --- a/sqlspec/adapters/pymssql/litestar/store.py +++ b/sqlspec/adapters/pymssql/litestar/store.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any from sqlspec.adapters.pymssql._typing import PymssqlCursor +from sqlspec.adapters.pymssql.core import escape_sql_literal, quote_bracket_identifier, split_schema_and_table from sqlspec.extensions.litestar.store import BaseSQLSpecStore from sqlspec.utils.sync_tools import async_ @@ -59,15 +60,18 @@ async def delete_expired(self) -> int: def _table_ddl(self) -> str: """Get SQL Server CREATE TABLE SQL with idempotent guards.""" + schema_name, table_name = split_schema_and_table(self._table_name) + table_ref = _table_ref(self._table_name) + index_name = quote_bracket_identifier(f"IX_{table_name}_expires_at") return f""" IF NOT EXISTS ( SELECT 1 FROM sys.tables - WHERE name = N'{self._table_name}' - AND schema_id = SCHEMA_ID(N'dbo') + WHERE name = N'{escape_sql_literal(table_name)}' + AND schema_id = SCHEMA_ID(N'{escape_sql_literal(schema_name)}') ) BEGIN - CREATE TABLE {self._table_name} ( + CREATE TABLE {table_ref} ( session_id NVARCHAR(255) PRIMARY KEY, data VARBINARY(MAX) NOT NULL, expires_at DATETIME2(6) NULL, @@ -75,15 +79,16 @@ def _table_ddl(self) -> str: updated_at DATETIME2(6) NOT NULL DEFAULT SYSUTCDATETIME() ); - CREATE INDEX IX_{self._table_name}_expires_at - ON {self._table_name}(expires_at) + CREATE INDEX {index_name} + ON {table_ref}(expires_at) WHERE expires_at IS NOT NULL; END; """ def _drop_table_sql(self) -> "list[str]": """Get SQL Server DROP TABLE statements.""" - return [f"IF OBJECT_ID(N'dbo.{self._table_name}', N'U') IS NOT NULL DROP TABLE dbo.{self._table_name};"] + table_ref = _table_ref(self._table_name) + return [f"IF OBJECT_ID(N'{escape_sql_literal(table_ref)}', N'U') IS NOT NULL DROP TABLE {table_ref};"] def _create_table(self) -> None: with self._config.provide_session() as driver: @@ -92,8 +97,9 @@ def _create_table(self) -> None: self._log_table_created() def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": + table_ref = _table_ref(self._table_name) sql = f""" - SELECT data, expires_at FROM {self._table_name} + SELECT data, expires_at FROM {table_ref} WHERE session_id = %s AND (expires_at IS NULL OR expires_at > SYSUTCDATETIME()) """ @@ -112,7 +118,7 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | with PymssqlCursor(conn) as update_cursor: update_cursor.execute( f""" - UPDATE {self._table_name} + UPDATE {table_ref} SET expires_at = %s, updated_at = SYSUTCDATETIME() WHERE session_id = %s """, @@ -125,8 +131,9 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | None" = None) -> None: data = self._value_to_bytes(value) expires_at = self._calculate_expires_at(expires_in) + table_ref = _table_ref(self._table_name) sql = f""" - MERGE INTO {self._table_name} AS target + MERGE INTO {table_ref} AS target USING (SELECT %s AS session_id, CONVERT(VARBINARY(MAX), %s) AS data, %s AS expires_at) AS src ON target.session_id = src.session_id WHEN MATCHED THEN @@ -143,20 +150,23 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No conn.commit() def _delete(self, key: str) -> None: + table_ref = _table_ref(self._table_name) with self._config.provide_connection() as conn, PymssqlCursor(conn) as cursor: - cursor.execute(f"DELETE FROM {self._table_name} WHERE session_id = %s", (key,)) + cursor.execute(f"DELETE FROM {table_ref} WHERE session_id = %s", (key,)) conn.commit() def _delete_all(self) -> None: + table_ref = _table_ref(self._table_name) with self._config.provide_connection() as conn, PymssqlCursor(conn) as cursor: - cursor.execute(f"TRUNCATE TABLE {self._table_name}") + cursor.execute(f"TRUNCATE TABLE {table_ref}") conn.commit() self._log_delete_all() def _exists(self, key: str) -> bool: + table_ref = _table_ref(self._table_name) sql = f""" SELECT 1 - FROM {self._table_name} + FROM {table_ref} WHERE session_id = %s AND (expires_at IS NULL OR expires_at > SYSUTCDATETIME()) """ @@ -165,8 +175,9 @@ def _exists(self, key: str) -> bool: return cursor.fetchone() is not None def _expires_in(self, key: str) -> "int | None": + table_ref = _table_ref(self._table_name) with self._config.provide_connection() as conn, PymssqlCursor(conn) as cursor: - cursor.execute(f"SELECT expires_at FROM {self._table_name} WHERE session_id = %s", (key,)) + cursor.execute(f"SELECT expires_at FROM {table_ref} WHERE session_id = %s", (key,)) row = cursor.fetchone() if row is None: @@ -178,8 +189,9 @@ def _expires_in(self, key: str) -> "int | None": return max(0, int(remaining.total_seconds())) def _delete_expired(self) -> int: + table_ref = _table_ref(self._table_name) sql = f""" - DELETE FROM {self._table_name} + DELETE FROM {table_ref} WHERE expires_at IS NOT NULL AND expires_at < SYSUTCDATETIME() """ @@ -192,6 +204,11 @@ def _delete_expired(self) -> int: return count +def _table_ref(table_name: str) -> str: + schema_name, bare_table_name = split_schema_and_table(table_name) + return f"{quote_bracket_identifier(schema_name)}.{quote_bracket_identifier(bare_table_name)}" + + def _row_value(row: object, key: str, index: int) -> Any: """Return a value from dict-like or sequence-like driver rows.""" if isinstance(row, dict): diff --git a/sqlspec/adapters/pymssql/migrations.py b/sqlspec/adapters/pymssql/migrations.py index 6eabae149..cccabcb4d 100644 --- a/sqlspec/adapters/pymssql/migrations.py +++ b/sqlspec/adapters/pymssql/migrations.py @@ -5,12 +5,17 @@ from contextlib import suppress from typing import TYPE_CHECKING +from sqlspec.adapters.pymssql.core import ( + escape_sql_literal, + format_identifier, + quote_bracket_identifier, + split_schema_and_table, +) from sqlspec.builder import CreateTable, sql from sqlspec.migrations.tracker import SyncMigrationTracker from sqlspec.migrations.version import parse_version from sqlspec.observability import resolve_db_system from sqlspec.utils.logging import get_logger, log_with_context -from sqlspec.utils.text import split_qualified_identifier if TYPE_CHECKING: from sqlspec.driver import SyncDriverAdapterBase @@ -18,7 +23,6 @@ __all__ = ("PymssqlSyncMigrationTracker",) logger = get_logger("sqlspec.migrations.pymssql") -_QUALIFIED_IDENTIFIER_MIN_PARTS = 2 class PymssqlMigrationTrackerMixin: @@ -46,9 +50,9 @@ def _tracking_table_ddl(self) -> CreateTable: def _idempotent_tracking_table_ddl_text(self) -> str: """Wrap CREATE TABLE in a T-SQL sys.tables existence probe.""" - schema_name, table_name = _split_schema_table(self.version_table) + schema_name, table_name = split_schema_and_table(self.version_table) create_sql = self._tracking_table_ddl_text().rstrip().rstrip(";") - return f"IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = '{_escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID('{_escape_sql_literal(schema_name)}')) BEGIN {create_sql}; END;" + return f"IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = '{escape_sql_literal(table_name)}' AND schema_id = SCHEMA_ID('{escape_sql_literal(schema_name)}')) BEGIN {create_sql}; END;" def _tracking_table_ddl_text(self) -> str: """Render CREATE TABLE text without routing SQL Server types through sqlglot.""" @@ -58,19 +62,19 @@ def _tracking_table_ddl_text(self) -> str: not_null_clause = " NOT NULL" if column_def.not_null else "" primary_key_clause = " PRIMARY KEY" if column_def.primary_key else "" column_lines.append( - f" {column_def.name} {column_def.dtype}{primary_key_clause}{default_clause}{not_null_clause}" + f" {quote_bracket_identifier(column_def.name)} {column_def.dtype}{primary_key_clause}{default_clause}{not_null_clause}" ) - return f"CREATE TABLE {self.version_table} (\n" + ",\n".join(column_lines) + "\n)" + return f"CREATE TABLE {format_identifier(self.version_table)} (\n" + ",\n".join(column_lines) + "\n)" def _existing_columns_query(self) -> str: """Return T-SQL query text for migration tracking table columns.""" - schema_name, table_name = _split_schema_table(self.version_table) + schema_name, table_name = split_schema_and_table(self.version_table) return f""" SELECT c.name AS column_name FROM sys.columns c INNER JOIN sys.tables t ON c.object_id = t.object_id - WHERE t.name = '{_escape_sql_literal(table_name)}' - AND t.schema_id = SCHEMA_ID('{_escape_sql_literal(schema_name)}') + WHERE t.name = '{escape_sql_literal(table_name)}' + AND t.schema_id = SCHEMA_ID('{escape_sql_literal(schema_name)}') """ def _add_column_statement_text(self, column_name: str) -> str | None: @@ -80,8 +84,8 @@ def _add_column_statement_text(self, column_name: str) -> str | None: if column_def is None: return None default_clause = f" DEFAULT {column_def.default}" if column_def.default else "" - nullable_clause = " NOT NULL" if column_def.not_null else " NULL" - return f"ALTER TABLE {self.version_table} ADD {column_def.name} {column_def.dtype}{default_clause}{nullable_clause};" + nullable_clause = " NOT NULL" if (column_def.not_null and column_def.default is not None) else " NULL" + return f"ALTER TABLE {format_identifier(self.version_table)} ADD {quote_bracket_identifier(column_def.name)} {column_def.dtype}{default_clause}{nullable_clause};" class PymssqlSyncMigrationTracker(PymssqlMigrationTrackerMixin, SyncMigrationTracker): @@ -90,11 +94,18 @@ class PymssqlSyncMigrationTracker(PymssqlMigrationTrackerMixin, SyncMigrationTra def ensure_tracking_table(self, driver: "SyncDriverAdapterBase") -> None: """Create the migration tracking table if it does not exist.""" driver.execute_script(self._idempotent_tracking_table_ddl_text()) - driver.commit() + self._commit_if_needed(driver) self._migrate_schema_if_needed(driver) def record_migration( - self, driver: "SyncDriverAdapterBase", version: str, description: str, execution_time_ms: int, checksum: str + self, + driver: "SyncDriverAdapterBase", + version: str, + description: str, + execution_time_ms: int, + checksum: str, + *, + commit: bool = True, ) -> None: """Record a successfully applied migration with T-SQL-compatible metadata.""" parsed_version = parse_version(version) @@ -112,6 +123,13 @@ def record_migration( os.environ.get("USER", "unknown"), ) ) + if commit: + self._commit_if_needed(driver) + + def _commit_if_needed(self, driver: "SyncDriverAdapterBase") -> None: + if hasattr(driver, "driver_features"): + self._safe_commit(driver) + return driver.commit() def _migrate_schema_if_needed(self, driver: "SyncDriverAdapterBase") -> None: @@ -124,7 +142,7 @@ def _migrate_schema_if_needed(self, driver: "SyncDriverAdapterBase") -> None: return for column_name in sorted(missing_columns): self._add_column(driver, column_name) - driver.commit() + self._commit_if_needed(driver) except Exception as exc: with suppress(Exception): driver.rollback() @@ -145,17 +163,3 @@ def _add_column(self, driver: "SyncDriverAdapterBase", column_name: str) -> None if add_column_sql is None: return driver.execute_script(add_column_sql) - - -def _escape_sql_literal(value: str) -> str: - """Escape a string for inclusion in a T-SQL string literal.""" - return value.replace("'", "''") - - -def _split_schema_table(table_name: str) -> tuple[str, str]: - """Split a schema-qualified table name into schema and table parts.""" - parts = split_qualified_identifier(table_name, quote_chars='"') - if len(parts) < _QUALIFIED_IDENTIFIER_MIN_PARTS: - return "dbo", parts[0] if parts else table_name - schema_name = ".".join(parts[:-1]) - return schema_name or "dbo", parts[-1] diff --git a/sqlspec/adapters/pymssql/pool.py b/sqlspec/adapters/pymssql/pool.py index 8ffabcc70..5bc87a61b 100644 --- a/sqlspec/adapters/pymssql/pool.py +++ b/sqlspec/adapters/pymssql/pool.py @@ -26,8 +26,10 @@ class PymssqlConnectionPool: """Thread-local connection manager for pymssql.""" __slots__ = ( + "_checked_out", "_connection_parameters", "_connection_registry", + "_connection_threads", "_generation", "_health_check_interval", "_on_connection_create", @@ -55,6 +57,8 @@ def __init__( self._connection_parameters = connection_parameters self._thread_local = threading.local() self._connection_registry: set[PymssqlConnection] = set() + self._connection_threads: dict[PymssqlConnection, threading.Thread] = {} + self._checked_out = 0 self._generation = 0 self._registry_lock = threading.Lock() self._recycle_seconds = recycle_seconds @@ -67,14 +71,6 @@ def _database_name(self) -> str: """Get sanitized database name for logging.""" return str(self._connection_parameters.get("database", "unknown")) - def _create_connection(self) -> PymssqlConnection: - connection = self.new_connection() - - with self._registry_lock: - self._connection_registry.add(connection) - - return connection - def new_connection(self) -> PymssqlConnection: """Open a standalone connection configured like a pooled one. @@ -91,6 +87,79 @@ def new_connection(self) -> PymssqlConnection: return cast("PymssqlConnection", connection) + @contextmanager + def get_connection(self) -> "Generator[PymssqlConnection, None, None]": + """Get a thread-local connection. + + Yields: + A thread-local database connection. + """ + connection = self.acquire() + try: + yield connection + except Exception: + with contextlib.suppress(Exception): + self._close_thread_connection() + raise + finally: + self.release(connection) + + def close(self) -> None: + """Close every connection this pool opened, on any thread.""" + self._close_thread_connection() + with self._registry_lock: + orphaned = list(self._connection_registry) + self._connection_registry.clear() + self._connection_threads.clear() + self._checked_out = 0 + self._generation += 1 + for connection in orphaned: + with contextlib.suppress(Exception): + connection.close() + + def acquire(self) -> PymssqlConnection: + connection = self._get_thread_connection() + with self._registry_lock: + self._checked_out += 1 + return connection + + def release(self, connection: PymssqlConnection) -> None: + _ = connection + with self._registry_lock: + if self._checked_out > 0: + self._checked_out -= 1 + + def size(self) -> int: + self._prune_dead_thread_connections() + with self._registry_lock: + return len(self._connection_registry) + + def checked_out(self) -> int: + with self._registry_lock: + return self._checked_out + + def _prune_dead_thread_connections(self) -> None: + dead_connections: list[PymssqlConnection] = [] + with self._registry_lock: + for conn, owner_thread in list(self._connection_threads.items()): + if not owner_thread.is_alive(): + self._connection_registry.discard(conn) + self._connection_threads.pop(conn, None) + dead_connections.append(conn) + for conn in dead_connections: + with contextlib.suppress(Exception): + conn.close() + + def _create_connection(self) -> PymssqlConnection: + self._prune_dead_thread_connections() + connection = self.new_connection() + + with self._registry_lock: + self._connection_registry.add(connection) + self._connection_threads[connection] = threading.current_thread() + + return connection + def _is_connection_alive(self, connection: PymssqlConnection) -> bool: try: cursor = connection.cursor() @@ -136,7 +205,11 @@ def _get_thread_connection(self) -> PymssqlConnection: return cast("PymssqlConnection", self._thread_local.connection) idle_time = time.time() - thread_state.get("last_used", 0) - if idle_time > self._health_check_interval and not self._is_connection_alive(self._thread_local.connection): + if ( + self._health_check_interval > 0 + and idle_time > self._health_check_interval + and not self._is_connection_alive(self._thread_local.connection) + ): log_with_context( logger, logging.DEBUG, @@ -158,6 +231,7 @@ def _retire_connection(self, connection: PymssqlConnection) -> None: """Close a pool-owned connection and drop it from the shutdown registry.""" with self._registry_lock: self._connection_registry.discard(connection) + self._connection_threads.pop(connection, None) with contextlib.suppress(Exception): connection.close() @@ -170,42 +244,3 @@ def _close_thread_connection(self) -> None: del self._thread_local.created_at if "last_used" in thread_state: del self._thread_local.last_used - - @contextmanager - def get_connection(self) -> "Generator[PymssqlConnection, None, None]": - """Get a thread-local connection. - - Yields: - A thread-local database connection. - """ - connection = self._get_thread_connection() - try: - yield connection - except Exception: - with contextlib.suppress(Exception): - self._close_thread_connection() - raise - - def close(self) -> None: - """Close every connection this pool opened, on any thread.""" - self._close_thread_connection() - with self._registry_lock: - orphaned = list(self._connection_registry) - self._connection_registry.clear() - self._generation += 1 - for connection in orphaned: - with contextlib.suppress(Exception): - connection.close() - - def acquire(self) -> PymssqlConnection: - return self._get_thread_connection() - - def release(self, connection: PymssqlConnection) -> None: - _ = connection - - def size(self) -> int: - with self._registry_lock: - return len(self._connection_registry) - - def checked_out(self) -> int: - return 0 diff --git a/sqlspec/adapters/pymysql/__init__.py b/sqlspec/adapters/pymysql/__init__.py index 65698581f..e2b3c1f55 100644 --- a/sqlspec/adapters/pymysql/__init__.py +++ b/sqlspec/adapters/pymysql/__init__.py @@ -1,3 +1,5 @@ +"""PyMySQL adapter for SQLSpec.""" + from sqlspec.adapters.pymysql._typing import PyMysqlConnection, PyMysqlCursor from sqlspec.adapters.pymysql.config import ( PyMysqlConfig, diff --git a/sqlspec/adapters/pymysql/_typing.py b/sqlspec/adapters/pymysql/_typing.py index d3b4463ce..c7571d2b7 100644 --- a/sqlspec/adapters/pymysql/_typing.py +++ b/sqlspec/adapters/pymysql/_typing.py @@ -70,17 +70,24 @@ def _pymysql_cloud_sql_connector(*args: Any, **kwargs: Any) -> Any: ) +_resolved_driver_type: "type[PyMysqlDriver] | None" = None + + class PyMysqlCursor: """Context manager for PyMySQL cursor operations.""" - __slots__ = ("connection", "cursor") + __slots__ = ("connection", "cursor", "cursor_class") - def __init__(self, connection: "PyMysqlConnection") -> None: + def __init__(self, connection: "PyMysqlConnection", cursor_class: "type[PyMysqlRawCursor] | None" = None) -> None: self.connection = connection + self.cursor_class = cursor_class self.cursor: PyMysqlRawCursor | None = None def __enter__(self) -> "PyMysqlRawCursor": - self.cursor = self.connection.cursor() + if self.cursor_class is not None: + self.cursor = self.connection.cursor(self.cursor_class) + else: + self.cursor = self.connection.cursor() return self.cursor def __exit__(self, *_: object) -> None: @@ -119,13 +126,13 @@ def __init__( self._driver: PyMysqlDriver | None = None def __enter__(self) -> "PyMysqlDriver": - from sqlspec.adapters.pymysql.driver import PyMysqlDriver - + driver_cls = _get_pymysql_driver_type() self._connection = self._acquire_connection() - self._driver = PyMysqlDriver( + driver = driver_cls( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) - return self._prepare_driver(self._driver) + self._driver = driver + return self._prepare_driver(driver) def __exit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" @@ -134,3 +141,12 @@ def __exit__( self._release_connection(self._connection, exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb) self._connection = None return None + + +def _get_pymysql_driver_type() -> "type[PyMysqlDriver]": + global _resolved_driver_type + driver_cls = _resolved_driver_type + if driver_cls is None: + driver_cls = import_optional_attr("sqlspec.adapters.pymysql.driver", "PyMysqlDriver") + _resolved_driver_type = driver_cls + return driver_cls diff --git a/sqlspec/adapters/pymysql/adk/store.py b/sqlspec/adapters/pymysql/adk/store.py index fea85fdbe..88fd93f6b 100644 --- a/sqlspec/adapters/pymysql/adk/store.py +++ b/sqlspec/adapters/pymysql/adk/store.py @@ -54,10 +54,16 @@ class PyMysqlADKConfig(ADKConfig): class PyMysqlADKStore(BaseSyncADKStore["PyMysqlConfig"]): """MySQL/MariaDB ADK store using PyMySQL.""" - __slots__ = () + __slots__ = ("_json_deserializer", "_json_serializer") def __init__(self, config: "PyMysqlConfig") -> None: super().__init__(config) + driver_features = getattr(config, "driver_features", None) + features = driver_features if isinstance(driver_features, Mapping) else {} + serializer = features.get("json_serializer") + deserializer = features.get("json_deserializer") + self._json_serializer = serializer if callable(serializer) else to_json + self._json_deserializer = deserializer if callable(deserializer) else from_json def create_tables(self) -> None: """Create all ADK session tables if they don't exist.""" @@ -66,6 +72,7 @@ def create_tables(self) -> None: return _create_tables(self) + self.reconcile_schema(assume_existing=True) def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None @@ -221,10 +228,16 @@ def _drop_tables_sql(self) -> "list[str]": class PyMysqlADKMemoryStore(BaseSyncADKMemoryStore["PyMysqlConfig"]): """MySQL/MariaDB ADK memory store using PyMySQL.""" - __slots__ = () + __slots__ = ("_json_deserializer", "_json_serializer") def __init__(self, config: "PyMysqlConfig") -> None: super().__init__(config) + driver_features = getattr(config, "driver_features", None) + features = driver_features if isinstance(driver_features, Mapping) else {} + serializer = features.get("json_serializer") + deserializer = features.get("json_deserializer") + self._json_serializer = serializer if callable(serializer) else to_json + self._json_deserializer = deserializer if callable(deserializer) else from_json def create_tables(self) -> None: """Create tables if they don't exist.""" @@ -233,6 +246,8 @@ def create_tables(self) -> None: return self._create_tables() + if self._enabled: + self.reconcile_schema(assume_existing=True) def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Bulk insert memory entries with deduplication.""" @@ -329,6 +344,8 @@ def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "objec """ with self._config.provide_connection() as conn: + if hasattr(conn, "begin"): + conn.begin() cursor = conn.cursor() try: for entry in entries: @@ -344,9 +361,11 @@ def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "objec entry.get("author"), owner_id, entry["timestamp"], - to_json(entry["content_json"]), + self._json_serializer(entry["content_json"]), entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + self._json_serializer(entry["metadata_json"]) + if entry.get("metadata_json") is not None + else None, entry["inserted_at"], ) else: @@ -359,13 +378,19 @@ def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "objec entry["event_id"], entry.get("author"), entry["timestamp"], - to_json(entry["content_json"]), + self._json_serializer(entry["content_json"]), entry["content_text"], - to_json(entry["metadata_json"]) if entry.get("metadata_json") is not None else None, + self._json_serializer(entry["metadata_json"]) + if entry.get("metadata_json") is not None + else None, entry["inserted_at"], ) cursor.execute(sql, params) inserted_count += cursor.rowcount + except Exception: + if hasattr(conn, "rollback"): + conn.rollback() + raise finally: cursor.close() conn.commit() @@ -418,9 +443,11 @@ def _search_entries( records: list[StoredMemory] = [] for row in rows: record = cast("StoredMemory", dict(zip(columns, row, strict=False))) - record["content_json"] = _json_dict(record.get("content_json")) + record["content_json"] = _json_dict(record.get("content_json"), deserializer=self._json_deserializer) metadata_val = record.get("metadata_json") - record["metadata_json"] = _json_dict(metadata_val) if metadata_val is not None else None + record["metadata_json"] = ( + _json_dict(metadata_val, deserializer=self._json_deserializer) if metadata_val is not None else None + ) record["embedding"] = None records.append(record) return records @@ -488,13 +515,13 @@ def _create_session( INSERT INTO {store._session_table} (id, app_name, user_id, {store._owner_id_column_name}, state, create_time, update_time) VALUES (%s, %s, %s, %s, %s, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) """ - params = (session_id, app_name, user_id, owner_id, to_json(state)) + params = (session_id, app_name, user_id, owner_id, store._json_serializer(state)) else: sql = f""" INSERT INTO {store._session_table} (id, app_name, user_id, state, create_time, update_time) VALUES (%s, %s, %s, %s, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) """ - params = (session_id, app_name, user_id, to_json(state)) + params = (session_id, app_name, user_id, store._json_serializer(state)) with store._config.provide_connection() as conn: cursor = conn.cursor() @@ -540,7 +567,7 @@ def _select_session( row = cursor.fetchone() finally: cursor.close() - return _session_record_from_row(row) if row is not None else None + return _session_record_from_row(row, deserializer=store._json_deserializer) if row is not None else None except PyMysqlMySQLError as exc: if _is_mysql_table_missing(exc): return None @@ -558,7 +585,7 @@ def _update_session_state( with store._config.provide_connection() as conn: cursor = conn.cursor() try: - cursor.execute(sql, (to_json(state), app_name, user_id, session_id)) + cursor.execute(sql, (store._json_serializer(state), app_name, user_id, session_id)) finally: cursor.close() conn.commit() @@ -590,7 +617,7 @@ def _list_sessions( rows = cursor.fetchall() finally: cursor.close() - return [_session_record_from_row(row) for row in rows] + return [_session_record_from_row(row, deserializer=store._json_deserializer) for row in rows] except PyMysqlMySQLError as exc: if _is_mysql_table_missing(exc): return [] @@ -617,7 +644,7 @@ def _append_event(store: PyMysqlADKStore, event_record: StoredEvent) -> None: with store._config.provide_connection() as conn: cursor = conn.cursor() try: - cursor.execute(sql, _event_insert_params(event_record)) + cursor.execute(sql, _event_insert_params(event_record, serializer=store._json_serializer)) finally: cursor.close() conn.commit() @@ -651,9 +678,11 @@ def _append_event_and_update_state( """ with store._config.provide_connection() as conn: + if hasattr(conn, "begin"): + conn.begin() cursor = conn.cursor() try: - cursor.execute(update_sql, (to_json(state), app_name, user_id, session_id)) + cursor.execute(update_sql, (store._json_serializer(state), app_name, user_id, session_id)) cursor.execute(select_sql, (app_name, user_id, session_id)) row = cursor.fetchone() if row is None: @@ -667,14 +696,17 @@ def _append_event_and_update_state( session_id, event_record["invocation_id"], event_record["timestamp"], - _json_for_storage(event_record["event_data"]), + _json_for_storage(event_record["event_data"], serializer=store._json_serializer), ), ) if app_state is not None: - cursor.execute(_mysql_upsert_app_state_sql(store._app_state_table), (app_name, to_json(app_state))) + cursor.execute( + _mysql_upsert_app_state_sql(store._app_state_table), (app_name, store._json_serializer(app_state)) + ) if user_state is not None: cursor.execute( - _mysql_upsert_user_state_sql(store._user_state_table), (app_name, user_id, to_json(user_state)) + _mysql_upsert_user_state_sql(store._user_state_table), + (app_name, user_id, store._json_serializer(user_state)), ) except Exception: conn.rollback() @@ -683,7 +715,7 @@ def _append_event_and_update_state( cursor.close() conn.commit() - return _session_record_from_row(row) + return _session_record_from_row(row, deserializer=store._json_deserializer) def _select_events( @@ -722,7 +754,7 @@ def _select_events( rows = cursor.fetchall() finally: cursor.close() - return [_event_record_from_row(row) for row in rows] + return [_event_record_from_row(row, deserializer=store._json_deserializer) for row in rows] except PyMysqlMySQLError as exc: if _is_mysql_table_missing(exc): return [] @@ -784,7 +816,7 @@ def _state( row = cursor.fetchone() finally: cursor.close() - return _json_dict(row[0]) if row is not None else None + return _json_dict(row[0], deserializer=store._json_deserializer) if row is not None else None except PyMysqlMySQLError as exc: if _is_mysql_table_missing(exc): return None @@ -792,11 +824,15 @@ def _state( def _upsert_app_state(store: PyMysqlADKStore, app_name: str, state: "dict[str, Any]") -> None: - _execute_commit(store, _mysql_upsert_app_state_sql(store._app_state_table), (app_name, to_json(state))) + _execute_commit( + store, _mysql_upsert_app_state_sql(store._app_state_table), (app_name, store._json_serializer(state)) + ) def _upsert_user_state(store: PyMysqlADKStore, app_name: str, user_id: str, state: "dict[str, Any]") -> None: - _execute_commit(store, _mysql_upsert_user_state_sql(store._user_state_table), (app_name, user_id, to_json(state))) + _execute_commit( + store, _mysql_upsert_user_state_sql(store._user_state_table), (app_name, user_id, store._json_serializer(state)) + ) def _metadata(store: PyMysqlADKStore, key: str) -> "str | None": @@ -872,25 +908,30 @@ def _is_mysql_table_missing(exc: BaseException) -> bool: ) -def _json_for_storage(value: Any) -> str: - return value if isinstance(value, str) else to_json(value) +def _json_for_storage(value: Any, *, serializer: Any = to_json) -> str: + return value if isinstance(value, str) else cast("str", serializer(value)) -def _json_dict(value: Any) -> "dict[str, Any]": +def _json_dict(value: Any, *, deserializer: Any = from_json) -> "dict[str, Any]": if isinstance(value, bytearray): value = bytes(value) if isinstance(value, (bytes, str)): - return cast("dict[str, Any]", from_json(value)) + return cast("dict[str, Any]", deserializer(value)) return cast("dict[str, Any]", value) -def _session_record_from_row(row: Any) -> StoredSession: +def _session_record_from_row(row: Any, *, deserializer: Any = from_json) -> StoredSession: return StoredSession( - id=row[0], app_name=row[1], user_id=row[2], state=_json_dict(row[3]), create_time=row[4], update_time=row[5] + id=row[0], + app_name=row[1], + user_id=row[2], + state=_json_dict(row[3], deserializer=deserializer), + create_time=row[4], + update_time=row[5], ) -def _event_record_from_row(row: Any) -> StoredEvent: +def _event_record_from_row(row: Any, *, deserializer: Any = from_json) -> StoredEvent: return StoredEvent( id=row[0], app_name=row[1], @@ -898,11 +939,11 @@ def _event_record_from_row(row: Any) -> StoredEvent: session_id=row[3], invocation_id=row[4], timestamp=row[5], - event_data=_json_dict(row[6]), + event_data=_json_dict(row[6], deserializer=deserializer), ) -def _event_insert_params(event_record: StoredEvent) -> "tuple[Any, ...]": +def _event_insert_params(event_record: StoredEvent, *, serializer: Any = to_json) -> "tuple[Any, ...]": return ( event_record["id"], event_record["app_name"], @@ -910,7 +951,7 @@ def _event_insert_params(event_record: StoredEvent) -> "tuple[Any, ...]": event_record["session_id"], event_record["invocation_id"], event_record["timestamp"], - _json_for_storage(event_record["event_data"]), + _json_for_storage(event_record["event_data"], serializer=serializer), ) diff --git a/sqlspec/adapters/pymysql/config.py b/sqlspec/adapters/pymysql/config.py index 899571975..3faad2241 100644 --- a/sqlspec/adapters/pymysql/config.py +++ b/sqlspec/adapters/pymysql/config.py @@ -1,11 +1,19 @@ """PyMySQL database configuration.""" +import contextlib import ssl from collections.abc import Callable, Mapping from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, TypedDict, cast from sqlspec.adapters.pymysql._typing import PyMysqlCloudSqlConnector as Connector -from sqlspec.adapters.pymysql._typing import PyMysqlConnection, PyMysqlCursor, PyMysqlRawCursor, PyMysqlSessionContext +from sqlspec.adapters.pymysql._typing import ( + PyMysqlConnection, + PyMysqlCursor, + PyMysqlDictCursor, + PyMysqlRawCursor, + PyMysqlSessionContext, + PyMysqlSSCursor, +) from sqlspec.adapters.pymysql.core import apply_driver_features, default_statement_config from sqlspec.adapters.pymysql.driver import PyMysqlDriver, PyMysqlExceptionHandler from sqlspec.adapters.pymysql.pool import PyMysqlConnectionPool @@ -160,19 +168,18 @@ class PyMysqlDriverFeatures(TypedDict): "password", "port", "ssl", + "ssl_ca", + "ssl_cert", + "ssl_disabled", + "ssl_key", + "ssl_key_password", + "ssl_verify_cert", + "ssl_verify_identity", "unix_socket", "user", )) -def _normalize_local_infile(connection_config: Mapping[str, Any]) -> dict[str, Any]: - """Normalize PyMySQL local-infile aliases to the native connection flag.""" - config = dict(connection_config) - allow_local_infile = bool(config.pop("allow_local_infile", False)) - config["local_infile"] = bool(config.get("local_infile", False) or allow_local_infile) - return config - - def build_connection_config( connection_config: "PyMysqlPoolParams | dict[str, Any] | Mapping[str, Any] | None", ) -> dict[str, Any]: @@ -225,11 +232,11 @@ def __call__(self) -> "PyMysqlConnection": "enable_iam_auth": self._config.driver_features.get("cloud_sql_enable_iam_auth", False), "ip_type": self._config.driver_features.get("cloud_sql_ip_type", "PRIVATE"), } - if self._user: + if self._user is not None: conn_kwargs["user"] = self._user - if self._password: + if self._password is not None: conn_kwargs["password"] = self._password - if self._database: + if self._database is not None: conn_kwargs["database"] = self._database return cast("PyMysqlConnection", connector.connect(**conn_kwargs)) @@ -307,6 +314,40 @@ def __init__( self._cloud_sql_connector: Any | None = None self._validate_connector_config() + def create_connection(self) -> PyMysqlConnection: + """Open a standalone connection owned by the caller. + + The connection carries the same parameters and creation hook the pool + applies, but it is not the pool's thread-local connection, so closing it + leaves the pool usable. + + Returns: + PyMysqlConnection: A newly opened connection. + """ + return self.provide_pool().new_connection() + + def get_signature_namespace(self) -> "dict[str, Any]": + namespace = super().get_signature_namespace() + namespace.update({ + "PyMysqlConnectionContext": PyMysqlConnectionContext, + "PyMysqlConnection": PyMysqlConnection, + "PyMysqlConnectionParams": PyMysqlConnectionParams, + "PyMysqlConnectionPool": PyMysqlConnectionPool, + "PyMysqlCursor": PyMysqlCursor, + "PyMysqlDictCursor": PyMysqlDictCursor, + "PyMysqlDriver": PyMysqlDriver, + "PyMysqlDriverFeatures": PyMysqlDriverFeatures, + "PyMysqlExceptionHandler": PyMysqlExceptionHandler, + "PyMysqlPoolParams": PyMysqlPoolParams, + "PyMysqlRawCursor": PyMysqlRawCursor, + "PyMysqlSSCursor": PyMysqlSSCursor, + "PyMysqlSessionContext": PyMysqlSessionContext, + }) + return namespace + + def get_event_runtime_hints(self) -> "EventRuntimeHints": + return EventRuntimeHints(poll_interval=0.25, lease_seconds=5, select_for_update=True, skip_locked=True) + def _get_cloud_sql_connector(self) -> Any | None: """Return the configured Cloud SQL connector instance.""" return self._cloud_sql_connector @@ -331,6 +372,9 @@ def _validate_connector_config(self) -> None: def _setup_cloud_sql_connector(self, config: "dict[str, Any]") -> "_PyMysqlCloudSqlConnector": """Setup Cloud SQL connector and return a pool connection factory.""" + if self._cloud_sql_connector is not None: + with contextlib.suppress(Exception): + self._cloud_sql_connector.close() self._cloud_sql_connector = Connector() user = config.get("user") @@ -349,13 +393,20 @@ def _create_pool(self) -> "PyMysqlConnectionPool": connection_factory = None if self.driver_features.get("enable_cloud_sql", False): connection_factory = self._setup_cloud_sql_connector(config) - return PyMysqlConnectionPool( - config, - recycle_seconds=pool_recycle, - health_check_interval=health_check, - on_connection_create=self._user_connection_hook, - connection_factory=connection_factory, - ) + try: + return PyMysqlConnectionPool( + config, + recycle_seconds=pool_recycle, + health_check_interval=health_check, + on_connection_create=self._user_connection_hook, + connection_factory=connection_factory, + ) + except BaseException: + if self._cloud_sql_connector is not None: + with contextlib.suppress(Exception): + self._cloud_sql_connector.close() + self._cloud_sql_connector = None + raise def _close_pool(self) -> None: if self.connection_instance: @@ -366,33 +417,10 @@ def _close_pool(self) -> None: self._cloud_sql_connector.close() self._cloud_sql_connector = None - def create_connection(self) -> PyMysqlConnection: - """Open a standalone connection owned by the caller. - - The connection carries the same parameters and creation hook the pool - applies, but it is not the pool's thread-local connection, so closing it - leaves the pool usable. - - Returns: - PyMysqlConnection: A newly opened connection. - """ - return self.provide_pool().new_connection() - - def get_signature_namespace(self) -> "dict[str, Any]": - namespace = super().get_signature_namespace() - namespace.update({ - "PyMysqlConnectionContext": PyMysqlConnectionContext, - "PyMysqlConnection": PyMysqlConnection, - "PyMysqlConnectionParams": PyMysqlConnectionParams, - "PyMysqlConnectionPool": PyMysqlConnectionPool, - "PyMysqlCursor": PyMysqlCursor, - "PyMysqlDriver": PyMysqlDriver, - "PyMysqlDriverFeatures": PyMysqlDriverFeatures, - "PyMysqlExceptionHandler": PyMysqlExceptionHandler, - "PyMysqlPoolParams": PyMysqlPoolParams, - "PyMysqlSessionContext": PyMysqlSessionContext, - }) - return namespace - def get_event_runtime_hints(self) -> "EventRuntimeHints": - return EventRuntimeHints(poll_interval=0.25, lease_seconds=5, select_for_update=True, skip_locked=True) +def _normalize_local_infile(connection_config: Mapping[str, Any]) -> dict[str, Any]: + """Normalize PyMySQL local-infile aliases to the native connection flag.""" + config = dict(connection_config) + allow_local_infile = bool(config.pop("allow_local_infile", False)) + config["local_infile"] = bool(config.get("local_infile", False) or allow_local_infile) + return config diff --git a/sqlspec/adapters/pymysql/core.py b/sqlspec/adapters/pymysql/core.py index 98de2c636..a36b809e2 100644 --- a/sqlspec/adapters/pymysql/core.py +++ b/sqlspec/adapters/pymysql/core.py @@ -8,6 +8,7 @@ bool_to_int, build_insert_statement, build_load_data_statement, + can_use_local_infile_records, collect_rows, collect_stream_rows, create_mapped_exception, @@ -39,6 +40,7 @@ "build_load_data_statement", "build_profile", "build_statement_config", + "can_use_local_infile_records", "collect_rows", "collect_stream_rows", "create_mapped_exception", diff --git a/sqlspec/adapters/pymysql/driver.py b/sqlspec/adapters/pymysql/driver.py index 4190f21f7..25cfa9e22 100644 --- a/sqlspec/adapters/pymysql/driver.py +++ b/sqlspec/adapters/pymysql/driver.py @@ -18,6 +18,7 @@ PymysqlStreamSource, build_insert_statement, build_load_data_statement, + can_use_local_infile_records, collect_rows, create_mapped_exception, default_statement_config, @@ -35,7 +36,6 @@ from sqlspec.adapters.pymysql.data_dictionary import PyMysqlDataDictionary from sqlspec.core import ArrowResult, get_cache_config, register_driver_profile from sqlspec.driver import BaseSyncExceptionHandler, SyncDriverAdapterBase, SyncRowStream -from sqlspec.exceptions import SQLSpecError from sqlspec.utils.logging import get_logger from sqlspec.utils.serializers import from_json from sqlspec.utils.type_guards import supports_json_type @@ -115,28 +115,18 @@ def __init__( "Callable[[Any], Any]", features.get("json_deserializer", from_json) ) - def _execute_cache_hit( - self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" - ) -> "SQLResult": - if ( - "%" in cached.compiled_sql - and escape_literal_percent( - cached.compiled_sql, params or (None,), self.statement_config.parameter_validator - ) - != cached.compiled_sql - ): - statement = self._cached_statement( - sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql - ) - return self._execute_cached_statement(statement) - return super()._execute_cache_hit(sql, params, cached) + @property + def data_dictionary(self) -> "PyMysqlDataDictionary": + if self._data_dictionary is None: + self._data_dictionary = PyMysqlDataDictionary() + return self._data_dictionary def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) sql = escape_literal_percent(sql, prepared_parameters, self.statement_config.parameter_validator) cursor.execute(sql, normalize_execute_parameters(prepared_parameters)) - if statement.returns_rows(): + if statement.returns_rows() or cursor.description is not None: fetched_data = cursor.fetchall() description = cursor.description or None row_plan = resolve_row_plan(description, PYMYSQL_JSON_TYPE_CODES) @@ -185,7 +175,8 @@ def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> "ExecutionRe last_cursor = cursor for stmt in statements: - cursor.execute(stmt, normalize_execute_parameters(prepared_parameters)) + stmt_sql = escape_literal_percent(stmt, prepared_parameters, self.statement_config.parameter_validator) + cursor.execute(stmt_sql, normalize_execute_parameters(prepared_parameters)) successful_count += 1 return self.create_execution_result( @@ -196,22 +187,28 @@ def begin(self) -> None: try: self.connection.begin() except PyMysqlMySQLError as exc: - msg = f"Failed to begin MySQL transaction: {exc}" - raise SQLSpecError(msg) from exc + mapped = create_mapped_exception(exc, logger=logger) + if isinstance(mapped, bool): + return + raise mapped from exc def commit(self) -> None: try: self.connection.commit() except PyMysqlMySQLError as exc: - msg = f"Failed to commit MySQL transaction: {exc}" - raise SQLSpecError(msg) from exc + mapped = create_mapped_exception(exc, logger=logger) + if isinstance(mapped, bool): + return + raise mapped from exc def rollback(self) -> None: try: self.connection.rollback() except PyMysqlMySQLError as exc: - msg = f"Failed to rollback MySQL transaction: {exc}" - raise SQLSpecError(msg) from exc + mapped = create_mapped_exception(exc, logger=logger) + if isinstance(mapped, bool): + return + raise mapped from exc def with_cursor(self, connection: "PyMysqlConnection") -> "PyMysqlCursor": return PyMysqlCursor(connection) @@ -270,14 +267,16 @@ def load_from_arrow( columns, records = self._arrow_table_to_rows(arrow_table) if records: needs_preparation = self._arrow_rows_need_preparation(arrow_table) - use_infile = bool(self.driver_features.get("enable_local_infile_bulk_load")) and not needs_preparation + use_infile = bool( + self.driver_features.get("enable_local_infile_bulk_load") + ) and can_use_local_infile_records(arrow_table, records) if use_infile: payload = encode_records_for_local_infile(records) fd, tmp_name = tempfile.mkstemp(suffix=".tsv") try: with os.fdopen(fd, "wb") as tmp: tmp.write(payload) - load_sql = build_load_data_statement(table, columns) + load_sql = build_load_data_statement(table, columns, escape_percent=True) exc_handler = self.handle_database_exceptions() with exc_handler, self.with_cursor(self.connection) as cursor: cursor.execute(load_sql, (tmp_name,)) @@ -315,12 +314,6 @@ def load_from_storage( arrow_table, inbound = self._read_storage_arrow(source, file_format=file_format) return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound) - @property - def data_dictionary(self) -> "PyMysqlDataDictionary": - if self._data_dictionary is None: - self._data_dictionary = PyMysqlDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect PyMySQL rows for the direct execution path.""" description = cursor.description or None @@ -332,6 +325,22 @@ def resolve_rowcount(self, cursor: Any) -> int: """Resolve rowcount from PyMySQL cursor for the direct execution path.""" return resolve_rowcount(cursor) + def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + if ( + "%" in cached.compiled_sql + and escape_literal_percent( + cached.compiled_sql, params or (None,), self.statement_config.parameter_validator + ) + != cached.compiled_sql + ): + statement = self._cached_statement( + sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql + ) + return self._execute_cached_statement(statement) + return super()._execute_cache_hit(sql, params, cached) + def _connection_in_transaction(self) -> bool: try: return bool(self.connection.server_status & PyMysqlServerStatus.SERVER_STATUS_IN_TRANS) diff --git a/sqlspec/adapters/pymysql/litestar/store.py b/sqlspec/adapters/pymysql/litestar/store.py index ae9ec743a..a10d61ba1 100644 --- a/sqlspec/adapters/pymysql/litestar/store.py +++ b/sqlspec/adapters/pymysql/litestar/store.py @@ -112,28 +112,23 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | try: cursor.execute(sql, (key,)) row = cursor.fetchone() + if row is None: + return None + + if renew_for is not None and row["expires_at"] is not None: + new_expires_at = self._calculate_expires_at(renew_for) + if new_expires_at is not None: + naive_expires_at = new_expires_at.replace(tzinfo=None) + update_sql = f""" + UPDATE {self._table_name} + SET expires_at = %s, updated_at = UTC_TIMESTAMP(6) + WHERE session_id = %s + """ + cursor.execute(update_sql, (naive_expires_at, key)) + conn.commit() finally: cursor.close() - if row is None: - return None - - if renew_for is not None and row["expires_at"] is not None: - new_expires_at = self._calculate_expires_at(renew_for) - if new_expires_at is not None: - naive_expires_at = new_expires_at.replace(tzinfo=None) - update_sql = f""" - UPDATE {self._table_name} - SET expires_at = %s, updated_at = UTC_TIMESTAMP(6) - WHERE session_id = %s - """ - update_cursor = conn.cursor() - try: - update_cursor.execute(update_sql, (naive_expires_at, key)) - finally: - update_cursor.close() - conn.commit() - return bytes(row["data"]) except PyMysqlMySQLError as exc: if "doesn't exist" in str(exc) or (exc.args[0] if exc.args else None) == MYSQL_TABLE_NOT_FOUND_ERROR: @@ -218,41 +213,51 @@ def _expires_in(self, key: str) -> "int | None": WHERE session_id = %s """ - with self._config.provide_connection() as conn: - cursor = conn.cursor() - try: - cursor.execute(sql, (key,)) - row = cursor.fetchone() - finally: - cursor.close() + try: + with self._config.provide_connection() as conn: + cursor = conn.cursor() + try: + cursor.execute(sql, (key,)) + row = cursor.fetchone() + finally: + cursor.close() - if row is None or row[0] is None: - return None + if row is None or row[0] is None: + return None - expires_at_naive = row[0] - expires_at_utc = expires_at_naive.replace(tzinfo=UTC) - now = datetime.now(UTC) + expires_at_naive = row[0] + expires_at_utc = expires_at_naive.replace(tzinfo=UTC) + now = datetime.now(UTC) - if expires_at_utc <= now: - return 0 + if expires_at_utc <= now: + return 0 - delta = expires_at_utc - now - return int(delta.total_seconds()) + delta = expires_at_utc - now + return int(delta.total_seconds()) + except PyMysqlMySQLError as exc: + if "doesn't exist" in str(exc) or (exc.args[0] if exc.args else None) == MYSQL_TABLE_NOT_FOUND_ERROR: + return None + raise def _delete_expired(self) -> int: sql = f"DELETE FROM {self._table_name} WHERE expires_at <= UTC_TIMESTAMP(6)" - with self._config.provide_connection() as conn: - cursor = conn.cursor() - try: - cursor.execute(sql) - conn.commit() - count: int = cursor.rowcount - finally: - cursor.close() - if count > 0: - self._log_delete_expired(count) - return count + try: + with self._config.provide_connection() as conn: + cursor = conn.cursor() + try: + cursor.execute(sql) + conn.commit() + count = cursor.rowcount if isinstance(cursor.rowcount, int) and cursor.rowcount > 0 else 0 + finally: + cursor.close() + if count > 0: + self._log_delete_expired(count) + return count + except PyMysqlMySQLError as exc: + if "doesn't exist" in str(exc) or (exc.args[0] if exc.args else None) == MYSQL_TABLE_NOT_FOUND_ERROR: + return 0 + raise def _mysql_table_options(litestar_config: "dict[str, Any]") -> str: diff --git a/sqlspec/adapters/pymysql/pool.py b/sqlspec/adapters/pymysql/pool.py index 08906a6a4..b55b18448 100644 --- a/sqlspec/adapters/pymysql/pool.py +++ b/sqlspec/adapters/pymysql/pool.py @@ -26,6 +26,7 @@ class PyMysqlConnectionPool: """Thread-local connection manager for PyMySQL.""" __slots__ = ( + "_checked_out", "_connection_factory", "_connection_parameters", "_connection_registry", @@ -59,6 +60,7 @@ def __init__( self._connection_factory = connection_factory self._thread_local = threading.local() self._connection_registry: set[PyMysqlConnection] = set() + self._checked_out = 0 self._generation = 0 self._registry_lock = threading.Lock() self._recycle_seconds = recycle_seconds @@ -71,14 +73,6 @@ def _database_name(self) -> str: """Get sanitized database name for logging.""" return str(self._connection_parameters.get("database", "unknown")) - def _create_connection(self) -> PyMysqlConnection: - connection = self.new_connection() - - with self._registry_lock: - self._connection_registry.add(connection) - - return connection - def new_connection(self) -> PyMysqlConnection: """Open a standalone connection configured like a pooled one. @@ -94,7 +88,91 @@ def new_connection(self) -> PyMysqlConnection: connection = _pymysql_connect(**self._connection_parameters) if self._on_connection_create is not None: - self._on_connection_create(connection) + try: + self._on_connection_create(connection) + except BaseException: + with contextlib.suppress(Exception): + connection.close() + raise + + return connection + + @contextmanager + def get_connection(self) -> "Generator[PyMysqlConnection, None, None]": + """Get a thread-local connection. + + Yields: + A thread-local database connection. + """ + connection = self.acquire() + try: + yield connection + except BaseException: + with self._registry_lock: + self._checked_out = max(0, self._checked_out - 1) + with contextlib.suppress(Exception): + self._close_thread_connection() + raise + else: + self.release(connection) + + def close(self) -> None: + """Close every connection this pool opened, on any thread.""" + self._close_thread_connection() + with self._registry_lock: + orphaned = list(self._connection_registry) + self._connection_registry.clear() + self._generation += 1 + for connection in orphaned: + with contextlib.suppress(Exception): + connection.close() + + def acquire(self) -> PyMysqlConnection: + connection = self._get_thread_connection() + with self._registry_lock: + self._checked_out += 1 + return connection + + def release(self, connection: PyMysqlConnection) -> None: + """Release connection back to the pool, sanitizing transactions.""" + try: + if ( + getattr(getattr(connection, "_result", None), "unbuffered_active", False) is True + or getattr(connection, "unbuffered_active", False) is True + ): + if getattr(self._thread_local, "connection", None) is connection: + self._close_thread_connection() + else: + self._retire_connection(connection) + return + + if bool(getattr(connection, "server_status", 0) & PyMysqlServerStatus.SERVER_STATUS_IN_TRANS): + try: + connection.rollback() + except Exception: + if getattr(self._thread_local, "connection", None) is connection: + self._close_thread_connection() + else: + self._retire_connection(connection) + raise + finally: + with self._registry_lock: + self._checked_out = max(0, self._checked_out - 1) + + def size(self) -> int: + """Report total active connections managed by this pool.""" + with self._registry_lock: + return len(self._connection_registry) + + def checked_out(self) -> int: + with self._registry_lock: + return self._checked_out + + def _create_connection(self) -> PyMysqlConnection: + connection = self.new_connection() + + with self._registry_lock: + self._connection_registry.add(connection) return connection @@ -172,54 +250,3 @@ def _close_thread_connection(self) -> None: del self._thread_local.created_at if "last_used" in thread_state: del self._thread_local.last_used - - @contextmanager - def get_connection(self) -> "Generator[PyMysqlConnection, None, None]": - """Get a thread-local connection. - - Yields: - A thread-local database connection. - """ - connection = self._get_thread_connection() - try: - yield connection - except Exception: - with contextlib.suppress(Exception): - self._close_thread_connection() - raise - else: - self.release(connection) - - def close(self) -> None: - """Close every connection this pool opened, on any thread.""" - self._close_thread_connection() - with self._registry_lock: - orphaned = list(self._connection_registry) - self._connection_registry.clear() - self._generation += 1 - for connection in orphaned: - with contextlib.suppress(Exception): - connection.close() - - def acquire(self) -> PyMysqlConnection: - return self._get_thread_connection() - - def release(self, connection: PyMysqlConnection) -> None: - """Release connection back to the pool, sanitizing transactions.""" - if bool(getattr(connection, "server_status", 0) & PyMysqlServerStatus.SERVER_STATUS_IN_TRANS): - try: - connection.rollback() - except Exception: - if getattr(self._thread_local, "connection", None) is connection: - self._close_thread_connection() - else: - self._retire_connection(connection) - raise - - def size(self) -> int: - """Report total active connections managed by this pool.""" - with self._registry_lock: - return len(self._connection_registry) - - def checked_out(self) -> int: - return 0 diff --git a/sqlspec/adapters/spanner/_typing.py b/sqlspec/adapters/spanner/_typing.py index b57e3138a..3f09222df 100644 --- a/sqlspec/adapters/spanner/_typing.py +++ b/sqlspec/adapters/spanner/_typing.py @@ -5,7 +5,7 @@ """ from importlib import import_module -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from sqlspec.typing import import_optional_attr @@ -17,10 +17,14 @@ class _UnavailableSpannerGoogleAPICallError(Exception): class _UnavailableSpannerTransaction: """Fallback Spanner transaction class when google-cloud-spanner is unavailable.""" + __slots__ = () + class _UnavailableSpannerAsyncTransaction: """Fallback Spanner async transaction class when google-cloud-spanner is unavailable.""" + __slots__ = () + if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -56,6 +60,8 @@ class _UnavailableSpannerAsyncTransaction: from google.cloud.spanner_v1._async.snapshot import Snapshot as SpannerAsyncSnapshot from google.cloud.spanner_v1._async.streamed import StreamedResultSet as SpannerAsyncStreamedResultSet from google.cloud.spanner_v1._async.transaction import Transaction as _SpannerAsyncTransaction + from google.cloud.spanner_v1.batch import Batch as SpannerBatch + from google.cloud.spanner_v1.batch import MutationGroups as SpannerMutationGroups from google.cloud.spanner_v1.data_types import Interval as SpannerInterval from google.cloud.spanner_v1.data_types import JsonObject as SpannerJsonObject from google.cloud.spanner_v1.database import Database as SpannerDatabase @@ -74,7 +80,7 @@ class _UnavailableSpannerAsyncTransaction: from sqlspec.adapters.spanner.driver import SpannerAsyncDriver, SpannerSyncDriver from sqlspec.core import StatementConfig - SpannerSyncConnection: TypeAlias = Snapshot | SnapshotCheckout | _SpannerTransaction + SpannerSyncConnection: TypeAlias = Snapshot | SnapshotCheckout | _SpannerTransaction | SpannerBatch SpannerAsyncConnection: TypeAlias = ( SpannerAsyncSnapshot | SpannerAsyncSnapshotCheckout | _SpannerAsyncTransaction | SpannerAsyncBatch ) @@ -118,6 +124,7 @@ class _UnavailableSpannerAsyncTransaction: "SpannerAsyncTransaction", "SpannerAsyncTransactionPingingPool", "SpannerAsyncTransactionType", + "SpannerBatch", "SpannerBurstyPool", "SpannerClient", "SpannerClientInfo", @@ -133,6 +140,7 @@ class _UnavailableSpannerAsyncTransaction: "SpannerGoogleAPICallError", "SpannerInterval", "SpannerJsonObject", + "SpannerMutationGroups", "SpannerNotFound", "SpannerPingingPool", "SpannerRequestOptions", @@ -217,10 +225,9 @@ def __init__( self._driver: SpannerSyncDriver | None = None def __enter__(self) -> "SpannerSyncDriver": - from sqlspec.adapters.spanner.driver import SpannerSyncDriver - + driver_cls = _get_sync_driver_type() self._connection = self._acquire_connection() - self._driver = SpannerSyncDriver( + self._driver = driver_cls( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -272,10 +279,9 @@ def __init__( self._driver: SpannerAsyncDriver | None = None async def __aenter__(self) -> "SpannerAsyncDriver": - from sqlspec.adapters.spanner.driver import SpannerAsyncDriver - + driver_cls = _get_async_driver_type() self._connection = await self._acquire_connection() - self._driver = SpannerAsyncDriver( + self._driver = driver_cls( connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features ) return self._prepare_driver(self._driver) @@ -306,8 +312,10 @@ async def __aexit__( "SpannerAsyncSnapshot": ("google.cloud.spanner_v1._async.snapshot", "Snapshot"), "SpannerAsyncStreamedResultSet": ("google.cloud.spanner_v1._async.streamed", "StreamedResultSet"), "SpannerAsyncTransactionPingingPool": ("google.cloud.spanner_v1._async.pool", "TransactionPingingPool"), + "SpannerBatch": ("google.cloud.spanner_v1.batch", "Batch"), "SpannerBurstyPool": ("google.cloud.spanner_v1.pool", "BurstyPool"), "SpannerFixedSizePool": ("google.cloud.spanner_v1.pool", "FixedSizePool"), + "SpannerMutationGroups": ("google.cloud.spanner_v1.batch", "MutationGroups"), "SpannerPingingPool": ("google.cloud.spanner_v1.pool", "PingingPool"), "SpannerTransactionPingingPool": ("google.cloud.spanner_v1.pool", "TransactionPingingPool"), "spanner_exceptions": ("google.api_core", "exceptions"), @@ -344,3 +352,13 @@ def __getattr__(name: str) -> Any: msg = f"Cannot import {attribute!r} from {module_name!r}" raise ImportError(msg) return value + + +def _get_sync_driver_type() -> "type[SpannerSyncDriver]": + driver_module = import_module("sqlspec.adapters.spanner.driver") + return cast("type[SpannerSyncDriver]", driver_module.SpannerSyncDriver) + + +def _get_async_driver_type() -> "type[SpannerAsyncDriver]": + driver_module = import_module("sqlspec.adapters.spanner.driver") + return cast("type[SpannerAsyncDriver]", driver_module.SpannerAsyncDriver) diff --git a/sqlspec/adapters/spanner/adk/artifact_store.py b/sqlspec/adapters/spanner/adk/artifact_store.py index da872000e..c73d82f20 100644 --- a/sqlspec/adapters/spanner/adk/artifact_store.py +++ b/sqlspec/adapters/spanner/adk/artifact_store.py @@ -2,11 +2,13 @@ from typing import TYPE_CHECKING, Any, ClassVar, cast +from sqlspec.adapters.spanner._typing import SpannerGoogleAPICallError from sqlspec.adapters.spanner._typing import SpannerNotFound as NotFound from sqlspec.adapters.spanner.adk.store import ( _DDL_TIMEOUT_SECONDS, SPANNER_PARAM_TYPES, _adk_config, + _extract_spanner_json_codecs, _is_spanner_table_missing, _json_param_type, _run_read_async, @@ -14,19 +16,23 @@ _SpannerAsyncUpdateJob, _SpannerReadProtocol, _SpannerSyncUpdateJob, + _SpannerWriteTransactionProtocol, ) from sqlspec.adapters.spanner.config import SpannerAsyncConfig, SpannerSyncConfig from sqlspec.adapters.spanner.core import ( + _convert_json_row_value, + create_mapped_exception, execute_ddl_async, execute_ddl_sync, list_existing_table_names_async, list_existing_table_names_sync, ) +from sqlspec.exceptions import SQLSpecError from sqlspec.extensions.adk.artifact._types import StoredArtifact from sqlspec.extensions.adk.artifact.store import BaseAsyncADKArtifactStore, BaseSyncADKArtifactStore -from sqlspec.utils.serializers import from_json, to_json if TYPE_CHECKING: + from collections.abc import Callable from datetime import datetime from sqlspec.adapters.spanner._typing import ( @@ -49,6 +55,8 @@ class _SpannerADKArtifactStoreMixin: if TYPE_CHECKING: _artifact_table: str _artifact_row_deletion_policy: str + _json_serializer: Callable[[Any], str] + _json_deserializer: Callable[[str], Any] def _artifact_table_ddl(self) -> "list[str]": ddl = f"""CREATE TABLE IF NOT EXISTS {self._artifact_table} ( @@ -99,7 +107,7 @@ def _build_insert_artifact_statement(self, record: StoredArtifact) -> "tuple[str "version": int(record["version"]), "mime_type": record["mime_type"], "canonical_uri": record["canonical_uri"], - "custom_metadata": to_json(metadata) if metadata is not None else None, + "custom_metadata": self._json_serializer(metadata) if metadata is not None else None, "created_at": record["created_at"], } types: dict[str, Any] = { @@ -293,14 +301,16 @@ def _build_delete_artifacts_older_than_statements( def _decode_metadata(self, value: Any) -> "dict[str, Any] | None": if value is None: return None - if isinstance(value, dict): - return cast("dict[str, Any]", value) - raw = getattr(value, "serialize", None) - if callable(raw): - value = raw() - if isinstance(value, str): - return cast("dict[str, Any]", from_json(value)) if value else None - return cast("dict[str, Any]", from_json(str(value))) + if isinstance(value, str) and not value: + return None + decoded = _convert_json_row_value(value, json_deserializer=self._json_deserializer) + if decoded is None: + return None + if isinstance(decoded, dict): + return cast("dict[str, Any]", decoded) + if isinstance(decoded, str): + return cast("dict[str, Any]", self._json_deserializer(decoded)) if decoded else None + return cast("dict[str, Any]", self._json_deserializer(str(decoded))) def _rows_to_artifacts(self, rows: "list[Any]") -> "list[StoredArtifact]": return [ @@ -322,7 +332,7 @@ def _rows_to_artifacts(self, rows: "list[Any]") -> "list[StoredArtifact]": class SpannerSyncADKArtifactStore(BaseSyncADKArtifactStore[SpannerSyncConfig], _SpannerADKArtifactStoreMixin): """Spanner ADK artifact store backed by synchronous Spanner client.""" - __slots__ = ("_artifact_row_deletion_policy",) + __slots__ = ("_artifact_row_deletion_policy", "_json_deserializer", "_json_serializer") connector_name: ClassVar[str] = "spanner" @@ -331,23 +341,33 @@ def __init__(self, config: SpannerSyncConfig) -> None: self._artifact_row_deletion_policy = _spanner_row_deletion_policy( _adk_config(config), "artifact_ttl_seconds", "created_at" ) + self._json_serializer, self._json_deserializer = _extract_spanner_json_codecs(config) def create_table(self) -> None: """Create the artifact versions table if it does not exist.""" if self._artifact_table in self._existing_tables(): return - execute_ddl_sync(self._database(), self._artifact_table_ddl(), timeout=_DDL_TIMEOUT_SECONDS) + try: + execute_ddl_sync(self._database(), self._artifact_table_ddl(), timeout=_DDL_TIMEOUT_SECONDS) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def drop_table(self) -> None: """Drop the artifact versions table if it exists.""" if self._artifact_table not in self._existing_tables(): return - execute_ddl_sync(self._database(), self._build_drop_artifact_table_sql(), timeout=_DDL_TIMEOUT_SECONDS) + try: + execute_ddl_sync(self._database(), self._build_drop_artifact_table_sql(), timeout=_DDL_TIMEOUT_SECONDS) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def insert_artifact(self, record: StoredArtifact) -> None: """Insert an artifact version metadata row.""" sql, params, types = self._build_insert_artifact_statement(record) - cast("Any", self._database()).run_in_transaction(_SpannerSyncUpdateJob(sql, params, types)) + try: + cast("Any", self._database()).run_in_transaction(_SpannerSyncUpdateJob(sql, params, types)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def get_artifact( self, app_name: str, user_id: str, filename: str, session_id: "str | None" = None, version: "int | None" = None @@ -356,9 +376,11 @@ def get_artifact( sql, params, types = self._build_get_artifact_query(app_name, user_id, filename, session_id, version) try: rows = self._run_read(sql, params, types) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._artifact_table): return None + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise if not rows: return None @@ -371,9 +393,11 @@ def list_artifact_versions( sql, params, types = self._build_list_artifact_versions_query(app_name, user_id, filename, session_id) try: rows = self._run_read(sql, params, types) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._artifact_table): return [] + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise return self._rows_to_artifacts(rows) @@ -382,9 +406,11 @@ def list_artifact_keys(self, app_name: str, user_id: str, session_id: "str | Non sql, params, types = self._build_list_artifact_keys_query(app_name, user_id, session_id) try: rows = self._run_read(sql, params, types) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._artifact_table): return [] + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise return [str(row[0]) for row in rows] @@ -396,7 +422,10 @@ def delete_artifact( app_name, user_id, filename, session_id ) job = _SpannerSyncArtifactDeleteJob(select_sql, delete_sql, params, types) - rows = cast("list[Any]", cast("Any", self._database()).run_in_transaction(job)) + try: + rows = cast("list[Any]", cast("Any", self._database()).run_in_transaction(job)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc return self._rows_to_artifacts(rows) def get_next_version(self, app_name: str, user_id: str, filename: str, session_id: "str | None" = None) -> int: @@ -404,9 +433,11 @@ def get_next_version(self, app_name: str, user_id: str, filename: str, session_i sql, params, types = self._build_get_next_version_query(app_name, user_id, filename, session_id) try: rows = self._run_read(sql, params, types) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._artifact_table): return 0 + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise if not rows or rows[0][0] is None: return 0 @@ -416,27 +447,36 @@ def delete_artifacts_older_than(self, before: "datetime", app_name: "str | None" """Delete artifact version rows created before a cutoff and return them.""" select_sql, delete_sql, params, types = self._build_delete_artifacts_older_than_statements(before, app_name) job = _SpannerSyncArtifactDeleteJob(select_sql, delete_sql, params, types) - rows = cast("list[Any]", cast("Any", self._database()).run_in_transaction(job)) + try: + rows = cast("list[Any]", cast("Any", self._database()).run_in_transaction(job)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc return self._rows_to_artifacts(rows) def _database(self) -> "SpannerDatabase": return self._config.get_database() def _existing_tables(self) -> "set[str]": - return list_existing_table_names_sync(self._database()) + try: + return list_existing_table_names_sync(self._database()) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _run_read( self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None ) -> "list[Any]": - with self._config.provide_connection() as snapshot: - reader = cast("_SpannerReadProtocol", snapshot) - return list(reader.execute_sql(sql, params=params, param_types=types)) + try: + with self._config.provide_connection(transaction=False) as snapshot: + reader = cast("_SpannerReadProtocol", snapshot) + return list(reader.execute_sql(sql, params=params, param_types=types)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc class SpannerAsyncADKArtifactStore(BaseAsyncADKArtifactStore[SpannerAsyncConfig], _SpannerADKArtifactStoreMixin): """Spanner ADK artifact store backed by asynchronous Spanner client.""" - __slots__ = ("_artifact_row_deletion_policy",) + __slots__ = ("_artifact_row_deletion_policy", "_json_deserializer", "_json_serializer") connector_name: ClassVar[str] = "spanner" @@ -445,26 +485,36 @@ def __init__(self, config: SpannerAsyncConfig) -> None: self._artifact_row_deletion_policy = _spanner_row_deletion_policy( _adk_config(config), "artifact_ttl_seconds", "created_at" ) + self._json_serializer, self._json_deserializer = _extract_spanner_json_codecs(config) async def create_table(self) -> None: """Create the artifact versions table if it does not exist.""" if self._artifact_table in await self._existing_tables(): return - await execute_ddl_async(await self._database(), self._artifact_table_ddl(), timeout=_DDL_TIMEOUT_SECONDS) + try: + await execute_ddl_async(await self._database(), self._artifact_table_ddl(), timeout=_DDL_TIMEOUT_SECONDS) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def drop_table(self) -> None: """Drop the artifact versions table if it exists.""" if self._artifact_table not in await self._existing_tables(): return - await execute_ddl_async( - await self._database(), self._build_drop_artifact_table_sql(), timeout=_DDL_TIMEOUT_SECONDS - ) + try: + await execute_ddl_async( + await self._database(), self._build_drop_artifact_table_sql(), timeout=_DDL_TIMEOUT_SECONDS + ) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def insert_artifact(self, record: StoredArtifact) -> None: """Insert an artifact version metadata row.""" sql, params, types = self._build_insert_artifact_statement(record) database = await self._database() - await database.run_in_transaction(_SpannerAsyncUpdateJob(sql, params, types)) + try: + await database.run_in_transaction(_SpannerAsyncUpdateJob(sql, params, types)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def get_artifact( self, app_name: str, user_id: str, filename: str, session_id: "str | None" = None, version: "int | None" = None @@ -473,9 +523,11 @@ async def get_artifact( sql, params, types = self._build_get_artifact_query(app_name, user_id, filename, session_id, version) try: rows = await self._run_read(sql, params, types) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._artifact_table): return None + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise if not rows: return None @@ -488,9 +540,11 @@ async def list_artifact_versions( sql, params, types = self._build_list_artifact_versions_query(app_name, user_id, filename, session_id) try: rows = await self._run_read(sql, params, types) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._artifact_table): return [] + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise return self._rows_to_artifacts(rows) @@ -499,9 +553,11 @@ async def list_artifact_keys(self, app_name: str, user_id: str, session_id: "str sql, params, types = self._build_list_artifact_keys_query(app_name, user_id, session_id) try: rows = await self._run_read(sql, params, types) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._artifact_table): return [] + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise return [str(row[0]) for row in rows] @@ -514,7 +570,10 @@ async def delete_artifact( ) database = await self._database() job = _SpannerAsyncArtifactDeleteJob(select_sql, delete_sql, params, types) - rows = cast("list[Any]", await database.run_in_transaction(job)) + try: + rows = cast("list[Any]", await database.run_in_transaction(job)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc return self._rows_to_artifacts(rows) async def get_next_version( @@ -524,9 +583,11 @@ async def get_next_version( sql, params, types = self._build_get_next_version_query(app_name, user_id, filename, session_id) try: rows = await self._run_read(sql, params, types) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._artifact_table): return 0 + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise if not rows or rows[0][0] is None: return 0 @@ -539,14 +600,20 @@ async def delete_artifacts_older_than( select_sql, delete_sql, params, types = self._build_delete_artifacts_older_than_statements(before, app_name) database = await self._database() job = _SpannerAsyncArtifactDeleteJob(select_sql, delete_sql, params, types) - rows = cast("list[Any]", await database.run_in_transaction(job)) + try: + rows = cast("list[Any]", await database.run_in_transaction(job)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc return self._rows_to_artifacts(rows) async def _database(self) -> "SpannerAsyncDatabase": return await self._config.get_database() async def _existing_tables(self) -> "set[str]": - return await list_existing_table_names_async(await self._database()) + try: + return await list_existing_table_names_async(await self._database()) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def _run_read( self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None @@ -573,7 +640,9 @@ def __call__(self, transaction: "SpannerTransaction") -> "list[Any]": ) if not rows: return [] - cast("Any", transaction).execute_update(self._delete_sql, params=self._params, param_types=self._types) + cast("_SpannerWriteTransactionProtocol", transaction).execute_update( + self._delete_sql, params=self._params, param_types=self._types + ) return rows diff --git a/sqlspec/adapters/spanner/adk/store.py b/sqlspec/adapters/spanner/adk/store.py index f8618d54e..f563620a8 100644 --- a/sqlspec/adapters/spanner/adk/store.py +++ b/sqlspec/adapters/spanner/adk/store.py @@ -1,6 +1,6 @@ """Spanner ADK store.""" -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, Protocol, cast @@ -8,18 +8,21 @@ from sqlglot import exp from typing_extensions import TypedDict -import sqlspec.dialects.spanner # noqa: F401 +from sqlspec.adapters.spanner._typing import SpannerGoogleAPICallError from sqlspec.adapters.spanner._typing import SpannerNotFound as NotFound from sqlspec.adapters.spanner._typing import spanner_param_types as param_types from sqlspec.adapters.spanner.config import SpannerAsyncConfig, SpannerSyncConfig from sqlspec.adapters.spanner.core import ( + _convert_json_row_value, + create_mapped_exception, execute_ddl_async, execute_ddl_sync, list_existing_table_names_async, list_existing_table_names_sync, ) from sqlspec.config import ADKConfig -from sqlspec.exceptions import OperationalError +from sqlspec.dialects.spanner import Spanner +from sqlspec.exceptions import OperationalError, SQLSpecError from sqlspec.extensions.adk import ( BaseAsyncADKStore, BaseSyncADKStore, @@ -52,6 +55,7 @@ "SpannerSyncADKStore", ) +_SPANNER_DIALECT = Spanner SPANNER_PARAM_TYPES: SpannerParamTypesProtocol = cast("SpannerParamTypesProtocol", param_types) _DDL_TIMEOUT_SECONDS = 300 _EMBEDDING_ROW_INDEX = 12 @@ -143,6 +147,8 @@ class _SpannerADKStoreMixin: _expires_index_options: str | None _session_row_deletion_policy: str _events_row_deletion_policy: str + _json_serializer: Callable[[Any], str] + _json_deserializer: Callable[[str], Any] def _session_param_types(self, include_owner: bool) -> "dict[str, Any]": json_type = _json_param_type() @@ -182,21 +188,16 @@ def _metadata_param_types(self) -> "dict[str, Any]": return {"key": SPANNER_PARAM_TYPES.STRING, "value": SPANNER_PARAM_TYPES.STRING} def _decode_state(self, raw: Any) -> Any: - if isinstance(raw, str): - return from_json(raw) - return raw + decoded = _convert_json_row_value(raw, json_deserializer=self._json_deserializer) + return decoded if decoded is not None else {} def _decode_json(self, raw: Any) -> Any: - if raw is None: - return None - if isinstance(raw, str): - return from_json(raw) - return raw + return _convert_json_row_value(raw, json_deserializer=self._json_deserializer) def _build_create_session_statement( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> "tuple[str, dict[str, Any], dict[str, Any]]": - state_json = to_json(state) + state_json = self._json_serializer(state) params: dict[str, Any] = {"id": session_id, "app_name": app_name, "user_id": user_id, "state": state_json} columns = "id, app_name, user_id, state, create_time, update_time" values = "@id, @app_name, @user_id, @state, PENDING_COMMIT_TIMESTAMP(), PENDING_COMMIT_TIMESTAMP()" @@ -266,7 +267,7 @@ def _decode_session_row(self, row: Any) -> StoredSession: def _build_update_session_state_statement( self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]" ) -> "tuple[str, dict[str, Any], dict[str, Any]]": - params = {"app_name": app_name, "user_id": user_id, "id": session_id, "state": to_json(state)} + params = {"app_name": app_name, "user_id": user_id, "id": session_id, "state": self._json_serializer(state)} json_type = _json_param_type() sql = f""" UPDATE {self._session_table} @@ -311,8 +312,6 @@ def _build_list_sessions_query( sql = f"{sql} AND user_id = @user_id" params["user_id"] = user_id types["user_id"] = SPANNER_PARAM_TYPES.STRING - if self._shard_count > 1: - sql = f"{sql} AND shard_id = MOD(FARM_FINGERPRINT(id), {self._shard_count})" sql = f"{sql} ORDER BY {column} {direction}, id {direction}" if page_limit is not None: sql = f"{sql} LIMIT @limit OFFSET @offset" @@ -328,7 +327,7 @@ def _build_delete_session_statements( shard_clause = ( f" AND shard_id = MOD(FARM_FINGERPRINT(@session_id), {self._shard_count})" if self._shard_count > 1 else "" ) - delete_events_sql = f"DELETE FROM {self._events_table} WHERE session_id = @session_id{shard_clause}" + delete_events_sql = f"DELETE FROM {self._events_table} WHERE app_name = @app_name AND user_id = @user_id AND session_id = @session_id{shard_clause}" delete_session_sql = f"DELETE FROM {self._session_table} WHERE app_name = @app_name AND user_id = @user_id AND id = @session_id{shard_clause}" params = {"app_name": app_name, "user_id": user_id, "session_id": session_id} types = { @@ -348,7 +347,7 @@ def _build_insert_event_statement( "session_id": event_record["session_id"], "invocation_id": event_record["invocation_id"], "timestamp": event_record["timestamp"], - "event_data": to_json(event_record["event_data"]), + "event_data": self._json_serializer(event_record["event_data"]), } insert_sql = f""" INSERT INTO {self._events_table} (id, app_name, user_id, session_id, invocation_id, timestamp, event_data) @@ -388,10 +387,11 @@ def _build_get_events_query( if limit == 0: return None + join_shard_clause = " AND e.shard_id = s.shard_id" if self._shard_count > 1 else "" sql = f""" SELECT e.id, e.session_id, e.invocation_id, e.timestamp, e.event_data, s.app_name, s.user_id FROM {self._events_table} e - JOIN {self._session_table} s ON e.session_id = s.id + JOIN {self._session_table} s ON e.session_id = s.id{join_shard_clause} WHERE s.app_name = @app_name AND s.user_id = @user_id AND e.session_id = @session_id """ if self._shard_count > 1: @@ -487,7 +487,7 @@ def _build_upsert_app_state_statement( INSERT OR UPDATE {self._app_state_table} (app_name, state, update_time) VALUES (@app_name, @state, PENDING_COMMIT_TIMESTAMP()) """ - return (sql, {"app_name": app_name, "state": to_json(state)}, self._app_state_param_types()) + return (sql, {"app_name": app_name, "state": self._json_serializer(state)}, self._app_state_param_types()) def _build_upsert_user_state_statement( self, app_name: str, user_id: str, state: "dict[str, Any]" @@ -498,7 +498,7 @@ def _build_upsert_user_state_statement( """ return ( sql, - {"app_name": app_name, "user_id": user_id, "state": to_json(state)}, + {"app_name": app_name, "user_id": user_id, "state": self._json_serializer(state)}, self._user_state_param_types(), ) @@ -634,6 +634,8 @@ class SpannerSyncADKStore(_SpannerADKStoreMixin, BaseSyncADKStore[SpannerSyncCon "_events_row_deletion_policy", "_events_table_options", "_expires_index_options", + "_json_deserializer", + "_json_serializer", "_session_row_deletion_policy", "_session_table_options", "_shard_count", @@ -651,6 +653,7 @@ def __init__(self, config: SpannerSyncConfig) -> None: self._session_row_deletion_policy, self._events_row_deletion_policy, ) = _extract_spanner_adk_options(config) + self._json_serializer, self._json_deserializer = _extract_spanner_json_codecs(config) def create_tables(self) -> None: """Create tables if they don't exist.""" @@ -672,9 +675,11 @@ def get_session( """Get session by ID.""" try: return self._get_session(app_name, user_id, session_id, renew_for=renew_for) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._session_table): return None + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise def update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: @@ -696,9 +701,11 @@ def list_sessions( return self._list_sessions( app_name, user_id, order_by=order_by, descending=descending, limit=limit, offset=offset ) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._session_table): return [] + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise def delete_session(self, app_name: str, user_id: str, session_id: str) -> None: @@ -736,9 +743,11 @@ def get_events( """Get events for a session.""" try: return self._get_events(app_name, user_id, session_id, after_timestamp, limit) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._events_table, self._session_table): return [] + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise def delete_expired_events(self, before: datetime, app_name: "str | None" = None) -> int: @@ -784,20 +793,32 @@ def _reset_drop_tables_sql(self) -> "list[str]": return _filter_existing_spanner_drops(self._reset_drop_statements(), self._existing_tables()) def _existing_tables(self) -> "set[str]": - return list_existing_table_names_sync(self._database()) + try: + return list_existing_table_names_sync(self._database()) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _run_read( self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None ) -> "list[Any]": - with self._config.provide_connection() as snapshot: - reader = cast("_SpannerReadProtocol", snapshot) - return list(reader.execute_sql(sql, params=params, param_types=types)) + try: + with self._config.provide_connection(transaction=False) as snapshot: + reader = cast("_SpannerReadProtocol", snapshot) + return list(reader.execute_sql(sql, params=params, param_types=types)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _run_write(self, statements: "list[tuple[str, dict[str, Any], dict[str, Any]]]") -> None: - cast("Any", self._database()).run_in_transaction(_SpannerSyncWriteJob(statements)) + try: + cast("Any", self._database()).run_in_transaction(_SpannerSyncWriteJob(statements)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _execute_update(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> int: - return int(cast("Any", self._database()).run_in_transaction(_SpannerSyncUpdateJob(sql, params, types))) + try: + return int(cast("Any", self._database()).run_in_transaction(_SpannerSyncUpdateJob(sql, params, types))) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None @@ -938,7 +959,10 @@ def _set_metadata(self, key: str, value: str) -> None: def _create_tables(self) -> None: ddl_statements = self._missing_table_ddl_statements(self._existing_tables()) - execute_ddl_sync(self._database(), ddl_statements, timeout=_DDL_TIMEOUT_SECONDS) + try: + execute_ddl_sync(self._database(), ddl_statements, timeout=_DDL_TIMEOUT_SECONDS) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _sessions_table_ddl(self) -> str: return self._build_sessions_table_ddl() @@ -963,6 +987,8 @@ class SpannerAsyncADKStore(_SpannerADKStoreMixin, BaseAsyncADKStore[SpannerAsync "_events_row_deletion_policy", "_events_table_options", "_expires_index_options", + "_json_deserializer", + "_json_serializer", "_session_row_deletion_policy", "_session_table_options", "_shard_count", @@ -980,6 +1006,7 @@ def __init__(self, config: SpannerAsyncConfig) -> None: self._session_row_deletion_policy, self._events_row_deletion_policy, ) = _extract_spanner_adk_options(config) + self._json_serializer, self._json_deserializer = _extract_spanner_json_codecs(config) async def create_tables(self) -> None: """Create tables if they don't exist.""" @@ -1001,9 +1028,11 @@ async def get_session( """Get session by ID.""" try: return await self._get_session(app_name, user_id, session_id, renew_for=renew_for) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._session_table): return None + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise async def update_session_state(self, app_name: str, user_id: str, session_id: str, state: "dict[str, Any]") -> None: @@ -1025,9 +1054,11 @@ async def list_sessions( return await self._list_sessions( app_name, user_id, order_by=order_by, descending=descending, limit=limit, offset=offset ) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._session_table): return [] + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise async def delete_session(self, app_name: str, user_id: str, session_id: str) -> None: @@ -1065,9 +1096,11 @@ async def get_events( """Get events for a session.""" try: return await self._get_events(app_name, user_id, session_id, after_timestamp, limit) - except NotFound as exc: + except (NotFound, SQLSpecError) as exc: if _is_spanner_table_missing(exc, self._events_table, self._session_table): return [] + if isinstance(exc, SpannerGoogleAPICallError): + raise create_mapped_exception(exc) from exc raise async def delete_expired_events(self, before: datetime, app_name: "str | None" = None) -> int: @@ -1113,7 +1146,10 @@ async def _reset_drop_tables_sql(self) -> "list[str]": return _filter_existing_spanner_drops(self._reset_drop_statements(), await self._existing_tables()) async def _existing_tables(self) -> "set[str]": - return await list_existing_table_names_async(await self._database()) + try: + return await list_existing_table_names_async(await self._database()) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def _run_read( self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None @@ -1122,11 +1158,17 @@ async def _run_read( async def _run_write(self, statements: "list[tuple[str, dict[str, Any], dict[str, Any]]]") -> None: database = await self._database() - await database.run_in_transaction(_SpannerAsyncWriteJob(statements)) + try: + await database.run_in_transaction(_SpannerAsyncWriteJob(statements)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def _execute_update(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> int: database = await self._database() - return int(await database.run_in_transaction(_SpannerAsyncUpdateJob(sql, params, types))) + try: + return int(await database.run_in_transaction(_SpannerAsyncUpdateJob(sql, params, types))) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def _create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None @@ -1269,7 +1311,10 @@ async def _set_metadata(self, key: str, value: str) -> None: async def _create_tables(self) -> None: ddl_statements = self._missing_table_ddl_statements(await self._existing_tables()) - await execute_ddl_async(await self._database(), ddl_statements, timeout=_DDL_TIMEOUT_SECONDS) + try: + await execute_ddl_async(await self._database(), ddl_statements, timeout=_DDL_TIMEOUT_SECONDS) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def _sessions_table_ddl(self) -> str: return self._build_sessions_table_ddl() @@ -1311,6 +1356,8 @@ class _SpannerADKMemoryStoreMixin: _enable_hybrid_search: bool _enable_memory_graph: bool _memory_graph_name: str + _json_serializer: Callable[[Any], str] + _json_deserializer: Callable[[str], Any] def _memory_param_types(self, include_owner: bool) -> "dict[str, Any]": types: dict[str, Any] = { @@ -1333,11 +1380,7 @@ def _memory_param_types(self, include_owner: bool) -> "dict[str, Any]": return types def _decode_json(self, raw: Any) -> Any: - if raw is None: - return None - if isinstance(raw, str): - return from_json(raw) - return raw + return _convert_json_row_value(raw, json_deserializer=self._json_deserializer) def _deduplicate_entries(self, entries: "list[StoredMemory]") -> "list[StoredMemory]": seen: set[str] = set() @@ -1466,10 +1509,12 @@ def _build_insert_memory_entry_statement( "event_id": entry["event_id"], "author": entry["author"], "timestamp": entry["timestamp"], - "content_json": to_json(entry["content_json"]), + "content_json": self._json_serializer(entry["content_json"]), "content_text": entry["content_text"], "embedding": embedding_param, - "metadata_json": to_json(entry["metadata_json"]) if entry["metadata_json"] is not None else None, + "metadata_json": self._json_serializer(entry["metadata_json"]) + if entry["metadata_json"] is not None + else None, "inserted_at": entry["inserted_at"], } if self._owner_id_column_name: @@ -1671,6 +1716,8 @@ class SpannerSyncADKMemoryStore(_SpannerADKMemoryStoreMixin, BaseSyncADKMemorySt "_enable_hybrid_search", "_enable_memory_graph", "_has_explicit_vector_dimensions", + "_json_deserializer", + "_json_serializer", "_memory_graph_name", "_memory_row_deletion_policy", "_memory_table_options", @@ -1697,6 +1744,7 @@ def __init__(self, config: SpannerSyncConfig) -> None: self._enable_memory_graph, self._memory_graph_name, ) = _extract_spanner_memory_options(config, self._memory_table) + self._json_serializer, self._json_deserializer = _extract_spanner_json_codecs(config) def create_tables(self) -> None: """Create tables if they don't exist.""" @@ -1737,22 +1785,34 @@ def _reset_drop_memory_table_sql(self) -> "list[str]": return _filter_existing_spanner_drops(self._reset_drop_memory_statements(), self._existing_tables()) def _existing_tables(self) -> "set[str]": - return list_existing_table_names_sync(self._database()) + try: + return list_existing_table_names_sync(self._database()) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _run_read( self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None ) -> "list[Any]": - with self._config.provide_connection() as snapshot: - reader = cast("_SpannerReadProtocol", snapshot) - return list(reader.execute_sql(sql, params=params, param_types=types)) + try: + with self._config.provide_connection(transaction=False) as snapshot: + reader = cast("_SpannerReadProtocol", snapshot) + return list(reader.execute_sql(sql, params=params, param_types=types)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _execute_update(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> int: - return int(cast("Any", self._database()).run_in_transaction(_SpannerSyncUpdateJob(sql, params, types))) + try: + return int(cast("Any", self._database()).run_in_transaction(_SpannerSyncUpdateJob(sql, params, types))) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _create_tables(self) -> None: if not self._enabled or self._memory_table in self._existing_tables(): return - execute_ddl_sync(self._database(), self._memory_table_ddl(), timeout=_DDL_TIMEOUT_SECONDS) + try: + execute_ddl_sync(self._database(), self._memory_table_ddl(), timeout=_DDL_TIMEOUT_SECONDS) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _memory_table_ddl(self) -> "list[str]": return self._build_memory_table_ddl() @@ -1772,7 +1832,10 @@ def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "objec (entry["event_id"], self._build_insert_memory_entry_statement(entry, owner_id)) for entry in unique_entries ] job = _SpannerSyncMemoryInsertJob(check_sql, check_params, check_types, candidates) - return int(cast("Any", self._database()).run_in_transaction(job)) + try: + return int(cast("Any", self._database()).run_in_transaction(job)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _search_entries( self, @@ -1813,6 +1876,8 @@ class SpannerAsyncADKMemoryStore(_SpannerADKMemoryStoreMixin, BaseAsyncADKMemory "_enable_hybrid_search", "_enable_memory_graph", "_has_explicit_vector_dimensions", + "_json_deserializer", + "_json_serializer", "_memory_graph_name", "_memory_row_deletion_policy", "_memory_table_options", @@ -1839,6 +1904,7 @@ def __init__(self, config: SpannerAsyncConfig) -> None: self._enable_memory_graph, self._memory_graph_name, ) = _extract_spanner_memory_options(config, self._memory_table) + self._json_serializer, self._json_deserializer = _extract_spanner_json_codecs(config) async def create_tables(self) -> None: """Create tables if they don't exist.""" @@ -1881,7 +1947,10 @@ async def _reset_drop_memory_table_sql(self) -> "list[str]": return _filter_existing_spanner_drops(self._reset_drop_memory_statements(), await self._existing_tables()) async def _existing_tables(self) -> "set[str]": - return await list_existing_table_names_async(await self._database()) + try: + return await list_existing_table_names_async(await self._database()) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def _run_read( self, sql: str, params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None @@ -1890,12 +1959,20 @@ async def _run_read( async def _execute_update(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") -> int: database = await self._database() - return int(await database.run_in_transaction(_SpannerAsyncUpdateJob(sql, params, types))) + try: + return int(await database.run_in_transaction(_SpannerAsyncUpdateJob(sql, params, types))) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def _create_tables(self) -> None: if not self._enabled or self._memory_table in await self._existing_tables(): return - await execute_ddl_async(await self._database(), await self._memory_table_ddl(), timeout=_DDL_TIMEOUT_SECONDS) + try: + await execute_ddl_async( + await self._database(), await self._memory_table_ddl(), timeout=_DDL_TIMEOUT_SECONDS + ) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def _memory_table_ddl(self) -> "list[str]": return self._build_memory_table_ddl() @@ -1916,7 +1993,10 @@ async def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: ] database = await self._database() job = _SpannerAsyncMemoryInsertJob(check_sql, check_params, check_types, candidates) - return int(await database.run_in_transaction(job)) + try: + return int(await database.run_in_transaction(job)) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc async def _search_entries( self, @@ -1958,9 +2038,12 @@ async def _run_read_async( params: "dict[str, Any] | None" = None, types: "dict[str, Any] | None" = None, ) -> "list[Any]": - async with cast("Any", database).snapshot() as snapshot: - result_set = await snapshot.execute_sql(sql, params=params, param_types=types) - return [row async for row in result_set] + try: + async with cast("Any", database).snapshot() as snapshot: + result_set = await snapshot.execute_sql(sql, params=params, param_types=types) + return [row async for row in result_set] + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc def _json_param_type() -> Any: @@ -1970,6 +2053,17 @@ def _json_param_type() -> Any: return SPANNER_PARAM_TYPES.STRING +def _extract_spanner_json_codecs(config: Any) -> "tuple[Callable[[Any], str], Callable[[str], Any]]": + driver_features = getattr(config, "driver_features", None) + features = driver_features if isinstance(driver_features, Mapping) else {} + serializer = features.get("json_serializer") + deserializer = features.get("json_deserializer") + return ( + cast("Callable[[Any], str]", serializer if callable(serializer) else to_json), + cast("Callable[[str], Any]", deserializer if callable(deserializer) else from_json), + ) + + def _extract_spanner_adk_options(config: Any) -> "tuple[int, str | None, str | None, str | None, str, str]": """Return shard count, table options, and row deletion policies for the ADK session tables.""" adk_config = _adk_config(config) @@ -2053,7 +2147,7 @@ def _adk_config(config: Any) -> SpannerADKConfig: return cast("SpannerADKConfig", adk_config) -def _is_spanner_table_missing(exc: NotFound, *table_names: str) -> bool: +def _is_spanner_table_missing(exc: Exception, *table_names: str) -> bool: message = str(exc).lower() return "not found" in message and any(table_name.lower() in message for table_name in table_names) @@ -2089,6 +2183,14 @@ def _spanner_drop_statement_table(statement: str, existing_tables: "set[str]") - return None +class _SpannerWriteTransactionProtocol(Protocol): + def batch_update(self, statements: "Sequence[Any]") -> "tuple[Any, Sequence[int]]": ... + + def execute_update( + self, sql: str, params: "dict[str, Any] | None" = None, param_types: "dict[str, Any] | None" = None + ) -> int: ... + + class _SpannerSyncWriteJob: """Callable transaction work item for Spanner sync ADK write batches.""" @@ -2098,14 +2200,15 @@ def __init__(self, statements: "list[tuple[str, dict[str, Any], dict[str, Any]]] self._statements = statements def __call__(self, transaction: "SpannerTransaction") -> None: + writer = cast("_SpannerWriteTransactionProtocol", transaction) if len(self._statements) > 1: - status, _row_counts = transaction.batch_update(self._statements) # type: ignore[no-untyped-call] + status, _row_counts = writer.batch_update(self._statements) if status.code != 0: msg = f"Spanner batch update failed (code {status.code}): {status.message}" raise OperationalError(msg) return for sql, params, types in self._statements: - transaction.execute_update(sql, params=params, param_types=types) # type: ignore[no-untyped-call] + writer.execute_update(sql, params=params, param_types=types) class _SpannerAsyncWriteJob: @@ -2196,7 +2299,8 @@ def __init__(self, sql: str, params: "dict[str, Any]", types: "dict[str, Any]") self._types = types def __call__(self, transaction: "SpannerTransaction") -> int: - return int(transaction.execute_update(self._sql, params=self._params, param_types=self._types)) # type: ignore[no-untyped-call] + writer = cast("_SpannerWriteTransactionProtocol", transaction) + return int(writer.execute_update(self._sql, params=self._params, param_types=self._types)) class _SpannerAsyncUpdateJob: diff --git a/sqlspec/adapters/spanner/config.py b/sqlspec/adapters/spanner/config.py index 17996a5af..5c934a1cc 100644 --- a/sqlspec/adapters/spanner/config.py +++ b/sqlspec/adapters/spanner/config.py @@ -1,7 +1,8 @@ """Spanner configuration.""" import contextlib -from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, TypedDict, cast +import warnings +from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, Protocol, TypedDict, cast from sqlspec.adapters.spanner._typing import SpannerAbstractSessionPool as AbstractSessionPool from sqlspec.adapters.spanner._typing import SpannerAsyncAbstractSessionPool as AsyncAbstractSessionPool @@ -25,7 +26,10 @@ from sqlspec.adapters.spanner.core import ( apply_driver_features, build_session_driver_features, + create_mapped_exception, default_statement_config, + execute_partitioned_dml_async, + execute_partitioned_dml_sync, resolve_transaction_completion, run_in_transaction_async, run_in_transaction_sync, @@ -228,94 +232,6 @@ def build_connection_config( return config -def _prepare_settings( - connection_config: "SpannerPoolParams | dict[str, Any] | None", - driver_features: "SpannerDriverFeatures | dict[str, Any] | None", - *, - default_pool_type: type[Any], -) -> "tuple[dict[str, Any], dict[str, Any]]": - """Normalize connection settings and move legacy session labels onto the pool settings.""" - config = build_connection_config(connection_config) - if "min_sessions" in config: - msg = "Spanner session pools do not support 'min_sessions'; use 'size' or 'target_size'." - raise ImproperConfigurationError(msg) - - raw_driver_features: dict[str, Any] = dict(driver_features) if driver_features else {} - legacy_session_labels = raw_driver_features.pop("session_labels", None) - if legacy_session_labels is not None and "session_labels" not in config and "labels" not in config: - config["session_labels"] = legacy_session_labels - - config.setdefault("size", config.pop("max_sessions", 10)) - config.setdefault("pool_type", default_pool_type) - return config, raw_driver_features - - -def _require_database_ids(connection_config: "dict[str, Any]") -> "tuple[str, str]": - instance_id = connection_config.get("instance_id") - database_id = connection_config.get("database_id") - if not instance_id or not database_id: - msg = "instance_id and database_id are required." - raise ImproperConfigurationError(msg) - return instance_id, database_id - - -def _connection_kwargs_for(connection_config: "dict[str, Any]", fields: "frozenset[str] | set[str]") -> dict[str, Any]: - return {field: connection_config[field] for field in fields if connection_config.get(field) is not None} - - -def _instance_kwargs(connection_config: "dict[str, Any]") -> dict[str, Any]: - instance_kwargs = _connection_kwargs_for(connection_config, _INSTANCE_CONFIG_FIELDS) - instance_labels = connection_config.get("instance_labels") - if instance_labels is not None: - instance_kwargs["labels"] = instance_labels - return instance_kwargs - - -def _resolve_pool_type( - connection_config: "dict[str, Any]", *, base: type[Any], config_name: str, expected: str -) -> type[Any]: - pool_type = connection_config["pool_type"] - if not isinstance(pool_type, type) or not issubclass(pool_type, base): - msg = f"{config_name} requires {expected} session pool class; got {pool_type!r}." - raise ImproperConfigurationError(msg) - return pool_type - - -def _pool_kwargs(connection_config: "dict[str, Any]", pool_kind: str) -> dict[str, Any]: - """Build session pool constructor arguments for a pool kind. - - Args: - connection_config: Normalized connection settings. - pool_kind: ``"pinging"``, ``"fixed"``, ``"bursty"``, or ``"custom"``. - - Returns: - Keyword arguments for the pool class. - """ - pool_kwargs: dict[str, Any] = {} - labels = connection_config.get("session_labels", connection_config.get("labels")) - if labels is not None: - pool_kwargs["labels"] = labels - database_role = connection_config.get("database_role") - if database_role is not None: - pool_kwargs["database_role"] = database_role - if pool_kind == "pinging": - pool_kwargs.update(_connection_kwargs_for(connection_config, {"size", "default_timeout"})) - pool_kwargs["ping_interval"] = connection_config.get("ping_interval", 1800) - elif pool_kind == "fixed": - pool_kwargs.update(_connection_kwargs_for(connection_config, {"size", "default_timeout", "max_age_minutes"})) - elif pool_kind == "bursty": - target_size = connection_config.get("target_size", connection_config.get("size")) - if target_size is not None: - pool_kwargs["target_size"] = target_size - else: - pool_kwargs.update( - _connection_kwargs_for( - connection_config, {"size", "target_size", "default_timeout", "ping_interval", "max_age_minutes"} - ) - ) - return pool_kwargs - - class SpannerSyncConnectionContext(SyncPoolConnectionContext): """Context manager for sync Spanner connections (Snapshot or Transaction).""" @@ -355,9 +271,16 @@ def __exit__( txn = cast("Any", self._connection) action = resolve_transaction_completion(txn, failed=exc_type is not None) if action == "commit": - txn.commit() + try: + txn.commit() + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc elif action == "rollback": - txn.rollback() + try: + txn.rollback() + except SpannerGoogleAPICallError as exc: + if exc_type is None: + raise create_mapped_exception(exc) from exc finally: if self._session is not None: cast("Any", self._config.get_database()).sessions_manager.put_session(self._session) @@ -451,11 +374,6 @@ def __init__( self._client: Client | None = None self._database: Database | None = None - def _get_client(self) -> "Client": - if self._client is None: - self._client = Client(**_connection_kwargs_for(self.connection_config, _CLIENT_CONFIG_FIELDS)) - return self._client - def get_database(self) -> "Database": """Return the configured database, creating the pool and client on first use. @@ -492,35 +410,6 @@ def create_connection(self) -> "SpannerSyncConnection": """ return cast("SpannerSyncConnection", cast("Any", self.get_database()).snapshot(multi_use=True)) - def _create_pool(self) -> "AbstractSessionPool": - _require_database_ids(self.connection_config) - pool_type = _resolve_pool_type( - self.connection_config, base=AbstractSessionPool, config_name="SpannerSyncConfig", expected="a sync" - ) - if issubclass(pool_type, PingingPool): - pool_kind = "pinging" - elif issubclass(pool_type, FixedSizePool): - pool_kind = "fixed" - elif issubclass(pool_type, BurstyPool): - pool_kind = "bursty" - else: - pool_kind = "custom" - return cast("AbstractSessionPool", pool_type(**_pool_kwargs(self.connection_config, pool_kind))) - - def _close_pool(self) -> None: - """Release pooled sessions, then close the database and client.""" - pool = self.connection_instance - if pool is not None: - with contextlib.suppress(SpannerGoogleAPICallError): - pool.clear() # type: ignore[no-untyped-call] - if self._database is not None: - with contextlib.suppress(SpannerGoogleAPICallError): - self._database.close() # type: ignore[no-untyped-call] - if self._client is not None: - self._client.close() # type: ignore[no-untyped-call] - self._client = None - self._database = None - def provide_connection( self, *args: Any, transaction: "bool" = _DEFAULT_SESSION_TRANSACTION, **kwargs: Any ) -> "SpannerSyncConnectionContext": @@ -649,12 +538,23 @@ def run_in_transaction(self, fn: "Callable[..., Any]", *args: Any, **kwargs: Any """ return run_in_transaction_sync(self.get_database(), self._transaction_driver, fn, *args, **kwargs) - def _transaction_driver(self, transaction: "SpannerSyncConnection") -> "SpannerSyncDriver": - driver = self.driver_type( - connection=transaction, statement_config=self.statement_config, driver_features=self.driver_features + def execute_partitioned_dml( + self, + sql: str, + params: "dict[str, Any] | None" = None, + param_types: "dict[str, Any] | None" = None, + **kwargs: Any, + ) -> int: + """Execute a Partitioned DML statement against the configured Spanner database.""" + return execute_partitioned_dml_sync( + self.get_database(), + sql, + params=params, + param_types=param_types, + json_serializer=self.driver_features.get("json_serializer"), + enable_uuid_conversion=self.driver_features.get("enable_uuid_conversion", True), + **kwargs, ) - driver._owns_transaction = False # pyright: ignore[reportPrivateUsage] - return self._prepare_driver(driver) def get_signature_namespace(self) -> "dict[str, Any]": """Get the signature namespace for SpannerSyncConfig types. @@ -679,6 +579,49 @@ def get_event_runtime_hints(self) -> "EventRuntimeHints": """Return queue defaults for Spanner JSON and transaction handling.""" return EventRuntimeHints(cleanup_on_ack=False, use_run_in_transaction=True) + def _get_client(self) -> "Client": + if self._client is None: + self._client = Client(**_connection_kwargs_for(self.connection_config, _CLIENT_CONFIG_FIELDS)) + return self._client + + def _create_pool(self) -> "AbstractSessionPool": + _require_database_ids(self.connection_config) + pool_type = _resolve_pool_type( + self.connection_config, base=AbstractSessionPool, config_name="SpannerSyncConfig", expected="a sync" + ) + if issubclass(pool_type, PingingPool): + pool_kind = "pinging" + elif issubclass(pool_type, FixedSizePool): + pool_kind = "fixed" + elif issubclass(pool_type, BurstyPool): + pool_kind = "bursty" + else: + pool_kind = "custom" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return cast("AbstractSessionPool", pool_type(**_pool_kwargs(self.connection_config, pool_kind))) + + def _close_pool(self) -> None: + """Release pooled sessions, then close the database and client.""" + pool = self.connection_instance + if pool is not None: + with contextlib.suppress(SpannerGoogleAPICallError): + cast("_SyncClearableProtocol", pool).clear() + if self._database is not None: + with contextlib.suppress(SpannerGoogleAPICallError): + cast("_SyncClosableProtocol", self._database).close() + if self._client is not None: + cast("_SyncClosableProtocol", self._client).close() + self._client = None + self._database = None + + def _transaction_driver(self, transaction: "SpannerSyncConnection") -> "SpannerSyncDriver": + driver = self.driver_type( + connection=transaction, statement_config=self.statement_config, driver_features=self.driver_features + ) + driver.bind_external_transaction() + return self._prepare_driver(driver) + class SpannerAsyncConnectionContext(AsyncPoolConnectionContext): """Context manager for async Spanner connections (Snapshot or Transaction).""" @@ -719,9 +662,16 @@ async def __aexit__( txn = cast("Any", self._connection) action = resolve_transaction_completion(txn, failed=exc_type is not None) if action == "commit": - await txn.commit() + try: + await txn.commit() + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc elif action == "rollback": - await txn.rollback() + try: + await txn.rollback() + except SpannerGoogleAPICallError as exc: + if exc_type is None: + raise create_mapped_exception(exc) from exc finally: if self._session is not None: database = await self._config.get_database() @@ -816,11 +766,6 @@ def __init__( self._client: AsyncClient | None = None self._database: AsyncDatabase | None = None - def _get_client(self) -> "AsyncClient": - if self._client is None: - self._client = AsyncClient(**_connection_kwargs_for(self.connection_config, _CLIENT_CONFIG_FIELDS)) - return self._client - async def get_database(self) -> "AsyncDatabase": """Return the configured database, creating the pool and client on first use. @@ -858,35 +803,6 @@ async def create_connection(self) -> "SpannerAsyncConnection": database = await self.get_database() return cast("SpannerAsyncConnection", cast("Any", database).snapshot(multi_use=True)) - async def _create_pool(self) -> "AsyncAbstractSessionPool": - _require_database_ids(self.connection_config) - pool_type = _resolve_pool_type( - self.connection_config, base=AsyncAbstractSessionPool, config_name="SpannerAsyncConfig", expected="an async" - ) - if issubclass(pool_type, AsyncPingingPool): - pool_kind = "pinging" - elif issubclass(pool_type, AsyncFixedSizePool): - pool_kind = "fixed" - elif issubclass(pool_type, AsyncBurstyPool): - pool_kind = "bursty" - else: - pool_kind = "custom" - return cast("AsyncAbstractSessionPool", pool_type(**_pool_kwargs(self.connection_config, pool_kind))) - - async def _close_pool(self) -> None: - """Release pooled sessions, then close the database and client.""" - pool = self.connection_instance - if pool is not None: - with contextlib.suppress(SpannerGoogleAPICallError): - await pool.clear() # type: ignore[no-untyped-call] - if self._database is not None: - with contextlib.suppress(SpannerGoogleAPICallError): - await self._database.close() - if self._client is not None: - self._client.close() # type: ignore[no-untyped-call] - self._client = None - self._database = None - def provide_connection( self, *args: Any, transaction: "bool" = _DEFAULT_SESSION_TRANSACTION, **kwargs: Any ) -> "SpannerAsyncConnectionContext": @@ -1016,12 +932,24 @@ async def run_in_transaction(self, fn: "Callable[..., Awaitable[Any]]", *args: A database = await self.get_database() return await run_in_transaction_async(database, self._transaction_driver, fn, *args, **kwargs) - def _transaction_driver(self, transaction: "SpannerAsyncConnection") -> "SpannerAsyncDriver": - driver = self.driver_type( - connection=transaction, statement_config=self.statement_config, driver_features=self.driver_features + async def execute_partitioned_dml( + self, + sql: str, + params: "dict[str, Any] | None" = None, + param_types: "dict[str, Any] | None" = None, + **kwargs: Any, + ) -> int: + """Execute a Partitioned DML statement against the configured async Spanner database.""" + database = await self.get_database() + return await execute_partitioned_dml_async( + database, + sql, + params=params, + param_types=param_types, + json_serializer=self.driver_features.get("json_serializer"), + enable_uuid_conversion=self.driver_features.get("enable_uuid_conversion", True), + **kwargs, ) - driver._owns_transaction = False # pyright: ignore[reportPrivateUsage] - return self._prepare_driver(driver) def get_signature_namespace(self) -> "dict[str, Any]": """Get the signature namespace for SpannerAsyncConfig types. @@ -1045,3 +973,146 @@ def get_signature_namespace(self) -> "dict[str, Any]": def get_event_runtime_hints(self) -> "EventRuntimeHints": """Return queue defaults for Spanner JSON and transaction handling.""" return EventRuntimeHints(cleanup_on_ack=False, use_run_in_transaction=True) + + def _get_client(self) -> "AsyncClient": + if self._client is None: + self._client = AsyncClient(**_connection_kwargs_for(self.connection_config, _CLIENT_CONFIG_FIELDS)) + return self._client + + async def _create_pool(self) -> "AsyncAbstractSessionPool": + _require_database_ids(self.connection_config) + pool_type = _resolve_pool_type( + self.connection_config, base=AsyncAbstractSessionPool, config_name="SpannerAsyncConfig", expected="an async" + ) + if issubclass(pool_type, AsyncPingingPool): + pool_kind = "pinging" + elif issubclass(pool_type, AsyncFixedSizePool): + pool_kind = "fixed" + elif issubclass(pool_type, AsyncBurstyPool): + pool_kind = "bursty" + else: + pool_kind = "custom" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return cast("AsyncAbstractSessionPool", pool_type(**_pool_kwargs(self.connection_config, pool_kind))) + + async def _close_pool(self) -> None: + """Release pooled sessions, then close the database and client.""" + pool = self.connection_instance + if pool is not None: + with contextlib.suppress(SpannerGoogleAPICallError): + await cast("_AsyncClearableProtocol", pool).clear() + if self._database is not None: + with contextlib.suppress(SpannerGoogleAPICallError): + await self._database.close() + if self._client is not None: + cast("_SyncClosableProtocol", self._client).close() + self._client = None + self._database = None + + def _transaction_driver(self, transaction: "SpannerAsyncConnection") -> "SpannerAsyncDriver": + driver = self.driver_type( + connection=transaction, statement_config=self.statement_config, driver_features=self.driver_features + ) + driver.bind_external_transaction() + return self._prepare_driver(driver) + + +class _SyncClearableProtocol(Protocol): + def clear(self) -> None: ... + + +class _AsyncClearableProtocol(Protocol): + async def clear(self) -> None: ... + + +class _SyncClosableProtocol(Protocol): + def close(self) -> None: ... + + +def _prepare_settings( + connection_config: "SpannerPoolParams | dict[str, Any] | None", + driver_features: "SpannerDriverFeatures | dict[str, Any] | None", + *, + default_pool_type: type[Any], +) -> "tuple[dict[str, Any], dict[str, Any]]": + """Normalize connection settings and move legacy session labels onto the pool settings.""" + config = build_connection_config(connection_config) + if "min_sessions" in config: + msg = "Spanner session pools do not support 'min_sessions'; use 'size' or 'target_size'." + raise ImproperConfigurationError(msg) + + raw_driver_features: dict[str, Any] = dict(driver_features) if driver_features else {} + legacy_session_labels = raw_driver_features.pop("session_labels", None) + if legacy_session_labels is not None and "session_labels" not in config and "labels" not in config: + config["session_labels"] = legacy_session_labels + + config.setdefault("size", config.pop("max_sessions", 10)) + config.setdefault("pool_type", default_pool_type) + return config, raw_driver_features + + +def _require_database_ids(connection_config: "dict[str, Any]") -> "tuple[str, str]": + instance_id = connection_config.get("instance_id") + database_id = connection_config.get("database_id") + if not instance_id or not database_id: + msg = "instance_id and database_id are required." + raise ImproperConfigurationError(msg) + return instance_id, database_id + + +def _connection_kwargs_for(connection_config: "dict[str, Any]", fields: "frozenset[str] | set[str]") -> dict[str, Any]: + return {field: connection_config[field] for field in fields if connection_config.get(field) is not None} + + +def _instance_kwargs(connection_config: "dict[str, Any]") -> dict[str, Any]: + instance_kwargs = _connection_kwargs_for(connection_config, _INSTANCE_CONFIG_FIELDS) + instance_labels = connection_config.get("instance_labels") + if instance_labels is not None: + instance_kwargs["labels"] = instance_labels + return instance_kwargs + + +def _resolve_pool_type( + connection_config: "dict[str, Any]", *, base: type[Any], config_name: str, expected: str +) -> type[Any]: + pool_type = connection_config["pool_type"] + if not isinstance(pool_type, type) or not issubclass(pool_type, base): + msg = f"{config_name} requires {expected} session pool class; got {pool_type!r}." + raise ImproperConfigurationError(msg) + return pool_type + + +def _pool_kwargs(connection_config: "dict[str, Any]", pool_kind: str) -> dict[str, Any]: + """Build session pool constructor arguments for a pool kind. + + Args: + connection_config: Normalized connection settings. + pool_kind: ``"pinging"``, ``"fixed"``, ``"bursty"``, or ``"custom"``. + + Returns: + Keyword arguments for the pool class. + """ + pool_kwargs: dict[str, Any] = {} + labels = connection_config.get("session_labels", connection_config.get("labels")) + if labels is not None: + pool_kwargs["labels"] = labels + database_role = connection_config.get("database_role") + if database_role is not None: + pool_kwargs["database_role"] = database_role + if pool_kind == "pinging": + pool_kwargs.update(_connection_kwargs_for(connection_config, {"size", "default_timeout"})) + pool_kwargs["ping_interval"] = connection_config.get("ping_interval", 1800) + elif pool_kind == "fixed": + pool_kwargs.update(_connection_kwargs_for(connection_config, {"size", "default_timeout", "max_age_minutes"})) + elif pool_kind == "bursty": + target_size = connection_config.get("target_size", connection_config.get("size")) + if target_size is not None: + pool_kwargs["target_size"] = target_size + else: + pool_kwargs.update( + _connection_kwargs_for( + connection_config, {"size", "target_size", "default_timeout", "ping_interval", "max_age_minutes"} + ) + ) + return pool_kwargs diff --git a/sqlspec/adapters/spanner/core.py b/sqlspec/adapters/spanner/core.py index 6367811a1..fe3455e1f 100644 --- a/sqlspec/adapters/spanner/core.py +++ b/sqlspec/adapters/spanner/core.py @@ -65,6 +65,8 @@ "driver_profile", "execute_ddl_async", "execute_ddl_sync", + "execute_partitioned_dml_async", + "execute_partitioned_dml_sync", "infer_param_types", "is_ddl_statement", "is_query_statement", @@ -705,6 +707,89 @@ async def execute_ddl_async(database: Any, statements: "list[str]", *, timeout: await operation.result(timeout=timeout) +def execute_partitioned_dml_sync( + database: Any, + sql: str, + params: "dict[str, Any] | None" = None, + param_types: "dict[str, Any] | None" = None, + *, + json_serializer: "Callable[[Any], str] | None" = None, + enable_uuid_conversion: bool = True, + **kwargs: Any, +) -> int: + """Execute a Partitioned DML statement on a sync Spanner database. + + Args: + database: Sync Spanner database. + sql: Partitioned DML statement. + params: Optional parameter dictionary. + param_types: Optional Spanner parameter types dictionary. + json_serializer: Optional JSON serializer for parameter coercion. + enable_uuid_conversion: Whether to convert UUID parameters to strings. + **kwargs: Additional keyword arguments passed to ``database.execute_partitioned_dml``. + + Returns: + Lower bound of the number of rows modified. + + Raises: + SQLSpecError: Mapped exception when the Spanner API call fails. + """ + resolved_types = param_types if param_types is not None else (infer_param_types(params) if params else None) + coerced_params = ( + coerce_params(params, json_serializer=json_serializer, enable_uuid_conversion=enable_uuid_conversion) + if params + else None + ) + try: + return int( + database.execute_partitioned_dml(sql, params=coerced_params, param_types=resolved_types, **kwargs) or 0 + ) + except api_exceptions.GoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc + + +async def execute_partitioned_dml_async( + database: Any, + sql: str, + params: "dict[str, Any] | None" = None, + param_types: "dict[str, Any] | None" = None, + *, + json_serializer: "Callable[[Any], str] | None" = None, + enable_uuid_conversion: bool = True, + **kwargs: Any, +) -> int: + """Execute a Partitioned DML statement on an async Spanner database. + + Args: + database: Async Spanner database. + sql: Partitioned DML statement. + params: Optional parameter dictionary. + param_types: Optional Spanner parameter types dictionary. + json_serializer: Optional JSON serializer for parameter coercion. + enable_uuid_conversion: Whether to convert UUID parameters to strings. + **kwargs: Additional keyword arguments passed to ``database.execute_partitioned_dml``. + + Returns: + Lower bound of the number of rows modified. + + Raises: + SQLSpecError: Mapped exception when the Spanner API call fails. + """ + resolved_types = param_types if param_types is not None else (infer_param_types(params) if params else None) + coerced_params = ( + coerce_params(params, json_serializer=json_serializer, enable_uuid_conversion=enable_uuid_conversion) + if params + else None + ) + try: + result = await database.execute_partitioned_dml( + sql, params=coerced_params, param_types=resolved_types, **kwargs + ) + return int(result or 0) + except api_exceptions.GoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc + + class _SpannerStreamSourceBase: """Row conversion shared by the sync and async Spanner stream sources.""" diff --git a/sqlspec/adapters/spanner/data_dictionary.py b/sqlspec/adapters/spanner/data_dictionary.py index 78ad1519e..c797f9f6f 100644 --- a/sqlspec/adapters/spanner/data_dictionary.py +++ b/sqlspec/adapters/spanner/data_dictionary.py @@ -1,5 +1,6 @@ """Spanner metadata queries using INFORMATION_SCHEMA.""" +import re from typing import TYPE_CHECKING, Any, ClassVar, cast from mypy_extensions import mypyc_attr @@ -194,11 +195,6 @@ def get_foreign_keys( schema_type=ForeignKeyMetadata, ) - def _build_domain_result(self, domain: str, rows: list[Any]) -> MetadataResult: - """Wrap domain query rows into a standard MetadataResult envelope.""" - capability = _spanner_capability_for_domain(domain, mode=self.mode) - return MetadataResult(domain, capability=capability, items=tuple(rows), warnings=capability.warnings) - def get_constraints( self, driver: "SpannerSyncDriver", table: "str | None" = None, schema: "str | None" = None ) -> MetadataResult: @@ -311,7 +307,7 @@ def get_ddl( ) if not ddl_statements: return DDLResult.unsupported(identity, source=MetadataSource.NATIVE_API, warnings=_SPANNER_DDL_WARNINGS) - ddl = _select_spanner_ddl_for_object(ddl_statements, object_name) + ddl = _select_spanner_ddl_for_object(ddl_statements, object_name, object_type=object_type) return DDLResult( identity=identity, status=MetadataSupport.SUPPORTED, @@ -321,6 +317,11 @@ def get_ddl( warnings=_SPANNER_DDL_WARNINGS, ) + def _build_domain_result(self, domain: str, rows: list[Any]) -> MetadataResult: + """Wrap domain query rows into a standard MetadataResult envelope.""" + capability = _spanner_capability_for_domain(domain, mode=self.mode) + return MetadataResult(domain, capability=capability, items=tuple(rows), warnings=capability.warnings) + @mypyc_attr(allow_interpreted_subclasses=True, native_class=False) class SpannerAsyncDataDictionary(AsyncDataDictionaryBase): @@ -445,11 +446,6 @@ async def get_foreign_keys( schema_type=ForeignKeyMetadata, ) - def _build_domain_result(self, domain: str, rows: list[Any]) -> MetadataResult: - """Wrap domain query rows into a standard MetadataResult envelope.""" - capability = _spanner_capability_for_domain(domain, mode=self.mode) - return MetadataResult(domain, capability=capability, items=tuple(rows), warnings=capability.warnings) - async def get_constraints( self, driver: "SpannerAsyncDriver", table: "str | None" = None, schema: "str | None" = None ) -> MetadataResult: @@ -562,7 +558,7 @@ async def get_ddl( ) if not ddl_statements: return DDLResult.unsupported(identity, source=MetadataSource.NATIVE_API, warnings=_SPANNER_DDL_WARNINGS) - ddl = _select_spanner_ddl_for_object(ddl_statements, object_name) + ddl = _select_spanner_ddl_for_object(ddl_statements, object_name, object_type=object_type) return DDLResult( identity=identity, status=MetadataSupport.SUPPORTED, @@ -572,6 +568,11 @@ async def get_ddl( warnings=_SPANNER_DDL_WARNINGS, ) + def _build_domain_result(self, domain: str, rows: list[Any]) -> MetadataResult: + """Wrap domain query rows into a standard MetadataResult envelope.""" + capability = _spanner_capability_for_domain(domain, mode=self.mode) + return MetadataResult(domain, capability=capability, items=tuple(rows), warnings=capability.warnings) + def _spanner_capability_for_domain(domain: str, *, mode: str) -> MetadataCapability: if mode not in {"googlesql", "postgresql"}: @@ -664,9 +665,25 @@ def _get_spanner_database_admin_api(database: Any) -> Any: return getattr(client, "database_admin_api", None) -def _select_spanner_ddl_for_object(statements: "tuple[str, ...]", object_name: str) -> str: - normalized_name = object_name.lower() +def _select_spanner_ddl_for_object( + statements: "tuple[str, ...]", object_name: str, *, object_type: str = "table" +) -> str: + escaped_name = re.escape(object_name.strip('`"')) + normalized_type = object_type.strip().replace("_", r"\s+") + typed_pattern = re.compile( + rf"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:UNIQUE|NULL_FILTERED|SEARCH|VECTOR|PROPERTY)\s+)*" + rf"{normalized_type}\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:[`\"]?\w+[`\"]?\.)?[`\"]?{escaped_name}[`\"]?(?=\s|\(|;|$)", + re.IGNORECASE, + ) + for statement in statements: + if typed_pattern.search(statement): + return statement + any_object_pattern = re.compile( + rf"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:[A-Z_]+\s+)+?(?:IF\s+NOT\s+EXISTS\s+)?" + rf"(?:[`\"]?\w+[`\"]?\.)?[`\"]?{escaped_name}[`\"]?(?=\s|\(|;|$)", + re.IGNORECASE, + ) for statement in statements: - if normalized_name in statement.lower(): + if any_object_pattern.search(statement): return statement return "\n".join(statements) diff --git a/sqlspec/adapters/spanner/driver.py b/sqlspec/adapters/spanner/driver.py index 37b4f6c65..ba57fcf28 100644 --- a/sqlspec/adapters/spanner/driver.py +++ b/sqlspec/adapters/spanner/driver.py @@ -29,6 +29,8 @@ driver_profile, execute_ddl_async, execute_ddl_sync, + execute_partitioned_dml_async, + execute_partitioned_dml_sync, infer_param_types, is_ddl_statement, is_query_statement, @@ -171,13 +173,22 @@ def __init__( self._row_plan_cache: dict[int, tuple[Any, list[str], tuple[tuple[int, Any], ...] | None]] = {} self._row_plan_deserializer = cast("Callable[[str], Any]", features.get("json_deserializer", from_json)) + @property + def data_dictionary(self) -> "SpannerSyncDataDictionary": + if self._data_dictionary is None: + dialect_str = str(self.statement_config.dialect) if self.statement_config.dialect else "spanner" + mode = "postgresql" if dialect_str in {"spangres", "postgres", "postgresql"} else "googlesql" + self._data_dictionary = SpannerSyncDataDictionary(mode=mode) + return self._data_dictionary + def dispatch_execute(self, cursor: "SpannerSyncConnection", statement: "SQL") -> ExecutionResult: sql, params = self._compiled_sql(statement, self.statement_config) if is_ddl_statement(sql): if not supports_write(cursor): raise SQLConversionError(_READ_ONLY_SNAPSHOT_DDL_ERROR_MESSAGE) self._commit_before_ddl() - execute_ddl_sync(self._resolve_database(), [sql]) + execute_kwargs = self._execute_kwargs() + execute_ddl_sync(self._resolve_database(), [sql], timeout=execute_kwargs.get("timeout")) return self.create_execution_result(cursor, rowcount_override=0) params = cast("dict[str, Any] | None", params) param_types_map = self._infer_param_types(params) @@ -278,6 +289,7 @@ def dispatch_execute_script(self, cursor: "SpannerSyncConnection", statement: "S coerced_params = self._coerce_params(script_params) read_execute_kwargs = self._execute_kwargs(for_read=True) write_execute_kwargs = self._execute_kwargs() + ddl_timeout = write_execute_kwargs.get("timeout") dialect_str = str(self.dialect) if self.dialect else "spanner" pending_ddl: list[str] = [] for index, stmt in enumerate(statements): @@ -289,7 +301,7 @@ def dispatch_execute_script(self, cursor: "SpannerSyncConnection", statement: "S continue if pending_ddl: self._commit_before_ddl() - execute_ddl_sync(self._resolve_database(), pending_ddl) + execute_ddl_sync(self._resolve_database(), pending_ddl, timeout=ddl_timeout) pending_ddl = [] cursor = self.connection reader = cast("_SpannerReadProtocol", cursor) @@ -312,7 +324,7 @@ def dispatch_execute_script(self, cursor: "SpannerSyncConnection", statement: "S if pending_ddl: self._commit_before_ddl() - execute_ddl_sync(self._resolve_database(), pending_ddl) + execute_ddl_sync(self._resolve_database(), pending_ddl, timeout=ddl_timeout) return self.create_execution_result( cursor, statement_count=count, successful_statements=count, is_script_result=True ) @@ -443,7 +455,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "SyncRowStream[SchemaT]": ... - @overload def select_stream( self, @@ -456,7 +467,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "SyncRowStream[dict[str, Any]]": ... - def select_stream( self, statement: "SQL | Statement | QueryBuilder", @@ -538,7 +548,7 @@ def load_from_arrow( delete_sql = f"DELETE FROM {table_sql} WHERE TRUE" if isinstance(self.connection, SpannerTransaction): writer = cast("_SpannerWriteProtocol", self.connection) - writer.execute_update(delete_sql) + writer.execute_update(delete_sql, **self._execute_kwargs()) else: msg = "Delete requires a Transaction context." raise SQLConversionError(msg) @@ -576,14 +586,6 @@ def load_from_storage( arrow_table, inbound = self._read_storage_arrow(source, file_format=file_format) return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound) - @property - def data_dictionary(self) -> "SpannerSyncDataDictionary": - if self._data_dictionary is None: - dialect_str = str(self.statement_config.dialect) if self.statement_config.dialect else "spanner" - mode = "postgresql" if dialect_str in {"spangres", "postgres", "postgresql"} else "googlesql" - self._data_dictionary = SpannerSyncDataDictionary(mode=mode) - return self._data_dictionary - def collect_rows(self, cursor: "SpannerSyncConnection", fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect Spanner rows for the direct execution path. @@ -605,6 +607,78 @@ def resolve_rowcount(self, cursor: "SpannerSyncConnection") -> int: """ return 0 + def run_in_transaction(self, fn: "Callable[..., Any]", *args: Any, **kwargs: Any) -> Any: + """Execute a callable inside Spanner's retryable transaction runner. + + ``fn`` is called as ``fn(driver, *args, **kwargs)`` with a driver bound + to the runner's transaction. A ``DeadlockError`` caused by + ``google.api_core.exceptions.Aborted`` is unwrapped so the runner + retries, and a terminal ``Aborted`` is raised as ``DeadlockError``. + """ + return run_in_transaction_sync(self._resolve_database(), self._transaction_driver, fn, *args, **kwargs) + + def execute_partitioned_dml( + self, + sql: str, + params: "dict[str, Any] | None" = None, + param_types: "dict[str, Any] | None" = None, + **kwargs: Any, + ) -> int: + """Execute a Partitioned DML statement against the Spanner database.""" + call_kwargs = {**self._execute_kwargs(), **kwargs} + return execute_partitioned_dml_sync( + self._resolve_database(), + sql, + params=params, + param_types=param_types, + json_serializer=self.driver_features.get("json_serializer"), + enable_uuid_conversion=self.driver_features.get("enable_uuid_conversion", True), + **call_kwargs, + ) + + def write_insert(self, table: str, columns: "Sequence[str]", values: "Sequence[Sequence[Any]]") -> None: + """Buffer Spanner INSERT mutations on the active transaction or batch.""" + self._apply_mutation(table, columns, values, method_name="insert") + + def write_upsert(self, table: str, columns: "Sequence[str]", values: "Sequence[Sequence[Any]]") -> None: + """Buffer Spanner INSERT_OR_UPDATE mutations on the active transaction or batch.""" + self._apply_mutation(table, columns, values, method_name="insert_or_update") + + def write_update(self, table: str, columns: "Sequence[str]", values: "Sequence[Sequence[Any]]") -> None: + """Buffer Spanner UPDATE mutations on the active transaction or batch.""" + self._apply_mutation(table, columns, values, method_name="update") + + def write_delete(self, table: str, keyset: Any) -> None: + """Buffer a Spanner DELETE mutation on the active transaction or batch.""" + if not supports_write(self.connection): + raise SQLConversionError(_READ_ONLY_SNAPSHOT_ERROR_MESSAGE) + writer = cast("_SpannerWriteProtocol", self.connection) + exc_handler = self.handle_database_exceptions() + with exc_handler: + writer.delete(table, keyset) + self._check_pending_exception(exc_handler) + + def bind_external_transaction(self) -> None: + """Mark the driver as bound to an externally managed transaction runner.""" + self._owns_transaction = False + + def _apply_mutation( + self, table: str, columns: "Sequence[str]", values: "Sequence[Sequence[Any]]", *, method_name: str + ) -> None: + if not supports_write(self.connection): + raise SQLConversionError(_READ_ONLY_SNAPSHOT_ERROR_MESSAGE) + column_list = list(columns) + records = [tuple(row) for row in values] + if not records: + return + chunks = self._chunk_mutation_rows(column_list, records) + mutation_fn = getattr(self.connection, method_name) + exc_handler = self.handle_database_exceptions() + with exc_handler: + for chunk in chunks: + mutation_fn(table, column_list, chunk) + self._check_pending_exception(exc_handler) + def _execute_kwargs(self, *, for_read: bool = False, for_batch: bool = False) -> dict[str, Any]: return build_execute_kwargs( self.driver_features, self._pending_execute_options, for_read=for_read, for_batch=for_batch @@ -629,16 +703,6 @@ def _batch_write_mutations(self, table: str, columns: "list[str]", chunks: "list msg = f"Spanner batch_write group failed: {status.message}" raise SQLConversionError(msg) - def run_in_transaction(self, fn: "Callable[..., Any]", *args: Any, **kwargs: Any) -> Any: - """Execute a callable inside Spanner's retryable transaction runner. - - ``fn`` is called as ``fn(driver, *args, **kwargs)`` with a driver bound - to the runner's transaction. A ``DeadlockError`` caused by - ``google.api_core.exceptions.Aborted`` is unwrapped so the runner - retries, and a terminal ``Aborted`` is raised as ``DeadlockError``. - """ - return run_in_transaction_sync(self._resolve_database(), self._transaction_driver, fn, *args, **kwargs) - def _commit_before_ddl(self) -> None: """Commit a begun session transaction before a schema change, as DDL does on MySQL or Oracle.""" if self._owns_transaction and isinstance(self.connection, SpannerTransaction): @@ -655,6 +719,8 @@ def _transaction_driver(self, transaction: "SpannerSyncConnection") -> "SpannerS connection=transaction, statement_config=self.statement_config, driver_features=self.driver_features ) driver._owns_transaction = False + if self._observability is not None: + driver.attach_observability(self._observability) return driver def _connection_in_transaction(self) -> bool: @@ -708,13 +774,22 @@ def __init__( self._row_plan_cache: dict[int, tuple[Any, list[str], tuple[tuple[int, Any], ...] | None]] = {} self._row_plan_deserializer = cast("Callable[[str], Any]", features.get("json_deserializer", from_json)) + @property + def data_dictionary(self) -> "SpannerAsyncDataDictionary": + if self._data_dictionary is None: + dialect_str = str(self.statement_config.dialect) if self.statement_config.dialect else "spanner" + mode = "postgresql" if dialect_str in {"spangres", "postgres", "postgresql"} else "googlesql" + self._data_dictionary = SpannerAsyncDataDictionary(mode=mode) + return self._data_dictionary + async def dispatch_execute(self, cursor: "SpannerAsyncConnection", statement: "SQL") -> ExecutionResult: sql, params = self._compiled_sql(statement, self.statement_config) if is_ddl_statement(sql): if not supports_write(cursor): raise SQLConversionError(_READ_ONLY_ASYNC_SNAPSHOT_DDL_ERROR_MESSAGE) await self._commit_before_ddl() - await execute_ddl_async(self._resolve_database(), [sql]) + execute_kwargs = self._execute_kwargs() + await execute_ddl_async(self._resolve_database(), [sql], timeout=execute_kwargs.get("timeout")) return self.create_execution_result(cursor, rowcount_override=0) params = cast("dict[str, Any] | None", params) param_types_map = self._infer_param_types(params) @@ -819,6 +894,7 @@ async def dispatch_execute_script(self, cursor: "SpannerAsyncConnection", statem coerced_params = self._coerce_params(script_params) read_execute_kwargs = self._execute_kwargs(for_read=True) write_execute_kwargs = self._execute_kwargs() + ddl_timeout = write_execute_kwargs.get("timeout") dialect_str = str(self.dialect) if self.dialect else "spanner" pending_ddl: list[str] = [] for index, stmt in enumerate(statements): @@ -830,7 +906,7 @@ async def dispatch_execute_script(self, cursor: "SpannerAsyncConnection", statem continue if pending_ddl: await self._commit_before_ddl() - await execute_ddl_async(self._resolve_database(), pending_ddl) + await execute_ddl_async(self._resolve_database(), pending_ddl, timeout=ddl_timeout) pending_ddl = [] cursor = self.connection reader = cast("_SpannerAsyncReadProtocol", cursor) @@ -856,7 +932,7 @@ async def dispatch_execute_script(self, cursor: "SpannerAsyncConnection", statem if pending_ddl: await self._commit_before_ddl() - await execute_ddl_async(self._resolve_database(), pending_ddl) + await execute_ddl_async(self._resolve_database(), pending_ddl, timeout=ddl_timeout) return self.create_execution_result( cursor, statement_count=count, successful_statements=count, is_script_result=True ) @@ -991,7 +1067,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "AsyncRowStream[SchemaT]": ... - @overload def select_stream( self, @@ -1004,7 +1079,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "AsyncRowStream[dict[str, Any]]": ... - def select_stream( self, statement: "SQL | Statement | QueryBuilder", @@ -1086,7 +1160,7 @@ async def load_from_arrow( delete_sql = f"DELETE FROM {table_sql} WHERE TRUE" if isinstance(self.connection, SpannerAsyncTransaction): writer = cast("_SpannerAsyncWriteProtocol", self.connection) - await writer.execute_update(delete_sql) + await writer.execute_update(delete_sql, **self._execute_kwargs()) else: msg = "Delete requires a Transaction context." raise SQLConversionError(msg) @@ -1126,14 +1200,6 @@ async def load_from_storage( table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound ) - @property - def data_dictionary(self) -> "SpannerAsyncDataDictionary": - if self._data_dictionary is None: - dialect_str = str(self.statement_config.dialect) if self.statement_config.dialect else "spanner" - mode = "postgresql" if dialect_str in {"spangres", "postgres", "postgresql"} else "googlesql" - self._data_dictionary = SpannerAsyncDataDictionary(mode=mode) - return self._data_dictionary - def collect_rows( self, cursor: "SpannerAsyncConnection", fetched: "list[Any]" ) -> "tuple[list[Any], list[str], int]": @@ -1157,6 +1223,78 @@ def resolve_rowcount(self, cursor: "SpannerAsyncConnection") -> int: """ return 0 + async def run_in_transaction(self, fn: "Callable[..., Awaitable[Any]]", *args: Any, **kwargs: Any) -> Any: + """Execute a coroutine function inside Spanner's async retryable transaction runner. + + ``fn`` is awaited as ``fn(driver, *args, **kwargs)`` with a driver bound + to the runner's transaction. A ``DeadlockError`` caused by + ``google.api_core.exceptions.Aborted`` is unwrapped so the runner + retries, and a terminal ``Aborted`` is raised as ``DeadlockError``. + """ + return await run_in_transaction_async(self._resolve_database(), self._transaction_driver, fn, *args, **kwargs) + + async def execute_partitioned_dml( + self, + sql: str, + params: "dict[str, Any] | None" = None, + param_types: "dict[str, Any] | None" = None, + **kwargs: Any, + ) -> int: + """Execute a Partitioned DML statement against the async Spanner database.""" + call_kwargs = {**self._execute_kwargs(), **kwargs} + return await execute_partitioned_dml_async( + self._resolve_database(), + sql, + params=params, + param_types=param_types, + json_serializer=self.driver_features.get("json_serializer"), + enable_uuid_conversion=self.driver_features.get("enable_uuid_conversion", True), + **call_kwargs, + ) + + def write_insert(self, table: str, columns: "Sequence[str]", values: "Sequence[Sequence[Any]]") -> None: + """Buffer Spanner INSERT mutations on the active async transaction or batch.""" + self._apply_mutation(table, columns, values, method_name="insert") + + def write_upsert(self, table: str, columns: "Sequence[str]", values: "Sequence[Sequence[Any]]") -> None: + """Buffer Spanner INSERT_OR_UPDATE mutations on the active async transaction or batch.""" + self._apply_mutation(table, columns, values, method_name="insert_or_update") + + def write_update(self, table: str, columns: "Sequence[str]", values: "Sequence[Sequence[Any]]") -> None: + """Buffer Spanner UPDATE mutations on the active async transaction or batch.""" + self._apply_mutation(table, columns, values, method_name="update") + + def write_delete(self, table: str, keyset: Any) -> None: + """Buffer a Spanner DELETE mutation on the active async transaction or batch.""" + if not supports_write(self.connection): + raise SQLConversionError(_READ_ONLY_ASYNC_SNAPSHOT_ERROR_MESSAGE) + writer = cast("_SpannerAsyncWriteProtocol", self.connection) + try: + writer.delete(table, keyset) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc + + def bind_external_transaction(self) -> None: + """Mark the driver as bound to an externally managed transaction runner.""" + self._owns_transaction = False + + def _apply_mutation( + self, table: str, columns: "Sequence[str]", values: "Sequence[Sequence[Any]]", *, method_name: str + ) -> None: + if not supports_write(self.connection): + raise SQLConversionError(_READ_ONLY_ASYNC_SNAPSHOT_ERROR_MESSAGE) + column_list = list(columns) + records = [tuple(row) for row in values] + if not records: + return + chunks = self._chunk_mutation_rows(column_list, records) + mutation_fn = getattr(self.connection, method_name) + try: + for chunk in chunks: + mutation_fn(table, column_list, chunk) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc + def _execute_kwargs(self, *, for_read: bool = False, for_batch: bool = False) -> dict[str, Any]: return build_execute_kwargs( self.driver_features, self._pending_execute_options, for_read=for_read, for_batch=for_batch @@ -1181,16 +1319,6 @@ async def _batch_write_mutations(self, table: str, columns: "list[str]", chunks: msg = f"Spanner batch_write group failed: {status.message}" raise SQLConversionError(msg) - async def run_in_transaction(self, fn: "Callable[..., Awaitable[Any]]", *args: Any, **kwargs: Any) -> Any: - """Execute a coroutine function inside Spanner's async retryable transaction runner. - - ``fn`` is awaited as ``fn(driver, *args, **kwargs)`` with a driver bound - to the runner's transaction. A ``DeadlockError`` caused by - ``google.api_core.exceptions.Aborted`` is unwrapped so the runner - retries, and a terminal ``Aborted`` is raised as ``DeadlockError``. - """ - return await run_in_transaction_async(self._resolve_database(), self._transaction_driver, fn, *args, **kwargs) - async def _commit_before_ddl(self) -> None: """Commit a begun session transaction before a schema change, as DDL does on MySQL or Oracle.""" if self._owns_transaction and isinstance(self.connection, SpannerAsyncTransaction): @@ -1207,6 +1335,8 @@ def _transaction_driver(self, transaction: "SpannerAsyncConnection") -> "Spanner connection=transaction, statement_config=self.statement_config, driver_features=self.driver_features ) driver._owns_transaction = False + if self._observability is not None: + driver.attach_observability(self._observability) return driver def _connection_in_transaction(self) -> bool: @@ -1262,8 +1392,14 @@ def batch_update( self, batch: "list[tuple[str, dict[str, Any] | None, dict[str, Any]]]", **kwargs: Any ) -> "tuple[Any, list[int]]": ... + def insert(self, table: str, columns: "list[str]", values: "list[list[Any]]") -> None: ... + def insert_or_update(self, table: str, columns: "list[str]", values: "list[list[Any]]") -> None: ... + def update(self, table: str, columns: "list[str]", values: "list[list[Any]]") -> None: ... + + def delete(self, table: str, keyset: Any) -> None: ... + def commit(self) -> None: ... def rollback(self) -> None: ... @@ -1300,8 +1436,14 @@ async def batch_update( self, batch: "list[tuple[str, dict[str, Any] | None, dict[str, Any]]]", **kwargs: Any ) -> "tuple[Any, list[int]]": ... + def insert(self, table: str, columns: "list[str]", values: "list[list[Any]]") -> None: ... + def insert_or_update(self, table: str, columns: "list[str]", values: "list[list[Any]]") -> None: ... + def update(self, table: str, columns: "list[str]", values: "list[list[Any]]") -> None: ... + + def delete(self, table: str, keyset: Any) -> None: ... + async def commit(self) -> None: ... async def rollback(self) -> None: ... diff --git a/sqlspec/adapters/spanner/events/store.py b/sqlspec/adapters/spanner/events/store.py index 4f09ff0a3..77dbf4857 100644 --- a/sqlspec/adapters/spanner/events/store.py +++ b/sqlspec/adapters/spanner/events/store.py @@ -42,6 +42,26 @@ def table_name(self) -> str: ... def _index_name(self) -> str: ... + def create_statements(self) -> "list[str]": + """Return separate statements for table and index creation. + + Spanner requires DDL statements to be executed individually. + The caller should handle errors for already-existing objects. + """ + statements = [self._table_ddl()] + index_sql = self._index_ddl() + if index_sql: + statements.append(index_sql) + return statements + + def drop_statements(self) -> "list[str]": + """Return drop statements in reverse dependency order. + + Spanner requires index to be dropped before the table. + The caller should handle errors for non-existent objects. + """ + return [f"DROP INDEX {self._index_name()}", f"DROP TABLE {self.table_name}"] + def _column_types(self) -> "tuple[str, str, str]": """Return Spanner-specific column types.""" return "JSON", "JSON", "TIMESTAMP" @@ -168,26 +188,6 @@ def _wrap_drop_statement(self, statement: str) -> str: """Return statement unchanged because Spanner does not support IF EXISTS.""" return statement - def create_statements(self) -> "list[str]": - """Return separate statements for table and index creation. - - Spanner requires DDL statements to be executed individually. - The caller should handle errors for already-existing objects. - """ - statements = [self._table_ddl()] - index_sql = self._index_ddl() - if index_sql: - statements.append(index_sql) - return statements - - def drop_statements(self) -> "list[str]": - """Return drop statements in reverse dependency order. - - Spanner requires index to be dropped before the table. - The caller should handle errors for non-existent objects. - """ - return [f"DROP INDEX {self._index_name()}", f"DROP TABLE {self.table_name}"] - def _log_ddl(self, event: str, statement_count: int) -> None: log_with_context( logger, diff --git a/sqlspec/adapters/spanner/litestar/store.py b/sqlspec/adapters/spanner/litestar/store.py index f118b23ee..74de1b742 100644 --- a/sqlspec/adapters/spanner/litestar/store.py +++ b/sqlspec/adapters/spanner/litestar/store.py @@ -1,10 +1,12 @@ """Spanner session store for Litestar integration.""" from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, Any, NotRequired, cast +from typing import TYPE_CHECKING, Any, NotRequired, Protocol, cast +from sqlspec.adapters.spanner._typing import SpannerGoogleAPICallError from sqlspec.adapters.spanner._typing import spanner_param_types as param_types from sqlspec.adapters.spanner.core import ( + create_mapped_exception, execute_ddl_async, execute_ddl_sync, list_existing_table_names_async, @@ -17,7 +19,6 @@ if TYPE_CHECKING: from collections.abc import Callable - from typing import Protocol from sqlspec.adapters.spanner._typing import SpannerAsyncTransaction as AsyncTransaction from sqlspec.adapters.spanner._typing import SpannerTransaction as Transaction @@ -49,14 +50,6 @@ class SpannerLitestarConfig(LitestarConfig): """Index DDL options.""" -def _extract_spanner_litestar_options(config: Any) -> "tuple[int, str | None, str | None]": - litestar_cfg = cast("dict[str, Any]", config.extension_config.get("litestar", {})) - shard_count = int(litestar_cfg.get("shard_count", 0)) if litestar_cfg.get("shard_count") else 0 - table_options = cast("str | None", litestar_cfg.get("table_options")) - index_options = cast("str | None", litestar_cfg.get("index_options")) - return shard_count, table_options, index_options - - class _SpannerLitestarStoreCommonMixin: """Shared SQL, DDL, and parameter helpers for Spanner Litestar session stores.""" @@ -154,6 +147,14 @@ def _build_delete_all_sql(self) -> str: return f"DELETE FROM {self._table_name} WHERE TRUE" def _build_exists_sql(self) -> str: + if self._shard_count > 1: + return f""" + SELECT 1 FROM {self._table_name} + WHERE shard_id = MOD(FARM_FINGERPRINT(@session_id), {self._shard_count}) + AND session_id = @session_id + AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP()) + LIMIT 1 + """ return f""" SELECT 1 FROM {self._table_name} WHERE session_id = @session_id @@ -253,9 +254,15 @@ async def create_table(self) -> None: def _database(self) -> "_SpannerSyncDatabaseProtocol": return cast("_SpannerSyncDatabaseProtocol", self._config.get_database()) + def _run_in_transaction(self, job: "Callable[[Transaction], Any]") -> Any: + try: + return self._database().run_in_transaction(job) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc + def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": sql = self._build_select_session_sql() - with self._config.provide_session() as driver: + with self._config.provide_read_session() as driver: result = driver.select_one_or_none(sql, {"session_id": key}) if result is None: @@ -269,7 +276,7 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | update_sql = self._build_renew_session_sql() params = self._build_params(key, new_expires) types = self._get_param_types(expires_at=True) - self._database().run_in_transaction(_SpannerSyncExecuteUpdateJob(update_sql, params, types)) + self._run_in_transaction(_SpannerSyncExecuteUpdateJob(update_sql, params, types)) return spanner_to_bytes(data) @@ -279,27 +286,27 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No params = self._build_params(key, expires_at, data) types = self._get_param_types(session_id=True, expires_at=True, data=True) update_sql, insert_sql = self._build_upsert_sql() - self._database().run_in_transaction(_SpannerSyncUpsertJob(update_sql, insert_sql, params, types)) + self._run_in_transaction(_SpannerSyncUpsertJob(update_sql, insert_sql, params, types)) def _delete(self, key: str) -> None: sql = self._build_delete_sql() params = {"session_id": key} types = self._get_param_types(session_id=True) - self._database().run_in_transaction(_SpannerSyncExecuteUpdateJob(sql, params, types)) + self._run_in_transaction(_SpannerSyncExecuteUpdateJob(sql, params, types)) def _delete_all(self) -> None: sql = self._build_delete_all_sql() - self._database().run_in_transaction(_SpannerSyncExecuteUpdateJob(sql)) + self._run_in_transaction(_SpannerSyncExecuteUpdateJob(sql)) def _exists(self, key: str) -> bool: sql = self._build_exists_sql() - with self._config.provide_session() as driver: + with self._config.provide_read_session() as driver: row = driver.select_one_or_none(sql, {"session_id": key}) return row is not None def _expires_in(self, key: str) -> "int | None": sql = self._build_expires_in_sql() - with self._config.provide_session() as driver: + with self._config.provide_read_session() as driver: row = driver.select_one_or_none(sql, {"session_id": key}) if row is None: return None @@ -311,13 +318,16 @@ def _expires_in(self, key: str) -> "int | None": def _delete_expired(self) -> int: sql = self._build_delete_expired_sql() - result = self._database().run_in_transaction(_SpannerSyncExecuteUpdateCountJob(sql)) + result = self._run_in_transaction(_SpannerSyncExecuteUpdateCountJob(sql)) return cast("int", result) def _create_table(self) -> None: - database = self._config.get_database() - if self._table_name not in list_existing_table_names_sync(database): - execute_ddl_sync(database, [self._table_ddl(), self._index_ddl()], timeout=300) + try: + database = self._config.get_database() + if self._table_name not in list_existing_table_names_sync(database): + execute_ddl_sync(database, [self._table_ddl(), self._index_ddl()], timeout=300) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc class SpannerAsyncStore(_SpannerLitestarStoreCommonMixin, BaseSQLSpecStore["SpannerAsyncConfig"]): @@ -334,12 +344,9 @@ def __init__(self, config: "SpannerAsyncConfig") -> None: super().__init__(config) self._shard_count, self._table_options, self._index_options = _extract_spanner_litestar_options(config) - async def _database(self) -> "_SpannerAsyncDatabaseProtocol": - return cast("_SpannerAsyncDatabaseProtocol", await self._config.get_database()) - async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": sql = self._build_select_session_sql() - async with self._config.provide_session() as driver: + async with self._config.provide_read_session() as driver: result = await driver.select_one_or_none(sql, {"session_id": key}) if result is None: @@ -353,8 +360,7 @@ async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "by update_sql = self._build_renew_session_sql() params = self._build_params(key, new_expires) types = self._get_param_types(expires_at=True) - database = await self._database() - await database.run_in_transaction(_SpannerAsyncExecuteUpdateJob(update_sql, params, types)) + await self._run_in_transaction(_SpannerAsyncExecuteUpdateJob(update_sql, params, types)) return spanner_to_bytes(data) @@ -364,30 +370,27 @@ async def set(self, key: str, value: "str | bytes", expires_in: "int | timedelta params = self._build_params(key, expires_at, data) types = self._get_param_types(session_id=True, expires_at=True, data=True) update_sql, insert_sql = self._build_upsert_sql() - database = await self._database() - await database.run_in_transaction(_SpannerAsyncUpsertJob(update_sql, insert_sql, params, types)) + await self._run_in_transaction(_SpannerAsyncUpsertJob(update_sql, insert_sql, params, types)) async def delete(self, key: str) -> None: sql = self._build_delete_sql() params = {"session_id": key} types = self._get_param_types(session_id=True) - database = await self._database() - await database.run_in_transaction(_SpannerAsyncExecuteUpdateJob(sql, params, types)) + await self._run_in_transaction(_SpannerAsyncExecuteUpdateJob(sql, params, types)) async def delete_all(self) -> None: sql = self._build_delete_all_sql() - database = await self._database() - await database.run_in_transaction(_SpannerAsyncExecuteUpdateJob(sql)) + await self._run_in_transaction(_SpannerAsyncExecuteUpdateJob(sql)) async def exists(self, key: str) -> bool: sql = self._build_exists_sql() - async with self._config.provide_session() as driver: + async with self._config.provide_read_session() as driver: row = await driver.select_one_or_none(sql, {"session_id": key}) return row is not None async def expires_in(self, key: str) -> "int | None": sql = self._build_expires_in_sql() - async with self._config.provide_session() as driver: + async with self._config.provide_read_session() as driver: row = await driver.select_one_or_none(sql, {"session_id": key}) if row is None: return None @@ -399,8 +402,7 @@ async def expires_in(self, key: str) -> "int | None": async def delete_expired(self) -> int: sql = self._build_delete_expired_sql() - database = await self._database() - result = await database.run_in_transaction(_SpannerAsyncExecuteUpdateCountJob(sql)) + result = await self._run_in_transaction(_SpannerAsyncExecuteUpdateCountJob(sql)) return int(result) async def create_table(self) -> None: @@ -410,10 +412,29 @@ async def create_table(self) -> None: await self._create_table() await self.reconcile_schema(assume_existing=True) + async def _database(self) -> "_SpannerAsyncDatabaseProtocol": + return cast("_SpannerAsyncDatabaseProtocol", await self._config.get_database()) + + async def _run_in_transaction(self, job: "Callable[[AsyncTransaction], Any]") -> Any: + try: + database = await self._database() + return await database.run_in_transaction(job) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc + async def _create_table(self) -> None: - database = await self._config.get_database() - if self._table_name not in await list_existing_table_names_async(database): - await execute_ddl_async(database, [self._table_ddl(), self._index_ddl()], timeout=300) + try: + database = await self._config.get_database() + if self._table_name not in await list_existing_table_names_async(database): + await execute_ddl_async(database, [self._table_ddl(), self._index_ddl()], timeout=300) + except SpannerGoogleAPICallError as exc: + raise create_mapped_exception(exc) from exc + + +class _SpannerSyncTransactionProtocol(Protocol): + def execute_update( + self, sql: str, params: "dict[str, Any] | None" = None, param_types: "dict[str, Any] | None" = None + ) -> int: ... class _SpannerSyncExecuteUpdateJob: @@ -425,10 +446,11 @@ def __init__(self, sql: str, params: "dict[str, Any] | None" = None, types: "dic self._types = types def __call__(self, transaction: "Transaction") -> None: + writer = cast("_SpannerSyncTransactionProtocol", transaction) if self._params is None and self._types is None: - transaction.execute_update(self._sql) # type: ignore[no-untyped-call] + writer.execute_update(self._sql) return - transaction.execute_update(self._sql, params=self._params or {}, param_types=self._types) # type: ignore[no-untyped-call] + writer.execute_update(self._sql, params=self._params or {}, param_types=self._types) class _SpannerAsyncExecuteUpdateJob: @@ -456,9 +478,10 @@ def __init__(self, update_sql: str, insert_sql: str, params: "dict[str, Any]", t self._types = types def __call__(self, transaction: "Transaction") -> None: - row_ct = transaction.execute_update(self._update_sql, params=self._params, param_types=self._types) # type: ignore[no-untyped-call] + writer = cast("_SpannerSyncTransactionProtocol", transaction) + row_ct = writer.execute_update(self._update_sql, params=self._params, param_types=self._types) if row_ct == 0: - transaction.execute_update(self._insert_sql, params=self._params, param_types=self._types) # type: ignore[no-untyped-call] + writer.execute_update(self._insert_sql, params=self._params, param_types=self._types) class _SpannerAsyncUpsertJob: @@ -483,7 +506,8 @@ def __init__(self, sql: str) -> None: self._sql = sql def __call__(self, transaction: "Transaction") -> int: - return int(transaction.execute_update(self._sql)) # type: ignore[no-untyped-call] + writer = cast("_SpannerSyncTransactionProtocol", transaction) + return int(writer.execute_update(self._sql)) class _SpannerAsyncExecuteUpdateCountJob: @@ -494,3 +518,11 @@ def __init__(self, sql: str) -> None: async def __call__(self, transaction: "AsyncTransaction") -> int: return int(await transaction.execute_update(self._sql)) + + +def _extract_spanner_litestar_options(config: Any) -> "tuple[int, str | None, str | None]": + litestar_cfg = cast("dict[str, Any]", config.extension_config.get("litestar", {})) + shard_count = int(litestar_cfg.get("shard_count", 0)) if litestar_cfg.get("shard_count") else 0 + table_options = cast("str | None", litestar_cfg.get("table_options")) + index_options = cast("str | None", litestar_cfg.get("index_options")) + return shard_count, table_options, index_options diff --git a/sqlspec/adapters/spanner/type_converter.py b/sqlspec/adapters/spanner/type_converter.py index f20ab9eb0..1863d0b24 100644 --- a/sqlspec/adapters/spanner/type_converter.py +++ b/sqlspec/adapters/spanner/type_converter.py @@ -27,6 +27,7 @@ from sqlspec.adapters.spanner._typing import spanner_param_types as param_types from sqlspec.core import TypedParameter from sqlspec.utils.module_loader import import_optional_attr +from sqlspec.utils.serializers import from_json from sqlspec.utils.type_converters import should_json_encode_sequence from sqlspec.utils.uuids import uuid_from_bytes @@ -52,6 +53,20 @@ _STRING_PARAM_TYPES: "tuple[type[Any], ...]" = (str, *_UUID_TYPES) +_NULL_PARAM_TYPE_NAMES: "dict[type[Any], str]" = { + bool: "BOOL", + int: "INT64", + float: "FLOAT64", + str: "STRING", + bytes: "BYTES", + datetime: "TIMESTAMP", + date: "DATE", + timedelta: "INTERVAL", + Decimal: "NUMERIC", + dict: "JSON", + **dict.fromkeys(_UUID_TYPES, "STRING"), +} + UUID_BYTE_LENGTH: int = 16 _SPANNER_PARAM_TYPES: "SpannerParamTypesProtocol | None" = None _JSON_OBJECT_TYPE: "type[Any] | None" = None @@ -127,11 +142,13 @@ def spanner_to_uuid(value: "bytes | None") -> "UUID | bytes | None": return value -def spanner_json(value: Any) -> Any: +def spanner_json(value: Any, json_serializer: "Callable[[Any], str] | None" = None) -> Any: """Wrap JSON values for Spanner JSON parameters. Args: value: JSON-compatible value (dict, list, tuple, or scalar). + json_serializer: Optional custom JSON serializer used to normalize values + before constructing ``JsonObject``. Returns: JsonObject wrapper when available, otherwise the original value. @@ -139,6 +156,9 @@ def spanner_json(value: Any) -> Any: json_type = _get_json_object_type() if isinstance(value, json_type): return value + if json_serializer is not None: + normalized = from_json(json_serializer(value)) + return json_type(normalized) return json_type(value) @@ -159,7 +179,7 @@ def coerce_params_for_spanner( Args: params: Parameter dictionary or None. - json_serializer: Optional JSON serializer (unused for JSON dicts). + json_serializer: Optional JSON serializer used to normalize JSON dicts/lists. enable_uuid_conversion: Enable automatic UUID string conversion. Returns: @@ -203,43 +223,107 @@ def coerce_params_for_spanner( elif isinstance(value, json_object_type): coerced[key] = value elif isinstance(value, dict): - coerced[key] = spanner_json(value) + coerced[key] = spanner_json(value, json_serializer=json_serializer) changed = True elif isinstance(value, (list, tuple)): - if any(isinstance(item, timedelta) for item in value): - coerced[key] = [ - Interval(days=item.days, nanos=(item.seconds * 1_000_000 + item.microseconds) * 1000) - if isinstance(item, timedelta) - else item - for item in value - ] - changed = True - elif should_json_encode_sequence(value): - coerced[key] = spanner_json(list(value)) - changed = True - elif isinstance(value, tuple): - coerced[key] = list(value) + if should_json_encode_sequence(value): + coerced[key] = spanner_json(list(value), json_serializer=json_serializer) changed = True else: - coerced[key] = value + coerced_seq, seq_changed = _coerce_sequence_elements( + value, enable_uuid_conversion=enable_uuid_conversion + ) + coerced[key] = coerced_seq + if seq_changed: + changed = True else: coerced[key] = value return coerced if changed else params -_NULL_PARAM_TYPE_NAMES: "dict[type[Any], str]" = { - bool: "BOOL", - int: "INT64", - float: "FLOAT64", - str: "STRING", - bytes: "BYTES", - datetime: "TIMESTAMP", - date: "DATE", - timedelta: "INTERVAL", - Decimal: "NUMERIC", - UUID: "STRING", - dict: "JSON", -} +def infer_spanner_param_types(params: "dict[str, Any] | None") -> "dict[str, Any]": + """Infer Spanner param_types from Python values. + + Args: + params: Parameter dictionary or None. + + Returns: + Dictionary mapping parameter names to Spanner param_types. + """ + if not params: + return {} + + param_types = _get_param_types() + json_object_type = _get_json_object_type() + types: dict[str, Any] = {} + json_type = _json_param_type() + for key, raw_value in params.items(): + is_typed = type(raw_value) is TypedParameter + value = raw_value.value if is_typed else raw_value + if is_typed: + declared_param_type = _resolve_declared_param_type(raw_value, param_types) + if declared_param_type is not None: + types[key] = declared_param_type + continue + if value is None: + null_type = _null_param_type(raw_value, param_types) + if null_type is not None: + types[key] = null_type + continue + if isinstance(value, bool): + types[key] = param_types.BOOL + elif isinstance(value, int): + types[key] = param_types.INT64 + elif isinstance(value, float): + types[key] = param_types.FLOAT64 + elif isinstance(value, Decimal): + types[key] = param_types.NUMERIC + elif isinstance(value, _STRING_PARAM_TYPES): + types[key] = param_types.STRING + elif isinstance(value, bytes): + types[key] = param_types.BYTES + elif isinstance(value, datetime): + types[key] = param_types.TIMESTAMP + elif isinstance(value, date): + types[key] = param_types.DATE + elif isinstance(value, timedelta): + types[key] = param_types.INTERVAL + elif isinstance(value, (dict, json_object_type)): + types[key] = json_type + elif isinstance(value, (list, tuple)): + seq_type = _infer_sequence_param_type(value, param_types, json_type) + if seq_type is not None: + types[key] = seq_type + return types + + +def _coerce_sequence_elements( + value: "list[Any] | tuple[Any, ...]", *, enable_uuid_conversion: bool +) -> "tuple[list[Any] | tuple[Any, ...], bool]": + """Coerce elements inside a 1-D sequence parameter when required by Spanner.""" + needs_element_coercion = any( + isinstance(item, (timedelta, bytes)) + or (enable_uuid_conversion and isinstance(item, _UUID_TYPES)) + or (isinstance(item, datetime) and item.tzinfo is None) + for item in value + ) + if not needs_element_coercion: + if isinstance(value, tuple): + return list(value), True + return value, False + coerced_items: list[Any] = [] + for item in value: + if isinstance(item, timedelta): + coerced_items.append(Interval(days=item.days, nanos=(item.seconds * 1_000_000 + item.microseconds) * 1000)) + elif enable_uuid_conversion and isinstance(item, _UUID_TYPES): + coerced_items.append(str(item)) + elif isinstance(item, bytes): + coerced_items.append(bytes_to_spanner(item)) + elif isinstance(item, datetime) and item.tzinfo is None: + coerced_items.append(item.replace(tzinfo=UTC)) + else: + coerced_items.append(item) + return coerced_items, True def _resolve_declared_param_type( @@ -291,78 +375,30 @@ def _infer_sequence_param_type(value: Any, param_types: Any, json_type: Any) -> return json_type if not value: return None - first = value[0] + first = next((item for item in value if item is not None), None) + if first is None: + return None if isinstance(first, bool): return param_types.Array(param_types.BOOL) if isinstance(first, int): return param_types.Array(param_types.INT64) - if isinstance(first, str): + if isinstance(first, _STRING_PARAM_TYPES): return param_types.Array(param_types.STRING) if isinstance(first, float): return param_types.Array(param_types.FLOAT64) if isinstance(first, Decimal): return param_types.Array(param_types.NUMERIC) + if isinstance(first, bytes): + return param_types.Array(param_types.BYTES) + if isinstance(first, datetime): + return param_types.Array(param_types.TIMESTAMP) + if isinstance(first, date): + return param_types.Array(param_types.DATE) if isinstance(first, timedelta): return param_types.Array(param_types.INTERVAL) return None -def infer_spanner_param_types(params: "dict[str, Any] | None") -> "dict[str, Any]": - """Infer Spanner param_types from Python values. - - Args: - params: Parameter dictionary or None. - - Returns: - Dictionary mapping parameter names to Spanner param_types. - """ - if not params: - return {} - - param_types = _get_param_types() - json_object_type = _get_json_object_type() - types: dict[str, Any] = {} - json_type = _json_param_type() - for key, raw_value in params.items(): - is_typed = type(raw_value) is TypedParameter - value = raw_value.value if is_typed else raw_value - if is_typed: - declared_param_type = _resolve_declared_param_type(raw_value, param_types) - if declared_param_type is not None: - types[key] = declared_param_type - continue - if value is None: - null_type = _null_param_type(raw_value, param_types) - if null_type is not None: - types[key] = null_type - continue - if isinstance(value, bool): - types[key] = param_types.BOOL - elif isinstance(value, int): - types[key] = param_types.INT64 - elif isinstance(value, float): - types[key] = param_types.FLOAT64 - elif isinstance(value, Decimal): - types[key] = param_types.NUMERIC - elif isinstance(value, _STRING_PARAM_TYPES): - types[key] = param_types.STRING - elif isinstance(value, bytes): - types[key] = param_types.BYTES - elif isinstance(value, datetime): - types[key] = param_types.TIMESTAMP - elif isinstance(value, date): - types[key] = param_types.DATE - elif isinstance(value, timedelta): - types[key] = param_types.INTERVAL - elif isinstance(value, (dict, json_object_type)): - types[key] = json_type - elif isinstance(value, (list, tuple)): - seq_type = _infer_sequence_param_type(value, param_types, json_type) - if seq_type is not None: - types[key] = seq_type - return types - - def _null_param_type(raw_value: Any, param_types: "SpannerParamTypesProtocol") -> "Any | None": """Resolve the Spanner type for a NULL parameter, when one is declared. diff --git a/sqlspec/adapters/sqlite/_typing.py b/sqlspec/adapters/sqlite/_typing.py index a8285c207..e2661eff7 100644 --- a/sqlspec/adapters/sqlite/_typing.py +++ b/sqlspec/adapters/sqlite/_typing.py @@ -5,11 +5,10 @@ """ import contextlib -import sqlite3 import sqlite3 as sqlite_module from typing import TYPE_CHECKING, Any -_SqliteConnection = sqlite3.Connection +_SqliteConnection = sqlite_module.Connection if TYPE_CHECKING: from collections.abc import Callable @@ -20,12 +19,12 @@ from sqlspec.core import StatementConfig SqliteConnection: TypeAlias = _SqliteConnection - SqliteConnectionFactory: TypeAlias = type[sqlite3.Connection] - SqliteRawCursor: TypeAlias = sqlite3.Cursor + SqliteConnectionFactory: TypeAlias = type[sqlite_module.Connection] + SqliteRawCursor: TypeAlias = sqlite_module.Cursor else: SqliteConnection = _SqliteConnection - SqliteConnectionFactory = type[sqlite3.Connection] - SqliteRawCursor = sqlite3.Cursor + SqliteConnectionFactory = type[sqlite_module.Connection] + SqliteRawCursor = sqlite_module.Cursor __all__ = ( "SqliteConnection", diff --git a/sqlspec/adapters/sqlite/adk/store.py b/sqlspec/adapters/sqlite/adk/store.py index cef7990ca..a8e563ea6 100644 --- a/sqlspec/adapters/sqlite/adk/store.py +++ b/sqlspec/adapters/sqlite/adk/store.py @@ -1,12 +1,20 @@ """SQLite sync ADK store for Google Agent Development Kit session/event storage.""" +import contextlib import re from collections.abc import Mapping from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, cast from sqlspec.adapters.sqlite._typing import sqlite_module as sqlite3 -from sqlspec.adapters.sqlite.core import end_transaction, render_pragmas +from sqlspec.adapters.sqlite.core import ( + JULIAN_EPOCH, + SECONDS_PER_DAY, + datetime_to_julian, + end_transaction, + julian_to_datetime, + render_pragmas, +) from sqlspec.config import ADKConfig from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.adk import BaseSyncADKStore, StoredEvent, StoredSession, normalize_session_list_options @@ -21,11 +29,9 @@ from sqlspec.adapters.sqlite.config import SqliteConfig from sqlspec.extensions.adk import SessionOrderBy, StoredMemory -__all__ = ("SqliteADKConfig", "SqliteADKMemoryStore", "SqliteADKStore") +__all__ = ("JULIAN_EPOCH", "SECONDS_PER_DAY", "SqliteADKConfig", "SqliteADKMemoryStore", "SqliteADKStore") -SECONDS_PER_DAY = 86400.0 -JULIAN_EPOCH = 2440587.5 SQLITE_TABLE_NOT_FOUND_ERROR: Final = "no such table" _FTS_DETAIL_VALUES: Final = frozenset({"full", "column", "none"}) _FTS_TOKENIZE_PATTERN: Final = re.compile(r"^[A-Za-z0-9_ -]+$") @@ -69,7 +75,7 @@ class SqliteADKStore(BaseSyncADKStore["SqliteConfig"]): config: SqliteConfig instance with extension_config["adk"] settings. """ - __slots__ = ("_pragma_overrides",) + __slots__ = ("_configured_connection_ids", "_pragma_overrides") def __init__(self, config: "SqliteConfig") -> None: """Initialize SQLite ADK store. @@ -79,6 +85,7 @@ def __init__(self, config: "SqliteConfig") -> None: """ super().__init__(config) self._pragma_overrides = _pragma_overrides(config) + self._configured_connection_ids: set[int] = set() def create_tables(self) -> None: """Create both sessions and events tables if they don't exist.""" @@ -93,6 +100,7 @@ def create_tables(self) -> None: driver.execute_script(self._app_states_table_ddl()) driver.execute_script(self._user_states_table_ddl()) driver.execute_script(self._metadata_table_ddl()) + end_transaction(driver.connection, commit=True) def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None @@ -129,8 +137,8 @@ def create_session( params = (session_id, app_name, user_id, state_json, now_julian, now_julian) with self._config.provide_connection() as conn: - self._apply_pragmas(conn) - conn.execute(sql, params) + self._ensure_pragmas(conn) + _close_cursor(conn.execute(sql, params)) end_transaction(conn, commit=True) return StoredSession( @@ -173,15 +181,19 @@ def get_session( try: with self._config.provide_connection() as conn: - self._apply_pragmas(conn) + self._ensure_pragmas(conn) if update_sql: - conn.execute(update_sql, update_params) + _close_cursor(conn.execute(update_sql, update_params)) end_transaction(conn, commit=True) cursor = conn.execute(sql, params) - row = cursor.fetchone() + try: + raw_row = cursor.fetchone() + finally: + _close_cursor(cursor) - if row is None: + if raw_row is None: return None + row = _row_values(raw_row) return StoredSession( id=row[0], @@ -215,8 +227,8 @@ def update_session_state(self, app_name: str, user_id: str, session_id: str, sta """ with self._config.provide_connection() as conn: - self._apply_pragmas(conn) - conn.execute(sql, (state_json, now_julian, app_name, user_id, session_id)) + self._ensure_pragmas(conn) + _close_cursor(conn.execute(sql, (state_json, now_julian, app_name, user_id, session_id))) end_transaction(conn, commit=True) def list_sessions( @@ -252,9 +264,12 @@ def list_sessions( try: with self._config.provide_connection() as conn: - self._apply_pragmas(conn) + self._ensure_pragmas(conn) cursor = conn.execute(sql, params) - rows = cursor.fetchall() + try: + rows = [_row_values(row) for row in cursor.fetchall()] + finally: + _close_cursor(cursor) return [ StoredSession( @@ -283,8 +298,8 @@ def delete_session(self, app_name: str, user_id: str, session_id: str) -> None: sql = f"DELETE FROM {self._session_table} WHERE app_name = ? AND user_id = ? AND id = ?" with self._config.provide_connection() as conn: - self._apply_pragmas(conn) - conn.execute(sql, (app_name, user_id, session_id)) + self._ensure_pragmas(conn) + _close_cursor(conn.execute(sql, (app_name, user_id, session_id))) end_transaction(conn, commit=True) def append_event(self, event_record: StoredEvent) -> None: @@ -303,18 +318,20 @@ def append_event(self, event_record: StoredEvent) -> None: """ with self._config.provide_connection() as conn: - self._apply_pragmas(conn) - conn.execute( - sql, - ( - event_record["id"], - event_record["app_name"], - event_record["user_id"], - event_record["session_id"], - event_record["invocation_id"], - timestamp_julian, - event_data_json, - ), + self._ensure_pragmas(conn) + _close_cursor( + conn.execute( + sql, + ( + event_record["id"], + event_record["app_name"], + event_record["user_id"], + event_record["session_id"], + event_record["invocation_id"], + timestamp_julian, + event_data_json, + ), + ) ) end_transaction(conn, commit=True) @@ -379,27 +396,35 @@ def append_event_and_update_state( """ with self._config.provide_connection() as conn: - self._apply_pragmas(conn) + self._ensure_pragmas(conn) try: cursor = conn.execute(update_sql, (state_json, now_julian, app_name, user_id, session_id)) - row = cursor.fetchone() + try: + raw_row = cursor.fetchone() + finally: + _close_cursor(cursor) + row = _row_values(raw_row) if raw_row is not None else None if row is not None: - conn.execute( - insert_sql, - ( - event_record["id"], - app_name, - user_id, - event_record["session_id"], - event_record["invocation_id"], - timestamp_julian, - event_data_json, - ), + _close_cursor( + conn.execute( + insert_sql, + ( + event_record["id"], + app_name, + user_id, + event_record["session_id"], + event_record["invocation_id"], + timestamp_julian, + event_data_json, + ), + ) ) if app_state is not None: - conn.execute(app_upsert_sql, (app_name, to_json(app_state), now_julian)) + _close_cursor(conn.execute(app_upsert_sql, (app_name, to_json(app_state), now_julian))) if user_state is not None: - conn.execute(user_upsert_sql, (app_name, user_id, to_json(user_state), now_julian)) + _close_cursor( + conn.execute(user_upsert_sql, (app_name, user_id, to_json(user_state), now_julian)) + ) except Exception: end_transaction(conn, commit=False) raise @@ -464,9 +489,12 @@ def get_events( try: with self._config.provide_connection() as conn: - self._apply_pragmas(conn) + self._ensure_pragmas(conn) cursor = conn.execute(sql, params) - rows = cursor.fetchall() + try: + rows = [_row_values(row) for row in cursor.fetchall()] + finally: + _close_cursor(cursor) return [ StoredEvent( @@ -495,9 +523,12 @@ def delete_expired_events(self, before: datetime, app_name: "str | None" = None) try: with self._config.provide_connection() as conn: - self._apply_pragmas(conn) + self._ensure_pragmas(conn) cursor = conn.execute(sql, tuple(params)) - deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + try: + deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + finally: + _close_cursor(cursor) end_transaction(conn, commit=True) return deleted_count except sqlite3.OperationalError as exc: @@ -515,9 +546,12 @@ def delete_idle_sessions(self, updated_before: datetime, app_name: "str | None" try: with self._config.provide_connection() as conn: - self._apply_pragmas(conn) + self._ensure_pragmas(conn) cursor = conn.execute(sql, tuple(params)) - deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + try: + deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + finally: + _close_cursor(cursor) end_transaction(conn, commit=True) return deleted_count except sqlite3.OperationalError as exc: @@ -535,9 +569,12 @@ def delete_idle_user_states(self, updated_before: datetime, app_name: "str | Non try: with self._config.provide_connection() as conn: - self._apply_pragmas(conn) + self._ensure_pragmas(conn) cursor = conn.execute(sql, tuple(params)) - deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + try: + deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + finally: + _close_cursor(cursor) end_transaction(conn, commit=True) return deleted_count except sqlite3.OperationalError as exc: @@ -551,9 +588,13 @@ def get_app_state(self, app_name: str) -> "dict[str, Any] | None": try: with self._config.provide_connection() as conn: - self._apply_pragmas(conn) + self._ensure_pragmas(conn) cursor = conn.execute(sql, (app_name,)) - row = cursor.fetchone() + try: + raw_row = cursor.fetchone() + finally: + _close_cursor(cursor) + row = _row_values(raw_row) if raw_row is not None else None return from_json(row[0]) if row is not None and row[0] else None except sqlite3.OperationalError as exc: if SQLITE_TABLE_NOT_FOUND_ERROR in str(exc): @@ -570,9 +611,13 @@ def get_user_state(self, app_name: str, user_id: str) -> "dict[str, Any] | None" try: with self._config.provide_connection() as conn: - self._apply_pragmas(conn) + self._ensure_pragmas(conn) cursor = conn.execute(sql, (app_name, user_id)) - row = cursor.fetchone() + try: + raw_row = cursor.fetchone() + finally: + _close_cursor(cursor) + row = _row_values(raw_row) if raw_row is not None else None return from_json(row[0]) if row is not None and row[0] else None except sqlite3.OperationalError as exc: if SQLITE_TABLE_NOT_FOUND_ERROR in str(exc): @@ -590,8 +635,8 @@ def upsert_app_state(self, app_name: str, state: "dict[str, Any]") -> None: """ with self._config.provide_connection() as conn: - self._apply_pragmas(conn) - conn.execute(sql, (app_name, to_json(state), _datetime_to_julian(datetime.now(UTC)))) + self._ensure_pragmas(conn) + _close_cursor(conn.execute(sql, (app_name, to_json(state), _datetime_to_julian(datetime.now(UTC))))) end_transaction(conn, commit=True) def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]") -> None: @@ -605,8 +650,10 @@ def upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any]" """ with self._config.provide_connection() as conn: - self._apply_pragmas(conn) - conn.execute(sql, (app_name, user_id, to_json(state), _datetime_to_julian(datetime.now(UTC)))) + self._ensure_pragmas(conn) + _close_cursor( + conn.execute(sql, (app_name, user_id, to_json(state), _datetime_to_julian(datetime.now(UTC)))) + ) end_transaction(conn, commit=True) def get_metadata(self, key: str) -> "str | None": @@ -615,9 +662,13 @@ def get_metadata(self, key: str) -> "str | None": try: with self._config.provide_connection() as conn: - self._apply_pragmas(conn) + self._ensure_pragmas(conn) cursor = conn.execute(sql, (key,)) - row = cursor.fetchone() + try: + raw_row = cursor.fetchone() + finally: + _close_cursor(cursor) + row = _row_values(raw_row) if raw_row is not None else None return str(row[0]) if row is not None else None except sqlite3.OperationalError as exc: if SQLITE_TABLE_NOT_FOUND_ERROR in str(exc): @@ -633,22 +684,28 @@ def set_metadata(self, key: str, value: str) -> None: """ with self._config.provide_connection() as conn: - self._apply_pragmas(conn) - conn.execute(sql, (key, value)) + self._ensure_pragmas(conn) + _close_cursor(conn.execute(sql, (key, value))) end_transaction(conn, commit=True) + def _ensure_pragmas(self, connection: Any) -> None: + """Apply PRAGMA optimization profile once per connection.""" + if id(connection) not in self._configured_connection_ids: + self._apply_pragmas(connection) + def _apply_pragmas(self, connection: Any) -> None: """Apply PRAGMA optimization profile for this connection. Args: connection: SQLite connection. """ - connection.execute("PRAGMA foreign_keys = ON") - connection.execute("PRAGMA cache_size = -64000") - connection.execute("PRAGMA mmap_size = 30000000") - connection.execute("PRAGMA journal_size_limit = 67108864") + _close_cursor(connection.execute("PRAGMA foreign_keys = ON")) + _close_cursor(connection.execute("PRAGMA cache_size = -64000")) + _close_cursor(connection.execute("PRAGMA mmap_size = 30000000")) + _close_cursor(connection.execute("PRAGMA journal_size_limit = 67108864")) for pragma_name, pragma_value in self._pragma_overrides: - connection.execute(f"PRAGMA {pragma_name} = {pragma_value}") + _close_cursor(connection.execute(f"PRAGMA {pragma_name} = {pragma_value}")) + self._configured_connection_ids.add(id(connection)) def _sessions_table_ddl(self) -> str: """Get SQLite CREATE TABLE SQL for sessions. @@ -768,7 +825,7 @@ class SqliteADKMemoryStore(BaseSyncADKMemoryStore["SqliteConfig"]): config: SqliteConfig with extension_config["adk"] settings. """ - __slots__ = ("_fts_options",) + __slots__ = ("_configured_connection_ids", "_fts_options", "_pragma_overrides") def __init__(self, config: "SqliteConfig") -> None: """Initialize SQLite ADK memory store. @@ -778,6 +835,8 @@ def __init__(self, config: "SqliteConfig") -> None: """ super().__init__(config) self._fts_options = _fts_options(config) + self._pragma_overrides = _pragma_overrides(config) + self._configured_connection_ids: set[int] = set() def create_tables(self) -> None: """Create the memory table and indexes if they don't exist. @@ -794,6 +853,7 @@ def create_tables(self) -> None: with self._config.provide_session() as driver: self._enable_foreign_keys(driver.connection) driver.execute_script(self._memory_table_ddl()) + end_transaction(driver.connection, commit=True) def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object | None" = None) -> int: """Bulk insert memory entries with deduplication. @@ -819,7 +879,7 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object return 0 with self._config.provide_connection() as conn: - self._enable_foreign_keys(conn) + self._ensure_foreign_keys(conn) params_list: list[tuple[Any, ...]] = [] if self._owner_id_column_name: @@ -882,7 +942,10 @@ def insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "object )) cursor = conn.executemany(sql, params_list) - inserted_count = cursor.rowcount if cursor.rowcount >= 0 else len(params_list) + try: + inserted_count = cursor.rowcount if cursor.rowcount >= 0 else len(params_list) + finally: + _close_cursor(cursor) end_transaction(conn, commit=True) return inserted_count @@ -909,6 +972,11 @@ def search_entries( if self._use_fts: try: return self._search_entries_fts(query, app_name, user_id, effective_limit, scope_filter) + except sqlite3.OperationalError as exc: + error_text = str(exc) + if SQLITE_TABLE_NOT_FOUND_ERROR in error_text and f"{self._memory_table}_fts" not in error_text: + return [] + logger.warning("FTS search failed; falling back to simple search: %s", exc) except Exception as exc: logger.warning("FTS search failed; falling back to simple search: %s", exc) try: @@ -926,13 +994,21 @@ def delete_entries_by_session(self, session_id: str) -> int: sql = f"DELETE FROM {self._memory_table} WHERE session_id = ?" - with self._config.provide_connection() as conn: - self._enable_foreign_keys(conn) - cursor = conn.execute(sql, (session_id,)) - deleted_count = cursor.rowcount - end_transaction(conn, commit=True) - - return deleted_count + try: + with self._config.provide_connection() as conn: + self._ensure_foreign_keys(conn) + cursor = conn.execute(sql, (session_id,)) + try: + deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + finally: + _close_cursor(cursor) + end_transaction(conn, commit=True) + except sqlite3.OperationalError as exc: + if SQLITE_TABLE_NOT_FOUND_ERROR in str(exc): + return 0 + raise + else: + return deleted_count def delete_entries_older_than(self, days: int, app_name: "str | None" = None, scope: "str | None" = None) -> int: """Delete memory entries older than specified days.""" @@ -951,13 +1027,21 @@ def delete_entries_older_than(self, days: int, app_name: "str | None" = None, sc sql += " AND scope = ?" params.append(scope) - with self._config.provide_connection() as conn: - self._enable_foreign_keys(conn) - cursor = conn.execute(sql, tuple(params)) - deleted_count = cursor.rowcount - end_transaction(conn, commit=True) - - return deleted_count + try: + with self._config.provide_connection() as conn: + self._ensure_foreign_keys(conn) + cursor = conn.execute(sql, tuple(params)) + try: + deleted_count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + finally: + _close_cursor(cursor) + end_transaction(conn, commit=True) + except sqlite3.OperationalError as exc: + if SQLITE_TABLE_NOT_FOUND_ERROR in str(exc): + return 0 + raise + else: + return deleted_count def _memory_table_ddl(self) -> str: """Get SQLite CREATE TABLE SQL for memory entries. @@ -1026,28 +1110,39 @@ def _drop_memory_table_sql(self) -> "list[str]": """Get SQLite DROP TABLE SQL statements. Returns: - List of SQL statements to drop the memory table and FTS table. + List of SQL statements to drop the memory table, FTS table, and triggers. """ statements = [f"DROP TABLE IF EXISTS {self._memory_table}"] if self._use_fts: statements.insert(0, f"DROP TABLE IF EXISTS {self._memory_table}_fts") + statements.extend([ + f"DROP TRIGGER IF EXISTS {self._memory_table}_ai", + f"DROP TRIGGER IF EXISTS {self._memory_table}_ad", + f"DROP TRIGGER IF EXISTS {self._memory_table}_au", + ]) return statements + def _ensure_foreign_keys(self, connection: Any) -> None: + if id(connection) not in self._configured_connection_ids: + self._enable_foreign_keys(connection) + def _enable_foreign_keys(self, connection: Any) -> None: - """Enable foreign key constraints for this connection. + """Enable foreign key constraints and apply PRAGMA overrides for this connection. Args: connection: SQLite connection. """ - connection.execute("PRAGMA foreign_keys = ON") + _close_cursor(connection.execute("PRAGMA foreign_keys = ON")) + for pragma_name, pragma_value in self._pragma_overrides: + _close_cursor(connection.execute(f"PRAGMA {pragma_name} = {pragma_value}")) + self._configured_connection_ids.add(id(connection)) def _search_entries_fts( self, query: str, app_name: str, user_id: str, limit: int, scope_filter: Literal["all", "user", "app"] = "all" ) -> "list[StoredMemory]": where_scope, scope_params = _build_sqlite_scope_clause("m.", app_name, user_id, scope_filter) sql = f""" - SELECT m.id, m.session_id, m.app_name, m.user_id, m.scope, m.event_id, m.author, - m.timestamp, m.content_json, m.content_text, m.metadata_json, m.inserted_at + SELECT m.* FROM {self._memory_table} m JOIN {self._memory_table}_fts fts ON m.rowid = fts.rowid WHERE {where_scope} @@ -1063,8 +1158,7 @@ def _search_entries_simple( ) -> "list[StoredMemory]": where_scope, scope_params = _build_sqlite_scope_clause("", app_name, user_id, scope_filter) sql = f""" - SELECT id, session_id, app_name, user_id, scope, event_id, author, - timestamp, content_json, content_text, metadata_json, inserted_at + SELECT * FROM {self._memory_table} WHERE {where_scope} AND content_text LIKE ? @@ -1077,27 +1171,55 @@ def _search_entries_simple( def _fetch_records(self, sql: str, params: "tuple[Any, ...]") -> "list[StoredMemory]": with self._config.provide_connection() as conn: - self._enable_foreign_keys(conn) + self._ensure_foreign_keys(conn) cursor = conn.execute(sql, params) - rows = cursor.fetchall() - return [ - { - "id": row[0], - "session_id": row[1], - "app_name": row[2], - "user_id": row[3], - "scope": row[4], - "event_id": row[5], - "author": row[6], - "timestamp": _julian_to_datetime(row[7]), - "content_json": from_json(row[8]) if row[8] else {}, - "content_text": row[9], - "metadata_json": from_json(row[10]) if row[10] else None, - "inserted_at": _julian_to_datetime(row[11]), - "embedding": None, - } - for row in rows - ] + try: + rows = cursor.fetchall() + description = getattr(cursor, "description", None) + columns = [col[0] for col in description] if description else [] + finally: + _close_cursor(cursor) + + records: list[StoredMemory] = [] + for raw_row in rows: + if isinstance(raw_row, dict): + raw = dict(raw_row) + elif columns: + raw = dict(zip(columns, raw_row, strict=False)) + else: + raw = { + "id": raw_row[0], + "session_id": raw_row[1], + "app_name": raw_row[2], + "user_id": raw_row[3], + "scope": raw_row[4], + "event_id": raw_row[5], + "author": raw_row[6], + "timestamp": raw_row[7], + "content_json": raw_row[8], + "content_text": raw_row[9], + "metadata_json": raw_row[10], + "inserted_at": raw_row[11], + } + raw["timestamp"] = _julian_to_datetime(raw["timestamp"]) + raw["inserted_at"] = _julian_to_datetime(raw["inserted_at"]) + raw["content_json"] = from_json(raw["content_json"]) if raw["content_json"] else {} + raw["metadata_json"] = from_json(raw["metadata_json"]) if raw["metadata_json"] else None + raw["embedding"] = None + records.append(cast("StoredMemory", raw)) + return records + + +def _close_cursor(cursor: Any) -> None: + if cursor is not None: + with contextlib.suppress(Exception): + cursor.close() + + +def _row_values(row: Any) -> Any: + if isinstance(row, dict): + return tuple(row.values()) + return row def _adk_config(config: "SqliteConfig") -> "dict[str, Any]": @@ -1155,33 +1277,17 @@ def _format_fts_options(options: "tuple[str, ...]") -> str: def _datetime_to_julian(dt: datetime) -> float: - """Convert datetime to Julian Day number for SQLite storage. - - Args: - dt: Datetime to convert (must be UTC-aware). - - Returns: - Julian Day number as REAL. - """ - if dt.tzinfo is None: - dt = dt.replace(tzinfo=UTC) - epoch = datetime(1970, 1, 1, tzinfo=UTC) - delta_days = (dt - epoch).total_seconds() / SECONDS_PER_DAY - return JULIAN_EPOCH + delta_days + """Convert datetime to Julian Day number for SQLite storage.""" + res = datetime_to_julian(dt) + assert res is not None + return res def _julian_to_datetime(julian: float) -> datetime: - """Convert Julian Day number back to datetime. - - Args: - julian: Julian Day number. - - Returns: - UTC-aware datetime. - """ - days_since_epoch = julian - JULIAN_EPOCH - timestamp = days_since_epoch * SECONDS_PER_DAY - return datetime.fromtimestamp(timestamp, tz=UTC) + """Convert Julian Day number back to datetime.""" + res = julian_to_datetime(julian) + assert res is not None + return res def _build_sqlite_scope_clause( diff --git a/sqlspec/adapters/sqlite/config.py b/sqlspec/adapters/sqlite/config.py index 513e07eeb..1505120d6 100644 --- a/sqlspec/adapters/sqlite/config.py +++ b/sqlspec/adapters/sqlite/config.py @@ -1,5 +1,6 @@ """SQLite database configuration with thread-local connections.""" +from collections.abc import Mapping, Sequence from os import PathLike from typing import TYPE_CHECKING, Any, ClassVar, Literal, NotRequired, TypedDict @@ -14,6 +15,7 @@ build_connection_config, default_statement_config, render_pragmas, + require_python_version, ) from sqlspec.adapters.sqlite.driver import SqliteDriver, SqliteExceptionHandler from sqlspec.adapters.sqlite.pool import SqliteConnectionPool @@ -26,7 +28,7 @@ from sqlspec.utils.uuids import uuid4 if TYPE_CHECKING: - from collections.abc import Callable, Mapping, Sequence + from collections.abc import Callable from sqlspec.core import StatementConfig from sqlspec.observability import ObservabilityConfig @@ -43,6 +45,8 @@ logger = get_logger("sqlspec.adapters.sqlite") +SQLiteAutocommitMode = bool | Literal[-1] + class SqliteConnectionParams(TypedDict): """SQLite connection parameters.""" @@ -55,7 +59,7 @@ class SqliteConnectionParams(TypedDict): factory: "NotRequired[SqliteConnectionFactory | None]" cached_statements: NotRequired[int] uri: NotRequired[bool] - autocommit: NotRequired[bool] + autocommit: NotRequired[SQLiteAutocommitMode] pool_recycle_seconds: NotRequired[int] health_check_interval: NotRequired[float] enable_optimizations: NotRequired[bool] @@ -101,8 +105,8 @@ class SqliteDriverFeatures(TypedDict): Controls optional type handling and serialization features for SQLite connections. enable_custom_adapters: Enable custom type adapters for JSON/UUID/datetime conversion. - Defaults to True for enhanced Python type support. - Set to False only if you need pure SQLite behavior without type conversions. + Defaults to False. + Set to True if you want global sqlite3 module type adapter/converter registration. json_serializer: Custom JSON serializer function. Defaults to sqlspec.utils.serializers.to_json. json_deserializer: Custom JSON deserializer function. @@ -163,6 +167,7 @@ class SqliteDriverFeatures(TypedDict): _ROW_FACTORY_LITERALS = frozenset({"dict", "row", "tuple"}) +_TRANSACTION_MODE_LITERALS = frozenset({"DEFERRED", "IMMEDIATE", "EXCLUSIVE"}) _RUNTIME_FEATURE_KEYS = ( "authorizer_callback", "custom_aggregates", @@ -276,6 +281,8 @@ def __init__( observability_config=observability_config, **kwargs, ) + if self._user_connection_hook is not None: + self.driver_features["on_connection_create"] = self._user_connection_hook def create_connection(self) -> SqliteConnection: """Open a standalone connection owned by the caller. @@ -367,7 +374,13 @@ def _close_pool(self) -> None: def _validate_entries(entries: Any, required_keys: "tuple[str, ...]", feature_name: str) -> None: + if not isinstance(entries, Sequence) or isinstance(entries, (str, bytes)): + msg = f"driver_features['{feature_name}'] must be a sequence of mappings" + raise ImproperConfigurationError(msg) for entry in entries: + if not isinstance(entry, Mapping): + msg = f"driver_features['{feature_name}'] entry must be a mapping" + raise ImproperConfigurationError(msg) for required_key in required_keys: if required_key not in entry: msg = f"driver_features['{feature_name}'] entry is missing required key {required_key!r}" @@ -375,6 +388,13 @@ def _validate_entries(entries: Any, required_keys: "tuple[str, ...]", feature_na def _build_runtime_setup(features: "dict[str, Any]") -> "dict[str, Any] | None": + if "default_transaction_mode" in features and features["default_transaction_mode"] is not None: + mode = features["default_transaction_mode"] + if not isinstance(mode, str) or mode.upper() not in _TRANSACTION_MODE_LITERALS: + msg = f"driver_features['default_transaction_mode'] must be DEFERRED, IMMEDIATE, or EXCLUSIVE; got {mode!r}" + raise ImproperConfigurationError(msg) + features["default_transaction_mode"] = mode.upper() + runtime_setup: dict[str, Any] = {} for key in _RUNTIME_FEATURE_KEYS: if key in features: @@ -382,9 +402,18 @@ def _build_runtime_setup(features: "dict[str, Any]") -> "dict[str, Any] | None": if not runtime_setup: return None - if "pragmas" in runtime_setup: + if "pragmas" in runtime_setup and runtime_setup["pragmas"] is not None: + if not isinstance(runtime_setup["pragmas"], Mapping): + msg = "driver_features['pragmas'] must be a mapping of PRAGMA names to values" + raise ImproperConfigurationError(msg) runtime_setup["pragmas"] = render_pragmas(runtime_setup["pragmas"]) + if "extensions" in runtime_setup and runtime_setup["extensions"] is not None: + extensions = runtime_setup["extensions"] + if not isinstance(extensions, Sequence) or isinstance(extensions, (str, bytes)): + msg = "driver_features['extensions'] must be a sequence of extension paths" + raise ImproperConfigurationError(msg) + row_factory = runtime_setup.get("row_factory") if row_factory is not None and not isinstance(row_factory, str) and not callable(row_factory): msg = f"driver_features['row_factory'] must be 'row', 'dict', 'tuple', or a callable; got {row_factory!r}" @@ -398,9 +427,10 @@ def _build_runtime_setup(features: "dict[str, Any]") -> "dict[str, Any] | None": _validate_entries( runtime_setup.get("custom_aggregates", ()), ("name", "narg", "aggregate_class"), "custom_aggregates" ) - _validate_entries( - runtime_setup.get("custom_window_functions", ()), ("name", "narg", "window_class"), "custom_window_functions" - ) + window_functions = runtime_setup.get("custom_window_functions", ()) + _validate_entries(window_functions, ("name", "narg", "window_class"), "custom_window_functions") + if window_functions: + require_python_version("Custom SQLite window functions", (3, 11)) interval = runtime_setup.get("progress_handler_interval") if interval is not None and (not isinstance(interval, int) or isinstance(interval, bool) or interval < 1): diff --git a/sqlspec/adapters/sqlite/core.py b/sqlspec/adapters/sqlite/core.py index 47fed0614..c79735386 100644 --- a/sqlspec/adapters/sqlite/core.py +++ b/sqlspec/adapters/sqlite/core.py @@ -4,7 +4,7 @@ import re import sys from collections.abc import Mapping -from datetime import date, datetime +from datetime import UTC, date, datetime, timedelta from decimal import Decimal from typing import TYPE_CHECKING, Any, cast @@ -41,6 +41,8 @@ from sqlspec.core.compiler import OperationType __all__ = ( + "JULIAN_EPOCH", + "SECONDS_PER_DAY", "SQLITE_CONNECT_SUPPORTS_AUTOCOMMIT", "SqliteStreamSource", "apply_driver_features", @@ -51,11 +53,13 @@ "build_statement_config", "collect_rows", "create_mapped_exception", + "datetime_to_julian", "default_statement_config", "driver_profile", "end_transaction", "extension_pragma_statements", "format_identifier", + "julian_to_datetime", "normalize_execute_many_parameters", "normalize_execute_parameters", "normalize_lastrowid", @@ -65,14 +69,19 @@ "resolve_rowcount", ) +JULIAN_EPOCH = 2440587.5 +SECONDS_PER_DAY = 86400.0 SQLITE_CONSTRAINT_UNIQUE_CODE = 2067 SQLITE_CONSTRAINT_PRIMARYKEY_CODE = 1555 +SQLITE_CONSTRAINT_ROWID_CODE = 2579 SQLITE_CONSTRAINT_FOREIGNKEY_CODE = 787 SQLITE_CONSTRAINT_NOTNULL_CODE = 1811 SQLITE_CONSTRAINT_CHECK_CODE = 531 +SQLITE_CONSTRAINT_DATATYPE_CODE = 3091 SQLITE_CONSTRAINT_CODE = 19 SQLITE_CANTOPEN_CODE = 14 SQLITE_IOERR_CODE = 10 +SQLITE_CORRUPT_CODE = 11 SQLITE_MISMATCH_CODE = 20 SQLITE_BUSY_CODE = 5 SQLITE_LOCKED_CODE = 6 @@ -94,6 +103,26 @@ ) +def datetime_to_julian(dt: "datetime | None") -> "float | None": + """Convert datetime to Julian Day number for SQLite storage.""" + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + epoch = datetime(1970, 1, 1, tzinfo=UTC) + delta_days = (dt - epoch).total_seconds() / SECONDS_PER_DAY + return JULIAN_EPOCH + delta_days + + +def julian_to_datetime(julian: "float | None") -> "datetime | None": + """Convert Julian Day number from SQLite to UTC-aware datetime.""" + if julian is None: + return None + delta_days = julian - JULIAN_EPOCH + timestamp = delta_days * SECONDS_PER_DAY + return datetime(1970, 1, 1, tzinfo=UTC) + timedelta(seconds=timestamp) + + def end_transaction( connection: "SqliteConnection | Any", *, @@ -110,10 +139,16 @@ def end_transaction( commit: Whether to commit rather than roll back. supports_autocommit: Whether this runtime's sqlite3 exposes autocommit. """ - if not getattr(connection, "in_transaction", True): + try: + in_transaction = getattr(connection, "in_transaction", True) + except sqlite3.ProgrammingError: + return + if not in_transaction: return if supports_autocommit and getattr(connection, "autocommit", None) is True: - connection.execute("COMMIT" if commit else "ROLLBACK") + cursor = connection.execute("COMMIT" if commit else "ROLLBACK") + with contextlib.suppress(Exception): + cursor.close() return if commit: connection.commit() @@ -147,7 +182,9 @@ def extension_pragma_statements(config: Any, extension_name: str) -> "tuple[str, def apply_extension_pragmas(connection: Any, statements: "tuple[str, ...]") -> None: for statement in statements: - connection.execute(statement) + cursor = connection.execute(statement) + with contextlib.suppress(Exception): + cursor.close() def render_pragmas(pragmas: "Mapping[str, Any]") -> "list[tuple[str, str]]": @@ -338,6 +375,8 @@ def fetch_chunk(self) -> "list[dict[str, Any]]": self._driver._check_pending_exception(handler) if not rows: return [] + if isinstance(rows[0], dict): + return [dict(row) for row in rows] if self._column_names is None: self._column_names = [description[0] for description in self._cursor.description] return rows_to_dicts(rows, self._column_names) @@ -441,65 +480,53 @@ def create_mapped_exception(error: BaseException, *, logger: Any | None = None) error_code = None error_name = None error_msg = str(error).lower() + primary_code = (error_code & 0xFF) if isinstance(error_code, int) else None - if error_code in (SQLITE_CONSTRAINT_UNIQUE_CODE, SQLITE_CONSTRAINT_PRIMARYKEY_CODE) or error_name in ( - "SQLITE_CONSTRAINT_UNIQUE", - "SQLITE_CONSTRAINT_PRIMARYKEY", - ): - return _create_sqlite_error(error, error_code, UniqueViolationError, "unique constraint violation") + constraint_error = _map_constraint_exception(error, error_code, error_name, primary_code, error_msg) + if constraint_error is not None: + return constraint_error - if error_code == SQLITE_BUSY_CODE or error_name == "SQLITE_BUSY": + if primary_code == SQLITE_BUSY_CODE or (isinstance(error_name, str) and error_name.startswith("SQLITE_BUSY")): return _create_sqlite_error(error, error_code, DeadlockError, "database busy") - if error_code == SQLITE_LOCKED_CODE or error_name == "SQLITE_LOCKED": + if primary_code == SQLITE_LOCKED_CODE or (isinstance(error_name, str) and error_name.startswith("SQLITE_LOCKED")): return _create_sqlite_error(error, error_code, DeadlockError, "database locked") if "locked" in error_msg or "busy" in error_msg: return _create_sqlite_error(error, error_code or 0, DeadlockError, "database locked") - if error_code == SQLITE_INTERRUPT_CODE or error_name == "SQLITE_INTERRUPT": + if primary_code == SQLITE_INTERRUPT_CODE or error_name == "SQLITE_INTERRUPT": return _create_sqlite_error(error, error_code, OperationCancelledError, "query interrupted") if "interrupt" in error_msg: return _create_sqlite_error(error, error_code or 0, OperationCancelledError, "query interrupted") - if error_code == SQLITE_PERM_CODE or error_name == "SQLITE_PERM": + if primary_code == SQLITE_PERM_CODE or error_name == "SQLITE_PERM": return _create_sqlite_error(error, error_code, PermissionDeniedError, "permission denied") - if error_code == SQLITE_READONLY_CODE or error_name == "SQLITE_READONLY": + if primary_code == SQLITE_READONLY_CODE or ( + isinstance(error_name, str) and error_name.startswith("SQLITE_READONLY") + ): return _create_sqlite_error(error, error_code, PermissionDeniedError, "database is read-only") if "permission denied" in error_msg or "readonly" in error_msg: return _create_sqlite_error(error, error_code or 0, PermissionDeniedError, "permission denied") - if not error_code: - if "unique constraint" in error_msg: - return _create_sqlite_error(error, 0, UniqueViolationError, "unique constraint violation") - if "foreign key constraint" in error_msg: - return _create_sqlite_error(error, 0, ForeignKeyViolationError, "foreign key constraint violation") - if "not null constraint" in error_msg: - return _create_sqlite_error(error, 0, NotNullViolationError, "not-null constraint violation") - if "check constraint" in error_msg: - return _create_sqlite_error(error, 0, CheckViolationError, "check constraint violation") - if "syntax" in error_msg: - return _create_sqlite_error(error, None, SQLParsingError, "SQL syntax error") - return _create_sqlite_error(error, None, SQLSpecError, "database error") - - if error_code == SQLITE_CONSTRAINT_FOREIGNKEY_CODE or error_name == "SQLITE_CONSTRAINT_FOREIGNKEY": - return _create_sqlite_error(error, error_code, ForeignKeyViolationError, "foreign key constraint violation") - if error_code == SQLITE_CONSTRAINT_NOTNULL_CODE or error_name == "SQLITE_CONSTRAINT_NOTNULL": - return _create_sqlite_error(error, error_code, NotNullViolationError, "not-null constraint violation") - if error_code == SQLITE_CONSTRAINT_CHECK_CODE or error_name == "SQLITE_CONSTRAINT_CHECK": - return _create_sqlite_error(error, error_code, CheckViolationError, "check constraint violation") - if error_code == SQLITE_CONSTRAINT_CODE or error_name == "SQLITE_CONSTRAINT": - return _create_sqlite_error(error, error_code, IntegrityError, "integrity constraint violation") - - if error_code == SQLITE_CANTOPEN_CODE or error_name == "SQLITE_CANTOPEN": + if primary_code == SQLITE_CANTOPEN_CODE or ( + isinstance(error_name, str) and error_name.startswith("SQLITE_CANTOPEN") + ): return _create_sqlite_error(error, error_code, DatabaseConnectionError, "connection error") - if error_code == SQLITE_IOERR_CODE or error_name == "SQLITE_IOERR": + if primary_code in (SQLITE_IOERR_CODE, SQLITE_CORRUPT_CODE) or ( + isinstance(error_name, str) and error_name.startswith(("SQLITE_IOERR", "SQLITE_CORRUPT")) + ): return _create_sqlite_error(error, error_code, OperationalError, "operational error") - if error_code == SQLITE_MISMATCH_CODE or error_name == "SQLITE_MISMATCH": + if primary_code == SQLITE_MISMATCH_CODE or error_name == "SQLITE_MISMATCH": return _create_sqlite_error(error, error_code, DataError, "data error") - if error_code == 1 or "syntax" in error_msg: + if "syntax" in error_msg: return _create_sqlite_error(error, error_code, SQLParsingError, "SQL syntax error") + if primary_code == 1 or isinstance(error, sqlite3.OperationalError): + return _create_sqlite_error(error, error_code, OperationalError, "operational error") + if isinstance(error, sqlite3.DataError): + return _create_sqlite_error(error, error_code, DataError, "data error") + return _create_sqlite_error(error, error_code, SQLSpecError, "database error") @@ -549,11 +576,13 @@ def apply_driver_features( ) -> "tuple[StatementConfig, dict[str, Any]]": """Apply SQLite driver feature defaults to statement config.""" features: dict[str, Any] = dict(driver_features) if driver_features else {} + has_custom_serializer = "json_serializer" in features + has_custom_deserializer = "json_deserializer" in features features.setdefault("enable_custom_adapters", False) json_serializer = features.setdefault("json_serializer", to_json) json_deserializer = features.setdefault("json_deserializer", from_json) - if json_serializer is not None: + if json_serializer is not None and (has_custom_serializer or has_custom_deserializer): parameter_config = statement_config.parameter_config.with_json_serializers( json_serializer, deserializer=json_deserializer ) @@ -562,6 +591,12 @@ def apply_driver_features( return statement_config, features +def _normalize_metadata_row(row: Any) -> Any: + if isinstance(row, dict): + return tuple(row.values()) + return row + + def _resolve_insert_target(expression: Any) -> "tuple[str | None, str] | None": if not isinstance(expression, exp.Insert): return None @@ -581,7 +616,7 @@ def _target_supports_rowid(connection: Any, target: "tuple[str | None, str]") -> try: table_cursor = connection.execute("PRAGMA table_list") try: - table_rows = table_cursor.fetchall() + table_rows = [_normalize_metadata_row(row) for row in table_cursor.fetchall()] finally: with contextlib.suppress(Exception): table_cursor.close() @@ -610,9 +645,10 @@ def _target_supports_rowid(connection: Any, target: "tuple[str | None, str]") -> try: database_cursor = connection.execute("PRAGMA database_list") try: + db_rows = [_normalize_metadata_row(row) for row in database_cursor.fetchall()] schema_order.extend( row[1] - for row in database_cursor.fetchall() + for row in db_rows if len(row) >= SQLITE_DATABASE_LIST_MIN_COLUMNS and isinstance(row[1], str) ) finally: @@ -635,9 +671,10 @@ def _target_supports_rowid_legacy(connection: Any, target: "tuple[str | None, st try: database_cursor = connection.execute("PRAGMA database_list") try: + db_rows = [_normalize_metadata_row(row) for row in database_cursor.fetchall()] schema_order.extend( row[1] - for row in database_cursor.fetchall() + for row in db_rows if len(row) >= SQLITE_DATABASE_LIST_MIN_COLUMNS and isinstance(row[1], str) and row[1] not in {"main", "temp"} @@ -658,7 +695,8 @@ def _target_supports_rowid_legacy(connection: Any, target: "tuple[str | None, st schema_cursor = connection.execute( f"SELECT type FROM {quoted_schema}.sqlite_master WHERE name = ? COLLATE NOCASE", (target_table,) ) - schema_row = schema_cursor.fetchone() + fetched_schema_row = schema_cursor.fetchone() + schema_row = _normalize_metadata_row(fetched_schema_row) if fetched_schema_row is not None else None except sqlite3.Error: pass finally: @@ -675,9 +713,10 @@ def _target_supports_rowid_legacy(connection: Any, target: "tuple[str | None, st table_info_cursor = connection.execute( f"PRAGMA {quoted_schema}.table_info({quote_identifier(target_table)})" ) + info_rows = [_normalize_metadata_row(row) for row in table_info_cursor.fetchall()] column_names = { row[1].casefold() - for row in table_info_cursor.fetchall() + for row in info_rows if len(row) >= SQLITE_TABLE_INFO_MIN_COLUMNS and isinstance(row[1], str) } except sqlite3.Error: @@ -702,6 +741,52 @@ def _target_supports_rowid_legacy(connection: Any, target: "tuple[str | None, st return False +def _map_constraint_exception( + error: Any, error_code: "int | None", error_name: "str | None", primary_code: "int | None", error_msg: str +) -> "SQLSpecError | None": + if error_code in ( + SQLITE_CONSTRAINT_UNIQUE_CODE, + SQLITE_CONSTRAINT_PRIMARYKEY_CODE, + SQLITE_CONSTRAINT_ROWID_CODE, + ) or error_name in ("SQLITE_CONSTRAINT_UNIQUE", "SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_ROWID"): + return _create_sqlite_error(error, error_code, UniqueViolationError, "unique constraint violation") + if error_code == SQLITE_CONSTRAINT_FOREIGNKEY_CODE or error_name == "SQLITE_CONSTRAINT_FOREIGNKEY": + return _create_sqlite_error(error, error_code, ForeignKeyViolationError, "foreign key constraint violation") + if error_code == SQLITE_CONSTRAINT_NOTNULL_CODE or error_name == "SQLITE_CONSTRAINT_NOTNULL": + return _create_sqlite_error(error, error_code, NotNullViolationError, "not-null constraint violation") + if error_code == SQLITE_CONSTRAINT_CHECK_CODE or error_name == "SQLITE_CONSTRAINT_CHECK": + return _create_sqlite_error(error, error_code, CheckViolationError, "check constraint violation") + if error_code == SQLITE_CONSTRAINT_DATATYPE_CODE or error_name == "SQLITE_CONSTRAINT_DATATYPE": + return _create_sqlite_error(error, error_code, DataError, "datatype constraint violation") + + if ( + not error_code + or primary_code == SQLITE_CONSTRAINT_CODE + or error_name == "SQLITE_CONSTRAINT" + or isinstance(error, sqlite3.IntegrityError) + ): + code_for_msg = error_code or 0 + if "unique constraint" in error_msg or "primary key" in error_msg: + return _create_sqlite_error(error, code_for_msg, UniqueViolationError, "unique constraint violation") + if "foreign key constraint" in error_msg: + return _create_sqlite_error( + error, code_for_msg, ForeignKeyViolationError, "foreign key constraint violation" + ) + if "not null constraint" in error_msg: + return _create_sqlite_error(error, code_for_msg, NotNullViolationError, "not-null constraint violation") + if "check constraint" in error_msg: + return _create_sqlite_error(error, code_for_msg, CheckViolationError, "check constraint violation") + if "cannot store" in error_msg: + return _create_sqlite_error(error, code_for_msg, DataError, "datatype constraint violation") + if ( + primary_code == SQLITE_CONSTRAINT_CODE + or error_name == "SQLITE_CONSTRAINT" + or isinstance(error, sqlite3.IntegrityError) + ): + return _create_sqlite_error(error, error_code, IntegrityError, "integrity constraint violation") + return None + + def _create_sqlite_error( error: Any, code: "int | None", error_class: type[SQLSpecError], description: str ) -> SQLSpecError: diff --git a/sqlspec/adapters/sqlite/driver.py b/sqlspec/adapters/sqlite/driver.py index d771eb71a..25e487fb1 100644 --- a/sqlspec/adapters/sqlite/driver.py +++ b/sqlspec/adapters/sqlite/driver.py @@ -35,7 +35,6 @@ from sqlspec.driver._exception_handler import BaseSyncExceptionHandler from sqlspec.driver._stream import SyncRowStream from sqlspec.driver._sync import SyncDriverAdapterBase -from sqlspec.exceptions import SQLSpecError from sqlspec.utils.type_guards import resolve_row_format if TYPE_CHECKING: @@ -111,6 +110,17 @@ def __init__( self._data_dictionary: SqliteDataDictionary | None = None self._rowid_target_cache: dict[tuple[str | None, str], bool] = {} + @property + def data_dictionary(self) -> "SqliteDataDictionary": + """Get the data dictionary for this driver. + + Returns: + Data dictionary instance for metadata queries + """ + if self._data_dictionary is None: + self._data_dictionary = SqliteDataDictionary() + return self._data_dictionary + def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": """Execute single SQL statement. @@ -130,11 +140,12 @@ def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": data, column_names, row_count = collect_rows(fetched_data, cursor.description) row_format = resolve_row_format(data) affected_rows = resolve_rowcount(cursor) + effective_rowcount = affected_rows or (row_count if statement.operation_type == "INSERT" else 0) last_inserted_id = resolve_lastrowid( self.connection, cursor, statement.operation_type, - affected_rows, + effective_rowcount, statement.expression, self._rowid_target_cache, ) @@ -252,19 +263,11 @@ def begin(self, mode: "Literal['DEFERRED', 'IMMEDIATE', 'EXCLUSIVE'] | None" = N try: if not self.connection.in_transaction: stmt = f"BEGIN {transaction_mode}" if transaction_mode else "BEGIN" - self.connection.execute(stmt) + cursor = self.connection.execute(stmt) + with contextlib.suppress(Exception): + cursor.close() except sqlite3.Error as e: - msg = f"Failed to begin transaction: {e}" - raise SQLSpecError(msg) from e - - def _in_autocommit_mode(self) -> bool: - """Report whether the connection runs in sqlite3's autocommit mode. - - Under that mode ``Connection.commit`` and ``Connection.rollback`` are - no-ops, so a manually started transaction has to be ended with an - explicit statement. - """ - return SQLITE_CONNECT_SUPPORTS_AUTOCOMMIT and getattr(self.connection, "autocommit", None) is True + raise create_mapped_exception(e) from e def commit(self) -> None: """Commit the current transaction. @@ -275,12 +278,13 @@ def commit(self) -> None: try: if self._in_autocommit_mode(): if self.connection.in_transaction: - self.connection.execute("COMMIT") + cursor = self.connection.execute("COMMIT") + with contextlib.suppress(Exception): + cursor.close() return self.connection.commit() except sqlite3.Error as e: - msg = f"Failed to commit transaction: {e}" - raise SQLSpecError(msg) from e + raise create_mapped_exception(e) from e def rollback(self) -> None: """Rollback the current transaction. @@ -291,12 +295,16 @@ def rollback(self) -> None: try: if self._in_autocommit_mode(): if self.connection.in_transaction: - self.connection.execute("ROLLBACK") + cursor = self.connection.execute("ROLLBACK") + with contextlib.suppress(Exception): + cursor.close() + self._rowid_target_cache.clear() return self.connection.rollback() + self._rowid_target_cache.clear() except sqlite3.Error as e: - msg = f"Failed to rollback transaction: {e}" - raise SQLSpecError(msg) from e + self._rowid_target_cache.clear() + raise create_mapped_exception(e) from e def with_cursor(self, connection: "SqliteConnection") -> "SqliteCursor": """Create context manager for SQLite cursor. @@ -369,7 +377,11 @@ def load_from_arrow( owns_transaction = not self.connection.in_transaction try: if owns_transaction: - self.connection.execute("BEGIN IMMEDIATE") + transaction_mode = cast( + "Literal['DEFERRED', 'IMMEDIATE', 'EXCLUSIVE']", + self.driver_features.get("default_transaction_mode") or "IMMEDIATE", + ) + self.begin(mode=transaction_mode) if overwrite: statement = f"DELETE FROM {format_identifier(table)}" with self.with_cursor(self.connection) as cursor: @@ -413,17 +425,6 @@ def load_from_storage( arrow_table, inbound = self._read_storage_arrow(source, file_format=file_format) return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound) - @property - def data_dictionary(self) -> "SqliteDataDictionary": - """Get the data dictionary for this driver. - - Returns: - Data dictionary instance for metadata queries - """ - if self._data_dictionary is None: - self._data_dictionary = SqliteDataDictionary() - return self._data_dictionary - def collect_rows(self, cursor: Any, fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": """Collect SQLite rows for the direct execution path.""" return collect_rows(fetched, cursor.description) @@ -432,6 +433,15 @@ def resolve_rowcount(self, cursor: Any) -> int: """Resolve rowcount from SQLite cursor for the direct execution path.""" return resolve_rowcount(cursor) + def _in_autocommit_mode(self) -> bool: + """Report whether the connection runs in sqlite3's autocommit mode. + + Under that mode ``Connection.commit`` and ``Connection.rollback`` are + no-ops, so a manually started transaction has to be ended with an + explicit statement. + """ + return SQLITE_CONNECT_SUPPORTS_AUTOCOMMIT and getattr(self.connection, "autocommit", None) is True + def _execute_cache_hit( self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" ) -> "SQLResult": @@ -465,13 +475,14 @@ def _execute_cache_hit( fetched_data = cursor.fetchall() affected_rows = resolve_rowcount(cursor) + effective_rowcount = affected_rows or (len(fetched_data) if cached.operation_type == "INSERT" else 0) last_inserted_id = None if cached.operation_type != "SELECT": last_inserted_id = resolve_lastrowid( self.connection, cursor, cached.operation_type, - affected_rows, + effective_rowcount, cached.processed_state.parsed_expression, self._rowid_target_cache, ) @@ -497,8 +508,6 @@ def _execute_cache_hit( cursor.close() if direct_statement is not None: self._release_pooled_statement(direct_statement) - msg = "unreachable" - raise AssertionError(msg) def _invalidate_rowid_target_cache(self, operation_type: "OperationType") -> None: if operation_type not in {"SELECT", "INSERT", "UPDATE", "DELETE"}: diff --git a/sqlspec/adapters/sqlite/events/store.py b/sqlspec/adapters/sqlite/events/store.py index 773de61f2..aa549a24a 100644 --- a/sqlspec/adapters/sqlite/events/store.py +++ b/sqlspec/adapters/sqlite/events/store.py @@ -1,12 +1,15 @@ """SQLite event queue store.""" -from typing import Any, NotRequired +from typing import TYPE_CHECKING, Any, NotRequired from sqlspec.adapters.sqlite.config import SqliteConfig from sqlspec.adapters.sqlite.core import apply_extension_pragmas, extension_pragma_statements from sqlspec.config import EventsConfig from sqlspec.extensions.events import BaseEventQueueStore +if TYPE_CHECKING: + from sqlspec.migrations.schema import SchemaEnsureResult + __all__ = ("SqliteEventQueueStore", "SqliteEventsConfig") @@ -41,6 +44,11 @@ def prepare_schema_sync(self, driver: Any) -> None: """Apply configured SQLite PRAGMAs before queue DDL.""" apply_extension_pragmas(driver.connection, self._pragma_statements) + def reconcile_schema_sync(self, driver: Any) -> "SchemaEnsureResult": + """Apply configured SQLite PRAGMAs and reconcile queue schema.""" + self.prepare_schema_sync(driver) + return super().reconcile_schema_sync(driver) + def _column_types(self) -> "tuple[str, str, str]": """Return SQLite-compatible column types for the event queue.""" return "TEXT", "TEXT", "TIMESTAMP" diff --git a/sqlspec/adapters/sqlite/litestar/store.py b/sqlspec/adapters/sqlite/litestar/store.py index 1fc1fbce4..01d3bee41 100644 --- a/sqlspec/adapters/sqlite/litestar/store.py +++ b/sqlspec/adapters/sqlite/litestar/store.py @@ -1,9 +1,18 @@ """SQLite sync session store for Litestar integration.""" +import contextlib from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, NotRequired -from sqlspec.adapters.sqlite.core import apply_extension_pragmas, end_transaction, extension_pragma_statements +from sqlspec.adapters.sqlite.core import ( + JULIAN_EPOCH, + SECONDS_PER_DAY, + apply_extension_pragmas, + datetime_to_julian, + end_transaction, + extension_pragma_statements, + julian_to_datetime, +) from sqlspec.config import LitestarConfig from sqlspec.extensions.litestar.store import BaseSQLSpecStore from sqlspec.utils.sync_tools import async_ @@ -11,11 +20,7 @@ if TYPE_CHECKING: from sqlspec.adapters.sqlite.config import SqliteConfig -__all__ = ("SQLiteStore", "SqliteLitestarConfig") - - -SECONDS_PER_DAY = 86400.0 -JULIAN_EPOCH = 2440587.5 +__all__ = ("JULIAN_EPOCH", "SECONDS_PER_DAY", "SQLiteStore", "SqliteLitestarConfig") class SqliteLitestarConfig(LitestarConfig): @@ -48,7 +53,7 @@ class SQLiteStore(BaseSQLSpecStore["SqliteConfig"]): config: SqliteConfig instance. """ - __slots__ = ("_pragma_statements",) + __slots__ = ("_configured_connection_ids", "_pragma_statements") extension_config_options = BaseSQLSpecStore.extension_config_options | frozenset({ "pragma_overrides", "pragma_profile", @@ -62,6 +67,7 @@ def __init__(self, config: "SqliteConfig") -> None: """ super().__init__(config) self._pragma_statements = extension_pragma_statements(config, "litestar") + self._configured_connection_ids: set[int] = set() async def create_table(self) -> None: """Create the session table if it doesn't exist.""" @@ -74,6 +80,7 @@ async def create_table(self) -> None: def prepare_schema_sync(self, driver: Any) -> None: """Apply configured SQLite PRAGMAs before migration DDL generation.""" apply_extension_pragmas(driver.connection, self._pragma_statements) + self._configured_connection_ids.add(id(driver.connection)) async def get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": """Get a session value by key. @@ -139,6 +146,11 @@ async def delete_expired(self) -> int: """ return await async_(self._delete_expired)() + def _ensure_pragmas(self, connection: Any) -> None: + if self._pragma_statements and id(connection) not in self._configured_connection_ids: + apply_extension_pragmas(connection, self._pragma_statements) + self._configured_connection_ids.add(id(connection)) + def _table_ddl(self) -> str: """Get SQLite CREATE TABLE SQL. @@ -164,43 +176,21 @@ def _drop_table_sql(self) -> "list[str]": return [f"DROP INDEX IF EXISTS idx_{self._table_name}_expires_at", f"DROP TABLE IF EXISTS {self._table_name}"] def _datetime_to_julian(self, dt: "datetime | None") -> "float | None": - """Convert datetime to Julian Day number for SQLite storage. - - Args: - dt: Datetime to convert (must be UTC-aware). - - Returns: - Julian Day number as REAL, or None if dt is None. - """ - if dt is None: - return None - - epoch = datetime(1970, 1, 1, tzinfo=UTC) - delta_days = (dt - epoch).total_seconds() / SECONDS_PER_DAY - return JULIAN_EPOCH + delta_days + """Convert datetime to Julian Day number for SQLite storage.""" + return datetime_to_julian(dt) def _julian_to_datetime(self, julian: "float | None") -> "datetime | None": - """Convert Julian Day number back to datetime. - - Args: - julian: Julian Day number. - - Returns: - UTC-aware datetime, or None if julian is None. - """ - if julian is None: - return None - - days_since_epoch = julian - JULIAN_EPOCH - timestamp = days_since_epoch * SECONDS_PER_DAY - return datetime.fromtimestamp(timestamp, tz=UTC) + """Convert Julian Day number back to datetime.""" + return julian_to_datetime(julian) def _create_table(self) -> None: """Synchronous implementation of create_table.""" sql = self._table_ddl() with self._config.provide_session() as driver: apply_extension_pragmas(driver.connection, self._pragma_statements) + self._configured_connection_ids.add(id(driver.connection)) driver.execute_script(sql) + end_transaction(driver.connection, commit=True) self._log_table_created() def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | None": @@ -212,13 +202,17 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | """ with self._config.provide_connection() as conn: + self._ensure_pragmas(conn) cursor = conn.execute(sql, (key,)) - row = cursor.fetchone() + try: + raw_row = cursor.fetchone() + finally: + _close_cursor(cursor) - if row is None: + if raw_row is None: return None - data, expires_at_julian = row + data, expires_at_julian = _row_values(raw_row) if renew_for is not None and expires_at_julian is not None: new_expires_at = self._calculate_expires_at(renew_for) @@ -229,7 +223,7 @@ def _get(self, key: str, renew_for: "int | timedelta | None" = None) -> "bytes | SET expires_at = ? WHERE session_id = ? """ - conn.execute(update_sql, (new_expires_at_julian, key)) + _close_cursor(conn.execute(update_sql, (new_expires_at_julian, key))) end_transaction(conn, commit=True) return bytes(data) @@ -246,7 +240,8 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No """ with self._config.provide_connection() as conn: - conn.execute(sql, (key, data, expires_at_julian)) + self._ensure_pragmas(conn) + _close_cursor(conn.execute(sql, (key, data, expires_at_julian))) end_transaction(conn, commit=True) def _delete(self, key: str) -> None: @@ -254,7 +249,8 @@ def _delete(self, key: str) -> None: sql = f"DELETE FROM {self._table_name} WHERE session_id = ?" with self._config.provide_connection() as conn: - conn.execute(sql, (key,)) + self._ensure_pragmas(conn) + _close_cursor(conn.execute(sql, (key,))) end_transaction(conn, commit=True) def _delete_all(self) -> None: @@ -262,7 +258,8 @@ def _delete_all(self) -> None: sql = f"DELETE FROM {self._table_name}" with self._config.provide_connection() as conn: - conn.execute(sql) + self._ensure_pragmas(conn) + _close_cursor(conn.execute(sql)) end_transaction(conn, commit=True) self._log_delete_all() @@ -275,8 +272,12 @@ def _exists(self, key: str) -> bool: """ with self._config.provide_connection() as conn: + self._ensure_pragmas(conn) cursor = conn.execute(sql, (key,)) - result = cursor.fetchone() + try: + result = cursor.fetchone() + finally: + _close_cursor(cursor) return result is not None def _expires_in(self, key: str) -> "int | None": @@ -287,10 +288,17 @@ def _expires_in(self, key: str) -> "int | None": """ with self._config.provide_connection() as conn: + self._ensure_pragmas(conn) cursor = conn.execute(sql, (key,)) - row = cursor.fetchone() + try: + raw_row = cursor.fetchone() + finally: + _close_cursor(cursor) - if row is None or row[0] is None: + if raw_row is None: + return None + row = _row_values(raw_row) + if row[0] is None: return None expires_at_julian = row[0] @@ -312,9 +320,25 @@ def _delete_expired(self) -> int: sql = f"DELETE FROM {self._table_name} WHERE expires_at IS NOT NULL AND expires_at <= julianday('now')" with self._config.provide_connection() as conn: + self._ensure_pragmas(conn) cursor = conn.execute(sql) - end_transaction(conn, commit=True) - count = cursor.rowcount + try: + end_transaction(conn, commit=True) + count = cursor.rowcount if cursor.rowcount and cursor.rowcount > 0 else 0 + finally: + _close_cursor(cursor) if count > 0: self._log_delete_expired(count) return count + + +def _close_cursor(cursor: Any) -> None: + if cursor is not None: + with contextlib.suppress(Exception): + cursor.close() + + +def _row_values(row: Any) -> Any: + if isinstance(row, dict): + return tuple(row.values()) + return row diff --git a/sqlspec/adapters/sqlite/pool.py b/sqlspec/adapters/sqlite/pool.py index f88b5fe09..a2de6cbf0 100644 --- a/sqlspec/adapters/sqlite/pool.py +++ b/sqlspec/adapters/sqlite/pool.py @@ -29,25 +29,6 @@ SQLITE_WAL_SWITCH_DELAY: Final = 0.01 -def _attempt_wal_switch(connection: "SqliteConnection", attempt: int) -> bool: - """Attempt a single WAL mode switch, returning True on success.""" - try: - connection.execute("PRAGMA journal_mode = WAL") - except sqlite3.OperationalError as exc: - if "locked" not in str(exc) or attempt == SQLITE_WAL_SWITCH_ATTEMPTS - 1: - raise - time.sleep(SQLITE_WAL_SWITCH_DELAY) - return False - return True - - -def _enable_wal(connection: "SqliteConnection") -> None: - """Retry database and table locks briefly while switching to WAL mode.""" - for attempt in range(SQLITE_WAL_SWITCH_ATTEMPTS): - if _attempt_wal_switch(connection, attempt): - return - - class SqliteConnectionPool: """Thread-local connection manager for SQLite. @@ -57,6 +38,7 @@ class SqliteConnectionPool: """ __slots__ = ( + "_anchor_connection", "_connection_parameters", "_connection_registry", "_enable_foreign_keys", @@ -64,6 +46,7 @@ class SqliteConnectionPool: "_generation", "_health_check_interval", "_is_memory_db", + "_is_shared_memory", "_on_connection_create", "_pool_id", "_recycle_seconds", @@ -97,7 +80,10 @@ def __init__( connection_parameters = {**connection_parameters, "check_same_thread": False} self._connection_parameters = connection_parameters database = self._connection_parameters.get("database", ":memory:") - self._is_memory_db = database == ":memory:" or "mode=memory" in str(database) + database_str = str(database) + self._is_memory_db = database == ":memory:" or "mode=memory" in database_str + self._is_shared_memory = self._is_memory_db and "cache=shared" in database_str + self._anchor_connection: SqliteConnection | None = None self._thread_local = threading.local() self._connection_registry: set[SqliteConnection] = set() self._generation = 0 @@ -118,13 +104,6 @@ def _database_name(self) -> str: return ":memory:" return str(db) - def _create_connection(self) -> SqliteConnection: - """Create a pool-owned connection and record it for shutdown.""" - connection = self.new_connection() - with self._registry_lock: - self._connection_registry.add(connection) - return connection - def new_connection(self) -> SqliteConnection: """Create a standalone connection configured like a pooled one. @@ -139,18 +118,20 @@ def new_connection(self) -> SqliteConnection: try: if self._enable_optimizations: if self._is_memory_db: - connection.execute("PRAGMA journal_mode = MEMORY") - connection.execute("PRAGMA synchronous = OFF") - connection.execute("PRAGMA temp_store = MEMORY") - connection.execute(f"PRAGMA cache_size = {SQLITE_MEMORY_CACHE_SIZE}") + _execute_and_close(connection, "PRAGMA journal_mode = MEMORY") + _execute_and_close(connection, "PRAGMA synchronous = OFF") + _execute_and_close(connection, "PRAGMA temp_store = MEMORY") + _execute_and_close(connection, f"PRAGMA cache_size = {SQLITE_MEMORY_CACHE_SIZE}") + if self._is_shared_memory: + _execute_and_close(connection, "PRAGMA read_uncommitted = ON") else: _enable_wal(connection) - connection.execute("PRAGMA synchronous = NORMAL") + _execute_and_close(connection, "PRAGMA synchronous = NORMAL") - connection.execute(f"PRAGMA busy_timeout = {SQLITE_BUSY_TIMEOUT}") + _execute_and_close(connection, f"PRAGMA busy_timeout = {SQLITE_BUSY_TIMEOUT}") if self._enable_foreign_keys: - connection.execute("PRAGMA foreign_keys = ON") + _execute_and_close(connection, "PRAGMA foreign_keys = ON") if self._runtime_setup is not None: _apply_runtime_setup(connection, self._runtime_setup) @@ -164,6 +145,84 @@ def new_connection(self) -> SqliteConnection: return cast("SqliteConnection", connection) + @contextmanager + def get_connection(self) -> "Generator[SqliteConnection, None, None]": + """Get a thread-local connection. + + Yields: + SqliteConnection: A thread-local connection. + """ + connection = self._get_thread_connection() + try: + yield connection + except BaseException: + with contextlib.suppress(Exception): + end_transaction(connection, commit=False) + raise + else: + try: + end_transaction(connection, commit=True) + except BaseException: + with contextlib.suppress(Exception): + end_transaction(connection, commit=False) + raise + + def close(self) -> None: + """Close every connection this pool opened, on any thread.""" + self._close_thread_connection() + with self._registry_lock: + orphaned = list(self._connection_registry) + self._connection_registry.clear() + anchor = self._anchor_connection + self._anchor_connection = None + self._generation += 1 + for connection in orphaned: + with contextlib.suppress(Exception): + connection.close() + if anchor is not None: + with contextlib.suppress(Exception): + anchor.close() + + def acquire(self) -> SqliteConnection: + """Acquire a thread-local connection. + + Returns: + SqliteConnection: A thread-local connection + """ + return self._get_thread_connection() + + def release(self, connection: SqliteConnection) -> None: + """Release a connection and roll back any uncommitted transaction. + + Args: + connection: The connection to release + """ + with contextlib.suppress(Exception): + end_transaction(connection, commit=False) + + def size(self) -> int: + """Get total number of active thread-local connections in pool.""" + with self._registry_lock: + return len(self._connection_registry) + + def checked_out(self) -> int: + """Get number of checked out connections (always 0).""" + return 0 + + def _create_connection(self) -> SqliteConnection: + """Create a pool-owned connection and record it for shutdown.""" + connection = self.new_connection() + with self._registry_lock: + if ( + self._is_shared_memory + and self._anchor_connection is None + and isinstance(connection, sqlite3.Connection) + ): + with contextlib.suppress(Exception): + self._anchor_connection = sqlite3.connect(**self._connection_parameters) + self._connection_registry.add(connection) + return connection + def _is_connection_alive(self, connection: SqliteConnection) -> bool: """Check if a connection is still alive and usable. @@ -174,7 +233,7 @@ def _is_connection_alive(self, connection: SqliteConnection) -> bool: True if connection is alive, False otherwise """ try: - connection.execute("SELECT 1") + _execute_and_close(connection, "SELECT 1") except Exception: return False return True @@ -258,59 +317,31 @@ def _close_thread_connection(self) -> None: self._thread_local.created_at = 0.0 self._thread_local.last_used = 0.0 - @contextmanager - def get_connection(self) -> "Generator[SqliteConnection, None, None]": - """Get a thread-local connection. - - Yields: - SqliteConnection: A thread-local connection. - """ - connection = self._get_thread_connection() - try: - yield connection - except Exception: - with contextlib.suppress(Exception): - end_transaction(connection, commit=False) - raise - else: - with contextlib.suppress(Exception): - end_transaction(connection, commit=True) - - def close(self) -> None: - """Close every connection this pool opened, on any thread.""" - self._close_thread_connection() - with self._registry_lock: - orphaned = list(self._connection_registry) - self._connection_registry.clear() - self._generation += 1 - for connection in orphaned: - with contextlib.suppress(Exception): - connection.close() - - def acquire(self) -> SqliteConnection: - """Acquire a thread-local connection. - Returns: - SqliteConnection: A thread-local connection - """ - return self._get_thread_connection() +def _execute_and_close(connection: "SqliteConnection", statement: str) -> None: + cursor = connection.execute(statement) + if cursor is not None: + with contextlib.suppress(Exception): + cursor.close() - def release(self, connection: SqliteConnection) -> None: - """Release a connection (no-op for thread-local connections). - Args: - connection: The connection to release (ignored) - """ +def _attempt_wal_switch(connection: "SqliteConnection", attempt: int) -> bool: + """Attempt a single WAL mode switch, returning True on success.""" + try: + _execute_and_close(connection, "PRAGMA journal_mode = WAL") + except sqlite3.OperationalError as exc: + if "locked" not in str(exc) or attempt == SQLITE_WAL_SWITCH_ATTEMPTS - 1: + raise + time.sleep(SQLITE_WAL_SWITCH_DELAY) + return False + return True - def size(self) -> int: - """Get pool size (always 1 for thread-local).""" - if getattr(self._thread_local, "connection", None) is not None: - return 1 - return 0 - def checked_out(self) -> int: - """Get number of checked out connections (always 0).""" - return 0 +def _enable_wal(connection: "SqliteConnection") -> None: + """Retry database and table locks briefly while switching to WAL mode.""" + for attempt in range(SQLITE_WAL_SWITCH_ATTEMPTS): + if _attempt_wal_switch(connection, attempt): + return def _dict_row_factory(cursor: Any, row: "tuple[Any, ...]") -> "dict[str, Any]": diff --git a/sqlspec/adapters/sqlite/type_converter.py b/sqlspec/adapters/sqlite/type_converter.py index fad74a2e3..74f1c7d91 100644 --- a/sqlspec/adapters/sqlite/type_converter.py +++ b/sqlspec/adapters/sqlite/type_converter.py @@ -1,4 +1,3 @@ -# Keep in sync with sqlspec/adapters/aiosqlite/type_converter.py """SQLite custom type handlers for optional JSON and type conversion support. Provides registration functions for SQLite's adapter/converter system to enable @@ -78,11 +77,14 @@ def register_type_handlers( def unregister_type_handlers() -> None: - """Unregister custom type handlers from sqlite3 module. - - Note: sqlite3 module does not provide an official unregister API, so this - function is a no-op placeholder for API consistency with other adapters. - """ + """Unregister custom type handlers from sqlite3 module.""" + adapters = getattr(sqlite3, "adapters", None) + if isinstance(adapters, dict): + adapters.pop((dict, sqlite3.PrepareProtocol), None) + adapters.pop((list, sqlite3.PrepareProtocol), None) + converters = getattr(sqlite3, "converters", None) + if isinstance(converters, dict): + converters.pop(DEFAULT_JSON_TYPE, None) def _make_json_adapter(serializer: "Callable[[Any], str] | None") -> "Callable[[Any], str]": diff --git a/sqlspec/base.py b/sqlspec/base.py index 11439079c..381d67bae 100644 --- a/sqlspec/base.py +++ b/sqlspec/base.py @@ -71,27 +71,15 @@ def __init__( if self._loader is not None: self._loader.set_observability_runtime(self._loader_runtime) - @overload - def add_config(self, config: "SyncConfigT") -> "SyncConfigT": ... - - @overload - def add_config(self, config: "AsyncConfigT") -> "AsyncConfigT": ... - - def add_config(self, config: "SyncConfigT | AsyncConfigT") -> "SyncConfigT | AsyncConfigT": - """Add a configuration instance to the registry. - - Args: - config: The configuration instance to add. + async def __aenter__(self) -> Self: + """Async context manager entry.""" + return self - Returns: - The same configuration instance (it IS the handle). - """ - config_id = id(config) - if config_id in self._configs: - logger.debug("Configuration for %s already exists. Overwriting.", config.__class__.__name__) - config.attach_observability(self._observability_config) - self._configs[config_id] = config - return config + async def __aexit__( + self, _exc_type: "type[BaseException] | None", _exc_val: "BaseException | None", _exc_tb: "TracebackType | None" + ) -> None: + """Async context manager exit with automatic cleanup.""" + await self.close_all_pools() @property def configs(self) -> "dict[int, DatabaseConfigProtocol[Any, Any, Any]]": @@ -111,16 +99,34 @@ def loader(self) -> SQLFileLoader: """ return self._ensure_loader() + @overload + def add_config(self, config: "SyncConfigT") -> "SyncConfigT": ... + @overload + def add_config(self, config: "AsyncConfigT") -> "AsyncConfigT": ... + def add_config(self, config: "SyncConfigT | AsyncConfigT") -> "SyncConfigT | AsyncConfigT": + """Add a configuration instance to the registry. + + Args: + config: The configuration instance to add. + + Returns: + The same configuration instance (it IS the handle). + """ + config_id = id(config) + if config_id in self._configs: + logger.debug("Configuration for %s already exists. Overwriting.", config.__class__.__name__) + config.attach_observability(self._observability_config) + self._configs[config_id] = config + return config + @overload def get_connection( self, config: "NoPoolSyncConfig[ConnectionT, DriverT] | SyncDatabaseConfig[ConnectionT, PoolT, DriverT]" ) -> "ConnectionT": ... - @overload def get_connection( self, config: "NoPoolAsyncConfig[ConnectionT, DriverT] | AsyncDatabaseConfig[ConnectionT, PoolT, DriverT]" ) -> "Awaitable[ConnectionT]": ... - def get_connection( self, config: "NoPoolSyncConfig[ConnectionT, DriverT] | SyncDatabaseConfig[ConnectionT, PoolT, DriverT] | NoPoolAsyncConfig[ConnectionT, DriverT] | AsyncDatabaseConfig[ConnectionT, PoolT, DriverT]", @@ -141,12 +147,10 @@ def get_connection( def get_session( self, config: "NoPoolSyncConfig[ConnectionT, DriverT] | SyncDatabaseConfig[ConnectionT, PoolT, DriverT]" ) -> "DriverT": ... - @overload def get_session( self, config: "NoPoolAsyncConfig[ConnectionT, DriverT] | AsyncDatabaseConfig[ConnectionT, PoolT, DriverT]" ) -> "Awaitable[DriverT]": ... - def get_session( self, config: "NoPoolSyncConfig[ConnectionT, DriverT] | SyncDatabaseConfig[ConnectionT, PoolT, DriverT] | NoPoolAsyncConfig[ConnectionT, DriverT] | AsyncDatabaseConfig[ConnectionT, PoolT, DriverT]", @@ -181,7 +185,6 @@ def provide_connection( *args: Any, **kwargs: Any, ) -> "AbstractContextManager[ConnectionT]": ... - @overload def provide_connection( self, @@ -189,7 +192,6 @@ def provide_connection( *args: Any, **kwargs: Any, ) -> "AbstractAsyncContextManager[ConnectionT]": ... - def provide_connection( self, config: "NoPoolSyncConfig[ConnectionT, DriverT] | SyncDatabaseConfig[ConnectionT, PoolT, DriverT] | NoPoolAsyncConfig[ConnectionT, DriverT] | AsyncDatabaseConfig[ConnectionT, PoolT, DriverT]", @@ -225,7 +227,6 @@ def provide_session( *args: Any, **kwargs: Any, ) -> "AbstractContextManager[DriverT]": ... - @overload def provide_session( self, @@ -233,7 +234,6 @@ def provide_session( *args: Any, **kwargs: Any, ) -> "AbstractAsyncContextManager[DriverT]": ... - def provide_session( self, config: "NoPoolSyncConfig[ConnectionT, DriverT] | SyncDatabaseConfig[ConnectionT, PoolT, DriverT] | NoPoolAsyncConfig[ConnectionT, DriverT] | AsyncDatabaseConfig[ConnectionT, PoolT, DriverT]", @@ -270,7 +270,6 @@ def get_pool( def get_pool(self, config: "SyncDatabaseConfig[ConnectionT, PoolT, DriverT]") -> "type[PoolT]": ... @overload def get_pool(self, config: "AsyncDatabaseConfig[ConnectionT, PoolT, DriverT]") -> "Awaitable[type[PoolT]]": ... - def get_pool( self, config: "NoPoolSyncConfig[ConnectionT, DriverT] | NoPoolAsyncConfig[ConnectionT, DriverT] | SyncDatabaseConfig[ConnectionT, PoolT, DriverT] | AsyncDatabaseConfig[ConnectionT, PoolT, DriverT]", @@ -293,12 +292,10 @@ def get_pool( def close_pool( self, config: "NoPoolSyncConfig[ConnectionT, DriverT] | SyncDatabaseConfig[ConnectionT, PoolT, DriverT]" ) -> None: ... - @overload def close_pool( self, config: "NoPoolAsyncConfig[ConnectionT, DriverT] | AsyncDatabaseConfig[ConnectionT, PoolT, DriverT]" ) -> "Awaitable[None]": ... - def close_pool( self, config: "NoPoolSyncConfig[ConnectionT, DriverT] | SyncDatabaseConfig[ConnectionT, PoolT, DriverT] | NoPoolAsyncConfig[ConnectionT, DriverT] | AsyncDatabaseConfig[ConnectionT, PoolT, DriverT]", @@ -325,7 +322,7 @@ async def close_all_pools(self) -> None: cleanup_tasks = [] sync_configs: list[DatabaseConfigProtocol[Any, Any, Any]] = [] - for config in self._configs.values(): + for config in tuple(self._configs.values()): if config.supports_connection_pooling: try: if config.is_async: @@ -357,32 +354,18 @@ async def close_all_pools(self) -> None: } logger.debug("Pool cleanup completed.", extra=summary) - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__( - self, _exc_type: "type[BaseException] | None", _exc_val: "BaseException | None", _exc_tb: "TracebackType | None" - ) -> None: - """Async context manager exit with automatic cleanup.""" - await self.close_all_pools() - @overload def event_channel(self, config: "type[SyncConfigT]") -> "SyncEventChannel": ... - @overload def event_channel(self, config: "type[AsyncConfigT]") -> "AsyncEventChannel": ... - @overload def event_channel( self, config: "SyncDatabaseConfig[Any, Any, Any] | NoPoolSyncConfig[Any, Any]" ) -> "SyncEventChannel": ... - @overload def event_channel( self, config: "AsyncDatabaseConfig[Any, Any, Any] | NoPoolAsyncConfig[Any, Any]" ) -> "AsyncEventChannel": ... - def event_channel( self, config: "type[SyncConfigT | AsyncConfigT] | SyncDatabaseConfig[Any, Any, Any] | NoPoolSyncConfig[Any, Any] | AsyncDatabaseConfig[Any, Any, Any] | NoPoolAsyncConfig[Any, Any]", @@ -399,7 +382,7 @@ def event_channel( """ if isinstance(config, type): config_obj: DatabaseConfigProtocol[Any, Any, Any] | None = None - for registered_config in self._configs.values(): + for registered_config in tuple(self._configs.values()): if isinstance(registered_config, config): config_obj = registered_config break @@ -420,7 +403,7 @@ def telemetry_snapshot(self) -> "dict[str, Any]": loader_metrics = self._loader_runtime.metrics_snapshot() if loader_metrics: diagnostics.add_metric_snapshot(loader_metrics) - for config in self._configs.values(): + for config in tuple(self._configs.values()): runtime = config.get_observability_runtime() diagnostics.add_lifecycle_snapshot(runtime.diagnostics_key, runtime.lifecycle_snapshot()) metrics_snapshot = runtime.metrics_snapshot() @@ -471,6 +454,7 @@ def configure_cache( sql_cache_size: int | None = None, fragment_cache_size: int | None = None, optimized_cache_size: int | None = None, + compiled_cache_enabled: bool | None = None, sql_cache_enabled: bool | None = None, fragment_cache_enabled: bool | None = None, optimized_cache_enabled: bool | None = None, @@ -481,6 +465,7 @@ def configure_cache( sql_cache_size: Size of the statement/builder cache. fragment_cache_size: Size of the expression/parameter/file cache. optimized_cache_size: Size of the optimized expression cache. + compiled_cache_enabled: Master switch for namespaced and compiled SQL caches. sql_cache_enabled: Enable/disable statement and builder cache. fragment_cache_enabled: Enable/disable expression/parameter/file cache. optimized_cache_enabled: Enable/disable optimized expression cache. @@ -488,6 +473,9 @@ def configure_cache( current_config = get_cache_config() update_cache_config( CacheConfig( + compiled_cache_enabled=compiled_cache_enabled + if compiled_cache_enabled is not None + else current_config.compiled_cache_enabled, sql_cache_size=sql_cache_size if sql_cache_size is not None else current_config.sql_cache_size, fragment_cache_size=fragment_cache_size if fragment_cache_size is not None @@ -627,8 +615,6 @@ def _ensure_registered( def _ensure_loader(self) -> SQLFileLoader: if self._loader is None: self._loader = SQLFileLoader(runtime=self._loader_runtime) - else: - self._loader.set_observability_runtime(self._loader_runtime) return self._loader async def _driver_from_async_connection( @@ -655,7 +641,7 @@ def _cleanup_sync_pools(self) -> None: cleaned_count = 0 failed_configs: list[str] = [] - for config in self._configs.values(): + for config in tuple(self._configs.values()): if config.supports_connection_pooling and not config.is_async: failure = self._safe_close_pool(config) if failure is None: diff --git a/sqlspec/builder/_base.py b/sqlspec/builder/_base.py index e320847b6..293d287a1 100644 --- a/sqlspec/builder/_base.py +++ b/sqlspec/builder/_base.py @@ -22,7 +22,9 @@ from sqlglot.optimizer.pushdown_predicates import pushdown_predicates as _pushdown_predicates_rule from sqlglot.optimizer.pushdown_projections import pushdown_projections as _pushdown_projections_rule from sqlglot.optimizer.qualify_columns import quote_identifiers as _quote_identifiers_rule +from sqlglot.optimizer.scope import build_scope from sqlglot.optimizer.simplify import simplify as _simplify_rule +from sqlglot.schema import MappingSchema from sqlspec.builder._locking import register_lock_generator from sqlspec.builder._parsing_utils import _normalize_dialect, _resolve_dialect @@ -41,13 +43,14 @@ from sqlspec.data_dictionary import DialectConfig, get_dialect_config from sqlspec.exceptions import SQLBuilderError from sqlspec.utils.logging import get_logger -from sqlspec.utils.type_guards import has_expression_and_parameters, has_name, is_expression +from sqlspec.utils.type_guards import has_expression_and_parameters, is_expression from sqlspec.utils.uuids import uuid4 __all__ = ("BuiltQuery", "ExpressionBuilder", "QueryBuilder") MAX_PARAMETER_COLLISION_ATTEMPTS = 1000 PARAMETER_INDEX_PATTERN = re.compile(r"^param_(?P\d+)$") +_PARAMETER_SUFFIX_PATTERN = re.compile(r"^(?P.+)_(?P\d+)$") _UPPER_FOLDING_DIALECTS: Final[frozenset[str]] = frozenset({"oracle", "db2"}) _UNQUOTED_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][\w$#]*$") @@ -123,6 +126,422 @@ def __init__( self._with_ctes: dict[str, exp.CTE] = {} self._merge_target_quoted = False + def __str__(self) -> str: + """Return the SQL string representation of the query. + + Returns: + str: The SQL string for this query. + """ + return self.build().sql + + @property + def dialect_name(self) -> "str | None": + """Returns the normalized name of the dialect, if set.""" + return _normalize_dialect(self.dialect) + + @property + def parameters(self) -> dict[str, Any]: + """Public access to query parameters.""" + return self._parameters + + @property + def with_ctes(self) -> "dict[str, exp.CTE]": + """Get WITH clause CTEs (public API).""" + return dict(self._with_ctes) + + def get_expression(self) -> exp.Expr | None: + """Get expression reference (no copy). + + Returns: + The current SQLGlot expression or None if not set + """ + return self._expression + + def set_expression(self, expression: exp.Expr) -> None: + """Set expression with validation. + + Args: + expression: SQLGlot expression to set + """ + if not is_expression(expression): + self._raise_invalid_expression_type(expression) + self._expression = expression + + def has_expression(self) -> bool: + """Check if expression exists. + + Returns: + True if expression is set, False otherwise + """ + return self._expression is not None + + def add_parameter_for_expression(self, value: Any, context: str | None = None) -> str: + """Add a parameter for expression parameterization. + + Args: + value: The value of the parameter. + context: Optional context hint for parameter naming. + + Returns: + Parameter placeholder name. + """ + return self._add_parameter(value, context=context) + + def add_parameter(self: Self, value: Any, name: str | None = None) -> tuple[Self, str]: + """Explicitly adds a parameter to the query. + + This is useful for parameters that are not directly tied to a + builder method like `where` or `values`. + + Args: + value: The value of the parameter. + name: Optional explicit name for the parameter. If None, a name + will be generated. + + Returns: + tuple[Self, str]: The builder instance and the parameter name. + """ + if name: + if name in self._parameters: + self._raise_builder_error(f"Parameter name '{name}' already exists.") + self._parameters[name] = value + self._sync_parameter_name_counter(name) + return self, name + + self._parameter_counter += 1 + param_name = f"param_{self._parameter_counter}" + self._parameters[param_name] = value + self._sync_parameter_name_counter(param_name) + return self, param_name + + def load_parameters(self, parameters: "Mapping[str, Any]") -> None: + """Load a parameter mapping into the builder. + + Args: + parameters: Mapping of parameter names to values. + """ + if not parameters: + return + + for name, value in parameters.items(): + if name in self._parameters: + self._raise_builder_error(f"Parameter name '{name}' already exists.") + self._parameters[name] = value + self._sync_parameter_name_counter(name) + + def load_ctes(self, ctes: "Iterable[exp.CTE]") -> None: + """Load SQLGlot CTE nodes into the builder. + + Args: + ctes: Iterable of CTE expressions to register. + """ + for cte in ctes: + alias = self._resolve_cte_alias(cte) + if alias in self._with_ctes: + self._raise_builder_error(f"CTE '{alias}' already exists.") + self._with_ctes[alias] = cte + + def create_placeholder(self, value: Any, base_name: str) -> tuple[exp.Placeholder, str]: + """Create placeholder expression with a unique parameter name. + + Args: + value: Parameter value to bind. + base_name: Seed for parameter naming. + + Returns: + Tuple of placeholder expression and the final parameter name. + """ + param_name = self._next_parameter_name(base_name) + _, param_name = self.add_parameter(value, name=param_name) + return exp.Placeholder(this=param_name), param_name + + def with_cte( + self: Self, + alias: str, + query: "QueryBuilder | exp.Select | exp.SetOperation | exp.Values | str | Any", + recursive: bool = False, + columns: "list[str] | None" = None, + ) -> Self: + """Adds a Common Table Expression (CTE) to the query. + + Args: + alias: The alias for the CTE. + query: The CTE query, which can be another QueryBuilder instance, + a raw SQL string, or a sqlglot Select or Values expression. + recursive: Whether the CTE is recursive. + columns: Optional list of column aliases for the CTE. + + Returns: + Self: The current builder instance for method chaining. + """ + if alias in self._with_ctes: + self._raise_builder_error(f"CTE with alias '{alias}' already exists.") + + cte_select_expression = self._resolve_cte_query(alias, query) + cte_columns = columns + if cte_columns is None and isinstance(query, exp.Values): + values_alias = query.args.get("alias") + if isinstance(values_alias, exp.TableAlias): + cte_columns = [column.name for column in values_alias.columns] + if cte_columns is None: + query_cols = getattr(query, "columns", None) + query_private_cols = getattr(query, "_columns", None) + if isinstance(query_cols, (list, tuple)): + cte_columns = [str(c) for c in query_cols] + elif isinstance(query_private_cols, (list, tuple)): + cte_columns = [str(c) for c in query_private_cols] + + if cte_columns: + alias_node: exp.Expr = exp.TableAlias( + this=exp.to_identifier(alias), columns=[exp.to_identifier(c) for c in cte_columns] + ) + else: + alias_node = exp.to_table(alias) + self._with_ctes[alias] = exp.CTE(this=cte_select_expression, alias=alias_node) + self._with_ctes[alias].meta["recursive"] = recursive + return self + + def with_( + self: Self, + name: str, + query: "QueryBuilder | exp.Select | exp.SetOperation | exp.Values | str | Any", + recursive: bool = False, + columns: "list[str] | None" = None, + ) -> Self: + """Alias for with_cte for parity across builders. + + Args: + name: The alias/name for the CTE. + query: The CTE query expression or builder. + recursive: Whether the CTE is recursive. + columns: Optional list of column aliases for the CTE. + + Returns: + Self: The current builder instance for method chaining. + """ + return self.with_cte(name, query, recursive=recursive, columns=columns) + + def build(self, dialect: DialectType = None) -> "BuiltQuery": + """Builds the SQL query string and parameters. + + Args: + dialect: Optional dialect override. If provided, generates SQL for this dialect + instead of the builder's default dialect. + + Returns: + BuiltQuery: A dataclass containing the SQL string and parameters. + """ + final_expression = self._prepare_expression_for_build(dialect=dialect, copy=True) + target_dialect = self._build_dialect(dialect) + self._validate_update_from(final_expression, _resolve_dialect(dialect, self.dialect)) + + if self.enable_optimization and isinstance(final_expression, exp.Expr): + final_expression = self._optimize_expression(final_expression, dialect=target_dialect) + + final_expression = self._prepare_dialect_expression(final_expression, target_dialect, dialect) + + try: + if isinstance(final_expression, exp.Expr): + normalized_expression = ( + self._unquote_identifiers(final_expression) + if self._folds_unquoted_to_upper(target_dialect) + else final_expression + ) + identify = self._should_identify(target_dialect) + if normalized_expression.find(exp.Lock) and target_dialect != "db2": + register_lock_generator(target_dialect) + sql_string = normalized_expression.sql( + dialect=target_dialect, pretty=True, identify=identify, copy=False + ) + sql_string = self._postprocess_built_sql(sql_string, dialect=dialect) + else: + sql_string = str(final_expression) + except Exception as e: + err_msg = f"Error generating SQL from expression: {e!s}" + self._raise_builder_error(err_msg, e) + + return BuiltQuery(sql=sql_string, parameters=self._parameters.copy(), dialect=target_dialect) + + def to_sql(self, show_parameters: bool = False, dialect: DialectType = None) -> str: + """Return SQL string with optional parameter substitution. + + Args: + show_parameters: If True, replace parameter placeholders with actual values (for debugging). + If False (default), return SQL with parameter placeholders. + dialect: Optional dialect override. If provided, generates SQL for this dialect + instead of the builder's default dialect. + + Returns: + SQL string with or without parameter values filled in + + Warning: + SQL with show_parameters=True is for debugging ONLY. + Never execute SQL with interpolated parameters directly - use parameterized queries. + """ + safe_query = self.build(dialect=dialect) + + if not show_parameters: + return safe_query.sql + + sql = safe_query.sql + parameters = safe_query.parameters + if not parameters: + return sql + + sorted_names = sorted(parameters, key=len, reverse=True) + pattern = re.compile(r":(" + "|".join(re.escape(name) for name in sorted_names) + r")\b") + + def _replace_match(match: "re.Match[str]") -> str: + param_name = match.group(1) + param_value = parameters[param_name] + if isinstance(param_value, str): + escaped = param_value.replace("'", "''") + return f"'{escaped}'" + if param_value is None: + return "NULL" + if isinstance(param_value, bool): + return "TRUE" if param_value else "FALSE" + return str(param_value) + + return pattern.sub(_replace_match, sql) + + def to_statement(self, config: "StatementConfig | None" = None) -> "SQL": + """Converts the built query into a SQL statement object. + + Args: + config: Optional SQL configuration. + + Returns: + SQL: A SQL statement object. + """ + cache_config = get_cache_config() + if ( + not cache_config.compiled_cache_enabled + or not cache_config.sql_cache_enabled + or (config is not None and not config.enable_caching) + ): + return self._to_statement(config) + + cache_key_str = self._cache_key(config) + + cache = get_cache() + cached_entry = cache.get_builder(cache_key_str) + if cached_entry is None: + cache_entry = self._create_builder_cache_entry(config) + cache.put_builder(cache_key_str, cache_entry) + else: + cache_entry = cast("_BuilderCacheEntry", cached_entry) + + return self._statement_from_cache_entry(cache_entry, config) + + def apply_filters(self, *filters: StatementFilter, config: "StatementConfig | None" = None) -> "SQL": + """Apply statement filters to this query builder. + + Converts the builder to a SQL statement, then applies each filter's + ``append_to_statement`` method to produce the final SQL with all + WHERE clauses, ORDER BY, LIMIT/OFFSET, etc. + + Args: + *filters: Statement filters. + config: Optional SQL configuration. + + Returns: + SQL statement with all filters applied. + """ + sql_statement = self.to_statement(config) + for filter_obj in filters: + sql_statement = filter_obj.append_to_statement(sql_statement) + return sql_statement + + def set_parameters(self, parameters: dict[str, Any]) -> None: + """Set query parameters (public API).""" + self._parameters = parameters.copy() + self._parameter_counter = 0 + self._parameter_name_counters.clear() + for name in self._parameters: + self._sync_parameter_name_counter(name) + + def generate_unique_parameter_name(self, base_name: str) -> str: + """Generate unique parameter name (public API).""" + return self._next_parameter_name(base_name) + + def build_static_expression( + self, + expression: exp.Expr | None = None, + parameters: dict[str, Any] | None = None, + *, + cache_key: str | None = None, + expression_factory: Callable[[], exp.Expr] | None = None, + copy: bool = True, + optimize_expression: bool | None = None, + dialect: DialectType | None = None, + ) -> "BuiltQuery": + """Compile a pre-built expression with optional caching and parameters. + + Designed for hot paths that construct an AST once and reuse it with + different parameters, avoiding repeated parse/optimize cycles. + + Args: + expression: Pre-built sqlglot expression to render (required when cache_key is not provided). + parameters: Optional parameter mapping to include in the result. + cache_key: When provided, the expression will be cached under this key. + expression_factory: Factory used to build the expression on cache miss. + copy: Copy the expression before rendering to avoid caller mutation. + optimize_expression: Override builder optimization toggle for this call. + dialect: Optional dialect override for SQL generation. + + Returns: + BuiltQuery containing SQL and parameters. + """ + expr: exp.Expr | None = None + target_dialect = self._build_dialect(dialect) + + if cache_key is not None: + cache = get_cache() + cached_expr = cache.get_expression(cache_key) + if cached_expr is None: + if expression_factory is None: + msg = "expression_factory is required when cache_key is provided" + self._raise_builder_error(msg) + expr_candidate = expression_factory() + if not is_expression(expr_candidate): + self._raise_invalid_expression_type(expr_candidate) + expr_to_store = expr_candidate.copy() + should_optimize = self.enable_optimization if optimize_expression is None else optimize_expression + if should_optimize: + expr_to_store = self._optimize_expression( + expr_to_store, force=optimize_expression is True, dialect=target_dialect + ) + cache.put_expression(cache_key, expr_to_store) + cached_expr = expr_to_store + expr = cached_expr.copy() + else: + if expression is None: + msg = "expression must be provided when cache_key is not set" + self._raise_builder_error(msg) + expr = expression.copy() if copy else expression + should_optimize = self.enable_optimization if optimize_expression is None else optimize_expression + if should_optimize: + expr = self._optimize_expression(expr, force=optimize_expression is True, dialect=target_dialect) + + if expr is None: + self._raise_builder_error("Static expression could not be resolved.") + + self._validate_update_from(expr, _resolve_dialect(dialect, self.dialect)) + expr = self._prepare_dialect_expression(expr, target_dialect, dialect) + if self._folds_unquoted_to_upper(target_dialect): + expr = self._unquote_identifiers(expr) + identify = self._should_identify(target_dialect) + if expr.find(exp.Lock) and target_dialect != "db2": + register_lock_generator(target_dialect) + sql_string = expr.sql(dialect=target_dialect, pretty=True, identify=identify, copy=not copy) + sql_string = self._postprocess_built_sql(sql_string, dialect=dialect) + return BuiltQuery( + sql=sql_string, + parameters=parameters.copy() if parameters else {}, + dialect=_resolve_dialect(dialect, self.dialect), + ) + @classmethod def _parse_init_options( cls, kwargs: "dict[str, Any]" @@ -161,32 +580,6 @@ def _initialize_expression(self) -> None: if not self._expression: self._raise_builder_error("QueryBuilder._create_base_expression must return a valid sqlglot expression.") - def get_expression(self) -> exp.Expr | None: - """Get expression reference (no copy). - - Returns: - The current SQLGlot expression or None if not set - """ - return self._expression - - def set_expression(self, expression: exp.Expr) -> None: - """Set expression with validation. - - Args: - expression: SQLGlot expression to set - """ - if not is_expression(expression): - self._raise_invalid_expression_type(expression) - self._expression = expression - - def has_expression(self) -> bool: - """Check if expression exists. - - Returns: - True if expression is set, False otherwise - """ - return self._expression is not None - @abstractmethod def _create_base_expression(self) -> exp.Expr: """Create the base sqlglot expression for the specific query type. @@ -248,6 +641,14 @@ def _raise_cte_parse_error(cause: BaseException) -> NoReturn: msg = f"Failed to parse CTE query: {cause!s}" raise SQLBuilderError(msg) from cause + def _prepare_expression_for_build(self, dialect: DialectType = None, *, copy: bool = True) -> exp.Expr: + """Validate builder state and return the expression ready for optimization and rendering.""" + return self._build_final_expression(copy=copy) + + def _postprocess_built_sql(self, sql_string: str, dialect: DialectType = None) -> str: + """Apply builder-specific post-processing to a rendered SQL string.""" + return self._strip_merge_target_quotes(sql_string) + def _build_final_expression(self, *, copy: bool = False) -> exp.Expr: """Construct the current expression with attached CTEs. @@ -290,6 +691,34 @@ def _spawn_like_self(self: Self) -> Self: simplify_expressions=self.simplify_expressions, ) + def _embed_subquery(self, subquery: Any, alias_hint: str = "subquery") -> exp.Expr: + """Extract an AST expression from a subquery builder and merge its parameters without collision.""" + subquery_expression: exp.Expr + parameters: dict[str, Any] = dict(getattr(subquery, "parameters", None) or {}) + if isinstance(subquery, QueryBuilder): + subquery_expression = subquery._prepare_expression_for_build(copy=True) + elif hasattr(subquery, "_build_final_expression"): + subquery_expression = subquery._build_final_expression(copy=True) + elif hasattr(subquery, "get_expression") and subquery.get_expression() is not None: + subquery_expression = subquery.get_expression().copy() + else: + built = subquery.build() if hasattr(subquery, "build") else subquery + sql_text = built.sql if isinstance(built, BuiltQuery) else str(built) + if isinstance(built, BuiltQuery) and not parameters: + parameters = dict(built.parameters) + subquery_expression = exp.maybe_parse(sql_text, dialect=self.dialect_name) or exp.convert(sql_text) + + if parameters: + param_mapping: dict[str, str] = {} + for old_name, value in parameters.items(): + target_name = self._next_parameter_name(old_name) if old_name in self._parameters else old_name + _, actual_name = self.add_parameter(value, name=target_name) + if actual_name != old_name: + param_mapping[old_name] = actual_name + if param_mapping: + subquery_expression = self._update_placeholders(subquery_expression, param_mapping) + return subquery_expression + def _resolve_cte_query( self, alias: str, query: "QueryBuilder | exp.Select | exp.SetOperation | exp.Values | str | Any" ) -> exp.Expr: @@ -368,20 +797,9 @@ def _add_parameter(self, value: Any, context: str | None = None) -> str: param_name = f"{context}_param_{self._parameter_counter}" if context else f"param_{self._parameter_counter}" self._parameters[param_name] = value + self._sync_parameter_name_counter(param_name) return param_name - def add_parameter_for_expression(self, value: Any, context: str | None = None) -> str: - """Add a parameter for expression parameterization. - - Args: - value: The value of the parameter. - context: Optional context hint for parameter naming. - - Returns: - Parameter placeholder name. - """ - return self._add_parameter(value, context=context) - def _parameterize_expression(self, expression: exp.Expr) -> exp.Expr: """Replace literal values in an expression with bound parameters. @@ -395,59 +813,7 @@ def _parameterize_expression(self, expression: exp.Expr) -> exp.Expr: Returns: A new expression with literals replaced by parameter placeholders """ - return expression.transform(_ExpressionParameterizer(self), copy=False) - - def add_parameter(self: Self, value: Any, name: str | None = None) -> tuple[Self, str]: - """Explicitly adds a parameter to the query. - - This is useful for parameters that are not directly tied to a - builder method like `where` or `values`. - - Args: - value: The value of the parameter. - name: Optional explicit name for the parameter. If None, a name - will be generated. - - Returns: - tuple[Self, str]: The builder instance and the parameter name. - """ - if name: - if name in self._parameters: - self._raise_builder_error(f"Parameter name '{name}' already exists.") - self._parameters[name] = value - return self, name - - self._parameter_counter += 1 - param_name = f"param_{self._parameter_counter}" - self._parameters[param_name] = value - return self, param_name - - def load_parameters(self, parameters: "Mapping[str, Any]") -> None: - """Load a parameter mapping into the builder. - - Args: - parameters: Mapping of parameter names to values. - """ - if not parameters: - return - - for name, value in parameters.items(): - if name in self._parameters: - self._raise_builder_error(f"Parameter name '{name}' already exists.") - self._parameters[name] = value - self._update_parameter_counter(name) - - def load_ctes(self, ctes: "Iterable[exp.CTE]") -> None: - """Load SQLGlot CTE nodes into the builder. - - Args: - ctes: Iterable of CTE expressions to register. - """ - for cte in ctes: - alias = self._resolve_cte_alias(cte) - if alias in self._with_ctes: - self._raise_builder_error(f"CTE '{alias}' already exists.") - self._with_ctes[alias] = cte + return expression.transform(_ExpressionParameterizer(self), copy=True) def _resolve_cte_alias(self, cte: exp.CTE) -> str: alias_name = cte.alias_or_name @@ -462,6 +828,16 @@ def _update_parameter_counter(self, name: str) -> None: index = int(match.group("index")) self._parameter_counter = max(self._parameter_counter, index) + def _sync_parameter_name_counter(self, name: str) -> None: + self._update_parameter_counter(name) + suffix_match = _PARAMETER_SUFFIX_PATTERN.match(name) + if suffix_match: + base_name = suffix_match.group("base") + index = int(suffix_match.group("index")) + self._parameter_name_counters[base_name] = max(self._parameter_name_counters.get(base_name, 0), index) + else: + self._parameter_name_counters.setdefault(name, 0) + def _next_parameter_name(self, base_name: str) -> str: """Generate unique parameter name when collision occurs. @@ -474,7 +850,6 @@ def _next_parameter_name(self, base_name: str) -> str: current_index = self._parameter_name_counters.get(base_name, 0) if base_name not in self._parameters: - # First use keeps the base name, counter stays at 0 self._parameter_name_counters[base_name] = current_index return base_name @@ -490,183 +865,69 @@ def _next_parameter_name(self, base_name: str) -> str: self._parameter_name_counters[base_name] = next_index return candidate - def create_placeholder(self, value: Any, base_name: str) -> tuple[exp.Placeholder, str]: - """Create placeholder expression with a unique parameter name. - - Args: - value: Parameter value to bind. - base_name: Seed for parameter naming. - - Returns: - Tuple of placeholder expression and the final parameter name. - """ - param_name = self._next_parameter_name(base_name) - _, param_name = self.add_parameter(value, name=param_name) - return exp.Placeholder(this=param_name), param_name - def _merge_cte_parameters(self, cte_name: str, parameters: dict[str, Any]) -> dict[str, str]: """Merge CTE parameters with unique naming to prevent collisions. Args: - cte_name: The name of the CTE for parameter prefixing - parameters: The CTE's parameter dictionary - - Returns: - Mapping of old parameter names to new unique names - """ - param_mapping = {} - for old_name, value in parameters.items(): - new_name = self._next_parameter_name(f"{cte_name}_{old_name}") - param_mapping[old_name] = new_name - self.add_parameter(value, name=new_name) - return param_mapping - - def _update_placeholders(self, expression: exp.Expr, param_mapping: dict[str, str]) -> exp.Expr: - """Update parameter placeholders in expression to use new names. - - Args: - expression: The SQLGlot expression to update - param_mapping: Mapping of old parameter names to new names - - Returns: - Updated expression with new placeholder names - """ - return expression.transform(_PlaceholderReplacer(param_mapping), copy=False) - - def _cache_key(self, config: "StatementConfig | None" = None) -> str: - """Generate cache key based on builder state and configuration. - - Args: - config: Optional SQL configuration that affects the generated SQL - - Returns: - A unique cache key representing the builder state and configuration - """ - if self._expression is None: - self._expression = self._create_base_expression() - final_expression = self._build_final_expression() - dialect = config.dialect if config is not None and config.dialect is not None else self.dialect_name - settings = { - "enable_optimization": self.enable_optimization, - "optimize_joins": self.optimize_joins, - "optimize_predicates": self.optimize_predicates, - "simplify_expressions": self.simplify_expressions, - } - fingerprint = _expression_cache_fingerprint( - final_expression, - parameter_signature=tuple(self._parameters), - dialect=dialect, - schema=self.schema, - settings=settings, - ) - return f"builder:{fingerprint}" - - def with_cte( - self: Self, - alias: str, - query: "QueryBuilder | exp.Select | exp.SetOperation | exp.Values | str | Any", - recursive: bool = False, - columns: "list[str] | None" = None, - ) -> Self: - """Adds a Common Table Expression (CTE) to the query. - - Args: - alias: The alias for the CTE. - query: The CTE query, which can be another QueryBuilder instance, - a raw SQL string, or a sqlglot Select or Values expression. - recursive: Whether the CTE is recursive. - columns: Optional list of column aliases for the CTE. - - Returns: - Self: The current builder instance for method chaining. - """ - if alias in self._with_ctes: - self._raise_builder_error(f"CTE with alias '{alias}' already exists.") - - cte_select_expression = self._resolve_cte_query(alias, query) - cte_columns = columns - if cte_columns is None and isinstance(query, exp.Values): - values_alias = query.args.get("alias") - if isinstance(values_alias, exp.TableAlias): - cte_columns = [column.name for column in values_alias.columns] - if cte_columns is None: - query_cols = getattr(query, "columns", None) - query_private_cols = getattr(query, "_columns", None) - if isinstance(query_cols, (list, tuple)): - cte_columns = [str(c) for c in query_cols] - elif isinstance(query_private_cols, (list, tuple)): - cte_columns = [str(c) for c in query_private_cols] - - if cte_columns: - alias_node: exp.Expr = exp.TableAlias( - this=exp.to_identifier(alias), columns=[exp.to_identifier(c) for c in cte_columns] - ) - else: - alias_node = exp.to_table(alias) - self._with_ctes[alias] = exp.CTE(this=cte_select_expression, alias=alias_node) - self._with_ctes[alias].meta["recursive"] = recursive - return self - - def with_( - self: Self, - name: str, - query: "QueryBuilder | exp.Select | exp.SetOperation | exp.Values | str | Any", - recursive: bool = False, - columns: "list[str] | None" = None, - ) -> Self: - """Alias for with_cte for parity across builders. - - Args: - name: The alias/name for the CTE. - query: The CTE query expression or builder. - recursive: Whether the CTE is recursive. - columns: Optional list of column aliases for the CTE. + cte_name: The name of the CTE for parameter prefixing + parameters: The CTE's parameter dictionary Returns: - Self: The current builder instance for method chaining. + Mapping of old parameter names to new unique names """ - return self.with_cte(name, query, recursive=recursive, columns=columns) + param_mapping = {} + for old_name, value in parameters.items(): + new_name = self._next_parameter_name(f"{cte_name}_{old_name}") + param_mapping[old_name] = new_name + self.add_parameter(value, name=new_name) + return param_mapping - def build(self, dialect: DialectType = None) -> "BuiltQuery": - """Builds the SQL query string and parameters. + def _update_placeholders(self, expression: exp.Expr, param_mapping: dict[str, str]) -> exp.Expr: + """Update parameter placeholders in expression to use new names. Args: - dialect: Optional dialect override. If provided, generates SQL for this dialect - instead of the builder's default dialect. + expression: The SQLGlot expression to update + param_mapping: Mapping of old parameter names to new names Returns: - BuiltQuery: A dataclass containing the SQL string and parameters. + Updated expression with new placeholder names """ - final_expression = self._build_final_expression(copy=True) - self._validate_update_from(final_expression, _resolve_dialect(dialect, self.dialect)) + return expression.transform(_PlaceholderReplacer(param_mapping), copy=False) - if self.enable_optimization and isinstance(final_expression, exp.Expr): - final_expression = self._optimize_expression(final_expression) + def _cache_key(self, config: "StatementConfig | None" = None) -> str: + """Generate cache key based on builder state and configuration. - target_dialect = self._build_dialect(dialect) - final_expression = self._prepare_dialect_expression(final_expression, target_dialect, dialect) + Args: + config: Optional SQL configuration that affects the generated SQL - try: - if isinstance(final_expression, exp.Expr): - normalized_expression = ( - self._unquote_identifiers(final_expression) - if self._folds_unquoted_to_upper(target_dialect) - else final_expression - ) - identify = self._should_identify(target_dialect) - if normalized_expression.find(exp.Lock) and target_dialect != "db2": - register_lock_generator(target_dialect) - sql_string = normalized_expression.sql( - dialect=target_dialect, pretty=True, identify=identify, copy=False - ) - sql_string = self._strip_merge_target_quotes(sql_string) - else: - sql_string = str(final_expression) - except Exception as e: - err_msg = f"Error generating SQL from expression: {e!s}" - self._raise_builder_error(err_msg, e) + Returns: + A unique cache key representing the builder state and configuration + """ + dialect_override = config.dialect if config is not None and config.dialect is not None else None + final_expression = self._prepare_expression_for_build(dialect=dialect_override, copy=True) + dialect = self._build_dialect(dialect_override) + hints = getattr(self, "_hints", None) + settings: dict[str, Any] = { + "enable_optimization": self.enable_optimization, + "optimize_joins": self.optimize_joins, + "optimize_predicates": self.optimize_predicates, + "simplify_expressions": self.simplify_expressions, + } + if hints: + settings["hints"] = tuple( + (h.get("hint"), h.get("location"), h.get("table"), h.get("dialect")) for h in hints + ) + fingerprint = _expression_cache_fingerprint( + final_expression, + parameter_signature=tuple(self._parameters), + dialect=dialect, + schema=self.schema, + settings=settings, + ) + return f"builder:{fingerprint}" - return BuiltQuery(sql=sql_string, parameters=self._parameters.copy(), dialect=target_dialect) + def _has_sql_postprocessing(self) -> bool: + return False def _build_dialect(self, dialect: DialectType = None) -> str | None: return _normalize_dialect(dialect or self.dialect) @@ -768,46 +1029,9 @@ def _validate_spangres_conflicts(self, expression: exp.Expr) -> None: }: self._raise_builder_error("Spanner PostgreSQL conflict updates must assign every inserted column.") - def to_sql(self, show_parameters: bool = False, dialect: DialectType = None) -> str: - """Return SQL string with optional parameter substitution. - - Args: - show_parameters: If True, replace parameter placeholders with actual values (for debugging). - If False (default), return SQL with parameter placeholders. - dialect: Optional dialect override. If provided, generates SQL for this dialect - instead of the builder's default dialect. - - Returns: - SQL string with or without parameter values filled in - - Warning: - SQL with show_parameters=True is for debugging ONLY. - Never execute SQL with interpolated parameters directly - use parameterized queries. - """ - safe_query = self.build(dialect=dialect) - - if not show_parameters: - return safe_query.sql - - sql = safe_query.sql - parameters = safe_query.parameters - - for param_name, param_value in parameters.items(): - placeholder = f":{param_name}" - if isinstance(param_value, str): - replacement = f"'{param_value}'" - elif param_value is None: - replacement = "NULL" - elif isinstance(param_value, bool): - replacement = "TRUE" if param_value else "FALSE" - else: - replacement = str(param_value) - - sql = sql.replace(placeholder, replacement) - - return sql - - def _optimize_expression(self, expression: exp.Expr, *, force: bool = False) -> exp.Expr: + def _optimize_expression( + self, expression: exp.Expr, *, force: bool = False, dialect: DialectType = None + ) -> exp.Expr: """Apply SQLGlot optimizations to the expression. The ``ON CONFLICT`` / ``ON DUPLICATE KEY UPDATE`` clause of an INSERT only receives @@ -817,6 +1041,7 @@ def _optimize_expression(self, expression: exp.Expr, *, force: bool = False) -> Args: expression: The expression to optimize force: Optimize even when the builder-level toggle is disabled. + dialect: Optional dialect override for optimization rules. Returns: The optimized expression @@ -827,9 +1052,12 @@ def _optimize_expression(self, expression: exp.Expr, *, force: bool = False) -> if not self.optimize_joins and not self.optimize_predicates and not self.simplify_expressions: return expression + target_dialect = _normalize_dialect(dialect) if dialect is not None else self.dialect_name + optimizer_dialect = None if target_dialect in {"oracle", "db2", "snowflake"} else target_dialect + conflict = expression.args.get("conflict") if isinstance(expression, exp.Insert) else None if isinstance(conflict, exp.OnConflict): - return self._optimize_insert_with_conflict(expression, conflict, force=force) + return self._optimize_insert_with_conflict(expression, conflict, force=force, dialect=optimizer_dialect) optimizer_settings = { "optimize_joins": self.optimize_joins, @@ -837,9 +1065,9 @@ def _optimize_expression(self, expression: exp.Expr, *, force: bool = False) -> "simplify_expressions": self.simplify_expressions, } - dialect_name = self.dialect_name or "default" + dialect_key = optimizer_dialect or "default" cache_key = hash_optimized_expression( - expression, dialect=dialect_name, schema=self.schema, optimizer_settings=optimizer_settings + expression, dialect=dialect_key, schema=self.schema, optimizer_settings=optimizer_settings ) cache = get_cache() @@ -861,7 +1089,9 @@ def _optimize_expression(self, expression: exp.Expr, *, force: bool = False) -> excluded_rules.add(_eliminate_ctes_rule) excluded_rules.add(_merge_subqueries_rule) excluded_rules.add(_pushdown_projections_rule) - elif self.schema is None and isinstance(expression, exp.Select) and expression.is_star: + elif self.schema is None and ( + (isinstance(expression, exp.Select) and expression.is_star) or expression.find(exp.Star) is not None + ): excluded_rules.add(_pushdown_projections_rule) rules = RULES if not excluded_rules else tuple(rule for rule in RULES if rule not in excluded_rules) @@ -869,11 +1099,15 @@ def _optimize_expression(self, expression: exp.Expr, *, force: bool = False) -> if _pushdown_projections_rule not in excluded_rules: rule_sets.append(tuple(rule for rule in rules if rule is not _pushdown_projections_rule)) - schema = cast("dict[str, object] | None", self.schema) + schema: Any = ( + cast("dict[str, object]", self.schema) + if self.schema is not None + else _infer_single_table_schema(expression, dialect=optimizer_dialect) + ) for rule_set in rule_sets: try: optimized = sqlglot_optimizer.optimize( - expression, schema=schema, dialect=self.dialect_name, rules=rule_set + expression, schema=schema, dialect=optimizer_dialect, rules=rule_set ) cache.put_optimized(cache_key, optimized.copy()) except SQLGlotOptimizeError: @@ -885,7 +1119,7 @@ def _optimize_expression(self, expression: exp.Expr, *, force: bool = False) -> return expression def _optimize_insert_with_conflict( - self, expression: exp.Expr, conflict: exp.OnConflict, *, force: bool + self, expression: exp.Expr, conflict: exp.OnConflict, *, force: bool, dialect: str | None = None ) -> exp.Expr: """Optimize an INSERT without its conflict clause, then attach the clause with quoted identifiers. @@ -893,6 +1127,7 @@ def _optimize_insert_with_conflict( expression: The INSERT expression owning ``conflict``. conflict: The INSERT's ``ON CONFLICT`` / ``ON DUPLICATE KEY UPDATE`` clause. force: Optimize even when the builder-level toggle is disabled. + dialect: Optional normalized dialect name for identifier normalization. Returns: The optimized INSERT with the normalized and quoted conflict clause, or the @@ -900,67 +1135,18 @@ def _optimize_insert_with_conflict( """ expression.set("conflict", None) try: - optimized = self._optimize_expression(expression, force=force) + optimized = self._optimize_expression(expression, force=force, dialect=dialect) finally: expression.set("conflict", conflict) if optimized is expression: return expression - dialect_name = self.dialect_name quoted_conflict = _quote_identifiers_rule( - _normalize_identifiers_rule(conflict.copy(), dialect=dialect_name), dialect=dialect_name + _normalize_identifiers_rule(conflict.copy(), dialect=dialect), dialect=dialect ) optimized.set("conflict", quoted_conflict) return optimized - def to_statement(self, config: "StatementConfig | None" = None) -> "SQL": - """Converts the built query into a SQL statement object. - - Args: - config: Optional SQL configuration. - - Returns: - SQL: A SQL statement object. - """ - cache_config = get_cache_config() - if ( - not cache_config.compiled_cache_enabled - or not cache_config.sql_cache_enabled - or (config is not None and not config.enable_caching) - ): - return self._to_statement(config) - - cache_key_str = self._cache_key(config) - - cache = get_cache() - cached_entry = cache.get_builder(cache_key_str) - if cached_entry is None: - cache_entry = self._create_builder_cache_entry(config) - cache.put_builder(cache_key_str, cache_entry) - else: - cache_entry = cast("_BuilderCacheEntry", cached_entry) - - return self._statement_from_cache_entry(cache_entry, config) - - def apply_filters(self, *filters: StatementFilter, config: "StatementConfig | None" = None) -> "SQL": - """Apply statement filters to this query builder. - - Converts the builder to a SQL statement, then applies each filter's - ``append_to_statement`` method to produce the final SQL with all - WHERE clauses, ORDER BY, LIMIT/OFFSET, etc. - - Args: - *filters: Statement filters. - config: Optional SQL configuration. - - Returns: - SQL statement with all filters applied. - """ - sql_statement = self.to_statement(config) - for filter_obj in filters: - sql_statement = filter_obj.append_to_statement(sql_statement) - return sql_statement - def _to_statement(self, config: "StatementConfig | None" = None) -> "SQL": """Internal method to create SQL statement. @@ -994,11 +1180,11 @@ def _validate_update_from(self, expression: exp.Expr, dialect: DialectType) -> N def _create_builder_cache_entry(self, config: "StatementConfig | None") -> "_BuilderCacheEntry": dialect_override = config.dialect if config is not None else None resolved_dialect = self._build_dialect(dialect_override) - statement_expression = self._build_final_expression(copy=True) - self._validate_update_from(statement_expression, resolved_dialect) + statement_expression = self._prepare_expression_for_build(dialect=dialect_override, copy=True) + self._validate_update_from(statement_expression, _resolve_dialect(dialect_override, self.dialect)) if self.enable_optimization and isinstance(statement_expression, exp.Expr): - statement_expression = self._optimize_expression(statement_expression) + statement_expression = self._optimize_expression(statement_expression, dialect=resolved_dialect) statement_expression = self._prepare_dialect_expression( statement_expression, resolved_dialect, dialect_override @@ -1011,7 +1197,11 @@ def _create_builder_cache_entry(self, config: "StatementConfig | None") -> "_Bui return _BuilderCacheEntry(statement_expression, resolved_dialect) def _statement_from_cache_entry(self, cache_entry: "_BuilderCacheEntry", config: "StatementConfig | None") -> "SQL": - statement_expression = cache_entry.expression.copy() + statement_target: exp.Expr | str + if self._has_sql_postprocessing(): + statement_target = self.build(dialect=cache_entry.dialect).sql + else: + statement_target = cache_entry.expression.copy() kwargs, parameters = self._statement_parameters(self._parameters.copy()) statement_config = config @@ -1026,10 +1216,10 @@ def _statement_from_cache_entry(self, cache_entry: "_BuilderCacheEntry", config: ) if kwargs: - return SQL(statement_expression, statement_config=statement_config, **kwargs) + return SQL(statement_target, statement_config=statement_config, **kwargs) if parameters: - return SQL(statement_expression, *parameters, statement_config=statement_config) - return SQL(statement_expression, statement_config=statement_config) + return SQL(statement_target, *parameters, statement_config=statement_config) + return SQL(statement_target, statement_config=statement_config) def _statement_parameters(self, raw_parameters: Any) -> "tuple[dict[str, Any] | None, tuple[Any, ...] | None]": """Extract parameters for SQL statement creation. @@ -1051,51 +1241,26 @@ def _statement_parameters(self, raw_parameters: Any) -> "tuple[dict[str, Any] | return None, None - def __str__(self) -> str: - """Return the SQL string representation of the query. - - Returns: - str: The SQL string for this query. - """ - return self.build().sql - - @property - def dialect_name(self) -> "str | None": - """Returns the name of the dialect, if set.""" - if isinstance(self.dialect, str): - return self.dialect - if self.dialect is None: - return None - if isinstance(self.dialect, type) and issubclass(self.dialect, Dialect): - return self.dialect.__name__.lower() - if isinstance(self.dialect, Dialect): - return type(self.dialect).__name__.lower() - if has_name(self.dialect): - return self.dialect.__name__.lower() - return str(self.dialect).lower() - - def _merge_parameters(self, sql_obj: Any) -> None: + def _merge_parameters(self, sql_obj: Any) -> dict[str, str]: """Merge parameters from a SQL object into the builder. Args: sql_obj: Object with parameters attribute containing parameter mappings + + Returns: + Mapping of original parameter names to renamed parameter names. """ + param_mapping: dict[str, str] = {} if not has_expression_and_parameters(sql_obj): - return + return param_mapping sql_parameters = sql_obj.parameters for param_name, param_value in sql_parameters.items(): - unique_name = self._next_parameter_name(param_name) - self.add_parameter(param_value, name=unique_name) - - @property - def parameters(self) -> dict[str, Any]: - """Public access to query parameters.""" - return self._parameters - - def set_parameters(self, parameters: dict[str, Any]) -> None: - """Set query parameters (public API).""" - self._parameters = parameters.copy() + target_name = self._next_parameter_name(param_name) if param_name in self._parameters else param_name + _, actual_name = self.add_parameter(param_value, name=target_name) + if actual_name != param_name: + param_mapping[param_name] = actual_name + return param_mapping def _folds_unquoted_to_upper(self, dialect: "DialectType | str | None") -> bool: """Check if the target dialect folds unquoted identifiers to uppercase.""" @@ -1113,7 +1278,6 @@ def _unquote_identifiers(self, expression: exp.Expr) -> exp.Expr: def _strip_merge_target_quotes(self, sql_string: str) -> str: if sql_string.startswith('MERGE INTO "') and not self._merge_target_quoted: - # Remove quotes around target table only, leave alias/rest intact end_quote = sql_string.find('"', len('MERGE INTO "')) if end_quote > 0: table_name = sql_string[len('MERGE INTO "') : end_quote] @@ -1127,83 +1291,6 @@ def _should_identify(self, dialect: "DialectType | str | None") -> bool: return True return str(dialect).lower() not in _UPPER_FOLDING_DIALECTS - @property - def with_ctes(self) -> "dict[str, exp.CTE]": - """Get WITH clause CTEs (public API).""" - return dict(self._with_ctes) - - def generate_unique_parameter_name(self, base_name: str) -> str: - """Generate unique parameter name (public API).""" - return self._next_parameter_name(base_name) - - def build_static_expression( - self, - expression: exp.Expr | None = None, - parameters: dict[str, Any] | None = None, - *, - cache_key: str | None = None, - expression_factory: Callable[[], exp.Expr] | None = None, - copy: bool = True, - optimize_expression: bool | None = None, - dialect: DialectType | None = None, - ) -> "BuiltQuery": - """Compile a pre-built expression with optional caching and parameters. - - Designed for hot paths that construct an AST once and reuse it with - different parameters, avoiding repeated parse/optimize cycles. - - Args: - expression: Pre-built sqlglot expression to render (required when cache_key is not provided). - parameters: Optional parameter mapping to include in the result. - cache_key: When provided, the expression will be cached under this key. - expression_factory: Factory used to build the expression on cache miss. - copy: Copy the expression before rendering to avoid caller mutation. - optimize_expression: Override builder optimization toggle for this call. - dialect: Optional dialect override for SQL generation. - - Returns: - BuiltQuery containing SQL and parameters. - """ - expr: exp.Expr | None = None - - if cache_key is not None: - cache = get_cache() - cached_expr = cache.get_expression(cache_key) - if cached_expr is None: - if expression_factory is None: - msg = "expression_factory is required when cache_key is provided" - self._raise_builder_error(msg) - expr_candidate = expression_factory() - if not is_expression(expr_candidate): - self._raise_invalid_expression_type(expr_candidate) - expr_to_store = expr_candidate.copy() if copy else expr_candidate - should_optimize = self.enable_optimization if optimize_expression is None else optimize_expression - if should_optimize: - expr_to_store = self._optimize_expression(expr_to_store, force=optimize_expression is True) - cache.put_expression(cache_key, expr_to_store) - cached_expr = expr_to_store - expr = cached_expr.copy() if copy else cached_expr - else: - if expression is None: - msg = "expression must be provided when cache_key is not set" - self._raise_builder_error(msg) - expr = expression.copy() if copy else expression - should_optimize = self.enable_optimization if optimize_expression is None else optimize_expression - if should_optimize: - expr = self._optimize_expression(expr, force=optimize_expression is True) - - if expr is None: - self._raise_builder_error("Static expression could not be resolved.") - - target_dialect = str(dialect) if dialect else self.dialect_name - identify = self._should_identify(target_dialect) - sql_string = expr.sql(dialect=target_dialect, pretty=True, identify=identify, copy=not copy) - return BuiltQuery( - sql=sql_string, - parameters=parameters.copy() if parameters else {}, - dialect=_resolve_dialect(dialect, self.dialect), - ) - class ExpressionBuilder(QueryBuilder): """Builder wrapper for a pre-parsed SQLGlot expression.""" @@ -1270,6 +1357,56 @@ def __call__(self, node: exp.Expr) -> exp.Expr: return node +def _infer_single_table_schema(expression: exp.Expr, dialect: DialectType = None) -> MappingSchema | None: + """Synthesize a lightweight MappingSchema for single-table scopes when no schema is provided.""" + try: + root = build_scope(expression) + if root is None: + return None + inferred: dict[str, Any] = {} + visible: dict[str, Any] = {} + for scope in root.traverse(): + if len(scope.selected_sources) != 1 or scope.lateral_sources: + continue + alias_name, (_, source) = next(iter(scope.selected_sources.items())) + if not isinstance(source, exp.Table) or not source.name: + continue + select_aliases = { + s.alias + for s in (scope.expression.selects if isinstance(scope.expression, exp.Select) else ()) + if isinstance(s, exp.Alias) + and s.alias + and not (isinstance(s.this, exp.Column) and s.this.name == s.alias) + } + cols = { + col.name: "UNKNOWN" + for col in scope.columns + if col.name + and col.name != "*" + and (col.table in {alias_name, source.name} or (not col.table and col.name not in select_aliases)) + } + if not cols: + continue + parts = [p.name for p in source.parts] + target = inferred + vis_target = visible + for part in parts[:-1]: + target = target.setdefault(part, {}) + vis_target = vis_target.setdefault(part, {}) + leaf = parts[-1] + existing = target.get(leaf) + if isinstance(existing, dict): + existing.update(cols) + else: + target[leaf] = cols + vis_target[leaf] = [] + if not inferred: + return None + return MappingSchema(inferred, visible=visible, dialect=dialect, normalize=False) + except Exception: + return None + + def _unquote_identifier(node: exp.Expr) -> exp.Expr: if ( isinstance(node, exp.Identifier) diff --git a/sqlspec/builder/_column.py b/sqlspec/builder/_column.py index f86cbb4d2..9fc6a079c 100644 --- a/sqlspec/builder/_column.py +++ b/sqlspec/builder/_column.py @@ -28,17 +28,17 @@ def __and__(self, other: "ColumnExpression") -> "ColumnExpression": """Combine with AND operator (&).""" if not isinstance(other, ColumnExpression): return NotImplemented - return ColumnExpression(exp.And(this=self._expression, expression=other._expression)) + return ColumnExpression(exp.And(this=self._expression.copy(), expression=other._expression.copy())) def __or__(self, other: "ColumnExpression") -> "ColumnExpression": """Combine with OR operator (|).""" if not isinstance(other, ColumnExpression): return NotImplemented - return ColumnExpression(exp.Or(this=self._expression, expression=other._expression)) + return ColumnExpression(exp.Or(this=self._expression.copy(), expression=other._expression.copy())) def __invert__(self) -> "ColumnExpression": """Apply NOT operator (~).""" - return ColumnExpression(exp.Not(this=self._expression)) + return ColumnExpression(exp.Not(this=self._expression.copy())) def __bool__(self) -> bool: """Prevent accidental use of 'and'/'or' keywords.""" @@ -60,22 +60,29 @@ class _ScalarExpressionMixin: Hosts the comparison, membership, casting, and ordering helpers that are identical between :class:`Column` and :class:`FunctionColumn`. Concrete - subclasses own the ``_expression`` slot along with their own ``__init__``, - equality, hashing, and ``like`` semantics. + subclasses own the ``_expression`` slot along with their own ``__init__`` + and hashing semantics. """ __slots__ = ("_expression",) _expression: "exp.Expr" - def _convert_value(self, value: Any) -> exp.Expr: - """Convert a Python value to a SQLGlot expression.""" - return _convert_value(value) + def __eq__(self, other: object) -> ColumnExpression: # type: ignore[override] + """Equal to (==).""" + if other is None: + return ColumnExpression(exp.Is(this=self._expression, expression=exp.Null())) + return ColumnExpression(exp.EQ(this=self._expression, expression=self._convert_value(other))) - @property - def sqlglot_expression(self) -> "exp.Expr": - """Get the underlying SQLGlot expression.""" - return self._expression + def __ne__(self, other: object) -> ColumnExpression: # type: ignore[override] + """Not equal to (!=).""" + if other is None: + return ColumnExpression(exp.Not(this=exp.Is(this=self._expression, expression=exp.Null()))) + return ColumnExpression(exp.NEQ(this=self._expression, expression=self._convert_value(other))) + + def __hash__(self) -> int: + """Hash based on the expression identity.""" + return hash(id(self._expression)) def __gt__(self, other: Any) -> ColumnExpression: """Greater than (>).""" @@ -93,6 +100,22 @@ def __le__(self, other: Any) -> ColumnExpression: """Less than or equal (<=).""" return ColumnExpression(exp.LTE(this=self._expression, expression=self._convert_value(other))) + def __invert__(self) -> ColumnExpression: + """Apply NOT operator (~).""" + return ColumnExpression(exp.Not(this=self._expression)) + + @property + def sqlglot_expression(self) -> "exp.Expr": + """Get the underlying SQLGlot expression.""" + return self._expression + + def like(self, pattern: str, escape: str | None = None) -> ColumnExpression: + """SQL LIKE pattern matching.""" + like_expr: exp.Expr = exp.Like(this=self._expression, expression=self._convert_value(pattern)) + if escape: + like_expr = exp.Escape(this=like_expr, expression=self._convert_value(escape)) + return ColumnExpression(like_expr) + def ilike(self, pattern: str) -> ColumnExpression: """Case-insensitive LIKE.""" return ColumnExpression(exp.ILike(this=self._expression, expression=self._convert_value(pattern))) @@ -102,6 +125,22 @@ def in_(self, values: Iterable[Any]) -> ColumnExpression: converted_values = [self._convert_value(v) for v in values] return ColumnExpression(exp.In(this=self._expression, expressions=converted_values)) + def not_in(self, values: Iterable[Any]) -> ColumnExpression: + """SQL NOT IN clause.""" + return ~self.in_(values) + + def not_in_(self, values: Iterable[Any]) -> ColumnExpression: + """SQL NOT IN clause.""" + return ~self.in_(values) + + def not_like(self, pattern: str, escape: str | None = None) -> ColumnExpression: + """SQL NOT LIKE pattern matching.""" + return ~self.like(pattern, escape) + + def not_ilike(self, pattern: str) -> ColumnExpression: + """Case-insensitive NOT LIKE.""" + return ~self.ilike(pattern) + def between(self, start: Any, end: Any) -> ColumnExpression: """SQL BETWEEN clause.""" return ColumnExpression( @@ -168,6 +207,10 @@ def as_(self, alias: str) -> exp.Alias: """Create an aliased expression.""" return cast("exp.Alias", exp.alias_(self._expression, alias)) + def _convert_value(self, value: Any) -> exp.Expr: + """Convert a Python value to a SQLGlot expression.""" + return _convert_value(value) + class Column(_ScalarExpressionMixin): """Represents a database column with Python operator support.""" @@ -183,40 +226,14 @@ def __init__(self, name: str, table: str | None = None) -> None: else: self._expression = exp.Column(this=exp.Identifier(this=name)) - def __eq__(self, other: object) -> ColumnExpression: # type: ignore[override] - """Equal to (==).""" - if other is None: - return ColumnExpression(exp.Is(this=self._expression, expression=exp.Null())) - return ColumnExpression(exp.EQ(this=self._expression, expression=self._convert_value(other))) - - def __ne__(self, other: object) -> ColumnExpression: # type: ignore[override] - """Not equal to (!=).""" - if other is None: - return ColumnExpression(exp.Not(this=exp.Is(this=self._expression, expression=exp.Null()))) - return ColumnExpression(exp.NEQ(this=self._expression, expression=self._convert_value(other))) - - def __invert__(self) -> ColumnExpression: - """Apply NOT operator (~).""" - return ColumnExpression(exp.Not(this=self._expression)) - - def like(self, pattern: str, escape: str | None = None) -> ColumnExpression: - """SQL LIKE pattern matching.""" - like_expr: exp.Expr = exp.Like(this=self._expression, expression=self._convert_value(pattern)) - if escape: - like_expr = exp.Escape(this=like_expr, expression=self._convert_value(escape)) - return ColumnExpression(like_expr) - - def not_in(self, values: Iterable[Any]) -> ColumnExpression: - """SQL NOT IN clause.""" - return ~self.in_(values) - - def not_like(self, pattern: str, escape: str | None = None) -> ColumnExpression: - """SQL NOT LIKE pattern matching.""" - return ~self.like(pattern, escape) + def __repr__(self) -> str: + if self.table: + return f"Column<{self.table}.{self.name}>" + return f"Column<{self.name}>" - def not_ilike(self, pattern: str) -> ColumnExpression: - """Case-insensitive NOT LIKE.""" - return ~self.ilike(pattern) + def __hash__(self) -> int: + """Hash based on table and column name.""" + return hash((self.table, self.name)) def lower(self) -> "FunctionColumn": """SQL LOWER() function.""" @@ -298,27 +315,6 @@ def count_all() -> "FunctionColumn": """SQL COUNT(*) function.""" return FunctionColumn(exp.Count(this=exp.Star())) - @staticmethod - def _normalize_metric(metric: str) -> str: - """Normalize and validate vector distance metric.""" - normalized_metric = metric.lower() - valid_metrics = {"euclidean", "cosine", "inner_product", "euclidean_squared"} - if normalized_metric not in valid_metrics: - msg = f"Invalid metric: {metric}. Must be one of {valid_metrics}" - raise ValueError(msg) - return normalized_metric - - def _convert_vector_value(self, value: "list[float] | Column | exp.Expr") -> "exp.Expr": - """Convert a vector input into a SQLGlot expression.""" - if isinstance(value, list): - return exp.Array(expressions=[exp.Literal.number(v) for v in value]) - if isinstance(value, Column): - return value._expression - if isinstance(value, exp.Expr): - return value - msg = f"Unsupported vector type: {type(value)}" - raise TypeError(msg) - def vector_distance( self, other_vector: "list[float] | Column | exp.Expr", metric: str = "euclidean" ) -> "FunctionColumn": @@ -361,14 +357,26 @@ def cosine_similarity(self, other_vector: "list[float] | Column | exp.Expr") -> similarity_expr = exp.Sub(this=exp.Literal.number(1), expression=exp.Paren(this=cosine_dist._expression)) # pyright: ignore[reportPrivateUsage] return FunctionColumn(similarity_expr) - def __repr__(self) -> str: - if self.table: - return f"Column<{self.table}.{self.name}>" - return f"Column<{self.name}>" + @staticmethod + def _normalize_metric(metric: str) -> str: + """Normalize and validate vector distance metric.""" + normalized_metric = metric.lower() + valid_metrics = {"euclidean", "cosine", "inner_product", "euclidean_squared"} + if normalized_metric not in valid_metrics: + msg = f"Invalid metric: {metric}. Must be one of {valid_metrics}" + raise ValueError(msg) + return normalized_metric - def __hash__(self) -> int: - """Hash based on table and column name.""" - return hash((self.table, self.name)) + def _convert_vector_value(self, value: "list[float] | Column | exp.Expr") -> "exp.Expr": + """Convert a vector input into a SQLGlot expression.""" + if isinstance(value, list): + return exp.Array(expressions=[exp.Literal.number(v) for v in value]) + if isinstance(value, Column): + return value._expression + if isinstance(value, exp.Expr): + return value + msg = f"Unsupported vector type: {type(value)}" + raise TypeError(msg) class FunctionColumn(_ScalarExpressionMixin): @@ -379,27 +387,6 @@ class FunctionColumn(_ScalarExpressionMixin): def __init__(self, expression: "exp.Expr") -> None: self._expression = expression - def __eq__(self, other: object) -> ColumnExpression: # type: ignore[override] - return ColumnExpression(exp.EQ(this=self._expression, expression=self._convert_value(other))) - - def __ne__(self, other: object) -> ColumnExpression: # type: ignore[override] - return ColumnExpression(exp.NEQ(this=self._expression, expression=self._convert_value(other))) - - def like(self, pattern: str) -> ColumnExpression: - return ColumnExpression(exp.Like(this=self._expression, expression=self._convert_value(pattern))) - - def not_in_(self, values: Iterable[Any]) -> ColumnExpression: - """SQL NOT IN clause.""" - return ~self.in_(values) - - def not_like(self, pattern: str) -> ColumnExpression: - """SQL NOT LIKE.""" - return ~self.like(pattern) - - def not_ilike(self, pattern: str) -> ColumnExpression: - """Case-insensitive NOT LIKE.""" - return ~self.ilike(pattern) - def __hash__(self) -> int: """Hash based on the expression identity.""" return hash(id(self._expression)) diff --git a/sqlspec/builder/_ddl.py b/sqlspec/builder/_ddl.py index ade70767b..8c87b1b66 100644 --- a/sqlspec/builder/_ddl.py +++ b/sqlspec/builder/_ddl.py @@ -10,12 +10,11 @@ from sqlglot import exp from sqlglot.errors import ParseError -from sqlspec.builder._base import BuiltQuery, QueryBuilder +from sqlspec.builder._base import QueryBuilder from sqlspec.builder._parsing_utils import _mark_explicit_table_quotes, _normalize_dialect -from sqlspec.builder._select import Select -from sqlspec.core import SQL, StatementConfig +from sqlspec.core import SQL from sqlspec.exceptions import SQLBuilderError -from sqlspec.utils.type_guards import has_sqlglot_expression, has_with_method +from sqlspec.utils.type_guards import has_sqlglot_expression if TYPE_CHECKING: from sqlglot.dialects.dialect import DialectType @@ -75,16 +74,20 @@ CURRENT_TIME_KEYWORD = "CURRENT_TIME" -def build_column_expression(col: "ColumnDefinition", dialect: "DialectType | None" = None) -> "exp.Expr": +def build_column_expression( + col: "ColumnDefinition", dialect: "DialectType | None" = None, *, include_primary_key: bool = True +) -> "exp.Expr": """Build SQLGlot expression for a column definition.""" - col_def = exp.ColumnDef(this=exp.to_identifier(col.name), kind=_parse_column_type(col.name, col.dtype, dialect)) + col_def = exp.ColumnDef( + this=_parse_ddl_identifier(col.name, dialect=dialect), kind=_parse_column_type(col.name, col.dtype, dialect) + ) constraints: list[exp.ColumnConstraint] = [] if col.not_null: constraints.append(exp.ColumnConstraint(kind=exp.NotNullColumnConstraint())) - if col.primary_key: + if col.primary_key and include_primary_key: constraints.append(exp.ColumnConstraint(kind=exp.PrimaryKeyColumnConstraint())) if col.unique: @@ -124,7 +127,11 @@ def build_column_expression(col: "ColumnDefinition", dialect: "DialectType | Non ) if col.collate: - constraints.append(exp.ColumnConstraint(kind=exp.CollateColumnConstraint(this=exp.to_identifier(col.collate)))) + constraints.append( + exp.ColumnConstraint( + kind=exp.CollateColumnConstraint(this=_parse_ddl_identifier(col.collate, dialect=dialect)) + ) + ) if constraints: col_def.set("constraints", constraints) @@ -132,13 +139,19 @@ def build_column_expression(col: "ColumnDefinition", dialect: "DialectType | Non return col_def -def build_constraint_expression(constraint: "ConstraintDefinition") -> "exp.Expr | None": +def build_constraint_expression( + constraint: "ConstraintDefinition", dialect: "DialectType | None" = None +) -> "exp.Expr | None": """Build SQLGlot expression for a table constraint.""" if constraint.constraint_type == CONSTRAINT_TYPE_PRIMARY_KEY: - pk_constraint = exp.PrimaryKey(expressions=[exp.to_identifier(col) for col in constraint.columns]) + pk_constraint = exp.PrimaryKey( + expressions=[_parse_ddl_identifier(col, dialect=dialect) for col in constraint.columns] + ) if constraint.name: - return exp.Constraint(this=exp.to_identifier(constraint.name), expressions=[pk_constraint]) + return exp.Constraint( + this=_parse_ddl_identifier(constraint.name, dialect=dialect), expressions=[pk_constraint] + ) return pk_constraint if constraint.constraint_type == CONSTRAINT_TYPE_FOREIGN_KEY: @@ -156,37 +169,47 @@ def build_constraint_expression(constraint: "ConstraintDefinition") -> "exp.Expr reference_options.append(f"ON UPDATE {constraint.on_update}") fk_constraint = exp.ForeignKey( - expressions=[exp.to_identifier(col) for col in constraint.columns], + expressions=[_parse_ddl_identifier(col, dialect=dialect) for col in constraint.columns], reference=exp.Reference( - this=exp.to_table(constraint.references_table) if constraint.references_table else None, - expressions=[exp.to_identifier(col) for col in constraint.references_columns], + this=_parse_ddl_table(constraint.references_table, dialect=dialect) + if constraint.references_table + else None, + expressions=[_parse_ddl_identifier(col, dialect=dialect) for col in constraint.references_columns], options=reference_options or None, ), options=fk_options or None, ) if constraint.name: - return exp.Constraint(this=exp.to_identifier(constraint.name), expressions=[fk_constraint]) + return exp.Constraint( + this=_parse_ddl_identifier(constraint.name, dialect=dialect), expressions=[fk_constraint] + ) return fk_constraint if constraint.constraint_type == CONSTRAINT_TYPE_UNIQUE: - unique_constraint = exp.UniqueKeyProperty(expressions=[exp.to_identifier(col) for col in constraint.columns]) + unique_constraint = exp.UniqueKeyProperty( + expressions=[_parse_ddl_identifier(col, dialect=dialect) for col in constraint.columns] + ) if constraint.name: - return exp.Constraint(this=exp.to_identifier(constraint.name), expressions=[unique_constraint]) + return exp.Constraint( + this=_parse_ddl_identifier(constraint.name, dialect=dialect), expressions=[unique_constraint] + ) return unique_constraint if constraint.constraint_type == CONSTRAINT_TYPE_CHECK: check_expr = exp.Check( this=constraint.condition_expr if constraint.condition_expr is not None - else exp.maybe_parse(constraint.condition) + else exp.maybe_parse(constraint.condition, dialect=dialect) if constraint.condition else None ) if constraint.name: - return exp.Constraint(this=exp.to_identifier(constraint.name), expressions=[check_expr]) + return exp.Constraint( + this=_parse_ddl_identifier(constraint.name, dialect=dialect), expressions=[check_expr] + ) return check_expr return None @@ -202,6 +225,13 @@ def __init__(self, dialect: "DialectType" = None) -> None: self._expression: exp.Expr | None = None self._expression_dialect: DialectType | None = None + def _prepare_expression_for_build(self, dialect: "DialectType" = None, *, copy: bool = True) -> exp.Expr: + target_dialect = _normalize_dialect(dialect or self.dialect) + self._expression_dialect = target_dialect + self.set_parameters({}) + self._expression = self._create_base_expression() + return super()._prepare_expression_for_build(dialect=target_dialect, copy=copy) + def _create_base_expression(self) -> exp.Expr: msg = "Subclasses must implement _create_base_expression." raise NotImplementedError(msg) @@ -211,53 +241,34 @@ def _require(self, value: object, message: str) -> None: self._raise_builder_error(message) def _resolve_select_query(self, query: object, context: str, *, require_select_type: bool = True) -> exp.Expr: + effective_dialect = self._expression_dialect or self.dialect_name select_parameters: dict[str, Any] | None = None if isinstance(query, SQL): - select_expr = query.expression + select_expr = ( + query.expression.copy() + if query.expression is not None + else exp.maybe_parse(query.sql, dialect=effective_dialect) + ) select_parameters = query.parameters - elif isinstance(query, Select): - select_expr = query.get_expression() + elif isinstance(query, QueryBuilder): + select_expr = query._prepare_expression_for_build(dialect=effective_dialect, copy=True) select_parameters = query.parameters elif isinstance(query, str): - select_expr = exp.maybe_parse(query) + select_expr = exp.maybe_parse(query, dialect=effective_dialect) elif isinstance(query, exp.Expr): - select_expr = query + select_expr = query.copy() else: self._raise_builder_error(f"Unsupported type for SELECT query in {context}.") - if select_expr is None or (require_select_type and not isinstance(select_expr, exp.Select)): + if select_expr is None or (require_select_type and not isinstance(select_expr, (exp.Select, exp.SetOperation))): self._raise_builder_error("SELECT query must be a valid SELECT expression.") if select_parameters: - for p_name, p_value in select_parameters.items(): - self._parameters[p_name] = p_value + self.load_parameters(select_parameters) return select_expr - def _prepare_expression(self, dialect: "DialectType" = None) -> None: - target_dialect = _normalize_dialect(dialect or self.dialect) - if self._expression is not None and target_dialect != self._expression_dialect: - self._expression = None - self._expression_dialect = target_dialect - if self._expression is None: - self._expression = self._create_base_expression() - - def build(self, dialect: "DialectType" = None) -> "BuiltQuery": - self._prepare_expression(dialect) - return super().build(dialect=self._expression_dialect) - - def to_statement(self, config: "StatementConfig | None" = None) -> SQL: - """Build a SQL statement using the configured target dialect for column types.""" - self._prepare_expression(config.dialect if config is not None else None) - if self._expression_dialect is not None: - config = ( - config.replace(dialect=self._expression_dialect) - if config is not None - else StatementConfig(dialect=self._expression_dialect) - ) - return super().to_statement(config) - @trait class _IfExistsDDLMixin: @@ -413,6 +424,15 @@ def __init__(self, table_name: str, dialect: "DialectType" = None) -> None: self._like_table: str | None = None self._partition_by: str | None = None + @property + def columns(self) -> "list[ColumnDefinition]": + """Get the list of column definitions for this table. + + Returns: + List of ColumnDefinition objects. + """ + return self._columns + def in_schema(self, schema_name: str) -> "Self": """Set the schema for the table.""" self._schema = schema_name @@ -438,15 +458,6 @@ def partition_by(self, partition_spec: str) -> "Self": self._partition_by = partition_spec return self - @property - def columns(self) -> "list[ColumnDefinition]": - """Get the list of column definitions for this table. - - Returns: - List of ColumnDefinition objects. - """ - return self._columns - def column( self, name: str, @@ -487,7 +498,7 @@ def column( self._columns.append(column_def) - if primary_key and not self._has_primary_key_constraint(): + if primary_key: self.primary_key_constraint([name]) return self @@ -501,6 +512,8 @@ def primary_key_constraint(self, columns: "str | list[str]", name: "str | None" existing_pk = self._find_primary_key_constraint() if existing_pk: + if name: + existing_pk.name = name for col in col_list: if col not in existing_pk.columns: existing_pk.columns.append(col) @@ -609,16 +622,22 @@ def _create_base_expression(self) -> "exp.Expr": self._require(self._columns or self._like_table, "Table must have at least one column or use LIKE clause") effective_dialect = self._expression_dialect or self.dialect_name + has_table_level_pk = any( + c.constraint_type == CONSTRAINT_TYPE_PRIMARY_KEY and not self._is_redundant_single_column_primary_key(c) + for c in self._constraints + ) column_defs: list[exp.Expr] = [] for col in self._columns: - col_expr = build_column_expression(col, dialect=effective_dialect) + col_expr = build_column_expression( + col, dialect=effective_dialect, include_primary_key=not has_table_level_pk + ) column_defs.append(col_expr) for constraint in self._constraints: if self._is_redundant_single_column_primary_key(constraint): continue - constraint_expr = build_constraint_expression(constraint) + constraint_expr = build_constraint_expression(constraint, dialect=effective_dialect) if constraint_expr: column_defs.append(constraint_expr) @@ -632,7 +651,10 @@ def _create_base_expression(self) -> "exp.Expr": if self._tablespace: props.append(exp.Property(this=exp.to_identifier("TABLESPACE"), value=exp.to_identifier(self._tablespace))) if self._partition_by: - props.append(exp.Property(this=exp.to_identifier("PARTITION BY"), value=exp.convert(self._partition_by))) + partition_expr: exp.Expr = exp.maybe_parse(self._partition_by, dialect=effective_dialect) or exp.var( + self._partition_by + ) + props.append(exp.PartitionedByProperty(this=partition_expr)) for key, value in self._table_options.items(): if key != "engine": @@ -645,7 +667,7 @@ def _create_base_expression(self) -> "exp.Expr": if self._temporary: props.append(exp.TemporaryProperty()) if self._like_table: - props.append(exp.LikeProperty(this=exp.to_table(self._like_table))) + props.append(exp.LikeProperty(this=_parse_ddl_table(self._like_table, dialect=effective_dialect))) properties_node = _wrap_properties(props) create_target: exp.Expr = table_identifier if self._like_table and not column_defs else schema_expr @@ -666,7 +688,11 @@ def _validate_foreign_key_action(self, action: "str | None", action_type: str) - def _is_redundant_single_column_primary_key(self, constraint: "ConstraintDefinition") -> bool: """Check if constraint is a redundant single-column primary key.""" - if constraint.constraint_type != CONSTRAINT_TYPE_PRIMARY_KEY or len(constraint.columns) != 1: + if ( + constraint.constraint_type != CONSTRAINT_TYPE_PRIMARY_KEY + or len(constraint.columns) != 1 + or constraint.name is not None + ): return False col_name = constraint.columns[0] @@ -694,7 +720,10 @@ def __init__(self, name: str, dialect: "DialectType" = None) -> None: self._cascade: bool | None = None def _build_drop_this(self) -> exp.Expr: - return exp.to_identifier(self._name) + effective_dialect = self._expression_dialect or self.dialect_name + if self._drop_kind == "SCHEMA": + return _parse_ddl_identifier(self._name, dialect=effective_dialect) + return _parse_ddl_table(self._name, dialect=effective_dialect) def _drop_expression_args(self) -> "dict[str, Any]": return {} @@ -731,10 +760,6 @@ def table(self, name: str) -> Self: self._name = name return self - def _build_drop_this(self) -> exp.Expr: - effective_dialect = self._expression_dialect or self.dialect_name - return _parse_ddl_table(self._name, dialect=effective_dialect) - class DropIndex(_SingleObjectDropBuilder): """Builder for DROP INDEX [IF EXISTS] ... [ON table] [CASCADE|RESTRICT].""" @@ -763,7 +788,12 @@ def on_table(self, table_name: str) -> Self: return self def _drop_expression_args(self) -> "dict[str, Any]": - return {"cluster": exp.OnProperty(this=exp.to_identifier(self._table_name)) if self._table_name else None} + effective_dialect = self._expression_dialect or self.dialect_name + return { + "cluster": exp.OnProperty(this=_parse_ddl_table(self._table_name, dialect=effective_dialect)) + if self._table_name + else None + } class DropView(_SingleObjectDropBuilder): @@ -890,10 +920,11 @@ def _create_base_expression(self) -> exp.Expr: """ self._require(self._index_name and self._table_name, "Index name and table name must be set for CREATE INDEX.") + effective_dialect = self._expression_dialect or self.dialect_name cols: list[exp.Expr] = [] for col in self._columns: if isinstance(col, str): - cols.append(exp.column(col)) + cols.append(exp.Column(this=_parse_ddl_identifier(col, dialect=effective_dialect))) else: cols.append(col) @@ -914,8 +945,8 @@ def _create_base_expression(self) -> exp.Expr: index_params.set("where", exp.Where(this=where_expr)) index_expr = exp.Index( - this=exp.to_identifier(self._index_name), - table=exp.to_table(cast("str", self._table_name)), + this=_parse_ddl_identifier(self._index_name, dialect=effective_dialect), + table=_parse_ddl_table(cast("str", self._table_name), dialect=effective_dialect), params=index_params, ) @@ -953,10 +984,19 @@ def continue_identity(self) -> Self: def _create_base_expression(self) -> exp.Expr: self._require(self._table_name, "Table name must be set for TRUNCATE TABLE.") + effective_dialect = self._expression_dialect or self.dialect_name identity_expr = exp.Var(this=self._identity) if self._identity else None - option_expr = exp.Var(this="CASCADE") if self._cascade else None + option_expr = ( + exp.Var(this="CASCADE") + if self._cascade is True + else exp.Var(this="RESTRICT") + if self._cascade is False + else None + ) return exp.TruncateTable( - expressions=[exp.to_table(self._table_name)], option=option_expr, identity=identity_expr + expressions=[_parse_ddl_table(self._table_name, dialect=effective_dialect)], + option=option_expr, + identity=identity_expr, ) @@ -1028,15 +1068,19 @@ def authorization(self, user_name: str) -> Self: def _create_base_expression(self) -> exp.Expr: self._require(self._schema_name, "Schema name must be set for CREATE SCHEMA.") + effective_dialect = self._expression_dialect or self.dialect_name props: list[exp.Property] = [] if self._authorization: props.append( - exp.Property(this=exp.to_identifier("AUTHORIZATION"), value=exp.to_identifier(self._authorization)) + exp.Property( + this=exp.to_identifier("AUTHORIZATION"), + value=_parse_ddl_identifier(self._authorization, dialect=effective_dialect), + ) ) properties_node = _wrap_properties(props) return exp.Create( kind="SCHEMA", - this=exp.to_identifier(self._schema_name), + this=_parse_ddl_identifier(self._schema_name, dialect=effective_dialect), exists=self._if_not_exists, properties=properties_node, ) @@ -1069,7 +1113,7 @@ def columns(self, *cols: str) -> Self: self._columns = list(cols) return self - def as_select(self, select_query: "str | exp.Expr") -> Self: + def as_select(self, select_query: "str | exp.Expr | Any") -> Self: self._select_query = select_query return self @@ -1078,17 +1122,15 @@ def _create_base_expression(self) -> exp.Expr: if self._select_query is None: self._raise_builder_error("SELECT query must be set for CREATE TABLE AS SELECT.") + effective_dialect = self._expression_dialect or self.dialect_name select_expr = self._resolve_select_query(self._select_query, "CTAS", require_select_type=False) - if isinstance(self._select_query, Select): - with_ctes = self._select_query.with_ctes - if with_ctes and select_expr and isinstance(select_expr, exp.Select): - for alias, cte in with_ctes.items(): - if has_with_method(select_expr): - select_expr = select_expr.with_(cte.this, as_=alias, copy=False) - - create_target: exp.Expr = exp.to_table(cast("str", self._table_name)) + + create_target: exp.Expr = _parse_ddl_table(cast("str", self._table_name), dialect=effective_dialect) if self._columns: - create_target = exp.Schema(this=create_target, expressions=[exp.to_identifier(c) for c in self._columns]) + create_target = exp.Schema( + this=create_target, + expressions=[_parse_ddl_identifier(c, dialect=effective_dialect) for c in self._columns], + ) return exp.Create(kind="TABLE", this=create_target, exists=self._if_not_exists, expression=select_expr) @@ -1120,7 +1162,7 @@ def __init__(self, view_name: str, dialect: "DialectType" = None) -> None: self._view_name = view_name self._if_not_exists = False self._columns: list[str] = [] - self._select_query: str | exp.Expr | None = None + self._select_query: Any = None self._with_data: bool | None = None self._refresh_mode: str | None = None self._storage_parameters: dict[str, Any] = {} @@ -1136,7 +1178,7 @@ def columns(self, *cols: str) -> Self: self._columns = list(cols) return self - def as_select(self, select_query: "str | exp.Expr") -> Self: + def as_select(self, select_query: "str | exp.Expr | Any") -> Self: self._select_query = select_query return self @@ -1173,9 +1215,10 @@ def _create_base_expression(self) -> exp.Expr: if self._select_query is None: self._raise_builder_error("SELECT query must be set for CREATE MATERIALIZED VIEW.") + effective_dialect = self._expression_dialect or self.dialect_name select_expr = self._resolve_select_query(self._select_query, "materialized view") - schema_expr = _build_optional_column_schema(self._columns) + schema_expr = _build_optional_column_schema(self._columns, dialect=effective_dialect) props: list[exp.Property] = [] if self._refresh_mode: @@ -1193,7 +1236,7 @@ def _create_base_expression(self) -> exp.Expr: props.extend(exp.Property(this=exp.to_identifier("HINT"), value=exp.convert(hint)) for hint in self._hints) properties_node = _wrap_properties(props) - create_target: exp.Expr = exp.to_table(self._view_name) + create_target: exp.Expr = _parse_ddl_table(self._view_name, dialect=effective_dialect) if schema_expr is not None: create_target = exp.Schema(this=create_target, expressions=schema_expr.expressions) @@ -1222,7 +1265,7 @@ def __init__(self, view_name: str, dialect: "DialectType" = None) -> None: self._view_name = view_name self._if_not_exists = False self._columns: list[str] = [] - self._select_query: str | exp.Expr | None = None + self._select_query: Any = None self._hints: list[str] = [] def name(self, view_name: str) -> Self: @@ -1233,7 +1276,7 @@ def columns(self, *cols: str) -> Self: self._columns = list(cols) return self - def as_select(self, select_query: "str | exp.Expr") -> Self: + def as_select(self, select_query: "str | exp.Expr | Any") -> Self: self._select_query = select_query return self @@ -1246,16 +1289,17 @@ def _create_base_expression(self) -> exp.Expr: if self._select_query is None: self._raise_builder_error("SELECT query must be set for CREATE VIEW.") + effective_dialect = self._expression_dialect or self.dialect_name select_expr = self._resolve_select_query(self._select_query, "view") - schema_expr = _build_optional_column_schema(self._columns) + schema_expr = _build_optional_column_schema(self._columns, dialect=effective_dialect) props: list[exp.Property] = [ exp.Property(this=exp.to_identifier("HINT"), value=exp.convert(h)) for h in self._hints ] properties_node = _wrap_properties(props) - create_target: exp.Expr = exp.to_table(self._view_name) + create_target: exp.Expr = _parse_ddl_table(self._view_name, dialect=effective_dialect) if schema_expr is not None: create_target = exp.Schema(this=create_target, expressions=schema_expr.expressions) @@ -1471,50 +1515,89 @@ def _create_base_expression(self) -> "exp.Expr": def _build_operation_expression(self, op: "AlterOperation") -> exp.Expr: """Build a structured SQLGlot expression for a single alter operation.""" op_type = op.operation_type.upper() + effective_dialect = self._expression_dialect or self.dialect_name if op_type == "ADD COLUMN": if not op.column_definition: self._raise_builder_error("Column definition required for ADD COLUMN") - effective_dialect = self._expression_dialect or self.dialect_name - return build_column_expression(op.column_definition, dialect=effective_dialect) + col_expr = build_column_expression(op.column_definition, dialect=effective_dialect) + if isinstance(col_expr, exp.ColumnDef) and (op.after_column or op.first): + constraints = list(col_expr.args.get("constraints") or []) + if op.after_column: + constraints.append( + exp.ColumnConstraint( + kind=exp.ColumnPosition( + this=_parse_ddl_identifier(op.after_column, dialect=effective_dialect), position="AFTER" + ) + ) + ) + elif op.first: + constraints.append(exp.ColumnConstraint(kind=exp.ColumnPosition(position="FIRST"))) + col_expr.set("constraints", constraints) + return col_expr if op_type == "DROP COLUMN": - return exp.Drop(tables=[exp.to_identifier(op.column_name)], kind="COLUMN", exists=True) + return exp.Drop( + tables=[_parse_ddl_identifier(cast("str", op.column_name), dialect=effective_dialect)], + kind="COLUMN", + exists=True, + ) if op_type == "DROP COLUMN CASCADE": - return exp.Drop(tables=[exp.to_identifier(op.column_name)], kind="COLUMN", cascade=True, exists=True) + return exp.Drop( + tables=[_parse_ddl_identifier(cast("str", op.column_name), dialect=effective_dialect)], + kind="COLUMN", + cascade=True, + exists=True, + ) if op_type == "ALTER COLUMN TYPE": if not op.new_type: self._raise_builder_error("New type required for ALTER COLUMN TYPE") return exp.AlterColumn( - this=exp.to_identifier(op.column_name), - dtype=_parse_column_type(op.column_name, op.new_type, self._expression_dialect or self.dialect_name), - using=exp.maybe_parse(op.using_expression) if op.using_expression else None, + this=_parse_ddl_identifier(cast("str", op.column_name), dialect=effective_dialect), + dtype=_parse_column_type(op.column_name, op.new_type, effective_dialect), + using=exp.maybe_parse(op.using_expression, dialect=effective_dialect) if op.using_expression else None, ) if op_type == "RENAME COLUMN": - return exp.RenameColumn(this=exp.to_identifier(op.column_name), to=exp.to_identifier(op.new_name)) + return exp.RenameColumn( + this=_parse_ddl_identifier(cast("str", op.column_name), dialect=effective_dialect), + to=_parse_ddl_identifier(cast("str", op.new_name), dialect=effective_dialect), + ) if op_type == "ADD CONSTRAINT": if not op.constraint_definition: self._raise_builder_error("Constraint definition required for ADD CONSTRAINT") - constraint_expr = build_constraint_expression(op.constraint_definition) + constraint_expr = build_constraint_expression(op.constraint_definition, dialect=effective_dialect) return exp.AddConstraint(expressions=[constraint_expr]) if op_type == "DROP CONSTRAINT": - return exp.Drop(tables=[exp.to_identifier(op.constraint_name)], kind="CONSTRAINT", exists=True) + return exp.Drop( + tables=[_parse_ddl_identifier(cast("str", op.constraint_name), dialect=effective_dialect)], + kind="CONSTRAINT", + exists=True, + ) if op_type == "DROP CONSTRAINT CASCADE": return exp.Drop( - tables=[exp.to_identifier(op.constraint_name)], kind="CONSTRAINT", cascade=True, exists=True + tables=[_parse_ddl_identifier(cast("str", op.constraint_name), dialect=effective_dialect)], + kind="CONSTRAINT", + cascade=True, + exists=True, ) if op_type == "ALTER COLUMN SET NOT NULL": - return exp.AlterColumn(this=exp.to_identifier(op.column_name), allow_null=False) + return exp.AlterColumn( + this=_parse_ddl_identifier(cast("str", op.column_name), dialect=effective_dialect), allow_null=False + ) if op_type == "ALTER COLUMN DROP NOT NULL": - return exp.AlterColumn(this=exp.to_identifier(op.column_name), drop=True, allow_null=True) + return exp.AlterColumn( + this=_parse_ddl_identifier(cast("str", op.column_name), dialect=effective_dialect), + drop=True, + allow_null=True, + ) if op_type == "ALTER COLUMN SET DEFAULT": if not op.column_definition or op.column_definition.default is None: @@ -1523,7 +1606,7 @@ def _build_operation_expression(self, op: "AlterOperation") -> exp.Expr: default_expr: exp.Expr | None if isinstance(default_val, str): if self._is_sql_function_default(default_val): - default_expr = exp.maybe_parse(default_val) + default_expr = exp.maybe_parse(default_val, dialect=effective_dialect) else: default_expr = exp.convert(default_val) elif isinstance(default_val, (int, float)): @@ -1534,10 +1617,14 @@ def _build_operation_expression(self, op: "AlterOperation") -> exp.Expr: default_expr = exp.false() else: default_expr = exp.convert(str(default_val)) - return exp.AlterColumn(this=exp.to_identifier(op.column_name), default=default_expr) + return exp.AlterColumn( + this=_parse_ddl_identifier(cast("str", op.column_name), dialect=effective_dialect), default=default_expr + ) if op_type == "ALTER COLUMN DROP DEFAULT": - return exp.AlterColumn(this=exp.to_identifier(op.column_name), drop=True) + return exp.AlterColumn( + this=_parse_ddl_identifier(cast("str", op.column_name), dialect=effective_dialect), drop=True + ) self._raise_builder_error(f"Unknown operation type: {op.operation_type}") raise AssertionError @@ -1585,14 +1672,22 @@ def is_(self, comment: str) -> Self: return self def _create_base_expression(self) -> exp.Expr: + effective_dialect = self._expression_dialect or self.dialect_name if self._target_type == "TABLE" and self._table and self._comment is not None: - return exp.Comment(this=exp.to_table(self._table), kind="TABLE", expression=exp.convert(self._comment)) - if self._target_type == "COLUMN" and self._table and self._column and self._comment is not None: return exp.Comment( - this=exp.Column(table=self._table, this=self._column), - kind="COLUMN", + this=_parse_ddl_table(self._table, dialect=effective_dialect), + kind="TABLE", expression=exp.convert(self._comment), ) + if self._target_type == "COLUMN" and self._table and self._column and self._comment is not None: + table_expr = _parse_ddl_table(self._table, dialect=effective_dialect) + col_expr = exp.Column( + this=_parse_ddl_identifier(self._column, dialect=effective_dialect), + table=table_expr.args.get("this"), + db=table_expr.args.get("db"), + catalog=table_expr.args.get("catalog"), + ) + return exp.Comment(this=col_expr, kind="COLUMN", expression=exp.convert(self._comment)) self._raise_builder_error("Must specify target and comment for COMMENT ON statement.") raise AssertionError @@ -1623,17 +1718,20 @@ def to(self, new_name: str) -> Self: def _create_base_expression(self) -> exp.Expr: self._require(self._old_name and self._new_name, "Both old and new table names must be set for RENAME TABLE.") + effective_dialect = self._expression_dialect or self.dialect_name return exp.Alter( - this=exp.to_table(self._old_name), + this=_parse_ddl_table(self._old_name, dialect=effective_dialect), kind="TABLE", - actions=[exp.AlterRename(this=exp.to_identifier(self._new_name))], + actions=[exp.AlterRename(this=_parse_ddl_table(cast("str", self._new_name), dialect=effective_dialect))], ) -def _build_optional_column_schema(columns: list[str]) -> exp.Schema | None: +def _build_optional_column_schema(columns: list[str], dialect: "DialectType | None" = None) -> exp.Schema | None: if not columns: return None - return exp.Schema(expressions=[exp.column(column) for column in columns]) + return exp.Schema( + expressions=[exp.Column(this=_parse_ddl_identifier(column, dialect=dialect)) for column in columns] + ) def _wrap_properties(properties: list[exp.Property]) -> exp.Properties | None: diff --git a/sqlspec/builder/_delete.py b/sqlspec/builder/_delete.py index 6d8bd6f6b..f3cef625a 100644 --- a/sqlspec/builder/_delete.py +++ b/sqlspec/builder/_delete.py @@ -43,14 +43,6 @@ def __init__(self, table: str | None = None, **kwargs: Any) -> None: if table: self.from_(table) - def _create_base_expression(self) -> "exp.Delete": - """Create a new sqlglot Delete expression. - - Returns: - A new sqlglot Delete expression. - """ - return exp.Delete() - def build(self, dialect: "DialectType" = None) -> "BuiltQuery": """Build the DELETE query with validation. @@ -63,13 +55,19 @@ def build(self, dialect: "DialectType" = None) -> "BuiltQuery": Raises: SQLBuilderError: If the table is not specified. """ + return super().build(dialect=dialect) - if self._expression is None or not isinstance(self._expression, exp.Delete): + def _prepare_expression_for_build(self, dialect: "DialectType" = None, *, copy: bool = True) -> exp.Expr: + """Validate that the DELETE target table is set before building.""" + if self._expression is None or not isinstance(self._expression, exp.Delete) or self._expression.this is None: msg = "DELETE requires a table to be specified. Use from() to set the table." raise SQLBuilderError(msg) + return super()._prepare_expression_for_build(dialect=dialect, copy=copy) - if self._expression.this is None: - msg = "DELETE requires a table to be specified. Use from() to set the table." - raise SQLBuilderError(msg) + def _create_base_expression(self) -> "exp.Delete": + """Create a new sqlglot Delete expression. - return super().build(dialect=dialect) + Returns: + A new sqlglot Delete expression. + """ + return exp.Delete() diff --git a/sqlspec/builder/_dml.py b/sqlspec/builder/_dml.py index 0aeaba6b6..b63ad43cc 100644 --- a/sqlspec/builder/_dml.py +++ b/sqlspec/builder/_dml.py @@ -6,7 +6,7 @@ from mypy_extensions import trait from sqlglot import exp -from sqlspec.builder._base import BuiltQuery, QueryBuilder +from sqlspec.builder._base import QueryBuilder from sqlspec.builder._parsing_utils import ( _mark_explicit_table_quotes, extract_expression, @@ -224,15 +224,32 @@ def from_select(self, select_builder: SQLBuilderProtocol) -> Self: if current_expr.args.get("this") is None: msg = "The target table must be set using .into() before adding values." raise SQLBuilderError(msg) - subquery_parameters = select_builder.parameters - if subquery_parameters: - builder_with_params = cast("SQLBuilderProtocol", self) - for param_name, param_value in subquery_parameters.items(): - builder_with_params.add_parameter(param_value, name=param_name) - - select_expr = select_builder.get_expression() - if select_expr and isinstance(select_expr, exp.Select): - current_expr.set("expression", select_expr.copy()) + + if isinstance(self, QueryBuilder): + select_expr: exp.Expr | None = self._embed_subquery(select_builder, alias_hint="insert_src") + else: + subquery_parameters = select_builder.parameters + if subquery_parameters: + builder_with_params = cast("SQLBuilderProtocol", self) + for param_name, param_value in subquery_parameters.items(): + builder_with_params.add_parameter(param_value, name=param_name) + raw_expr = select_builder.get_expression() + select_expr = raw_expr.copy() if isinstance(raw_expr, exp.Expr) else None + + if select_expr is not None and isinstance(select_expr, (exp.Select, exp.SetOperation)): + select_with = select_expr.args.get("with_") + if isinstance(select_with, exp.With): + select_expr.set("with_", None) + existing_with = current_expr.args.get("with_") + if isinstance(existing_with, exp.With): + for cte_node in select_with.expressions: + if cte_node not in existing_with.expressions: + existing_with.append("expressions", cte_node.copy()) + if select_with.args.get("recursive"): + existing_with.set("recursive", True) + else: + current_expr.set("with_", select_with) + current_expr.set("expression", select_expr) else: msg = "SelectBuilder must have a valid SELECT expression." raise SQLBuilderError(msg) @@ -265,27 +282,6 @@ class UpdateSetClauseMixin: def get_expression(self) -> exp.Expr | None: ... def set_expression(self, expression: exp.Expr) -> None: ... - - def _process_update_value(self, val: Any, col: Any) -> exp.Expr: - if isinstance(val, exp.Expr): - return val - if has_parameter_builder(val): - subquery = val.build() - sql_text = subquery.sql if isinstance(subquery, BuiltQuery) else str(subquery) - query_builder = cast("QueryBuilder", self) - value_expr = exp.paren(exp.maybe_parse(sql_text, dialect=query_builder.dialect)) - for p_name, p_value in val.parameters.items(): - query_builder.add_parameter(p_value, name=p_name) - return value_expr - if has_expression_and_sql(val): - return extract_sql_object_expression(val, builder=self) - sql_builder = cast("SQLBuilderProtocol", self) - column_name = col if isinstance(col, str) else str(col) - if "." in column_name: - column_name = column_name.split(".")[-1] - placeholder, _ = sql_builder.create_placeholder(val, column_name) - return placeholder - def set(self, *args: Any, **kwargs: Any) -> Self: if not args and not kwargs: return self @@ -337,6 +333,25 @@ def set_from(self, data: Any, *, exclude_unset: bool = True) -> Self: payload = schema_dump(data, exclude_unset=exclude_unset, wire_format=False) return self.set(**payload) + def _process_update_value(self, val: Any, col: Any) -> exp.Expr: + if isinstance(val, exp.Expr): + return val + if has_parameter_builder(val): + query_builder = cast("QueryBuilder", self) + column_name = col if isinstance(col, str) else str(col) + if "." in column_name: + column_name = column_name.split(".")[-1] + subquery_expr = query_builder._embed_subquery(val, alias_hint=column_name or "set_src") + return exp.paren(subquery_expr) + if has_expression_and_sql(val): + return extract_sql_object_expression(val, builder=self) + sql_builder = cast("SQLBuilderProtocol", self) + column_name = col if isinstance(col, str) else str(col) + if "." in column_name: + column_name = column_name.split(".")[-1] + placeholder, _ = sql_builder.create_placeholder(val, column_name) + return placeholder + @trait class UpdateFromClauseMixin: diff --git a/sqlspec/builder/_explain.py b/sqlspec/builder/_explain.py index ec5f9014a..61d9056c0 100644 --- a/sqlspec/builder/_explain.py +++ b/sqlspec/builder/_explain.py @@ -286,6 +286,25 @@ def __init__( self._statement_sql = self._resolve_statement_sql(statement) + def __repr__(self) -> str: + """String representation.""" + return f"Explain({self._statement_sql!r}, dialect={self._dialect!r}, options={self._options!r})" + + @property + def options(self) -> "ExplainOptions": + """Get current ExplainOptions.""" + return self._options + + @property + def dialect(self) -> "DialectType | None": + """Get current dialect.""" + return self._dialect + + @property + def parameters(self) -> dict[str, Any]: + """Get parameters from the underlying statement.""" + return self._parameters.copy() + def analyze(self, enabled: bool = True) -> Self: """Enable ANALYZE option (execute statement for real statistics). @@ -432,21 +451,6 @@ def with_options(self, options: "ExplainOptions") -> Self: self._options = options return self - @property - def options(self) -> "ExplainOptions": - """Get current ExplainOptions.""" - return self._options - - @property - def dialect(self) -> "DialectType | None": - """Get current dialect.""" - return self._dialect - - @property - def parameters(self) -> dict[str, Any]: - """Get parameters from the underlying statement.""" - return self._parameters.copy() - def build(self, dialect: "DialectType | None" = None) -> "SQL": """Build the EXPLAIN statement as a SQL object. @@ -459,9 +463,7 @@ def build(self, dialect: "DialectType | None" = None) -> "SQL": target_dialect = dialect or self._dialect statement_config = self._resolve_statement_config(target_dialect) if normalize_dialect_name(target_dialect) in ORACLE_DIALECTS: - oracle_statement = exp.Command( - this="EXPLAIN", expression=exp.Literal.string(f"PLAN FOR {self._statement_sql}") - ) + oracle_statement = exp.Command(this="EXPLAIN PLAN FOR", expression=exp.var(self._statement_sql)) oracle_statement.meta[ORACLE_MANAGED_EXPLAIN_META_KEY] = True statement: str | exp.Expr = oracle_statement else: @@ -487,10 +489,6 @@ def to_sql(self, dialect: "DialectType | None" = None) -> str: target_dialect = dialect or self._dialect return build_explain_sql(self._statement_sql, self._options, target_dialect) - def __repr__(self) -> str: - """String representation.""" - return f"Explain({self._statement_sql!r}, dialect={self._dialect!r}, options={self._options!r})" - def _resolve_statement_sql(self, statement: "str | exp.Expr | SQL | SQLBuilderProtocol") -> str: """Resolve statement to SQL string. diff --git a/sqlspec/builder/_expression_wrappers.py b/sqlspec/builder/_expression_wrappers.py index 6d2c14abc..64c79ca84 100644 --- a/sqlspec/builder/_expression_wrappers.py +++ b/sqlspec/builder/_expression_wrappers.py @@ -22,17 +22,17 @@ class ExpressionWrapper: def __init__(self, expression: exp.Expr) -> None: self._expression = expression - def as_(self, alias: str) -> exp.Alias: - """Create an aliased expression.""" - return cast("exp.Alias", exp.alias_(self._expression, alias)) + def __str__(self) -> str: + return str(self.expression) @property def expression(self) -> exp.Expr: """Get the underlying SQLGlot expression.""" return self._expression - def __str__(self) -> str: - return str(self._expression) + def as_(self, alias: str) -> exp.Alias: + """Create an aliased expression.""" + return cast("exp.Alias", exp.alias_(self.expression, alias)) @final diff --git a/sqlspec/builder/_factory.py b/sqlspec/builder/_factory.py index 42fdc3c17..bc8379ec2 100644 --- a/sqlspec/builder/_factory.py +++ b/sqlspec/builder/_factory.py @@ -47,6 +47,7 @@ _coerce_column, _cube_expression, _grouping_sets_expression, + _normalize_dialect, _normalize_order_by, _normalize_partition_by, _resolve_dialect, @@ -57,7 +58,7 @@ from sqlspec.builder._select import Case, Select, SubqueryBuilder, WindowFunctionBuilder from sqlspec.builder._update import Update from sqlspec.builder._values import Values -from sqlspec.core import SQL +from sqlspec.core import SQL, StatementConfig from sqlspec.core.explain import ExplainFormat, ExplainOptions from sqlspec.exceptions import SQLBuilderError from sqlspec.utils.logging import get_logger @@ -177,8 +178,9 @@ def __call__(self, statement: str, dialect: DialectType = None) -> "Any": SQLBuilderError: If the SQL is not a SELECT/CTE/DML+RETURNING statement. """ + resolved_dialect = _normalize_dialect(_resolve_dialect(dialect, self.dialect)) try: - parsed_expr = sqlglot.parse_one(statement, read=_resolve_dialect(dialect, self.dialect)) + parsed_expr = sqlglot.parse_one(statement, read=resolved_dialect) except Exception as e: msg = f"Failed to parse SQL: {e}" raise SQLBuilderError(msg) from e @@ -187,11 +189,13 @@ def __call__(self, statement: str, dialect: DialectType = None) -> "Any": if actual_type_str == "SELECT" or ( actual_type_str == "WITH" and parsed_expr.this and isinstance(parsed_expr.this, exp.Select) ): - builder = Select(dialect=_resolve_dialect(dialect, self.dialect)) + builder = Select(dialect=resolved_dialect) builder.set_expression(parsed_expr) return builder if actual_type_str in {"INSERT", "UPDATE", "DELETE"} and parsed_expr.args.get("returning") is not None: + if resolved_dialect is not None: + return SQL(parsed_expr, statement_config=StatementConfig(dialect=resolved_dialect)) return SQL(parsed_expr) msg = ( @@ -201,6 +205,156 @@ def __call__(self, statement: str, dialect: DialectType = None) -> "Any": ) raise SQLBuilderError(msg) + @property + def merge_(self) -> "Merge": + """Create a new MERGE builder (property shorthand). + + Property that returns a new Merge builder instance using the factory's + default dialect. Cleaner syntax alternative to merge() method. + + Returns: + New Merge builder instance + """ + return Merge(dialect=self.dialect) + + @property + def case_(self) -> "Case": + """Create a CASE expression builder. + + Returns: + Case builder instance for CASE expression building. + """ + return Case() + + @property + def row_number_(self) -> "WindowFunctionBuilder": + """Create a ROW_NUMBER() window function builder.""" + return WindowFunctionBuilder("row_number") + + @property + def rank_(self) -> "WindowFunctionBuilder": + """Create a RANK() window function builder.""" + return WindowFunctionBuilder("rank") + + @property + def dense_rank_(self) -> "WindowFunctionBuilder": + """Create a DENSE_RANK() window function builder.""" + return WindowFunctionBuilder("dense_rank") + + @property + def lag_(self) -> "WindowFunctionBuilder": + """Create a LAG() window function builder.""" + return WindowFunctionBuilder("lag") + + @property + def lead_(self) -> "WindowFunctionBuilder": + """Create a LEAD() window function builder.""" + return WindowFunctionBuilder("lead") + + @property + def count_over_(self) -> "WindowFunctionBuilder": + """Create a COUNT(*) OVER() window function builder. + + Returns a WindowFunctionBuilder pre-configured with COUNT(*) for fluent chaining. + Useful for pagination queries where you want to get the total count in the same query. + + Returns: + WindowFunctionBuilder configured for COUNT(*) OVER() + """ + return WindowFunctionBuilder("count", exp.Star()) + + @property + def sum_over_(self) -> "WindowFunctionBuilder": + """Create a SUM() OVER() window function builder.""" + return WindowFunctionBuilder("sum") + + @property + def avg_over_(self) -> "WindowFunctionBuilder": + """Create an AVG() OVER() window function builder.""" + return WindowFunctionBuilder("avg") + + @property + def max_over_(self) -> "WindowFunctionBuilder": + """Create a MAX() OVER() window function builder.""" + return WindowFunctionBuilder("max") + + @property + def min_over_(self) -> "WindowFunctionBuilder": + """Create a MIN() OVER() window function builder.""" + return WindowFunctionBuilder("min") + + @property + def exists_(self) -> "SubqueryBuilder": + """Create an EXISTS subquery builder.""" + return SubqueryBuilder("exists") + + @property + def in_(self) -> "SubqueryBuilder": + """Create an IN subquery builder.""" + return SubqueryBuilder("in") + + @property + def any_(self) -> "SubqueryBuilder": + """Create an ANY subquery builder.""" + return SubqueryBuilder("any") + + @property + def all_(self) -> "SubqueryBuilder": + """Create an ALL subquery builder.""" + return SubqueryBuilder("all") + + @property + def inner_join_(self) -> "JoinBuilder": + """Create an INNER JOIN builder.""" + return create_join_builder("inner join") + + @property + def left_join_(self) -> "JoinBuilder": + """Create a LEFT JOIN builder.""" + return create_join_builder("left join") + + @property + def right_join_(self) -> "JoinBuilder": + """Create a RIGHT JOIN builder.""" + return create_join_builder("right join") + + @property + def full_join_(self) -> "JoinBuilder": + """Create a FULL OUTER JOIN builder.""" + return create_join_builder("full join") + + @property + def cross_join_(self) -> "JoinBuilder": + """Create a CROSS JOIN builder.""" + return create_join_builder("cross join") + + @property + def lateral_join_(self) -> "JoinBuilder": + """Create a LATERAL JOIN builder. + + Returns: + JoinBuilder configured for LATERAL JOIN + """ + return create_join_builder("lateral join", lateral=True) + + @property + def left_lateral_join_(self) -> "JoinBuilder": + """Create a LEFT LATERAL JOIN builder. + + Returns: + JoinBuilder configured for LEFT LATERAL JOIN + """ + return create_join_builder("left join", lateral=True) + + @property + def cross_lateral_join_(self) -> "JoinBuilder": + """Create a CROSS LATERAL JOIN builder. + + Returns: + JoinBuilder configured for CROSS LATERAL JOIN + """ + return create_join_builder("cross join", lateral=True) + def select( self, *columns_or_sql: Union[str, exp.Expr, Column, "SQL", "Case"], dialect: DialectType = None ) -> "Select": @@ -340,18 +494,6 @@ def explain( return Explain(statement, dialect=builder_dialect, options=options) - @property - def merge_(self) -> "Merge": - """Create a new MERGE builder (property shorthand). - - Property that returns a new Merge builder instance using the factory's - default dialect. Cleaner syntax alternative to merge() method. - - Returns: - New Merge builder instance - """ - return Merge(dialect=self.dialect) - def upsert(self, table: str, dialect: DialectType = None) -> "Merge | Insert": """Create an upsert builder (MERGE or INSERT ON CONFLICT). @@ -604,144 +746,6 @@ def column(self, name: str, table: str | None = None) -> Column: """ return Column(name, table) - @property - def case_(self) -> "Case": - """Create a CASE expression builder. - - Returns: - Case builder instance for CASE expression building. - """ - return Case() - - @property - def row_number_(self) -> "WindowFunctionBuilder": - """Create a ROW_NUMBER() window function builder.""" - return WindowFunctionBuilder("row_number") - - @property - def rank_(self) -> "WindowFunctionBuilder": - """Create a RANK() window function builder.""" - return WindowFunctionBuilder("rank") - - @property - def dense_rank_(self) -> "WindowFunctionBuilder": - """Create a DENSE_RANK() window function builder.""" - return WindowFunctionBuilder("dense_rank") - - @property - def lag_(self) -> "WindowFunctionBuilder": - """Create a LAG() window function builder.""" - return WindowFunctionBuilder("lag") - - @property - def lead_(self) -> "WindowFunctionBuilder": - """Create a LEAD() window function builder.""" - return WindowFunctionBuilder("lead") - - @property - def count_over_(self) -> "WindowFunctionBuilder": - """Create a COUNT(*) OVER() window function builder. - - Returns a WindowFunctionBuilder pre-configured with COUNT(*) for fluent chaining. - Useful for pagination queries where you want to get the total count in the same query. - - Returns: - WindowFunctionBuilder configured for COUNT(*) OVER() - """ - return WindowFunctionBuilder("count", exp.Star()) - - @property - def sum_over_(self) -> "WindowFunctionBuilder": - """Create a SUM() OVER() window function builder.""" - return WindowFunctionBuilder("sum") - - @property - def avg_over_(self) -> "WindowFunctionBuilder": - """Create an AVG() OVER() window function builder.""" - return WindowFunctionBuilder("avg") - - @property - def max_over_(self) -> "WindowFunctionBuilder": - """Create a MAX() OVER() window function builder.""" - return WindowFunctionBuilder("max") - - @property - def min_over_(self) -> "WindowFunctionBuilder": - """Create a MIN() OVER() window function builder.""" - return WindowFunctionBuilder("min") - - @property - def exists_(self) -> "SubqueryBuilder": - """Create an EXISTS subquery builder.""" - return SubqueryBuilder("exists") - - @property - def in_(self) -> "SubqueryBuilder": - """Create an IN subquery builder.""" - return SubqueryBuilder("in") - - @property - def any_(self) -> "SubqueryBuilder": - """Create an ANY subquery builder.""" - return SubqueryBuilder("any") - - @property - def all_(self) -> "SubqueryBuilder": - """Create an ALL subquery builder.""" - return SubqueryBuilder("all") - - @property - def inner_join_(self) -> "JoinBuilder": - """Create an INNER JOIN builder.""" - return create_join_builder("inner join") - - @property - def left_join_(self) -> "JoinBuilder": - """Create a LEFT JOIN builder.""" - return create_join_builder("left join") - - @property - def right_join_(self) -> "JoinBuilder": - """Create a RIGHT JOIN builder.""" - return create_join_builder("right join") - - @property - def full_join_(self) -> "JoinBuilder": - """Create a FULL OUTER JOIN builder.""" - return create_join_builder("full join") - - @property - def cross_join_(self) -> "JoinBuilder": - """Create a CROSS JOIN builder.""" - return create_join_builder("cross join") - - @property - def lateral_join_(self) -> "JoinBuilder": - """Create a LATERAL JOIN builder. - - Returns: - JoinBuilder configured for LATERAL JOIN - """ - return create_join_builder("lateral join", lateral=True) - - @property - def left_lateral_join_(self) -> "JoinBuilder": - """Create a LEFT LATERAL JOIN builder. - - Returns: - JoinBuilder configured for LEFT LATERAL JOIN - """ - return create_join_builder("left join", lateral=True) - - @property - def cross_lateral_join_(self) -> "JoinBuilder": - """Create a CROSS LATERAL JOIN builder. - - Returns: - JoinBuilder configured for CROSS LATERAL JOIN - """ - return create_join_builder("cross join", lateral=True) - @staticmethod def raw(sql_fragment: str, **parameters: Any) -> "exp.Expr | SQL": """Create a raw SQL expression from a string fragment with optional parameters. @@ -1263,16 +1267,17 @@ def bulk_insert(table_name: str, column_count: int, placeholder_style: str = "?" ) ) - def truncate(self, table_name: str) -> "Truncate": + def truncate(self, table_name: str, dialect: DialectType = None) -> "Truncate": """Create a TRUNCATE TABLE builder. Args: table_name: Name of the table to truncate + dialect: Optional SQL dialect Returns: TruncateTable builder instance """ - return Truncate(table_name, dialect=self.dialect) + return Truncate(table_name, dialect=_resolve_dialect(dialect, self.dialect)) @staticmethod def case() -> "Case": diff --git a/sqlspec/builder/_insert.py b/sqlspec/builder/_insert.py index 972eacd65..77bd2dcbc 100644 --- a/sqlspec/builder/_insert.py +++ b/sqlspec/builder/_insert.py @@ -54,73 +54,10 @@ def __init__(self, table: str | None = None, **kwargs: Any) -> None: if table: self.into(table) - def _create_base_expression(self) -> exp.Insert: - """Create a base INSERT expression. - - This method is called by the base QueryBuilder during initialization. - - Returns: - A new sqlglot Insert expression. - """ - return exp.Insert() - - def _insert_expression(self) -> exp.Insert: - """Safely gets and casts the internal expression to exp.Insert. - - Returns: - The internal expression as exp.Insert. - - Raises: - SQLBuilderError: If the expression is not initialized or is not an Insert. - """ - if self._expression is None: - raise SQLBuilderError(ERR_MSG_EXPRESSION_NOT_INITIALIZED) - if not isinstance(self._expression, exp.Insert): - raise SQLBuilderError(ERR_MSG_INTERNAL_EXPRESSION_TYPE) - return self._expression - def get_insert_expression(self) -> exp.Insert: """Get the insert expression (public API).""" return self._insert_expression() - def _bind_mapping_values(self, data: "Mapping[str, Any]") -> "Self": - """Bind a single mapping row.""" - insert_expr = self._insert_expression() - if insert_expr.args.get("this") is None: - raise SQLBuilderError(ERR_MSG_TABLE_NOT_SET) - - data_keys = list(data.keys()) - if not self._columns: - self.columns(*data_keys) - elif set(self._columns) != set(data_keys): - msg = f"Dictionary keys {set(data_keys)} do not match existing columns {set(self._columns)}." - raise SQLBuilderError(msg) - - return self.values(*[data[col] for col in self._columns]) - - def _bind_mapping_values_many(self, data: "Sequence[Mapping[str, Any]]") -> "Self": - """Bind many mapping rows.""" - if not data: - return self - - first_dict = data[0] - if not self._columns: - self.columns(*first_dict.keys()) - - expected_keys = set(self._columns) - for i, row_dict in enumerate(data): - if set(row_dict.keys()) != expected_keys: - msg = ( - f"Dictionary at index {i} has keys {set(row_dict.keys())} " - f"which do not match expected keys {expected_keys}." - ) - raise SQLBuilderError(msg) - - for row_dict in data: - self.values(*[row_dict[col] for col in self._columns]) - - return self - def values_from(self, data: Any, *, exclude_unset: bool = True) -> "Self": """Add a row of values from a dict, dataclass, msgspec.Struct, Pydantic model, or attrs class. @@ -209,6 +146,69 @@ def on_duplicate_key_update(self, **kwargs: Any) -> "Insert": return self + def _create_base_expression(self) -> exp.Insert: + """Create a base INSERT expression. + + This method is called by the base QueryBuilder during initialization. + + Returns: + A new sqlglot Insert expression. + """ + return exp.Insert() + + def _insert_expression(self) -> exp.Insert: + """Safely gets and casts the internal expression to exp.Insert. + + Returns: + The internal expression as exp.Insert. + + Raises: + SQLBuilderError: If the expression is not initialized or is not an Insert. + """ + if self._expression is None: + raise SQLBuilderError(ERR_MSG_EXPRESSION_NOT_INITIALIZED) + if not isinstance(self._expression, exp.Insert): + raise SQLBuilderError(ERR_MSG_INTERNAL_EXPRESSION_TYPE) + return self._expression + + def _bind_mapping_values(self, data: "Mapping[str, Any]") -> "Self": + """Bind a single mapping row.""" + insert_expr = self._insert_expression() + if insert_expr.args.get("this") is None: + raise SQLBuilderError(ERR_MSG_TABLE_NOT_SET) + + data_keys = list(data.keys()) + if not self._columns: + self.columns(*data_keys) + elif set(self._columns) != set(data_keys): + msg = f"Dictionary keys {set(data_keys)} do not match existing columns {set(self._columns)}." + raise SQLBuilderError(msg) + + return self.values(*[data[col] for col in self._columns]) + + def _bind_mapping_values_many(self, data: "Sequence[Mapping[str, Any]]") -> "Self": + """Bind many mapping rows.""" + if not data: + return self + + first_dict = data[0] + if not self._columns: + self.columns(*first_dict.keys()) + + expected_keys = set(self._columns) + for i, row_dict in enumerate(data): + if set(row_dict.keys()) != expected_keys: + msg = ( + f"Dictionary at index {i} has keys {set(row_dict.keys())} " + f"which do not match expected keys {expected_keys}." + ) + raise SQLBuilderError(msg) + + for row_dict in data: + self.values(*[row_dict[col] for col in self._columns]) + + return self + class ConflictBuilder: """Builder for ON CONFLICT clauses in INSERT statements. diff --git a/sqlspec/builder/_join.py b/sqlspec/builder/_join.py index 5acaa32c1..bbc552e92 100644 --- a/sqlspec/builder/_join.py +++ b/sqlspec/builder/_join.py @@ -280,24 +280,8 @@ def on(self, condition: str | exp.Expr) -> exp.Expr: def create_join_builder(join_type: str, lateral: bool = False) -> "JoinBuilder": - """Create a JoinBuilder without tripping trait instantiation errors. - - This guards against runtime environments where a trait-decorated JoinBuilder - may raise on direct construction. - """ - try: - return JoinBuilder(join_type, lateral=lateral) - except TypeError as exc: - if "traits may not be directly created" not in str(exc): - raise - builder = object.__new__(JoinBuilder) - builder._join_type = join_type.upper() - builder._lateral = lateral - builder._table = None - builder._alias = None - builder._as_of = None - builder._as_of_type = None - return builder + """Create a JoinBuilder instance.""" + return JoinBuilder(join_type, lateral=lateral) def _parse_join_condition(builder: "SQLBuilderProtocol", on: Union[str, exp.Expr, "SQL"] | None) -> exp.Expr | None: @@ -318,19 +302,22 @@ def parse_join_condition(sql_text: str) -> exp.Expr: def _table_from_builder(table: Any, alias: str | None, builder: "SQLBuilderProtocol") -> exp.Expr: subquery_expression: exp.Expr - builder_table = cast("HasParameterBuilderProtocol", table) - parameters = builder_table.parameters - - if isinstance(table, QueryBuilder): - subquery_expression = table._build_final_expression(copy=True) + if isinstance(builder, QueryBuilder): + subquery_expression = builder._embed_subquery(table, alias_hint=alias or "join_src") else: - subquery_result = builder_table.build() - sql_text = subquery_result.sql if isinstance(subquery_result, BuiltQuery) else str(subquery_result) - subquery_expression = exp.maybe_parse(sql_text, dialect=builder.dialect) or exp.convert(sql_text) + builder_table = cast("HasParameterBuilderProtocol", table) + parameters = builder_table.parameters + + if isinstance(table, QueryBuilder): + subquery_expression = table._build_final_expression(copy=True) + else: + subquery_result = builder_table.build() + sql_text = subquery_result.sql if isinstance(subquery_result, BuiltQuery) else str(subquery_result) + subquery_expression = exp.maybe_parse(sql_text, dialect=builder.dialect) or exp.convert(sql_text) - if parameters: - for param_name, param_value in parameters.items(): - builder.add_parameter(param_value, name=param_name) + if parameters: + for param_name, param_value in parameters.items(): + builder.add_parameter(param_value, name=param_name) subquery_exp = exp.paren(subquery_expression) return exp.alias_(subquery_exp, alias) if alias else subquery_exp diff --git a/sqlspec/builder/_merge.py b/sqlspec/builder/_merge.py index c331f8bde..6775de65e 100644 --- a/sqlspec/builder/_merge.py +++ b/sqlspec/builder/_merge.py @@ -18,7 +18,7 @@ from sqlspec.builder._base import QueryBuilder from sqlspec.builder._explain import ExplainMixin -from sqlspec.builder._parsing_utils import _coerce_column, _resolve_dialect, extract_sql_object_expression +from sqlspec.builder._parsing_utils import _coerce_column, _is_simple_identifier, extract_sql_object_expression from sqlspec.builder._select import is_explicitly_quoted from sqlspec.exceptions import DialectNotSupportedError, SQLBuilderError from sqlspec.utils.dispatch import TypeDispatcher @@ -34,6 +34,8 @@ MERGE_UNSUPPORTED_DIALECTS = frozenset({"mysql", "sqlite", "duckdb"}) +_MERGE_SQL_KEYWORD_LITERALS = frozenset({"CURRENT_TIMESTAMP", "CURRENT_DATE", "CURRENT_TIME"}) +_MERGE_ARITHMETIC_NODES = (exp.Add, exp.Sub, exp.Mul, exp.Div, exp.Mod, exp.Paren) _POSTGRES_TYPE_DISPATCHER = TypeDispatcher[str]() _ORACLE_TYPE_DISPATCHER = TypeDispatcher[str]() @@ -75,43 +77,67 @@ class _MergeAssignmentMixin: __slots__ = () + def _known_merge_qualifiers(self) -> set[str]: + qualifiers: set[str] = set() + builder = cast("SQLBuilderProtocol", self) + expr = builder.get_expression() + if not isinstance(expr, exp.Merge): + return qualifiers + for node in (expr.this, expr.args.get("using")): + if node is None: + continue + alias = node.alias + if alias: + qualifiers.add(alias.lower()) + if isinstance(node, exp.Table) and node.name: + qualifiers.add(node.name.lower()) + elif isinstance(node, exp.Alias) and isinstance(node.this, exp.Table) and node.this.name: + qualifiers.add(node.this.name.lower()) + return qualifiers + def _is_column_reference(self, value: str) -> bool: - """Check if value is a SQL expression rather than a literal string. + """Check if value is a qualified MERGE column reference or CURRENT_* keyword. - Returns True for qualified column references, SQL keywords, functions, and expressions. + Returns True only for CURRENT_TIMESTAMP/CURRENT_DATE/CURRENT_TIME keywords, + qualified column references matching the MERGE target/source tables or aliases, + or arithmetic expressions composed exclusively of such qualified columns. Returns False for plain literal strings that should be parameterized. """ if not isinstance(value, str): return False + stripped = value.strip() + if not stripped: + return False + + if stripped.upper() in _MERGE_SQL_KEYWORD_LITERALS: + return True + + known_qualifiers = self._known_merge_qualifiers() + if not known_qualifiers: + return False + + if _is_simple_identifier(stripped) and "." in stripped: + prefix = stripped.split(".", 1)[0].strip('"`[]').lower() + return prefix in known_qualifiers + builder = cast("QueryBuilder", self) with contextlib.suppress(ParseError): - parsed: exp.Expr | None = exp.maybe_parse(value.strip(), dialect=builder.dialect) + parsed: exp.Expr | None = exp.maybe_parse(stripped, dialect=builder.dialect) if parsed is None: return False if isinstance(parsed, exp.Column): - return parsed.table is not None and bool(parsed.table) - - return isinstance( - parsed, - ( - exp.Dot, - exp.Add, - exp.Sub, - exp.Mul, - exp.Div, - exp.Mod, - exp.Func, - exp.Anonymous, - exp.Null, - exp.CurrentTimestamp, - exp.CurrentDate, - exp.CurrentTime, - exp.Paren, - exp.Case, - ), - ) + return bool(parsed.table) and parsed.table.lower() in known_qualifiers + + if isinstance(parsed, _MERGE_ARITHMETIC_NODES): + columns = list(parsed.find_all(exp.Column)) + if not columns: + return False + for node in parsed.walk(): + if not isinstance(node, (*_MERGE_ARITHMETIC_NODES, exp.Column, exp.Identifier)): + return False + return all(bool(col.table) and col.table.lower() in known_qualifiers for col in columns) return False def _process_assignment(self, target_column: str, value: Any) -> exp.Expr: @@ -181,6 +207,41 @@ class MergeUsingClauseMixin(_MergeAssignmentMixin): def get_expression(self) -> exp.Expr | None: ... def set_expression(self, expression: exp.Expr) -> None: ... + def using(self, source: str | exp.Expr | Any, alias: str | None = None) -> Self: + current_expr = _ensure_merge_expression(cast("SQLBuilderProtocol", self)) + source_expr: exp.Expr + if isinstance(source, str): + source_expr = exp.to_table(source, alias=alias) + elif isinstance(source, (dict, list)): + paren_expr = self._create_dict_source_expression(source, alias) + if alias and isinstance(paren_expr, exp.Paren): + source_expr = exp.Subquery(this=paren_expr.this, alias=exp.to_identifier(alias)) + else: + source_expr = paren_expr + elif isinstance(source, QueryBuilder): + builder = cast("QueryBuilder", self) + subquery_expression_source = builder._embed_subquery(source, alias_hint=alias or "using_src") + if alias: + source_expr = exp.Subquery(this=subquery_expression_source, alias=exp.to_identifier(alias)) + else: + source_expr = exp.paren(subquery_expression_source) + elif isinstance(source, exp.Expr): + if isinstance(source, exp.Select): + source_expr = exp.Subquery(this=source, alias=exp.to_identifier(alias)) if alias else exp.paren(source) + elif isinstance(source, exp.Paren) and alias: + inner = source.this + source_expr = exp.Subquery(this=inner, alias=exp.to_identifier(alias)) + elif isinstance(source, exp.Subquery) and alias: + source.set("alias", exp.to_identifier(alias)) + source_expr = source + else: + source_expr = exp.alias_(source, alias) if alias else source + else: + msg = f"Unsupported source type for USING clause: {type(source)}" + raise SQLBuilderError(msg) + + current_expr.set("using", source_expr) + return self def _create_dict_source_expression( self, source: "dict[str, Any] | list[dict[str, Any]]", alias: "str | None" @@ -463,47 +524,6 @@ def _create_select_union_source( return exp.paren(select_expr) - def using(self, source: str | exp.Expr | Any, alias: str | None = None) -> Self: - current_expr = _ensure_merge_expression(cast("SQLBuilderProtocol", self)) - source_expr: exp.Expr - if isinstance(source, str): - source_expr = exp.to_table(source, alias=alias) - elif isinstance(source, (dict, list)): - paren_expr = self._create_dict_source_expression(source, alias) - if alias and isinstance(paren_expr, exp.Paren): - source_expr = exp.Subquery(this=paren_expr.this, alias=exp.to_identifier(alias)) - else: - source_expr = paren_expr - elif isinstance(source, QueryBuilder): - builder = cast("QueryBuilder", self) - for param_name, param_value in source.parameters.items(): - builder.add_parameter(param_value, name=param_name) - subquery_expression_source = source.get_expression() - if not isinstance(subquery_expression_source, exp.Expr): - subquery_expression_source = exp.select() - - if alias: - source_expr = exp.Subquery(this=subquery_expression_source, alias=exp.to_identifier(alias)) - else: - source_expr = exp.paren(subquery_expression_source) - elif isinstance(source, exp.Expr): - if isinstance(source, exp.Select): - source_expr = exp.Subquery(this=source, alias=exp.to_identifier(alias)) if alias else exp.paren(source) - elif isinstance(source, exp.Paren) and alias: - inner = source.this - source_expr = exp.Subquery(this=inner, alias=exp.to_identifier(alias)) - elif isinstance(source, exp.Subquery) and alias: - source.set("alias", exp.to_identifier(alias)) - source_expr = source - else: - source_expr = exp.alias_(source, alias) if alias else source - else: - msg = f"Unsupported source type for USING clause: {type(source)}" - raise SQLBuilderError(msg) - - current_expr.set("using", source_expr) - return self - @trait class MergeOnClauseMixin: @@ -604,16 +624,18 @@ def when_not_matched_then_insert( raise SQLBuilderError(msg) column_names = [str(column) for column in columns] if values is None: - using_alias = None + using_alias: str | None = None using_expr = current_expr.args.get("using") if using_expr is not None and isinstance(using_expr, (exp.Subquery, exp.Table)): - using_alias = using_expr.alias + using_alias = using_expr.alias or None elif using_expr is not None: try: - using_alias = using_expr.alias + using_alias = using_expr.alias or None except AttributeError: using_alias = None - column_values = [f"{using_alias}.{col}" for col in column_names] if using_alias else column_names + column_values = [ + exp.column(col, table=using_alias) if using_alias else exp.column(col) for col in column_names + ] else: column_values = list(values) if len(column_names) != len(column_values): @@ -719,6 +741,28 @@ def __init__(self, target_table: str | None = None, **kwargs: Any) -> None: if target_table: self.into(target_table) + def build(self, dialect: "DialectType" = None) -> "Any": + """Build MERGE statement with dialect validation. + + Args: + dialect: Optional dialect override for SQL generation. + + Returns: + Built statement object. + """ + return super().build(dialect=dialect) + + def _prepare_expression_for_build(self, dialect: "DialectType" = None, *, copy: bool = True) -> exp.Expr: + """Validate MERGE dialect support and apply dialect-specific expression lowering.""" + self._validate_dialect_support(dialect) + expression = super()._prepare_expression_for_build(dialect=dialect, copy=copy) + resolved_dialect = self._build_dialect(dialect) + if resolved_dialect == "oracle": + if not copy: + expression = expression.copy() + self._move_oracle_when_conditions_on_expr(expression) + return expression + def _create_base_expression(self) -> "exp.Merge": """Create a base MERGE expression. @@ -727,11 +771,11 @@ def _create_base_expression(self) -> "exp.Merge": """ return exp.Merge(this=None, using=None, on=None, whens=exp.Whens(expressions=[])) - def _validate_dialect_support(self) -> None: - """Validate that the current dialect supports MERGE statements.""" - dialect = self.dialect_name - if dialect and dialect in MERGE_UNSUPPORTED_DIALECTS: - self._raise_dialect_not_supported(dialect) + def _validate_dialect_support(self, dialect: "DialectType" = None) -> None: + """Validate that the target dialect supports MERGE statements.""" + resolved_dialect = self._build_dialect(dialect) + if resolved_dialect and resolved_dialect in MERGE_UNSUPPORTED_DIALECTS: + self._raise_dialect_not_supported(resolved_dialect) def _raise_dialect_not_supported(self, dialect: str) -> None: """Raise error with helpful alternatives for unsupported dialects. @@ -756,45 +800,16 @@ def _raise_dialect_not_supported(self, dialect: str) -> None: ) raise DialectNotSupportedError(msg) - def build(self, dialect: "DialectType" = None) -> "Any": - """Build MERGE statement with dialect validation. - - Args: - dialect: Optional dialect override for SQL generation. - - Returns: - Built statement object. - """ - self._validate_dialect_support() - target_dialect = _resolve_dialect(dialect, self.dialect) - if isinstance(target_dialect, str): - dialect_name = target_dialect - elif isinstance(target_dialect, type): - dialect_name = target_dialect.__name__ - else: - dialect_name = None - if dialect_name: - dialect_name = dialect_name.lower() - original_expression = self._expression - if dialect_name == "oracle" and original_expression is not None: - self._expression = original_expression.copy() - try: - self._move_oracle_when_conditions(dialect_name) - return super().build(dialect=dialect) - finally: - self._expression = original_expression - def _move_oracle_when_conditions(self, dialect_name: str | None) -> None: - """Normalize WHEN clause conditions for dialect quirks. - - Oracle requires conditional logic on UPDATE/DELETE branches to live in the - clause-specific WHERE, not on the WHEN predicate. Move the condition down - when present so generated SQL matches Oracle syntax. - """ + """Normalize WHEN clause conditions for dialect quirks on the builder's expression.""" if dialect_name != "oracle": return - merge_expr = self.get_expression() + if merge_expr is not None: + self._move_oracle_when_conditions_on_expr(merge_expr) + + def _move_oracle_when_conditions_on_expr(self, merge_expr: exp.Expr) -> None: + """Normalize WHEN clause conditions for Oracle on a target MERGE expression.""" if not isinstance(merge_expr, exp.Merge): return diff --git a/sqlspec/builder/_parsing_utils.py b/sqlspec/builder/_parsing_utils.py index 701641f80..f4a64750f 100644 --- a/sqlspec/builder/_parsing_utils.py +++ b/sqlspec/builder/_parsing_utils.py @@ -127,15 +127,7 @@ def parse_column_expression(column_input: str | exp.Expr | Any, builder: Any | N return exp.maybe_parse(column_input) or exp.column(column_input) if has_expression_and_sql(column_input): - if column_input.expression is not None and isinstance(column_input.expression, exp.Expr): - _merge_sql_parameters(column_input, builder) - return column_input.expression - - _merge_sql_parameters(column_input, builder) - sql_str = getattr(column_input, "raw_sql", None) - if sql_str is None: - sql_str = column_input.sql - return exp.maybe_parse(sql_str) or exp.column(sql_str) + return extract_sql_object_expression(column_input, builder) if has_expression_attr(column_input) and isinstance(column_input._expression, exp.Expr): # pyright: ignore[reportPrivateUsage] return column_input._expression # pyright: ignore[reportPrivateUsage] @@ -143,38 +135,6 @@ def parse_column_expression(column_input: str | exp.Expr | Any, builder: Any | N return exp.maybe_parse(column_input) or exp.column(str(column_input)) # pyright: ignore[reportArgumentType] -def _mark_explicit_table_quotes(table: exp.Table, source: str) -> exp.Table: - """Distinguish supplied table quotes from quoting added by optimization.""" - if not any(quote in source for quote in ('"', "`", "[")): - return table - remaining = source.strip() - for identifier in table.parts: - if not isinstance(identifier, exp.Identifier) or not remaining: - break - name = identifier.name - opening = remaining[0] - if opening in ('"', "`", "["): - closing = "]" if opening == "[" else opening - spelling = opening + name.replace(closing, closing * 2) + closing - if not remaining.startswith(spelling): - # SQLGlot's permissive fallback can retain literal quote delimiters. - if not (name.startswith(opening) and name.endswith(closing) and remaining.startswith(name)): - break - spelling = name - identifier.set("this", name[1:-1].replace(closing * 2, closing)) - identifier.set("quoted", True) - identifier.meta["sqlspec_explicit_table_quote"] = True - else: - spelling = name - if not remaining.startswith(spelling): - break - remaining = remaining[len(spelling) :].lstrip() - if not remaining.startswith("."): - break - remaining = remaining[1:].lstrip() - return table - - def parse_table_expression( table_input: str, explicit_alias: "str | None" = None, dialect: "DialectType | None" = None ) -> exp.Expr: @@ -272,11 +232,7 @@ def parse_condition_expression(condition_input: str | exp.Expr | tuple[str, Any] param_name = builder.generate_unique_parameter_name(column_name) # pyright: ignore[reportAttributeAccessIssue] _, param_name = builder.add_parameter(value, name=param_name) return exp.EQ(this=column_expr, expression=exp.Placeholder(this=param_name)) - if isinstance(value, str): - return exp.EQ(this=column_expr, expression=exp.convert(value)) - if isinstance(value, (int, float)): - return exp.EQ(this=column_expr, expression=exp.convert(str(value))) - return exp.EQ(this=column_expr, expression=exp.convert(str(value))) + return exp.EQ(this=column_expr, expression=exp.convert(value)) if not isinstance(condition_input, str): condition_input = str(condition_input) @@ -285,11 +241,12 @@ def parse_condition_expression(condition_input: str | exp.Expr | tuple[str, Any] if param_info: converted_condition = condition_input - for param in param_info[::-1]: # Reverse to preserve positions + for param in param_info[::-1]: if param.style in { ParameterStyle.NUMERIC, ParameterStyle.POSITIONAL_PYFORMAT, ParameterStyle.POSITIONAL_COLON, + ParameterStyle.QMARK, }: placeholder = f":param_{param.ordinal}" converted_condition = ( @@ -338,21 +295,25 @@ def extract_sql_object_expression( raise ValueError(msg) if value.expression is not None and isinstance(value.expression, exp.Expr): - _merge_sql_parameters(value, builder) - return value.expression + expr = value.expression.copy() + param_mapping = _merge_sql_parameters(value, builder) + if param_mapping and builder is not None and hasattr(builder, "_update_placeholders"): + expr = builder._update_placeholders(expr, param_mapping) + return expr if not has_sql: msg = f"Value has no SQL text fallback: {type(value)}" raise ValueError(msg) - _merge_sql_parameters(value, builder) + param_mapping = _merge_sql_parameters(value, builder) sql_text = getattr(value, "raw_sql", None) if sql_text is None: sql_text = value.sql if not callable(value.sql) else str(value) - if parse_sql is not None: - return parse_sql(sql_text) - return exp.maybe_parse(sql_text) or exp.convert(str(sql_text)) + expr = parse_sql(sql_text) if parse_sql is not None else (exp.maybe_parse(sql_text) or exp.convert(str(sql_text))) + if param_mapping and builder is not None and hasattr(builder, "_update_placeholders"): + expr = builder._update_placeholders(expr, param_mapping) + return expr def extract_expression(value: Any) -> exp.Expr: @@ -364,16 +325,12 @@ def extract_expression(value: Any) -> exp.Expr: Returns: Raw SQLGlot expression. """ - from sqlspec.builder._select import Case - if isinstance(value, str): return exp.column(value) if isinstance(value, Column): return value.sqlglot_expression if isinstance(value, ExpressionWrapper): return value.expression - if isinstance(value, Case): - return exp.Case(ifs=value.conditions, default=value.default) if isinstance(value, exp.Expr): return value return exp.convert(value) @@ -393,6 +350,37 @@ def to_expression(value: Any) -> exp.Expr: return exp.convert(value) +def _mark_explicit_table_quotes(table: exp.Table, source: str) -> exp.Table: + """Distinguish supplied table quotes from quoting added by optimization.""" + if not any(quote in source for quote in ('"', "`", "[")): + return table + remaining = source.strip() + for identifier in table.parts: + if not isinstance(identifier, exp.Identifier) or not remaining: + break + name = identifier.name + opening = remaining[0] + if opening in ('"', "`", "["): + closing = "]" if opening == "[" else opening + spelling = opening + name.replace(closing, closing * 2) + closing + if not remaining.startswith(spelling): + if not (name.startswith(opening) and name.endswith(closing) and remaining.startswith(name)): + break + spelling = name + identifier.set("this", name[1:-1].replace(closing * 2, closing)) + identifier.set("quoted", True) + identifier.meta["sqlspec_explicit_table_quote"] = True + else: + spelling = name + if not remaining.startswith(spelling): + break + remaining = remaining[len(spelling) :].lstrip() + if not remaining.startswith("."): + break + remaining = remaining[1:].lstrip() + return table + + def _normalize_partition_by(partition_by: str | list[str] | exp.Expr | None) -> list[exp.Expr] | None: if isinstance(partition_by, str): return [exp.column(partition_by)] @@ -449,21 +437,37 @@ def _normalize_dialect(dialect: "DialectType | None") -> str | None: name = dialect.__name__.lower() else: name = type(dialect).__name__.lower() - return {"mssql": "tsql", "mariadb": "mysql", "cockroachdb": "postgres"}.get(name, name) + return {"mssql": "tsql", "mariadb": "mysql", "cockroachdb": "postgres", "postgresql": "postgres"}.get(name, name) -def _merge_sql_parameters(sql_obj: Any, builder: Any) -> None: - """Merge parameters from SQL object into builder. +def _merge_sql_parameters(sql_obj: Any, builder: Any) -> dict[str, str]: + """Merge parameters from SQL object into builder and return renamed placeholder mapping. Args: sql_obj: SQL object with parameters attribute builder: Builder instance with add_parameter method + + Returns: + Mapping of original parameter names to renamed parameter names. """ + param_mapping: dict[str, str] = {} if not (builder and has_expression_and_parameters(sql_obj) and has_parameter_builder(builder)): - return + return param_mapping + existing_params = getattr(builder, "parameters", None) + if existing_params is None: + existing_params = getattr(builder, "_parameters", {}) for param_name, param_value in sql_obj.parameters.items(): - builder.add_parameter(param_value, name=param_name) + target_name = param_name + if param_name in existing_params: + if hasattr(builder, "generate_unique_parameter_name"): + target_name = builder.generate_unique_parameter_name(param_name) # pyright: ignore[reportAttributeAccessIssue] + elif hasattr(builder, "_next_parameter_name"): + target_name = builder._next_parameter_name(param_name) # pyright: ignore[reportAttributeAccessIssue,reportPrivateUsage] + _, actual_name = builder.add_parameter(param_value, name=target_name) + if actual_name != param_name: + param_mapping[param_name] = actual_name + return param_mapping def _is_simple_identifier(value: str) -> bool: diff --git a/sqlspec/builder/_select.py b/sqlspec/builder/_select.py index 5d2100104..42d934dfb 100644 --- a/sqlspec/builder/_select.py +++ b/sqlspec/builder/_select.py @@ -14,12 +14,14 @@ from sqlspec.builder._base import BuiltQuery, QueryBuilder from sqlspec.builder._explain import ExplainMixin +from sqlspec.builder._expression_wrappers import ExpressionWrapper from sqlspec.builder._join import JoinClauseMixin, _attach_as_of_version from sqlspec.builder._parsing_utils import ( _PARAMETER_VALIDATOR, _coerce_column, _cube_expression, _grouping_sets_expression, + _normalize_dialect, _rollup_expression, extract_column_name, extract_expression, @@ -48,7 +50,6 @@ from sqlglot.dialects.dialect import DialectType from sqlspec.builder._column import Column, ColumnExpression, FunctionColumn - from sqlspec.builder._expression_wrappers import ExpressionWrapper from sqlspec.protocols import SQLBuilderProtocol __all__ = ( @@ -83,33 +84,20 @@ def is_explicitly_quoted(identifier: Any) -> bool: ) -def _expr_like_exp(col: "exp.Expr", placeholder: "exp.Placeholder") -> "exp.Expr": - return exp.Like(this=col, expression=placeholder) - - -_SIMPLE_OPERATOR_MAP: dict[str, Any] = { - "=": expr_eq, - "==": expr_eq, - "!=": expr_neq, - "<>": expr_neq, - ">": expr_gt, - ">=": expr_gte, - "<": expr_lt, - "<=": expr_lte, - "LIKE": _expr_like_exp, - "NOT LIKE": expr_not_like, -} - - -class Case: +class Case(ExpressionWrapper): """Represent a SQL CASE expression with structured components.""" __slots__ = ("conditions", "default") def __init__(self, *ifs: exp.Expr, default: exp.Expr | None = None) -> None: + super().__init__(exp.Case()) self.conditions = list(ifs) self.default = default + @property + def expression(self) -> exp.Case: + return exp.Case(ifs=self.conditions, default=self.default) + def when(self, condition: str | exp.Expr, result: Any) -> "Case": condition_expr = parse_condition_expression(condition) result_expr = to_expression(result) @@ -123,13 +111,6 @@ def else_(self, value: Any) -> "Case": def end(self) -> "Case": return self - def as_(self, alias: str) -> exp.Alias: - return cast("exp.Alias", exp.alias_(self.expression, alias)) - - @property - def expression(self) -> exp.Case: - return exp.Case(ifs=self.conditions, default=self.default) - class CaseBuilder: """Fluent builder for CASE expressions used within SELECT clauses.""" @@ -204,10 +185,6 @@ def order_by(self, *columns: str | exp.Expr) -> "WindowFunctionBuilder": self._order_by = ordered_columns return self - def _build_function_expression(self) -> exp.Expr: - expressions = self._function_args or [] - return exp.Anonymous(this=self._function_name, expressions=expressions) - def build(self) -> exp.Window: over_args: dict[str, Any] = {} if self._partition_by: @@ -219,6 +196,10 @@ def build(self) -> exp.Window: def as_(self, alias: str) -> exp.Alias: return cast("exp.Alias", exp.alias_(self.build(), alias)) + def _build_function_expression(self) -> exp.Expr: + expressions = self._function_args or [] + return exp.Anonymous(this=self._function_name, expressions=expressions) + @trait class SelectClauseMixin: @@ -383,12 +364,176 @@ def offset(self, value: int) -> Self: class WhereClauseMixin: __slots__ = () - def _merge_parameters(self, sql_obj: Any) -> None: - builder = cast("SQLBuilderProtocol", self) - builder._merge_parameters(sql_obj) - def get_expression(self) -> exp.Expr | None: ... def set_expression(self, expression: exp.Expr) -> None: ... + def where( + self, + condition: Union[str, exp.Expr, tuple[str, Any], tuple[str, str, Any], "ColumnExpression", SQL], + *values: Any, + operator: str | None = None, + **kwargs: Any, + ) -> Self: + builder = cast("SQLBuilderProtocol", self) + current_expr = builder.get_expression() + if current_expr is None: + msg = "Cannot add WHERE clause: expression is not initialized." + raise SQLBuilderError(msg) + + if isinstance(current_expr, exp.Delete) and not current_expr.args.get("this"): + msg = "WHERE clause requires a table to be set. Use from() to set the table first." + raise SQLBuilderError(msg) + + where_expr = self._process_where_condition(condition, values, operator, kwargs) + + if isinstance(current_expr, (exp.Select, exp.Update, exp.Delete)): + updated_expr = current_expr.where(where_expr, copy=False) + builder.set_expression(updated_expr) + return cast("Self", builder) + msg = f"WHERE clause not supported for {type(current_expr).__name__}" + raise SQLBuilderError(msg) + + def where_eq(self, column: str | exp.Column, value: Any) -> Self: + return self.where(self._create_parameterized_condition(column, value, expr_eq)) + + def where_neq(self, column: str | exp.Column, value: Any) -> Self: + return self.where(self._create_parameterized_condition(column, value, expr_neq)) + + def where_lt(self, column: str | exp.Column, value: Any) -> Self: + return self.where(self._create_parameterized_condition(column, value, expr_lt)) + + def where_lte(self, column: str | exp.Column, value: Any) -> Self: + return self.where(self._create_parameterized_condition(column, value, expr_lte)) + + def where_gt(self, column: str | exp.Column, value: Any) -> Self: + return self.where(self._create_parameterized_condition(column, value, expr_gt)) + + def where_gte(self, column: str | exp.Column, value: Any) -> Self: + return self.where(self._create_parameterized_condition(column, value, expr_gte)) + + def where_between(self, column: str | exp.Column, low: Any, high: Any) -> Self: + return self.where(self._build_between_condition(column, low, high)) + + def where_like(self, column: str | exp.Column, pattern: str, escape: str | None = None) -> Self: + return self.where(self._build_like_condition(column, pattern, escape)) + + def where_not_like(self, column: str | exp.Column, pattern: str) -> Self: + return self.where(self._create_parameterized_condition(column, pattern, expr_not_like)) + + def where_ilike(self, column: str | exp.Column, pattern: str) -> Self: + return self.where(self._create_parameterized_condition(column, pattern, _expr_ilike)) + + def where_is_null(self, column: str | exp.Column) -> Self: + return self.where(self._build_is_null_condition(column)) + + def where_is_not_null(self, column: str | exp.Column) -> Self: + return self.where(self._build_is_not_null_condition(column)) + + def where_in(self, column: str | exp.Column, values: Any) -> Self: + return self.where(self._build_in_condition(column, values)) + + def where_not_in(self, column: str | exp.Column, values: Any) -> Self: + return self.where(self._build_not_in_condition(column, values)) + + def where_any(self, column: str | exp.Column, subquery: Any) -> Self: + return self.where(self._build_any_condition(column, subquery)) + + def where_not_any(self, column: str | exp.Column, subquery: Any) -> Self: + return self.where(self._build_not_any_condition(column, subquery)) + + def where_exists(self, subquery: Any) -> Self: + return self.where(self._build_exists_condition(subquery)) + + def where_not_exists(self, subquery: Any) -> Self: + return self.where(self._build_not_exists_condition(subquery)) + + def where_like_any(self, column: str | exp.Column, patterns: list[str]) -> Self: + conditions = [self._create_parameterized_condition(column, pattern, _expr_like_exp) for pattern in patterns] + or_condition = self._create_or_expression(conditions) + return self.where(or_condition) + + def or_where_eq(self, column: str | exp.Column, value: Any) -> Self: + return self._combine_with_or(self._create_parameterized_condition(column, value, expr_eq)) + + def or_where_neq(self, column: str | exp.Column, value: Any) -> Self: + return self._combine_with_or(self._create_parameterized_condition(column, value, expr_neq)) + + def or_where_lt(self, column: str | exp.Column, value: Any) -> Self: + return self._combine_with_or(self._create_parameterized_condition(column, value, expr_lt)) + + def or_where_lte(self, column: str | exp.Column, value: Any) -> Self: + return self._combine_with_or(self._create_parameterized_condition(column, value, expr_lte)) + + def or_where_gt(self, column: str | exp.Column, value: Any) -> Self: + return self._combine_with_or(self._create_parameterized_condition(column, value, expr_gt)) + + def or_where_gte(self, column: str | exp.Column, value: Any) -> Self: + return self._combine_with_or(self._create_parameterized_condition(column, value, expr_gte)) + + def or_where_between(self, column: str | exp.Column, low: Any, high: Any) -> Self: + return self._combine_with_or(self._build_between_condition(column, low, high)) + + def or_where_like(self, column: str | exp.Column, pattern: str, escape: str | None = None) -> Self: + return self._combine_with_or(self._build_like_condition(column, pattern, escape)) + + def or_where_not_like(self, column: str | exp.Column, pattern: str) -> Self: + return self._combine_with_or(self._create_parameterized_condition(column, pattern, expr_not_like)) + + def or_where_ilike(self, column: str | exp.Column, pattern: str) -> Self: + return self._combine_with_or(self._create_parameterized_condition(column, pattern, _expr_ilike)) + + def or_where_is_null(self, column: str | exp.Column) -> Self: + return self._combine_with_or(self._build_is_null_condition(column)) + + def or_where_is_not_null(self, column: str | exp.Column) -> Self: + return self._combine_with_or(self._build_is_not_null_condition(column)) + + def or_where_null(self, column: str | exp.Column) -> Self: + return self.or_where_is_null(column) + + def or_where_not_null(self, column: str | exp.Column) -> Self: + return self.or_where_is_not_null(column) + + def or_where_in(self, column: str | exp.Column, values: Any) -> Self: + return self._combine_with_or(self._build_in_condition(column, values)) + + def or_where_not_in(self, column: str | exp.Column, values: Any) -> Self: + return self._combine_with_or(self._build_not_in_condition(column, values)) + + def or_where_any(self, column: str | exp.Column, subquery: Any) -> Self: + return self._combine_with_or(self._build_any_condition(column, subquery)) + + def or_where_not_any(self, column: str | exp.Column, subquery: Any) -> Self: + return self._combine_with_or(self._build_not_any_condition(column, subquery)) + + def or_where_exists(self, subquery: Any) -> Self: + return self._combine_with_or(self._build_exists_condition(subquery)) + + def or_where_not_exists(self, subquery: Any) -> Self: + return self._combine_with_or(self._build_not_exists_condition(subquery)) + + def where_or(self, *conditions: str | tuple[str, Any] | tuple[str, str, Any] | exp.Expr) -> Self: + if not conditions: + msg = "where_or() requires at least one condition" + raise SQLBuilderError(msg) + + builder = cast("SQLBuilderProtocol", self) + if builder.get_expression() is None: + msg = "Cannot add WHERE OR clause: expression is not initialized." + raise SQLBuilderError(msg) + + processed_conditions = [self._process_where_condition(condition, (), None, {}) for condition in conditions] + or_condition = self._create_or_expression(processed_conditions) + return self.where(or_condition) + + def or_where( + self, + condition: Union[str, exp.Expr, exp.Condition, tuple[str, Any], tuple[str, str, Any], "ColumnExpression", SQL], + *values: Any, + operator: str | None = None, + **kwargs: Any, + ) -> Self: + or_condition = self._process_where_condition(condition, values, operator, kwargs) + return self._combine_with_or(or_condition) def _create_parameterized_condition( self, column: str | exp.Column, value: Any, condition_factory: "Callable[[exp.Expr, exp.Placeholder], exp.Expr]" @@ -504,14 +649,16 @@ def _any_comparison(self, column_expr: exp.Expr, values: Any, column_name: str, if parsed_expr is not None: return comparison(this=column_expr, expression=exp.Any(this=parsed_expr)) if has_expression_and_sql(values): - self._merge_parameters(values) - expression_attr = values.expression - if isinstance(expression_attr, exp.Expr): - return comparison(this=column_expr, expression=exp.Any(this=expression_attr)) - sql_text = values.sql - parsed_expr = exp.maybe_parse(sql_text, dialect=builder.dialect) - if parsed_expr is not None: - return comparison(this=column_expr, expression=exp.Any(this=parsed_expr)) + + def parse_any_sql(sql_text: str) -> exp.Expr: + parsed: exp.Expr | None = exp.maybe_parse(sql_text, dialect=builder.dialect) + if parsed is None: + msg = f"Unsupported type for 'values' in {error_context}" + raise SQLBuilderError(msg) + return parsed + + value_expr = extract_sql_object_expression(values, builder=self, parse_sql=parse_any_sql) + return comparison(this=column_expr, expression=exp.Any(this=value_expr)) if isinstance(values, str): parsed_expr = exp.maybe_parse(values, dialect=builder.dialect) if isinstance(parsed_expr, (exp.Select, exp.Union, exp.Subquery)): @@ -660,8 +807,8 @@ def _normalize_condition_expression(expression: exp.Expr) -> exp.Expr: ) raise SQLBuilderError(msg) - for index, value in enumerate(values): - param_dict[f"param_{index}"] = value + for param, value in zip(positional_params, values, strict=True): + param_dict[f"param_{param.ordinal}"] = value condition = SQL(condition, param_dict) elif len(values) == 1 and not kwargs: @@ -700,37 +847,6 @@ def _normalize_condition_expression(expression: exp.Expr) -> exp.Expr: msg = f"Unsupported condition type: {type(condition).__name__}" raise SQLBuilderError(msg) - def where( - self, - condition: Union[str, exp.Expr, tuple[str, Any], tuple[str, str, Any], "ColumnExpression", SQL], - *values: Any, - operator: str | None = None, - **kwargs: Any, - ) -> Self: - builder = cast("SQLBuilderProtocol", self) - current_expr = builder.get_expression() - if current_expr is None: - msg = "Cannot add WHERE clause: expression is not initialized." - raise SQLBuilderError(msg) - - if isinstance(current_expr, exp.Delete) and not current_expr.args.get("this"): - msg = "WHERE clause requires a table to be set. Use from() to set the table first." - raise SQLBuilderError(msg) - - where_expr = self._process_where_condition(condition, values, operator, kwargs) - - if isinstance(current_expr, (exp.Select, exp.Update, exp.Delete)): - updated_expr = current_expr.where(where_expr, copy=False) - builder.set_expression(updated_expr) - return cast("Self", builder) - msg = f"WHERE clause not supported for {type(current_expr).__name__}" - raise SQLBuilderError(msg) - - def _build_comparison_condition( - self, column: str | exp.Column, value: Any, condition_factory: "Callable[[exp.Expr, exp.Placeholder], exp.Expr]" - ) -> exp.Expr: - return self._create_parameterized_condition(column, value, condition_factory) - def _build_between_condition(self, column: str | exp.Column, low: Any, high: Any) -> exp.Expr: column_expr = parse_column_expression(column) if not isinstance(column, exp.Column) else column return self._handle_between_operator(column_expr, (low, high), extract_column_name(column)) @@ -779,173 +895,30 @@ def _build_exists_condition(self, subquery: Any) -> exp.Expr: def _build_not_exists_condition(self, subquery: Any) -> exp.Expr: return exp.Not(this=self._build_exists_condition(subquery)) - def where_eq(self, column: str | exp.Column, value: Any) -> Self: - return self.where(self._build_comparison_condition(column, value, expr_eq)) - - def where_neq(self, column: str | exp.Column, value: Any) -> Self: - return self.where(self._build_comparison_condition(column, value, expr_neq)) - - def where_lt(self, column: str | exp.Column, value: Any) -> Self: - return self.where(self._build_comparison_condition(column, value, expr_lt)) - - def where_lte(self, column: str | exp.Column, value: Any) -> Self: - return self.where(self._build_comparison_condition(column, value, expr_lte)) - def where_gt(self, column: str | exp.Column, value: Any) -> Self: - return self.where(self._build_comparison_condition(column, value, expr_gt)) +@trait +class HavingClauseMixin: + __slots__ = () - def where_gte(self, column: str | exp.Column, value: Any) -> Self: - return self.where(self._build_comparison_condition(column, value, expr_gte)) + def having(self, condition: str | exp.Expr | exp.Condition | tuple[str, Any] | tuple[str, str, Any]) -> Self: + builder = cast("SQLBuilderProtocol", self) + current_expr = builder.get_expression() + if current_expr is None or not isinstance(current_expr, exp.Select): + return cast("Self", builder) - def where_between(self, column: str | exp.Column, low: Any, high: Any) -> Self: - return self.where(self._build_between_condition(column, low, high)) + if isinstance(condition, tuple): + where_mixin = cast("WhereClauseMixin", self) + having_expr = where_mixin._process_tuple_condition(condition) + else: + having_expr = parse_condition_expression(condition) - def where_like(self, column: str | exp.Column, pattern: str, escape: str | None = None) -> Self: - return self.where(self._build_like_condition(column, pattern, escape)) + builder.set_expression(current_expr.having(having_expr, copy=False)) + return cast("Self", builder) - def where_not_like(self, column: str | exp.Column, pattern: str) -> Self: - return self.where(self._build_comparison_condition(column, pattern, expr_not_like)) - def where_ilike(self, column: str | exp.Column, pattern: str) -> Self: - return self.where(self._build_comparison_condition(column, pattern, _expr_ilike)) - - def where_is_null(self, column: str | exp.Column) -> Self: - return self.where(self._build_is_null_condition(column)) - - def where_is_not_null(self, column: str | exp.Column) -> Self: - return self.where(self._build_is_not_null_condition(column)) - - def where_in(self, column: str | exp.Column, values: Any) -> Self: - return self.where(self._build_in_condition(column, values)) - - def where_not_in(self, column: str | exp.Column, values: Any) -> Self: - return self.where(self._build_not_in_condition(column, values)) - - def where_any(self, column: str | exp.Column, subquery: Any) -> Self: - return self.where(self._build_any_condition(column, subquery)) - - def where_not_any(self, column: str | exp.Column, subquery: Any) -> Self: - return self.where(self._build_not_any_condition(column, subquery)) - - def where_exists(self, subquery: Any) -> Self: - return self.where(self._build_exists_condition(subquery)) - - def where_not_exists(self, subquery: Any) -> Self: - return self.where(self._build_not_exists_condition(subquery)) - - def where_like_any(self, column: str | exp.Column, patterns: list[str]) -> Self: - conditions = [self._create_parameterized_condition(column, pattern, _expr_like_method) for pattern in patterns] - or_condition = self._create_or_expression(conditions) - return self.where(or_condition) - - def or_where_eq(self, column: str | exp.Column, value: Any) -> Self: - return self._combine_with_or(self._build_comparison_condition(column, value, expr_eq)) - - def or_where_neq(self, column: str | exp.Column, value: Any) -> Self: - return self._combine_with_or(self._build_comparison_condition(column, value, expr_neq)) - - def or_where_lt(self, column: str | exp.Column, value: Any) -> Self: - return self._combine_with_or(self._build_comparison_condition(column, value, expr_lt)) - - def or_where_lte(self, column: str | exp.Column, value: Any) -> Self: - return self._combine_with_or(self._build_comparison_condition(column, value, expr_lte)) - - def or_where_gt(self, column: str | exp.Column, value: Any) -> Self: - return self._combine_with_or(self._build_comparison_condition(column, value, expr_gt)) - - def or_where_gte(self, column: str | exp.Column, value: Any) -> Self: - return self._combine_with_or(self._build_comparison_condition(column, value, expr_gte)) - - def or_where_between(self, column: str | exp.Column, low: Any, high: Any) -> Self: - return self._combine_with_or(self._build_between_condition(column, low, high)) - - def or_where_like(self, column: str | exp.Column, pattern: str, escape: str | None = None) -> Self: - return self._combine_with_or(self._build_like_condition(column, pattern, escape)) - - def or_where_not_like(self, column: str | exp.Column, pattern: str) -> Self: - return self._combine_with_or(self._build_comparison_condition(column, pattern, expr_not_like)) - - def or_where_ilike(self, column: str | exp.Column, pattern: str) -> Self: - return self._combine_with_or(self._build_comparison_condition(column, pattern, _expr_ilike)) - - def or_where_is_null(self, column: str | exp.Column) -> Self: - return self._combine_with_or(self._build_is_null_condition(column)) - - def or_where_is_not_null(self, column: str | exp.Column) -> Self: - return self._combine_with_or(self._build_is_not_null_condition(column)) - - def or_where_null(self, column: str | exp.Column) -> Self: - return self.or_where_is_null(column) - - def or_where_not_null(self, column: str | exp.Column) -> Self: - return self.or_where_is_not_null(column) - - def or_where_in(self, column: str | exp.Column, values: Any) -> Self: - return self._combine_with_or(self._build_in_condition(column, values)) - - def or_where_not_in(self, column: str | exp.Column, values: Any) -> Self: - return self._combine_with_or(self._build_not_in_condition(column, values)) - - def or_where_any(self, column: str | exp.Column, subquery: Any) -> Self: - return self._combine_with_or(self._build_any_condition(column, subquery)) - - def or_where_not_any(self, column: str | exp.Column, subquery: Any) -> Self: - return self._combine_with_or(self._build_not_any_condition(column, subquery)) - - def or_where_exists(self, subquery: Any) -> Self: - return self._combine_with_or(self._build_exists_condition(subquery)) - - def or_where_not_exists(self, subquery: Any) -> Self: - return self._combine_with_or(self._build_not_exists_condition(subquery)) - - def where_or(self, *conditions: str | tuple[str, Any] | tuple[str, str, Any] | exp.Expr) -> Self: - if not conditions: - msg = "where_or() requires at least one condition" - raise SQLBuilderError(msg) - - builder = cast("SQLBuilderProtocol", self) - if builder.get_expression() is None: - msg = "Cannot add WHERE OR clause: expression is not initialized." - raise SQLBuilderError(msg) - - processed_conditions = [self._process_where_condition(condition, (), None, {}) for condition in conditions] - or_condition = self._create_or_expression(processed_conditions) - return self.where(or_condition) - - def or_where( - self, - condition: Union[str, exp.Expr, exp.Condition, tuple[str, Any], tuple[str, str, Any], "ColumnExpression", SQL], - *values: Any, - operator: str | None = None, - **kwargs: Any, - ) -> Self: - or_condition = self._process_where_condition(condition, values, operator, kwargs) - return self._combine_with_or(or_condition) - - -@trait -class HavingClauseMixin: - __slots__ = () - - def having(self, condition: str | exp.Expr | exp.Condition | tuple[str, Any] | tuple[str, str, Any]) -> Self: - builder = cast("SQLBuilderProtocol", self) - current_expr = builder.get_expression() - if current_expr is None or not isinstance(current_expr, exp.Select): - return cast("Self", builder) - - if isinstance(condition, tuple): - where_mixin = cast("WhereClauseMixin", self) - having_expr = where_mixin._process_tuple_condition(condition) - else: - having_expr = parse_condition_expression(condition) - - builder.set_expression(current_expr.having(having_expr, copy=False)) - return cast("Self", builder) - - -@trait -class PivotClauseMixin: - __slots__ = () +@trait +class PivotClauseMixin: + __slots__ = () def pivot( self, @@ -1157,12 +1130,6 @@ def __init__(self, *columns: str, **kwargs: Any) -> None: if columns: self.select(*columns) - def _create_base_expression(self) -> exp.Select: - """Create base SELECT expression.""" - if self._expression is None or not isinstance(self._expression, exp.Select): - self._expression = exp.Select() - return self._expression - def with_hint( self, hint: "str", *, location: "str" = "statement", table: "str | None" = None, dialect: "str | None" = None ) -> "Self": @@ -1180,53 +1147,131 @@ def with_hint( self._hints.append({"hint": hint, "location": location, "table": table, "dialect": dialect}) return self - def build(self, dialect: "DialectType" = None) -> "BuiltQuery": - """Builds the SQL query string and parameters with hint injection. + def for_update( + self, *, skip_locked: bool = False, nowait: bool = False, of: "str | list[str] | None" = None + ) -> "Self": + """Add FOR UPDATE clause to SELECT statement for row-level locking. Args: - dialect: Optional dialect override for SQL generation. + skip_locked: Skip rows that are already locked (SKIP LOCKED) + nowait: Return immediately if row is locked (NOWAIT) + of: Table names/aliases to lock (FOR UPDATE OF table) Returns: - BuiltQuery: A dataclass containing the SQL string and parameters. + Self for method chaining """ - target_dialect = str(dialect) if dialect else self.dialect_name + return self._add_lock(update=True, skip_locked=skip_locked, nowait=nowait, of=of) + + def for_share( + self, *, skip_locked: bool = False, nowait: bool = False, of: "str | list[str] | None" = None + ) -> "Self": + """Add FOR SHARE clause for shared row-level locking. + + Args: + skip_locked: Skip rows that are already locked (SKIP LOCKED) + nowait: Return immediately if row is locked (NOWAIT) + of: Table names/aliases to lock (FOR SHARE OF table) - modified_expr = self._expression or self._create_base_expression() - original_hint = modified_expr.args.get("hint") if isinstance(modified_expr, exp.Select) else None - had_original_hint = isinstance(modified_expr, exp.Select) and "hint" in modified_expr.args + Returns: + Self for method chaining + """ + return self._add_lock(update=False, skip_locked=skip_locked, nowait=nowait, of=of) - if isinstance(modified_expr, exp.Select): - statement_hints = [h["hint"] for h in self._hints if h.get("location") == "statement"] + def for_key_share(self) -> "Self": + """Add FOR KEY SHARE clause (PostgreSQL-specific). + + FOR KEY SHARE is like FOR SHARE, but the lock is weaker: + SELECT FOR UPDATE is blocked, but not SELECT FOR NO KEY UPDATE. + + Returns: + Self for method chaining + """ + self._validate_select_expression() + + assert self._expression is not None + select_expr = cast("exp.Select", self._expression) + + lock = exp.Lock(update=False, key=True) + + current_locks = select_expr.args.get("locks", []) + current_locks.append(lock) + select_expr.set("locks", current_locks) + + return self + + def for_no_key_update(self) -> "Self": + """Add FOR NO KEY UPDATE clause (PostgreSQL-specific). + + FOR NO KEY UPDATE is like FOR UPDATE, but the lock is weaker: + it does not block SELECT FOR KEY SHARE commands that attempt to + acquire a share lock on the same rows. + + Returns: + Self for method chaining + """ + self._validate_select_expression() + + assert self._expression is not None + select_expr = cast("exp.Select", self._expression) + + lock = exp.Lock(update=True, key=True) + + current_locks = select_expr.args.get("locks", []) + current_locks.append(lock) + select_expr.set("locks", current_locks) + + return self + + def _prepare_expression_for_build(self, dialect: "DialectType" = None, *, copy: bool = True) -> exp.Expr: + target_dialect = _normalize_dialect(dialect or self.dialect) + base_expr = super()._prepare_expression_for_build(dialect=dialect, copy=True if self._hints else copy) + if isinstance(base_expr, exp.Select) and self._hints: + statement_hints = [ + h["hint"] + for h in self._hints + if h.get("location") == "statement" + and ( + not h.get("dialect") or _normalize_dialect(cast("DialectType", h.get("dialect"))) == target_dialect + ) + ] if statement_hints: hint_expressions: list[exp.Expr] = [ _parse_hint_expression(hint, target_dialect) for hint in statement_hints ] - if hint_expressions: - modified_expr.set("hint", exp.Hint(expressions=hint_expressions)) - - try: - safe_query = super().build(dialect=dialect) - finally: - if isinstance(modified_expr, exp.Select): - modified_expr.set("hint", original_hint if had_original_hint else None) + base_expr.set("hint", exp.Hint(expressions=hint_expressions)) + return base_expr + def _postprocess_built_sql(self, sql_string: str, dialect: "DialectType" = None) -> str: if not self._hints: - return safe_query - - modified_sql = safe_query.sql - + return sql_string + target_dialect = _normalize_dialect(dialect or self.dialect) + modified_sql = sql_string for hint_dict in self._hints: - if hint_dict.get("location") == "table" and hint_dict.get("table"): + if ( + hint_dict.get("location") == "table" + and hint_dict.get("table") + and ( + not hint_dict.get("dialect") + or _normalize_dialect(cast("DialectType", hint_dict.get("dialect"))) == target_dialect + ) + ): table = str(hint_dict["table"]) hint = str(hint_dict["hint"]) pattern = TABLE_HINT_PATTERN.format(re.escape(table)) - modified_sql = re.sub( pattern, _TableHintReplacer(hint, table), modified_sql, count=1, flags=re.IGNORECASE ) + return modified_sql - return BuiltQuery(sql=modified_sql, parameters=safe_query.parameters, dialect=safe_query.dialect) + def _has_sql_postprocessing(self) -> bool: + return bool(self._hints) + + def _create_base_expression(self) -> exp.Select: + """Create base SELECT expression.""" + if self._expression is None or not isinstance(self._expression, exp.Select): + self._expression = exp.Select() + return self._expression def _validate_select_expression(self) -> None: """Validate that current expression is a valid SELECT statement. @@ -1280,84 +1325,35 @@ def _add_lock( return self - def for_update( - self, *, skip_locked: bool = False, nowait: bool = False, of: "str | list[str] | None" = None - ) -> "Self": - """Add FOR UPDATE clause to SELECT statement for row-level locking. - - Args: - skip_locked: Skip rows that are already locked (SKIP LOCKED) - nowait: Return immediately if row is locked (NOWAIT) - of: Table names/aliases to lock (FOR UPDATE OF table) - - Returns: - Self for method chaining - """ - return self._add_lock(update=True, skip_locked=skip_locked, nowait=nowait, of=of) - - def for_share( - self, *, skip_locked: bool = False, nowait: bool = False, of: "str | list[str] | None" = None - ) -> "Self": - """Add FOR SHARE clause for shared row-level locking. - - Args: - skip_locked: Skip rows that are already locked (SKIP LOCKED) - nowait: Return immediately if row is locked (NOWAIT) - of: Table names/aliases to lock (FOR SHARE OF table) - Returns: - Self for method chaining - """ - return self._add_lock(update=False, skip_locked=skip_locked, nowait=nowait, of=of) - - def for_key_share(self) -> "Self": - """Add FOR KEY SHARE clause (PostgreSQL-specific). - - FOR KEY SHARE is like FOR SHARE, but the lock is weaker: - SELECT FOR UPDATE is blocked, but not SELECT FOR NO KEY UPDATE. - - Returns: - Self for method chaining - """ - self._validate_select_expression() - - assert self._expression is not None - select_expr = cast("exp.Select", self._expression) - - lock = exp.Lock(update=False, key=True) - - current_locks = select_expr.args.get("locks", []) - current_locks.append(lock) - select_expr.set("locks", current_locks) - - return self - - def for_no_key_update(self) -> "Self": - """Add FOR NO KEY UPDATE clause (PostgreSQL-specific). - - FOR NO KEY UPDATE is like FOR UPDATE, but the lock is weaker: - it does not block SELECT FOR KEY SHARE commands that attempt to - acquire a share lock on the same rows. - - Returns: - Self for method chaining - """ - self._validate_select_expression() +class _TableHintReplacer: + __slots__ = ("_hint", "_table") - assert self._expression is not None - select_expr = cast("exp.Select", self._expression) + def __init__(self, hint: str, table: str) -> None: + self._hint = hint + self._table = table - lock = exp.Lock(update=True, key=True) + def __call__(self, match: "re.Match[str]") -> str: + alias_part = match.group(1) or "" + return f"/*+ {self._hint} */ {self._table}{alias_part}" - current_locks = select_expr.args.get("locks", []) - current_locks.append(lock) - select_expr.set("locks", current_locks) - return self +def _expr_like_exp(col: "exp.Expr", placeholder: "exp.Placeholder") -> "exp.Expr": + return exp.Like(this=col, expression=placeholder) -def _expr_like_method(col: "exp.Expr", placeholder: "exp.Placeholder") -> "exp.Expr": - return cast("exp.Expr", col.like(placeholder)) +_SIMPLE_OPERATOR_MAP: dict[str, Any] = { + "=": expr_eq, + "==": expr_eq, + "!=": expr_neq, + "<>": expr_neq, + ">": expr_gt, + ">=": expr_gte, + "<": expr_lt, + "<=": expr_lte, + "LIKE": _expr_like_exp, + "NOT LIKE": expr_not_like, +} def _expr_ilike(col: "exp.Expr", placeholder: "exp.Placeholder") -> "exp.Expr": @@ -1385,15 +1381,3 @@ def _parse_hint_expression(hint: Any, dialect: "DialectType | str | None") -> ex return hint_expr or exp.Anonymous(this=hint_str) except Exception: return exp.Anonymous(this=str(hint)) - - -class _TableHintReplacer: - __slots__ = ("_hint", "_table") - - def __init__(self, hint: str, table: str) -> None: - self._hint = hint - self._table = table - - def __call__(self, match: "re.Match[str]") -> str: - alias_part = match.group(1) or "" - return f"/*+ {self._hint} */ {self._table}{alias_part}" diff --git a/sqlspec/builder/_temporal.py b/sqlspec/builder/_temporal.py index 750fad3df..b4dd9844f 100644 --- a/sqlspec/builder/_temporal.py +++ b/sqlspec/builder/_temporal.py @@ -132,7 +132,8 @@ def _snowflake_version_sql(self: "SnowflakeGenerator", expression: exp.Version) def _duckdb_version_sql(self: "DuckDBGenerator", expression: exp.Version) -> str: """DuckDB: AT (TIMESTAMP => timestamp).""" expr = self.sql(expression, "expression") - return f"AT (TIMESTAMP => {expr})" + this = expression.name or "TIMESTAMP" + return f"AT ({this} => {expr})" def _cockroachdb_version_sql(self: "PostgresGenerator", expression: exp.Version) -> str: diff --git a/sqlspec/builder/_update.py b/sqlspec/builder/_update.py index 91018af52..37b15a76d 100644 --- a/sqlspec/builder/_update.py +++ b/sqlspec/builder/_update.py @@ -59,14 +59,6 @@ def __init__(self, table: str | None = None, **kwargs: Any) -> None: if table: self.table(table) - def _create_base_expression(self) -> exp.Update: - """Create a base UPDATE expression. - - Returns: - A new sqlglot Update expression with empty clauses. - """ - return exp.Update(this=None, expressions=[]) - def join( self, table: "str | exp.Expr | Select", @@ -114,6 +106,10 @@ def build(self, dialect: "DialectType" = None) -> "BuiltQuery": Raises: SQLBuilderError: If no table is set or expression is not an UPDATE. """ + return super().build(dialect=dialect) + + def _prepare_expression_for_build(self, dialect: "DialectType" = None, *, copy: bool = True) -> exp.Expr: + """Validate that the UPDATE target table and SET clauses are set before building.""" if self._expression is None: msg = "UPDATE expression not initialized." raise SQLBuilderError(msg) @@ -130,4 +126,12 @@ def build(self, dialect: "DialectType" = None) -> "BuiltQuery": msg = "At least one SET clause must be specified for UPDATE statement." raise SQLBuilderError(msg) - return super().build(dialect=dialect) + return super()._prepare_expression_for_build(dialect=dialect, copy=copy) + + def _create_base_expression(self) -> exp.Update: + """Create a base UPDATE expression. + + Returns: + A new sqlglot Update expression with empty clauses. + """ + return exp.Update(this=None, expressions=[]) diff --git a/sqlspec/builder/_values.py b/sqlspec/builder/_values.py index 8bdbce041..9ea8d3c77 100644 --- a/sqlspec/builder/_values.py +++ b/sqlspec/builder/_values.py @@ -51,10 +51,6 @@ def __init__( if rows is not None: self.add_rows(rows) - def _create_base_expression(self) -> exp.Values: - """Create initial empty VALUES expression.""" - return exp.Values() - @property def alias_name(self) -> str | None: """Get the table alias name if defined. @@ -171,6 +167,10 @@ def add_rows(self, rows: Sequence[Sequence[Any] | Mapping[str, Any]]) -> Self: self._rebuild_expression() return self + def _create_base_expression(self) -> exp.Values: + """Create initial empty VALUES expression.""" + return exp.Values() + def _rebuild_expression(self) -> None: """Rebuild the underlying sqlglot expression and parameter bindings.""" cte_parameters = { diff --git a/sqlspec/builder/_vector_distance.py b/sqlspec/builder/_vector_distance.py index d99a8fd28..f57304a0c 100644 --- a/sqlspec/builder/_vector_distance.py +++ b/sqlspec/builder/_vector_distance.py @@ -91,6 +91,8 @@ def render_vector_distance_postgres(left: str, right: str, metric: str) -> str: """Render PostgreSQL pgvector operator syntax.""" operator = _POSTGRES_OPERATOR_MAP.get(metric) if operator: + if right.startswith("ARRAY["): + right = f"CAST({right} AS VECTOR)" return f"{left} {operator} {right}" return render_vector_distance_generic(left, right, metric) @@ -100,8 +102,14 @@ def render_vector_distance_mysql(left: str, right: str, metric: str) -> str: """Render MySQL DISTANCE function syntax.""" mysql_metric = _MYSQL_METRIC_MAP.get(metric, "EUCLIDEAN") - if ("ARRAY" in right or "[" in right) and "STRING_TO_VECTOR" not in right: - right = f"STRING_TO_VECTOR({right})" + if "STRING_TO_VECTOR" not in right: + if right.startswith("ARRAY[") and right.endswith("]"): + inner = right[len("ARRAY[") : -1] + right = f"STRING_TO_VECTOR('[{inner}]')" + elif right.startswith("[") and right.endswith("]"): + right = f"STRING_TO_VECTOR('{right}')" + elif right.startswith("'[") and right.endswith("]'"): + right = f"STRING_TO_VECTOR({right})" return f"DISTANCE({left}, {right}, '{mysql_metric}')" @@ -110,8 +118,14 @@ def render_vector_distance_oracle(left: str, right: str, metric: str) -> str: """Render Oracle VECTOR_DISTANCE function syntax.""" oracle_metric = _ORACLE_METRIC_MAP.get(metric, "EUCLIDEAN") - if ("[" in right or "ARRAY" in right) and "TO_VECTOR" not in right: - right = f"TO_VECTOR({right})" + if "TO_VECTOR" not in right: + if right.startswith("ARRAY[") and right.endswith("]"): + inner = right[len("ARRAY[") : -1] + right = f"TO_VECTOR('[{inner}]')" + elif right.startswith("[") and right.endswith("]"): + right = f"TO_VECTOR('{right}')" + elif right.startswith("'[") and right.endswith("]'"): + right = f"TO_VECTOR({right})" return f"VECTOR_DISTANCE({left}, {right}, {oracle_metric})" @@ -145,9 +159,19 @@ def _render_with_metric(generator: "Generator", expression: exp.Operator, dialec right_sql = generator.sql(expression, "expression") metric = vector_distance_metric(expression) + dialect_class = getattr(generator.dialect, "__class__", None) + if dialect_class is not None and dialect_class.__name__ == "Spangres": + dialect = "bigquery" + if dialect == "postgres": return render_vector_distance_postgres(left_sql, right_sql, metric) if dialect == "mysql": + if isinstance(expression.expression, exp.Array): + values = [ + str(item.this) if isinstance(item, exp.Literal) else generator.sql(item) + for item in expression.expression.expressions + ] + right_sql = f"STRING_TO_VECTOR('[{', '.join(values)}]')" return render_vector_distance_mysql(left_sql, right_sql, metric) if dialect == "oracle": if isinstance(expression.expression, exp.Array): @@ -238,8 +262,6 @@ def _register_with_sqlglot() -> None: from sqlglot.dialects.postgres import Postgres from sqlglot.generator import Generator - # The sqlspec Spanner and Spangres dialects alias the BigQuery and Postgres - # generator classes, so registering on the base TRANSFORMS dicts covers them. _BASE_OPERATOR_TRANSFORM = cast("_OperatorTransform", Generator.TRANSFORMS[exp.Operator]) _POSTGRES_OPERATOR_TRANSFORM = cast("_OperatorTransform", Postgres.Generator.TRANSFORMS[exp.Operator]) _MYSQL_OPERATOR_TRANSFORM = cast("_OperatorTransform", MySQL.Generator.TRANSFORMS[exp.Operator]) diff --git a/sqlspec/cli.py b/sqlspec/cli.py index da3531628..063fc0170 100644 --- a/sqlspec/cli.py +++ b/sqlspec/cli.py @@ -11,10 +11,11 @@ from rich.prompt import Confirm, Prompt from rich.table import Table -from sqlspec.config import AsyncDatabaseConfig, SyncDatabaseConfig +from sqlspec.config import AsyncDatabaseConfig, NoPoolAsyncConfig, NoPoolSyncConfig, SyncDatabaseConfig from sqlspec.exceptions import SQLSpecError from sqlspec.migrations.squash import parse_version_range from sqlspec.utils.config_tools import discover_config_from_pyproject, resolve_config_sync +from sqlspec.utils.logging import get_logger from sqlspec.utils.module_loader import import_string from sqlspec.utils.sync_tools import run_ @@ -26,7 +27,13 @@ __all__ = ("add_migration_commands", "get_sqlspec_group") -AnyDatabaseConfig: TypeAlias = AsyncDatabaseConfig[Any, Any, Any] | SyncDatabaseConfig[Any, Any, Any] +AnyDatabaseConfig: TypeAlias = ( + AsyncDatabaseConfig[Any, Any, Any] + | SyncDatabaseConfig[Any, Any, Any] + | NoPoolAsyncConfig[Any, Any] + | NoPoolSyncConfig[Any, Any] +) +logger = get_logger("cli") def get_sqlspec_group() -> "Group": @@ -91,11 +98,17 @@ def sqlspec_group(ctx: "click.Context", config: str | None, validate_config: boo else: all_configs.append(config_result) # pyright: ignore - configs_by_key: dict[str | None, AnyDatabaseConfig] = {} + configs_list: list[AnyDatabaseConfig] = [] + seen_bind_keys: dict[str, int] = {} for cfg in all_configs: - configs_by_key[cfg.bind_key] = cfg + if cfg.bind_key is not None and cfg.bind_key in seen_bind_keys: + configs_list[seen_bind_keys[cfg.bind_key]] = cfg + else: + if cfg.bind_key is not None: + seen_bind_keys[cfg.bind_key] = len(configs_list) + configs_list.append(cfg) - ctx.obj["configs"] = list(configs_by_key.values()) + ctx.obj["configs"] = configs_list if not ctx.obj["configs"]: console.print("[red]Error: No valid configs found after resolution.[/]") @@ -241,7 +254,9 @@ def _is_adk_memory_enabled(config: "AnyDatabaseConfig") -> bool: async def _cleanup_memory_entries_async(store: "BaseAsyncADKMemoryStore[Any]", days: int) -> int: return await store.delete_entries_older_than(days) - async def _verify_memory_table_async(config: "AsyncDatabaseConfig[Any, Any, Any]", sql: str) -> None: + async def _verify_memory_table_async( + config: "AsyncDatabaseConfig[Any, Any, Any] | NoPoolAsyncConfig[Any, Any]", sql: str + ) -> None: async with config.provide_session() as driver: await driver.execute(sql) @@ -394,6 +409,7 @@ def _run_multi_config_operation( failure_message: Builds the per-config failure line. """ sync_configs, async_configs = _partition_configs_by_async(configs_to_process) + errors: list[tuple[str, Exception]] = [] for config_name, config in sync_configs: if progress_message is not None and should_echo(config): @@ -403,6 +419,7 @@ def _run_multi_config_operation( if success_message is not None and should_echo(config): console.print(success_message(config_name)) except Exception as e: + errors.append((config_name, e)) if should_echo(config): console.print(failure_message(config_name, e)) @@ -417,11 +434,17 @@ async def _run_async_configs() -> None: if success_message is not None and should_echo(config): console.print(success_message(config_name)) except Exception as e: + errors.append((config_name, e)) if should_echo(config): console.print(failure_message(config_name, e)) run_(_run_async_configs)() + if errors: + failed = ", ".join(name for name, _ in errors) + msg = f"Operation failed for config(s): {failed}" + raise click.ClickException(msg) + def process_multiple_configs( ctx: "click.Context", bind_key: str | None, @@ -775,7 +798,9 @@ def init_sqlspec( # pyright: ignore[reportUnusedFunction] target_directory = ( str(migration_config_dict.get("script_location", "migrations")) if directory is None else directory ) - migration_commands = create_migration_commands(config=config) + migration_commands: SyncMigrationCommands[Any] = cast( + "SyncMigrationCommands[Any]", create_migration_commands(config=cast("Any", config)) + ) migration_commands.init(directory=target_directory, package=package) if async_configs: @@ -789,7 +814,7 @@ async def _init_async_configs() -> None: else directory ) migration_commands: AsyncMigrationCommands[Any] = cast( - "AsyncMigrationCommands[Any]", create_migration_commands(config=config) + "AsyncMigrationCommands[Any]", create_migration_commands(config=cast("Any", config)) ) await migration_commands.init(directory=target_directory, package=package) @@ -961,7 +986,7 @@ def cleanup_memory(bind_key: str | None, days: int) -> None: # pyright: ignore[ console.print(f"[yellow]No memory store found for {config_name}; skipping.[/]") continue - if isinstance(cfg, AsyncDatabaseConfig): + if isinstance(cfg, (AsyncDatabaseConfig, NoPoolAsyncConfig)): async_store = cast("BaseAsyncADKMemoryStore[Any]", store_class(cfg)) deleted = run_(_cleanup_memory_entries_async)(async_store, days) console.print(f"[green]✓[/] {config_name}: deleted {deleted} memory entries older than {days} days") @@ -993,8 +1018,8 @@ def verify_memory(bind_key: str | None) -> None: # pyright: ignore[reportUnused continue try: - if isinstance(cfg, AsyncDatabaseConfig): - async_cfg: AsyncDatabaseConfig[Any, Any, Any] = cfg + if isinstance(cfg, (AsyncDatabaseConfig, NoPoolAsyncConfig)): + async_cfg: AsyncDatabaseConfig[Any, Any, Any] | NoPoolAsyncConfig[Any, Any] = cfg async_store = cast("BaseAsyncADKMemoryStore[Any]", store_class(async_cfg)) sql = f"SELECT 1 FROM {async_store.memory_table} WHERE 1 = 0" run_(_verify_memory_table_async)(async_cfg, sql) diff --git a/sqlspec/config.py b/sqlspec/config.py index e9839cb46..8e33dfdbd 100644 --- a/sqlspec/config.py +++ b/sqlspec/config.py @@ -828,11 +828,7 @@ def __hash__(self) -> int: return id(self) def __eq__(self, other: object) -> bool: - if not isinstance(other, type(self)): - return False - return bool( - self.connection_instance == other.connection_instance and self.migration_config == other.migration_config - ) + return self is other def __repr__(self) -> str: parts = ", ".join([ @@ -1612,7 +1608,9 @@ def __init__( bind_key: "str | None" = None, extension_config: "ExtensionConfigs | None" = None, observability_config: "ObservabilityConfig | None" = None, + **kwargs: Any, ) -> None: + self._reject_unexpected_kwargs(kwargs) self._init_config_state( connection_config=connection_config, connection_instance=connection_instance, @@ -1668,7 +1666,9 @@ def __init__( bind_key: "str | None" = None, extension_config: "ExtensionConfigs | None" = None, observability_config: "ObservabilityConfig | None" = None, + **kwargs: Any, ) -> None: + self._reject_unexpected_kwargs(kwargs) self._init_config_state( connection_config=connection_config, connection_instance=connection_instance, diff --git a/sqlspec/core/_cursor.py b/sqlspec/core/_cursor.py index 4fcce5970..a8b0f7d82 100644 --- a/sqlspec/core/_cursor.py +++ b/sqlspec/core/_cursor.py @@ -40,30 +40,6 @@ def __init__(self, values: tuple[Any, ...], backward: bool) -> None: self.backward = backward -def _float(value: str) -> float: - result = float(value) - if not math.isfinite(result): - msg = "Non-finite cursor float" - raise ValueError(msg) - return result - - -def _decimal(value: str) -> Decimal: - result = Decimal(value) - if not result.is_finite() or abs(result.adjusted()) > _DECIMAL_MAX_EXPONENT: - msg = "Out-of-range cursor decimal" - raise ValueError(msg) - return result - - -def _timedelta(value: str) -> timedelta: - days, seconds, microseconds = (int(part) for part in value.split(":")) - if abs(days) > _TIMEDELTA_MAX_DAYS: - msg = "Out-of-range cursor timedelta" - raise ValueError(msg) - return timedelta(days=days, seconds=seconds, microseconds=microseconds) - - _ENCODERS: dict[type, tuple[str, Callable[[Any], str]]] = { float: ("f", repr), str: ("s", str), @@ -75,17 +51,6 @@ def _timedelta(value: str) -> timedelta: time: ("t", lambda value: value.isoformat()), timedelta: ("td", lambda value: f"{value.days}:{value.seconds}:{value.microseconds}"), } -_DECODERS: dict[str, Callable[[str], Any]] = { - "f": _float, - "s": str, - "y": lambda value: base64.b64decode(value, validate=True), - "d": _decimal, - "u": UUID, - "dt": datetime.fromisoformat, - "da": date.fromisoformat, - "t": time.fromisoformat, - "td": _timedelta, -} def register_cursor_type( @@ -129,32 +94,6 @@ def order_fingerprint(parts: Sequence[tuple[str, str, str | None]]) -> str: ).hexdigest()[:16] -def _encode_value(value: Any) -> list[Any]: - if value is None: - return ["n", None] - if type(value) is bool: - return ["b", value] - for value_type in type(value).__mro__: - if value_type is int: - result = int(value) - if abs(result) >= 2**127: - msg = "Out-of-range cursor integer" - raise ValueError(msg) - return ["i", result] - if value_type in _ENCODERS: - tag, encoder = _ENCODERS[value_type] - encoded = encoder(value) - if tag in {"f", "d", "td"}: - _DECODERS[tag](encoded) - return [tag, encoded] - msg = f"Cannot encode cursor value of type {type(value).__name__}; register it with register_cursor_type()" - raise TypeError(msg) - - -def _b64(value: bytes) -> str: - return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - - def encode_cursor( values: Sequence[Any], fingerprint: str, *, backward: bool = False, secret: bytes | None = None ) -> str: @@ -184,23 +123,6 @@ def encode_cursor( return token -def _decode_value(entry: Any) -> Any: - if not isinstance(entry, list) or len(entry) != _ENTRY_LENGTH or not isinstance(entry[0], str): - msg = "Invalid cursor entry" - raise ValueError(msg) - tag, value = entry - if tag == "n" and value is None: - return None - if tag == "b" and type(value) is bool: - return value - if tag == "i" and type(value) is int and abs(value) < 2**127: - return value - if tag in _DECODERS and isinstance(value, str): - return _DECODERS[tag](value) - msg = "Invalid cursor value" - raise ValueError(msg) - - def decode_cursor(token: object, fingerprint: str, key_count: int, *, secret: bytes | None = None) -> DecodedCursor: """Validate a cursor and recover its sort keys. @@ -261,3 +183,83 @@ def decode_cursor(token: object, fingerprint: str, key_count: int, *, secret: by msg = "Invalid pagination cursor: malformed token" raise InvalidCursorError(msg) from exc return DecodedCursor(values, payload["d"] == "b") + + +def _float(value: str) -> float: + result = float(value) + if not math.isfinite(result): + msg = "Non-finite cursor float" + raise ValueError(msg) + return result + + +def _decimal(value: str) -> Decimal: + result = Decimal(value) + if not result.is_finite() or abs(result.adjusted()) > _DECIMAL_MAX_EXPONENT: + msg = "Out-of-range cursor decimal" + raise ValueError(msg) + return result + + +def _timedelta(value: str) -> timedelta: + days, seconds, microseconds = (int(part) for part in value.split(":")) + if abs(days) > _TIMEDELTA_MAX_DAYS: + msg = "Out-of-range cursor timedelta" + raise ValueError(msg) + return timedelta(days=days, seconds=seconds, microseconds=microseconds) + + +_DECODERS: dict[str, Callable[[str], Any]] = { + "f": _float, + "s": str, + "y": lambda value: base64.b64decode(value, validate=True), + "d": _decimal, + "u": UUID, + "dt": datetime.fromisoformat, + "da": date.fromisoformat, + "t": time.fromisoformat, + "td": _timedelta, +} + + +def _encode_value(value: Any) -> list[Any]: + if value is None: + return ["n", None] + if type(value) is bool: + return ["b", value] + for value_type in type(value).__mro__: + if value_type is int: + result = int(value) + if abs(result) >= 2**127: + msg = "Out-of-range cursor integer" + raise ValueError(msg) + return ["i", result] + if value_type in _ENCODERS: + tag, encoder = _ENCODERS[value_type] + encoded = encoder(value) + if tag in {"f", "d", "td"}: + _DECODERS[tag](encoded) + return [tag, encoded] + msg = f"Cannot encode cursor value of type {type(value).__name__}; register it with register_cursor_type()" + raise TypeError(msg) + + +def _b64(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def _decode_value(entry: Any) -> Any: + if not isinstance(entry, list) or len(entry) != _ENTRY_LENGTH or not isinstance(entry[0], str): + msg = "Invalid cursor entry" + raise ValueError(msg) + tag, value = entry + if tag == "n" and value is None: + return None + if tag == "b" and type(value) is bool: + return value + if tag == "i" and type(value) is int and abs(value) < 2**127: + return value + if tag in _DECODERS and isinstance(value, str): + return _DECODERS[tag](value) + msg = "Invalid cursor value" + raise ValueError(msg) diff --git a/sqlspec/core/_operators.py b/sqlspec/core/_operators.py index d631dccc7..773e661ee 100644 --- a/sqlspec/core/_operators.py +++ b/sqlspec/core/_operators.py @@ -3,6 +3,8 @@ from sqlglot import exp from sqlglot.generator import Generator +__all__ = () + def _jsonb_top_key(generator: Generator, expression: exp.Binary) -> str: return generator.binary(expression, "??") diff --git a/sqlspec/core/_pipeline.py b/sqlspec/core/_pipeline.py index 040cd1322..d99706d57 100644 --- a/sqlspec/core/_pipeline.py +++ b/sqlspec/core/_pipeline.py @@ -29,12 +29,6 @@ DEFAULT_PIPELINE_COUNT: Final[int] = 32 -def _is_truthy(value: "str | None") -> bool: - return bool(value and value.strip().lower() in {"1", "true", "yes", "on"}) - - -_RECORD_PIPELINE_METRICS: Final[bool] = _is_truthy(os.environ.get(DEBUG_ENV_FLAG)) - _METRIC_KEYS: Final[tuple[str, ...]] = ( "hits", "misses", @@ -257,3 +251,10 @@ def metrics(self) -> "dict[str, int] | None": if self._metrics is None: return None return self._metrics.snapshot() + + +def _is_truthy(value: "str | None") -> bool: + return bool(value and value.strip().lower() in {"1", "true", "yes", "on"}) + + +_RECORD_PIPELINE_METRICS: Final[bool] = _is_truthy(os.environ.get(DEBUG_ENV_FLAG)) diff --git a/sqlspec/core/cache.py b/sqlspec/core/cache.py index 099e264ec..0dd9b9a05 100644 --- a/sqlspec/core/cache.py +++ b/sqlspec/core/cache.py @@ -86,11 +86,6 @@ def __init__(self, key_data: "tuple[Any, ...]") -> None: self._key_data = key_data self._hash = hash(key_data) - @property - def key_data(self) -> "tuple[Any, ...]": - """Get the key data tuple.""" - return self._key_data - def __hash__(self) -> int: """Return cached hash value.""" return self._hash @@ -108,6 +103,11 @@ def __repr__(self) -> str: """String representation of the cache key.""" return f"CacheKey({self._key_data!r})" + @property + def key_data(self) -> "tuple[Any, ...]": + """Get the key data tuple.""" + return self._key_data + @mypyc_attr(allow_interpreted_subclasses=False) class CacheStats: @@ -126,6 +126,14 @@ def __init__(self) -> None: self.total_operations = 0 self.memory_usage = 0 + def __repr__(self) -> str: + """String representation of cache statistics.""" + return ( + f"CacheStats(hit_rate={self.hit_rate:.1f}%, " + f"hits={self.hits}, misses={self.misses}, " + f"evictions={self.evictions}, ops={self.total_operations})" + ) + @property def hit_rate(self) -> float: """Calculate cache hit rate as percentage.""" @@ -159,14 +167,6 @@ def reset(self) -> None: self.total_operations = 0 self.memory_usage = 0 - def __repr__(self) -> str: - """String representation of cache statistics.""" - return ( - f"CacheStats(hit_rate={self.hit_rate:.1f}%, " - f"hits={self.hits}, misses={self.misses}, " - f"evictions={self.evictions}, ops={self.total_operations})" - ) - @mypyc_attr(allow_interpreted_subclasses=False) class CacheNode: @@ -224,6 +224,20 @@ def __init__( self._head.next = self._tail self._tail.prev = self._head + def __len__(self) -> int: + """Get current cache size.""" + return len(self._cache) + + def __contains__(self, key: CacheKey) -> bool: + """Check if key exists in cache.""" + with self._lock: + node = self._cache.get(key) + if node is None: + return False + + ttl = self._ttl + return not (ttl is not None and time.time() - node.timestamp > ttl) + def get(self, key: CacheKey) -> Any | None: """Get value from cache. @@ -373,20 +387,6 @@ def _move_to_head(self, node: CacheNode) -> None: self._remove_node(node) self._add_to_head(node) - def __len__(self) -> int: - """Get current cache size.""" - return len(self._cache) - - def __contains__(self, key: CacheKey) -> bool: - """Check if key exists in cache.""" - with self._lock: - node = self._cache.get(key) - if node is None: - return False - - ttl = self._ttl - return not (ttl is not None and time.time() - node.timestamp > ttl) - _default_cache: LRUCache | None = None _cache_lock = threading.Lock() @@ -685,72 +685,6 @@ def __init__(self, config: "CacheConfig | None" = None, ttl_seconds: int | None self._config = config or get_cache_config() self._caches = self._build_caches(self._config, ttl_seconds) - @staticmethod - def _build_caches(config: "CacheConfig", ttl_seconds: int | None) -> "dict[str, LRUCache]": - caches: dict[str, LRUCache] = {} - for namespace, (_, size_getter) in NAMESPACED_CACHE_CONFIG.items(): - size = size_getter(config) - caches[namespace] = LRUCache(size, ttl_seconds, namespace=namespace) - return caches - - def _is_enabled(self, namespace: str) -> bool: - if not self._config.compiled_cache_enabled: - return False - enabled_getter = NAMESPACED_CACHE_CONFIG[namespace][0] - return bool(enabled_getter(self._config)) - - def _get(self, namespace: str, key: str, dialect: str | None = None) -> Any | None: - """Get cached value by namespace. - - Args: - namespace: Cache namespace. - key: Cache key. - dialect: Optional SQL dialect. - - Returns: - Cached value or None if not found. - """ - if not self._is_enabled(namespace): - return None - cache = self._caches[namespace] - full_key = create_cache_key(namespace, key, dialect) - cache_key = CacheKey((full_key,)) - return cache.get(cache_key) - - def _put(self, namespace: str, key: str, value: Any, dialect: str | None = None) -> None: - """Put cached value by namespace. - - Args: - namespace: Cache namespace. - key: Cache key. - value: Value to cache. - dialect: Optional SQL dialect. - """ - if not self._is_enabled(namespace): - return - cache = self._caches[namespace] - full_key = create_cache_key(namespace, key, dialect) - cache_key = CacheKey((full_key,)) - cache.put(cache_key, value) - - def _delete(self, namespace: str, key: str, dialect: str | None = None) -> bool: - """Delete cached value by namespace. - - Args: - namespace: Cache namespace. - key: Cache key. - dialect: Optional SQL dialect. - - Returns: - True when the key was found and deleted. - """ - if not self._is_enabled(namespace): - return False - cache = self._caches[namespace] - full_key = create_cache_key(namespace, key, dialect) - cache_key = CacheKey((full_key,)) - return cache.delete(cache_key) - def get_statement(self, key: str, dialect: str | None = None) -> Any | None: """Get cached statement data. @@ -948,6 +882,72 @@ def get_stats(self) -> CacheStats: aggregated.memory_usage += stats.memory_usage return aggregated + @staticmethod + def _build_caches(config: "CacheConfig", ttl_seconds: int | None) -> "dict[str, LRUCache]": + caches: dict[str, LRUCache] = {} + for namespace, (_, size_getter) in NAMESPACED_CACHE_CONFIG.items(): + size = size_getter(config) + caches[namespace] = LRUCache(size, ttl_seconds, namespace=namespace) + return caches + + def _is_enabled(self, namespace: str) -> bool: + if not self._config.compiled_cache_enabled: + return False + enabled_getter = NAMESPACED_CACHE_CONFIG[namespace][0] + return bool(enabled_getter(self._config)) + + def _get(self, namespace: str, key: str, dialect: str | None = None) -> Any | None: + """Get cached value by namespace. + + Args: + namespace: Cache namespace. + key: Cache key. + dialect: Optional SQL dialect. + + Returns: + Cached value or None if not found. + """ + if not self._is_enabled(namespace): + return None + cache = self._caches[namespace] + full_key = create_cache_key(namespace, key, dialect) + cache_key = CacheKey((full_key,)) + return cache.get(cache_key) + + def _put(self, namespace: str, key: str, value: Any, dialect: str | None = None) -> None: + """Put cached value by namespace. + + Args: + namespace: Cache namespace. + key: Cache key. + value: Value to cache. + dialect: Optional SQL dialect. + """ + if not self._is_enabled(namespace): + return + cache = self._caches[namespace] + full_key = create_cache_key(namespace, key, dialect) + cache_key = CacheKey((full_key,)) + cache.put(cache_key, value) + + def _delete(self, namespace: str, key: str, dialect: str | None = None) -> bool: + """Delete cached value by namespace. + + Args: + namespace: Cache namespace. + key: Cache key. + dialect: Optional SQL dialect. + + Returns: + True when the key was found and deleted. + """ + if not self._is_enabled(namespace): + return False + cache = self._caches[namespace] + full_key = create_cache_key(namespace, key, dialect) + cache_key = CacheKey((full_key,)) + return cache.delete(cache_key) + _namespaced_cache: NamespacedCache | None = None @@ -1106,7 +1106,6 @@ def _canonicalize_cache_filters(filters: "list[Filter]") -> "tuple[Filter, ...]" if not filters: return () - # Deduplicate and sort for canonical representation unique_filters = set(filters) return tuple(sorted(unique_filters, key=_filter_sort_key)) diff --git a/sqlspec/core/compiler.py b/sqlspec/core/compiler.py index 42744b3f0..aedc4a8e1 100644 --- a/sqlspec/core/compiler.py +++ b/sqlspec/core/compiler.py @@ -61,19 +61,16 @@ ] OPERATION_TYPE_MAP: Final[dict[type[exp.Expr], OperationType]] = { - # Queries exp.Select: "SELECT", exp.Union: "SELECT", exp.Except: "SELECT", exp.Intersect: "SELECT", - exp.Summarize: "SELECT", # DuckDB SUMMARIZE — returns rows - # DML + exp.Summarize: "SELECT", exp.Insert: "INSERT", exp.Update: "UPDATE", exp.Delete: "DELETE", exp.Merge: "MERGE", - exp.LoadData: "INSERT", # Hive LOAD DATA — ingests data into a table - # DDL + exp.LoadData: "INSERT", exp.Create: "DDL", exp.Drop: "DDL", exp.Alter: "DDL", @@ -81,21 +78,17 @@ exp.Grant: "DDL", exp.Revoke: "DDL", exp.Comment: "DDL", - # Procedure execution - exp.Execute: "EXECUTE", # TSQL EXEC/EXECUTE - # Session / Admin + exp.Execute: "EXECUTE", exp.Pragma: "PRAGMA", exp.Command: "COMMAND", exp.Set: "COMMAND", - exp.Show: "COMMAND", # MySQL dialect + exp.Show: "COMMAND", exp.Describe: "COMMAND", exp.Use: "COMMAND", exp.Kill: "COMMAND", exp.Analyze: "COMMAND", - exp.Install: "COMMAND", # DuckDB - exp.Attach: "COMMAND", # DuckDB / SQLite - # Transaction control — mapped to COMMAND for now; a dedicated "TRANSACTION" - # OperationType could be added if downstream code needs to distinguish these. + exp.Install: "COMMAND", + exp.Attach: "COMMAND", exp.Transaction: "COMMAND", exp.Commit: "COMMAND", exp.Rollback: "COMMAND", @@ -165,13 +158,13 @@ def __init__(self, returns_rows: bool = False, modifies_rows: bool = False) -> N self.returns_rows = returns_rows self.modifies_rows = modifies_rows + def __repr__(self) -> str: + return f"OperationProfile(returns_rows={self.returns_rows!r}, modifies_rows={self.modifies_rows!r})" + @classmethod def empty(cls) -> "OperationProfile": return cls(returns_rows=False, modifies_rows=False) - def __repr__(self) -> str: - return f"OperationProfile(returns_rows={self.returns_rows!r}, modifies_rows={self.modifies_rows!r})" - @final @mypyc_attr(allow_interpreted_subclasses=False) @@ -355,6 +348,40 @@ def __init__( default_execution_style = parameter_config.default_execution_parameter_style self._exec_style = default_execution_style.value if default_execution_style else self._input_style + @property + def cache_stats(self) -> "dict[str, int]": + """Get cache statistics. + + Returns: + Dictionary with cache statistics + """ + total_requests = self._cache_hits + self._cache_misses + hit_rate_pct = int((self._cache_hits / total_requests) * 100) if total_requests > 0 else 0 + parse_total = self._parse_cache_hits + self._parse_cache_misses + parse_hit_rate_pct = int((self._parse_cache_hits / parse_total) * 100) if parse_total > 0 else 0 + parameter_stats = self._parameter_processor.cache_stats() + + return { + "hits": self._cache_hits, + "misses": self._cache_misses, + "size": len(self._cache), + "max_size": self._max_cache_size, + "hit_rate_percent": hit_rate_pct, + "parse_hits": self._parse_cache_hits, + "parse_misses": self._parse_cache_misses, + "parse_size": len(self._parse_cache), + "parse_max_size": self._parse_cache_max_size, + "parse_hit_rate_percent": parse_hit_rate_pct, + "parameter_hits": parameter_stats["hits"], + "parameter_misses": parameter_stats["misses"], + "parameter_size": parameter_stats["size"], + "parameter_max_size": parameter_stats["max_size"], + "validator_hits": parameter_stats["validator_hits"], + "validator_misses": parameter_stats["validator_misses"], + "validator_size": parameter_stats["validator_size"], + "validator_max_size": parameter_stats["validator_max_size"], + } + def compile( self, sql: str, @@ -375,8 +402,6 @@ def compile( Returns: CompiledSQL with execution information """ - # Parameter AST transforms may remove or inline values. Their final SQL - # cannot be rebound from a structural parameter fingerprint alone. if not self._cache_enabled or self._parameter_config.ast_transformer is not None: return self._apply_dynamic_sqlcommenter( self._compile_uncached(sql, parameters, is_many, expression, param_fingerprint=None) @@ -384,9 +409,8 @@ def compile( if param_fingerprint is None: param_fingerprint = self._parameter_fingerprint(parameters, is_many) - cache_key = self._make_cache_key(sql, param_fingerprint, is_many) + cache_key = self._make_cache_key(sql, param_fingerprint, is_many, expression=expression) - # MICRO-CACHE: Fast path for repeating statements if cache_key == self._last_cache_key and self._last_result is not None: self._cache_hits += 1 return self._apply_dynamic_sqlcommenter( @@ -408,11 +432,40 @@ def compile( if len(self._cache) >= self._max_cache_size: self._cache.popitem(last=False) - self._cache[cache_key] = result + cached_entry = ( + CompiledSQL( + compiled_sql=result.compiled_sql, + execution_parameters=result.execution_parameters, + operation_type=result.operation_type, + expression=result.expression.copy(), + parameter_style=result.parameter_style, + supports_many=result.supports_many, + parameter_casts=result.parameter_casts, + parameter_profile=result.parameter_profile, + operation_profile=result.operation_profile, + input_named_parameters=result.input_named_parameters, + applied_wrap_types=result.applied_wrap_types, + ) + if result.expression is not None + else result + ) + self._cache[cache_key] = cached_entry self._last_cache_key = cache_key - self._last_result = result + self._last_result = cached_entry return self._apply_dynamic_sqlcommenter(result) + def clear_cache(self) -> None: + """Clear compilation cache and reset statistics.""" + self._cache.clear() + self._cache_hits = 0 + self._cache_misses = 0 + self._last_cache_key = None + self._last_result = None + self._parse_cache.clear() + self._parse_cache_hits = 0 + self._parse_cache_misses = 0 + self._parameter_processor.clear_cache() + def _has_dynamic_sqlcommenter(self) -> bool: """Return whether SQLCommenter resolves per-call context during compilation.""" return bool( @@ -438,7 +491,7 @@ def _apply_dynamic_sqlcommenter(self, result: CompiledSQL) -> CompiledSQL: compiled_sql=compiled_sql, execution_parameters=result.execution_parameters, operation_type=result.operation_type, - expression=result.expression, + expression=result.expression.copy() if result.expression is not None else None, parameter_style=result.parameter_style, supports_many=result.supports_many, parameter_casts=result.parameter_casts, @@ -450,11 +503,24 @@ def _apply_dynamic_sqlcommenter(self, result: CompiledSQL) -> CompiledSQL: def _materialize_cached_result(self, cached_result: CompiledSQL, parameters: Any, is_many: bool) -> CompiledSQL: """Return cached compilation metadata with parameters for the current call.""" + cloned_expr = cached_result.expression.copy() if cached_result.expression is not None else None if self._parameter_config.needs_static_script_compilation: - return cached_result + if cloned_expr is None: + return cached_result + return CompiledSQL( + compiled_sql=cached_result.compiled_sql, + execution_parameters=cached_result.execution_parameters, + operation_type=cached_result.operation_type, + expression=cloned_expr, + parameter_style=cached_result.parameter_style, + supports_many=cached_result.supports_many, + parameter_casts=cached_result.parameter_casts, + parameter_profile=cached_result.parameter_profile, + operation_profile=cached_result.operation_profile, + input_named_parameters=cached_result.input_named_parameters, + applied_wrap_types=cached_result.applied_wrap_types, + ) - # Structural fingerprinting means same SQL structure = same cache entry, - # but we must still process the caller's actual parameter values. processed_params = self._parameter_processor.transform_cached_parameters( parameters, cached_result.parameter_profile, @@ -473,7 +539,7 @@ def _materialize_cached_result(self, cached_result: CompiledSQL, parameters: Any compiled_sql=final_sql, execution_parameters=processed_params, operation_type=cached_result.operation_type, - expression=cached_result.expression, + expression=cloned_expr, parameter_style=cached_result.parameter_style, supports_many=cached_result.supports_many, parameter_casts=cached_result.parameter_casts, @@ -552,9 +618,8 @@ def _store_parse_cache( """ if len(self._parse_cache) >= self._parse_cache_max_size: self._parse_cache.popitem(last=False) - # Store expression reference directly; _unpack_parse_cache_entry returns the same object. self._parse_cache[parse_cache_key] = ( - expression, + expression.copy() if expression is not None else None, operation_type, (operation_profile.returns_rows, operation_profile.modifies_rows), ) @@ -572,11 +637,12 @@ def _unpack_parse_cache_entry( Parsed expression metadata. """ cached_expression, cached_operation, cached_profile = parse_cache_entry - # Return expression reference without copying - _apply_ast_transformers will copy - # if transformers are configured and will modify it. This avoids unnecessary copies - # when no transformers are active (common case). operation_profile = OperationProfile(returns_rows=cached_profile[0], modifies_rows=cached_profile[1]) - return cached_expression, cached_operation, operation_profile + return ( + cached_expression.copy() if cached_expression is not None else None, + cached_operation, + operation_profile, + ) def _resolve_expression( self, sqlglot_sql: str, dialect_str: "str | None", expression_override: "exp.Expr | None" @@ -593,7 +659,7 @@ def _resolve_expression( """ parse_cache_key = None parse_cache_entry = None - if self._parse_cache_max_size > 0: + if self._parse_cache_max_size > 0 and expression_override is None: parse_cache_key = SQLProcessor._make_parse_cache_key(sqlglot_sql, dialect_str) parse_cache_entry = self._parse_cache.get(parse_cache_key) if parse_cache_entry is not None: @@ -644,12 +710,8 @@ def _apply_ast_transformers( if expression is None or (not statement_transformers and not ast_transformer): return expression, parameters, False, operation_type, operation_profile - # Must copy the expression before transformers modify it to avoid corrupting: - # 1. Cache entries (for both cache hits and misses that will be cached) - # 2. User-provided expression_override references should_copy = False if parse_cache_key is not None: - # Either cache miss (will be stored) or cache hit (reference from cache) should_copy = True if expression_override is not None and expression is expression_override: should_copy = True @@ -711,7 +773,6 @@ def _finalize_compilation( if self._parameter_config.needs_static_script_compilation and processed_params is None: return processed_sql, processed_params, parameter_profile, (), False if ast_was_transformed and expression is not None: - # Pass the transformed expression through the pipeline to avoid re-parsing transformed_result = self._parameter_processor.process_for_execution( sql=expression.sql(dialect=dialect_str), parameters=parameters, @@ -891,19 +952,23 @@ def _parameter_fingerprint(self, parameters: Any, is_many: bool) -> Any: return value_fingerprint(parameters) return structural_fingerprint(parameters, is_many) - def _make_cache_key(self, sql: str, param_fingerprint: Any, is_many: bool = False) -> tuple[Any, ...]: + def _make_cache_key( + self, sql: str, param_fingerprint: Any, is_many: bool = False, expression: "exp.Expr | None" = None + ) -> tuple[Any, ...]: """Generate cache key. Args: sql: SQL string param_fingerprint: Precomputed parameter fingerprint is_many: Whether this is for execute_many operation + expression: Optional pre-parsed SQLGlot expression override Returns: Cache key tuple """ + expr_key = hash(expression) if expression is not None else None return _make_cache_key_tuple( - sql, param_fingerprint, self._input_style, self._exec_style, self._dialect_str, is_many + sql, param_fingerprint, self._input_style, self._exec_style, self._dialect_str, is_many, extra_key=expr_key ) @staticmethod @@ -929,8 +994,6 @@ def _operation_type(expression: "exp.Expr") -> "OperationType": return "COPY_TO" return "COPY" - # COMMAND is the generic fallback for any database command - # that doesn't fit specific categories return "COMMAND" @staticmethod @@ -958,14 +1021,12 @@ def _parameter_casts(expression: "exp.Expr | None") -> "dict[int, str]": position = None if isinstance(cast_target, exp.Parameter): - # Handle $1, $2 style parameters param_value = cast_target.this if isinstance(param_value, exp.Literal): position = int(param_value.this) elif isinstance(cast_target, exp.Placeholder): position = _placeholder_position(cast_target, placeholder_positions, placeholder_counter) elif isinstance(cast_target, exp.Column): - # Handle cases where $1 gets parsed as a column column_name = str(cast_target.this) if cast_target.this else str(cast_target) if column_name.startswith("$") and column_name[1:].isdigit(): position = int(column_name[1:]) @@ -1085,58 +1146,11 @@ def _statement_text_returns_rows(sql: str) -> bool: return parts[0].upper() in ROW_RETURNING_STATEMENT_KEYWORDS - def clear_cache(self) -> None: - """Clear compilation cache and reset statistics.""" - self._cache.clear() - self._cache_hits = 0 - self._cache_misses = 0 - self._last_cache_key = None - self._last_result = None - self._parse_cache.clear() - self._parse_cache_hits = 0 - self._parse_cache_misses = 0 - self._parameter_processor.clear_cache() - @staticmethod def _make_parse_cache_key(sql: str, dialect: "str | None") -> Any: dialect_marker = dialect or "default" - # Use tuple as cache key instead of hashing. Python handles tuple keys efficiently. return (dialect_marker, sql) - @property - def cache_stats(self) -> "dict[str, int]": - """Get cache statistics. - - Returns: - Dictionary with cache statistics - """ - total_requests = self._cache_hits + self._cache_misses - hit_rate_pct = int((self._cache_hits / total_requests) * 100) if total_requests > 0 else 0 - parse_total = self._parse_cache_hits + self._parse_cache_misses - parse_hit_rate_pct = int((self._parse_cache_hits / parse_total) * 100) if parse_total > 0 else 0 - parameter_stats = self._parameter_processor.cache_stats() - - return { - "hits": self._cache_hits, - "misses": self._cache_misses, - "size": len(self._cache), - "max_size": self._max_cache_size, - "hit_rate_percent": hit_rate_pct, - "parse_hits": self._parse_cache_hits, - "parse_misses": self._parse_cache_misses, - "parse_size": len(self._parse_cache), - "parse_max_size": self._parse_cache_max_size, - "parse_hit_rate_percent": parse_hit_rate_pct, - "parameter_hits": parameter_stats["hits"], - "parameter_misses": parameter_stats["misses"], - "parameter_size": parameter_stats["size"], - "parameter_max_size": parameter_stats["max_size"], - "validator_hits": parameter_stats["validator_hits"], - "validator_misses": parameter_stats["validator_misses"], - "validator_size": parameter_stats["validator_size"], - "validator_max_size": parameter_stats["validator_max_size"], - } - def _placeholder_position( placeholder: "exp.Placeholder", placeholder_positions: "dict[str, int]", placeholder_counter: "list[int]" @@ -1161,13 +1175,11 @@ def _placeholder_position( def _parameters_empty(value: Any) -> bool: if value is None: return True - # Fast type dispatch: check concrete types first value_type = type(value) if value_type is dict or value_type is list or value_type is tuple: return len(value) == 0 if value_type is set or value_type is frozenset: return len(value) == 0 - # Fallback to ABC check for custom Mapping types if isinstance(value, Mapping): return len(value) == 0 return False diff --git a/sqlspec/core/correlation.py b/sqlspec/core/correlation.py index f85237ef1..a6a1ca3ca 100644 --- a/sqlspec/core/correlation.py +++ b/sqlspec/core/correlation.py @@ -86,6 +86,17 @@ def __init__( self._headers = tuple(dict.fromkeys(headers)) self._max_length = max_length if max_length is not None else self.DEFAULT_MAX_LENGTH + def __repr__(self) -> str: + return f"CorrelationExtractor(headers={self._headers!r}, max_length={self._max_length!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, CorrelationExtractor): + return NotImplemented + return self._headers == other._headers and self._max_length == other._max_length + + def __hash__(self) -> int: + return hash((self._headers, self._max_length)) + @property def headers(self) -> tuple[str, ...]: """Get the ordered list of headers to check.""" @@ -132,14 +143,3 @@ def _sanitize(self, value: str) -> str: """ sanitized = value.strip()[: self._max_length] return sanitized or CorrelationContext.generate() - - def __repr__(self) -> str: - return f"CorrelationExtractor(headers={self._headers!r}, max_length={self._max_length!r})" - - def __eq__(self, other: object) -> bool: - if not isinstance(other, CorrelationExtractor): - return NotImplemented - return self._headers == other._headers and self._max_length == other._max_length - - def __hash__(self) -> int: - return hash((self._headers, self._max_length)) diff --git a/sqlspec/core/filters.py b/sqlspec/core/filters.py index 0a3f80c50..7a3e76c37 100644 --- a/sqlspec/core/filters.py +++ b/sqlspec/core/filters.py @@ -85,6 +85,20 @@ class StatementFilter: _is_statement_filter: bool = True + def __reduce__(self) -> "tuple[Any, ...]": + """Reconstruct via ``cls(*ctor_args)`` — works on mypyc native classes.""" + return (type(self), self._reconstruction_args()) + + def __eq__(self, other: object) -> bool: + """Compare filters by their cache key for round-trip and deduplication checks.""" + if not isinstance(other, StatementFilter): + return NotImplemented + return self.get_cache_key() == other.get_cache_key() + + def __hash__(self) -> int: + """Hash via the canonical filter key so unhashable members are normalized.""" + return hash(_canonical_filter_key(self)) + @abstractmethod def append_to_statement(self, statement: "SQL") -> "SQL": """Append the filter to the statement. @@ -107,6 +121,14 @@ def extract_parameters(self) -> "tuple[list[Any], dict[str, Any]]": """ return [], {} + @abstractmethod + def get_cache_key(self) -> "tuple[Any, ...]": + """Return a cache key for this filter's configuration. + + Returns: + Tuple of hashable values representing the filter's configuration + """ + def _resolve_parameter_conflicts(self, statement: "SQL", proposed_names: "list[str]") -> "list[str]": """Resolve parameter name conflicts. @@ -171,28 +193,6 @@ def _reconstruction_args(self) -> "tuple[Any, ...]": ) raise NotImplementedError(msg) - def __reduce__(self) -> "tuple[Any, ...]": - """Reconstruct via ``cls(*ctor_args)`` — works on mypyc native classes.""" - return (type(self), self._reconstruction_args()) - - def __eq__(self, other: object) -> bool: - """Compare filters by their cache key for round-trip and deduplication checks.""" - if not isinstance(other, StatementFilter): - return NotImplemented - return self.get_cache_key() == other.get_cache_key() - - def __hash__(self) -> int: - """Hash via the canonical filter key so unhashable members are normalized.""" - return hash(_canonical_filter_key(self)) - - @abstractmethod - def get_cache_key(self) -> "tuple[Any, ...]": - """Return a cache key for this filter's configuration. - - Returns: - Tuple of hashable values representing the filter's configuration - """ - class _DatetimeBoundFilter(StatementFilter): """Private base for datetime lower/upper-bound filters.""" @@ -383,19 +383,6 @@ def extract_parameters(self) -> "tuple[list[Any], dict[str, Any]]": named_parameters[param_names[i]] = value return [], named_parameters - def _build_collection_condition(self, col_expr: exp.Expr, placeholders: "list[exp.Expr]") -> exp.Expr: - condition_type = self._condition_type - if condition_type == "in": - return exp.In(this=col_expr, expressions=placeholders) - if condition_type == "not_in": - return exp.Not(this=exp.In(this=col_expr, expressions=placeholders)) - - array_expr = exp.Array(expressions=placeholders) - condition = exp.EQ(this=col_expr, expression=exp.Any(this=array_expr)) - if condition_type == "not_any": - return exp.Not(this=condition) - return condition - def append_to_statement(self, statement: "SQL") -> "SQL": values = self.values if values is None: @@ -418,6 +405,19 @@ def get_cache_key(self) -> "tuple[Any, ...]": values_tuple = tuple(self.values) if self.values is not None else None return (self._cache_key_name, self.field_name, values_tuple) + def _build_collection_condition(self, col_expr: exp.Expr, placeholders: "list[exp.Expr]") -> exp.Expr: + condition_type = self._condition_type + if condition_type == "in": + return exp.In(this=col_expr, expressions=placeholders) + if condition_type == "not_in": + return exp.Not(this=exp.In(this=col_expr, expressions=placeholders)) + + array_expr = exp.Array(expressions=placeholders) + condition = exp.EQ(this=col_expr, expression=exp.Any(this=array_expr)) + if condition_type == "not_any": + return exp.Not(this=condition) + return condition + def _reconstruction_args(self) -> "tuple[Any, ...]": return (self._field_name, self._values) @@ -597,6 +597,20 @@ def __init__( self._nulls = nulls self._result_name = name + def __eq__(self, other: object) -> bool: + if not isinstance(other, CursorKey): + return NotImplemented + return self._identity() == other._identity() + + def __hash__(self) -> int: + return hash(self._identity()) + + def __repr__(self) -> str: + return f"CursorKey({self._field_name!r}, {self._sort_order!r}, nulls={self._nulls!r}, result_name={self._result_name!r})" + + def __reduce__(self) -> tuple[Any, ...]: + return CursorKey, self._identity() + @property def field_name(self) -> str: return self._field_name @@ -613,34 +627,10 @@ def nulls(self) -> Literal["first", "last"] | None: def result_name(self) -> str: return self._result_name - def __eq__(self, other: object) -> bool: - if not isinstance(other, CursorKey): - return NotImplemented - return self._identity() == other._identity() - - def __hash__(self) -> int: - return hash(self._identity()) - - def __repr__(self) -> str: - return f"CursorKey({self._field_name!r}, {self._sort_order!r}, nulls={self._nulls!r}, result_name={self._result_name!r})" - - def __reduce__(self) -> tuple[Any, ...]: - return CursorKey, self._identity() - def _identity(self) -> tuple[str, str, str | None, str]: return self._field_name, self._sort_order, self._nulls, self._result_name -def _fold_or(a: exp.Expr | bool, b: exp.Expr | bool) -> exp.Expr | bool: - if a is True or b is True: - return True - if a is False: - return b - if b is False: - return a - return exp.Paren(this=exp.Or(this=a, expression=b)) - - _CURSOR_KEY_PAIR_LENGTH = 2 CursorKeys: TypeAlias = str | abc.Sequence[CursorKey | str | tuple[str, Literal["asc", "desc"]]] @@ -676,16 +666,6 @@ def normalize_cursor_keys(keys: CursorKeys) -> tuple[CursorKey, ...]: return tuple(normalized) -def _fold_and(a: exp.Expr | bool, b: exp.Expr | bool) -> exp.Expr | bool: - if a is False or b is False: - return False - if a is True: - return b - if b is True: - return a - return exp.And(this=a, expression=b) - - class CursorFilter(PaginationFilter): """Paginate by ordered keys whose last key uniquely identifies each row. @@ -838,20 +818,6 @@ def encode(self, row: abc.Mapping[str, Any], *, backward: bool) -> str: """ return encode_cursor(self._row_values(row), self._fingerprint, backward=backward, secret=self._secret) - def _row_values(self, row: abc.Mapping[str, Any]) -> list[Any]: - values = [] - for key in self._keys: - try: - value = row[key.result_name] - except KeyError as exc: - msg = f"Cursor key column '{key.result_name}' is not present in the result row; select it or set CursorKey.result_name" - raise ImproperConfigurationError(msg) from exc - if value is None and key.nulls is None: - msg = f"Cursor key '{key.field_name}' returned NULL; declare nulls='first' or nulls='last'" - raise ImproperConfigurationError(msg) - values.append(value) - return values - def build_page(self, rows: list[dict[str, Any]]) -> CursorPagination[dict[str, Any]]: """Trim the sentinel and mint adjacent-page cursors. @@ -882,9 +848,6 @@ def get_cache_key(self) -> tuple[Any, ...]: """Return SQL-affecting configuration without the signing secret.""" return ("CursorFilter", tuple(key._identity() for key in self._keys), self._limit, self._cursor) - def _reconstruction_args(self) -> tuple[Any, ...]: - return self._keys, self._limit, self._cursor, self._secret - def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]: """Return the proposed names and values for cursor parameters.""" parameters: dict[str, Any] = {"cursor_limit": self._limit + 1} @@ -894,6 +857,23 @@ def extract_parameters(self) -> tuple[list[Any], dict[str, Any]]: }) return [], parameters + def _row_values(self, row: abc.Mapping[str, Any]) -> list[Any]: + values = [] + for key in self._keys: + try: + value = row[key.result_name] + except KeyError as exc: + msg = f"Cursor key column '{key.result_name}' is not present in the result row; select it or set CursorKey.result_name" + raise ImproperConfigurationError(msg) from exc + if value is None and key.nulls is None: + msg = f"Cursor key '{key.field_name}' returned NULL; declare nulls='first' or nulls='last'" + raise ImproperConfigurationError(msg) + values.append(value) + return values + + def _reconstruction_args(self) -> tuple[Any, ...]: + return self._keys, self._limit, self._cursor, self._secret + class OrderByFilter(StatementFilter): """Filter for ORDER BY clauses. @@ -1311,6 +1291,26 @@ def canonicalize_filters(filters: "abc.Sequence[StatementFilter]") -> "tuple[Sta return tuple(sorted(unique_filters.values(), key=_filter_sort_key)) +def _fold_or(a: exp.Expr | bool, b: exp.Expr | bool) -> exp.Expr | bool: + if a is True or b is True: + return True + if a is False: + return b + if b is False: + return a + return exp.Paren(this=exp.Or(this=a, expression=b)) + + +def _fold_and(a: exp.Expr | bool, b: exp.Expr | bool) -> exp.Expr | bool: + if a is False or b is False: + return False + if a is True: + return b + if b is True: + return a + return exp.And(this=a, expression=b) + + def _filter_sort_key(f: "StatementFilter") -> "tuple[str, str, str]": """Sort key for canonicalizing filters by type, field name, and value.""" class_name = type(f).__name__ diff --git a/sqlspec/core/hashing.py b/sqlspec/core/hashing.py index 5e3393def..daa531897 100644 --- a/sqlspec/core/hashing.py +++ b/sqlspec/core/hashing.py @@ -125,8 +125,7 @@ def hash_parameters( if isinstance(original_parameters, list): param_hash ^= hash(("original_count", len(original_parameters))) if original_parameters: - sample_size = min(3, len(original_parameters)) - sample_hash = hash(repr(original_parameters[:sample_size])) + sample_hash = hash(repr(original_parameters)) param_hash ^= hash(("original_sample", sample_hash)) else: param_hash ^= hash(("original", repr(original_parameters))) @@ -176,6 +175,7 @@ def hash_sql_statement(statement: "SQL") -> str: hash(statement.dialect), hash(statement.is_many), hash(statement.is_script), + hash(statement.statement_config), ] return f"sql:{hash(tuple(state_components))}" diff --git a/sqlspec/core/parameters/_alignment.py b/sqlspec/core/parameters/_alignment.py index 280edbb21..9966d6708 100644 --- a/sqlspec/core/parameters/_alignment.py +++ b/sqlspec/core/parameters/_alignment.py @@ -152,7 +152,6 @@ def _expected_identifiers(parameter_profile: "ParameterProfile") -> "set[tuple[s if not parameters: return identifiers - # Check if we have mixed styles - if so, use ordinal-based counting for positional styles styles = {p.style for p in parameters} has_mixed_positional_styles = len(styles) > 1 and any( s in styles for s in {ParameterStyle.QMARK, ParameterStyle.POSITIONAL_PYFORMAT} @@ -164,7 +163,6 @@ def _expected_identifiers(parameter_profile: "ParameterProfile") -> "set[tuple[s if style in _NAMED_STYLES: identifiers.add(("named", name or f"param_{parameter.ordinal}")) elif style in {ParameterStyle.NUMERIC, ParameterStyle.POSITIONAL_COLON}: - # When mixed with ordinal styles (like QMARK), use ordinal instead of explicit index if has_mixed_positional_styles: identifiers.add(("index", parameter.ordinal)) elif name and name.isdigit(): diff --git a/sqlspec/core/parameters/_converter.py b/sqlspec/core/parameters/_converter.py index e6d95f1ba..314586357 100644 --- a/sqlspec/core/parameters/_converter.py +++ b/sqlspec/core/parameters/_converter.py @@ -102,6 +102,17 @@ def convert_placeholder_style( ) return converted_sql, converted_parameters + def convert_parameter_info_style( + self, + param_info: "list[ParameterInfo]", + target_style: "ParameterStyle", + precomputed_plan: "tuple[list[ParameterInfo], dict[str, int]] | None" = None, + ) -> "list[ParameterInfo]": + _, converted_param_info = self._render_conversion( + "", param_info, target_style, precomputed_plan, collect_metadata=True + ) + return converted_param_info + def _build_conversion_plan( self, param_info: "list[ParameterInfo]", target_style: "ParameterStyle" ) -> "tuple[list[ParameterInfo], dict[str, int]]": @@ -176,17 +187,6 @@ def _render_conversion( segments.append(sql[last_end:]) return "".join(segments), converted_param_info - def convert_parameter_info_style( - self, - param_info: "list[ParameterInfo]", - target_style: "ParameterStyle", - precomputed_plan: "tuple[list[ParameterInfo], dict[str, int]] | None" = None, - ) -> "list[ParameterInfo]": - _, converted_param_info = self._render_conversion( - "", param_info, target_style, precomputed_plan, collect_metadata=True - ) - return converted_param_info - def _convert_builtin( self, sql: str, @@ -433,10 +433,8 @@ def _convert_parameter_format( preserve_original_batch: bool = False, ) -> "ConvertedParameters": if not parameters or not param_info: - # When parameters is falsy, it's either None or empty - return None if parameters is None: return None - # For empty containers, convert to concrete type if isinstance(parameters, Mapping): return dict(parameters) if isinstance(parameters, (list, tuple)): @@ -528,7 +526,6 @@ def _convert_parameter_format( return param_values - # Fallback for non-standard parameters - return None return None @staticmethod @@ -626,6 +623,41 @@ def _parameter_value( raise SQLSpecError(msg) +def ambiguous_index_placeholder(param_info: "list[ParameterInfo]") -> "ParameterInfo | None": + """Return the first ``$n`` / ``:n`` placeholder whose written index is not its slot. + + Only statements that mix index placeholders with another placeholder style are + inspected. A slot is the rank of a placeholder among the distinct placeholders of + the statement, which is the position a sequence value is bound to. + """ + has_index = False + has_other = False + for param in param_info: + if _explicit_index(param) >= 0: + has_index = True + else: + has_other = True + if not (has_index and has_other): + return None + slots: dict[str, int] = {} + for param in _ordered_parameter_info(param_info): + key = _parameter_lookup_key(param) + if key not in slots: + slots[key] = len(slots) + written = _explicit_index(param) + if written >= 0 and written != slots[key]: + return param + return None + + +def ambiguous_index_message(param: "ParameterInfo") -> str: + return ( + f"Ambiguous positional binding: index placeholder {param.placeholder_text!r} is mixed with other " + "placeholder styles and its index does not match its position among the statement's placeholders. " + "Use a single placeholder style, number index placeholders by position, or pass a mapping." + ) + + def _placeholder_qmark(_: Any) -> str: return "?" @@ -745,41 +777,6 @@ def _named_placeholder_keys(param_info: "list[ParameterInfo]") -> "set[str]": return keys -def ambiguous_index_placeholder(param_info: "list[ParameterInfo]") -> "ParameterInfo | None": - """Return the first ``$n`` / ``:n`` placeholder whose written index is not its slot. - - Only statements that mix index placeholders with another placeholder style are - inspected. A slot is the rank of a placeholder among the distinct placeholders of - the statement, which is the position a sequence value is bound to. - """ - has_index = False - has_other = False - for param in param_info: - if _explicit_index(param) >= 0: - has_index = True - else: - has_other = True - if not (has_index and has_other): - return None - slots: dict[str, int] = {} - for param in _ordered_parameter_info(param_info): - key = _parameter_lookup_key(param) - if key not in slots: - slots[key] = len(slots) - written = _explicit_index(param) - if written >= 0 and written != slots[key]: - return param - return None - - -def ambiguous_index_message(param: "ParameterInfo") -> str: - return ( - f"Ambiguous positional binding: index placeholder {param.placeholder_text!r} is mixed with other " - "placeholder styles and its index does not match its position among the statement's placeholders. " - "Use a single placeholder style, number index placeholders by position, or pass a mapping." - ) - - def _sequence_to_positional( parameters: "Sequence[Any]", param_info: "list[ParameterInfo]", target_style: ParameterStyle ) -> "list[Any] | tuple[Any, ...]": diff --git a/sqlspec/core/parameters/_declared.py b/sqlspec/core/parameters/_declared.py index ebafbbd83..8b6ae92dc 100644 --- a/sqlspec/core/parameters/_declared.py +++ b/sqlspec/core/parameters/_declared.py @@ -28,43 +28,6 @@ _JSON_VALUE_TYPES: Final[tuple[type, ...]] = (dict, list, str, int, float, bool) -def _is_json_value(value: object) -> bool: - """Return whether a value can be encoded by SQLSpec's JSON serializer.""" - if not isinstance(value, _JSON_VALUE_TYPES): - return False - try: - to_json(value) - except (TypeError, ValueError): - return False - return True - - -_TYPE_REGISTRY: Final[dict[str, ParamTypeMatcher]] = { - "str": str, - "int": int, - "float": float, - "bool": bool, - "bytes": bytes, - "date": date, - "datetime": datetime, - "time": time, - "decimal": Decimal, - "uuid": UUID, - "uuid.uuid": UUID, - "dict": dict, - "dict[str,any]": dict, - "dict[str,object]": dict, - "json": _is_json_value, - "jsonb": _is_json_value, - "list[int]": list, - "list[str]": list, - "list[float]": list, - "list[bool]": list, - "list": list, - "tuple": tuple, -} - - class ParameterDeclaration: """A single parameter declared in a SQL file header.""" @@ -138,6 +101,43 @@ def matches_param_type(type_str: str, value: object) -> bool: return resolved(value) +def _is_json_value(value: object) -> bool: + """Return whether a value can be encoded by SQLSpec's JSON serializer.""" + if not isinstance(value, _JSON_VALUE_TYPES): + return False + try: + to_json(value) + except (TypeError, ValueError): + return False + return True + + +_TYPE_REGISTRY: Final[dict[str, ParamTypeMatcher]] = { + "str": str, + "int": int, + "float": float, + "bool": bool, + "bytes": bytes, + "date": date, + "datetime": datetime, + "time": time, + "decimal": Decimal, + "uuid": UUID, + "uuid.uuid": UUID, + "dict": dict, + "dict[str,any]": dict, + "dict[str,object]": dict, + "json": _is_json_value, + "jsonb": _is_json_value, + "list[int]": list, + "list[str]": list, + "list[float]": list, + "list[bool]": list, + "list": list, + "tuple": tuple, +} + + def _normalize_type_key(type_str: str) -> str: """Normalize a declared type string to its registry lookup key.""" return "".join(type_str.split()).lower() diff --git a/sqlspec/core/parameters/_processor.py b/sqlspec/core/parameters/_processor.py index 0f0c418dd..eedfd26be 100644 --- a/sqlspec/core/parameters/_processor.py +++ b/sqlspec/core/parameters/_processor.py @@ -67,39 +67,30 @@ def structural_fingerprint(parameters: "ParameterPayload", is_many: bool = False if parameters is None: return None - # Fast type dispatch: check concrete types first param_type = type(parameters) - # Handle dict (most common Mapping type) - fast path if param_type is dict: dict_params = cast("dict[str, Any]", parameters) if not dict_params: return ("dict",) - # Use dict insertion order (Python 3.7+ guaranteed) instead of sorted() - # This is O(n) vs O(n log n) and produces consistent fingerprints for - # parameters constructed in the same order (typical usage pattern) keys = tuple(dict_params.keys()) type_sig = tuple(type(v) for v in dict_params.values()) return ("dict", keys, type_sig) - # Handle list and tuple (most common Sequence types) - fast path if param_type is list or param_type is tuple: seq_params = cast("Sequence[Any]", parameters) if not seq_params: return ("seq",) - # Optimization: Fast path for single-item sequence (extremely common) if len(seq_params) == 1: return ("seq", (type(seq_params[0]),)) if is_many: return _fingerprint_execute_many(seq_params) - # Single execution with sequence parameters type_sig = tuple(type(v) for v in seq_params) return ("seq", type_sig) - # Fallback to ABC checks for custom types (Mapping, Sequence subclasses) if isinstance(parameters, Mapping): if not parameters: return ("dict",) @@ -120,7 +111,6 @@ def structural_fingerprint(parameters: "ParameterPayload", is_many: bool = False type_sig = tuple(type(v) for v in parameters) return ("seq", type_sig) - # Scalar parameter return ("scalar", param_type) @@ -139,8 +129,6 @@ def value_fingerprint(parameters: "ParameterPayload") -> Any: if parameters is None: return None - # Use repr for value-based hashing - includes both structure and values - # Return as tuple to match structural_fingerprint return type (hashable) return ("values", repr(parameters)) @@ -272,6 +260,76 @@ def process_for_execution( param_fingerprint=param_fingerprint, ) + def transform_cached_parameters( + self, + parameters: "ParameterPayload", + cached_profile: "ParameterProfile", + config: "ParameterStyleConfig", + *, + input_named_parameters: "tuple[str, ...]", + is_many: bool, + apply_wrap_types: bool, + ) -> "ConvertedParameters": + """Apply parameter transformations for a cache hit. + + Uses cached metadata to efficiently transform parameters without re-parsing SQL. + This ensures new parameter values undergo the same transformations as the original + cached request (type wrapping, coercion, named-to-positional mapping). + + Args: + parameters: New parameter payload to transform. + cached_profile: Cached ParameterProfile with execution parameter metadata. + config: Parameter style configuration. + input_named_parameters: Cached input named parameter order. + is_many: Whether this is execute_many. + apply_wrap_types: Whether to wrap parameters with type metadata. + + Returns: + Transformed parameters matching the cached SQL's placeholder format. + """ + if parameters is None: + return None + + processed = parameters + + if apply_wrap_types and processed: + processed = self._wrap_parameter_types(processed, is_many=is_many) + + if config.type_coercion_map and processed: + processed = self._coerce_parameter_types(processed, config.type_coercion_map, is_many) + + if processed: + cached_styles = cached_profile.styles + index_order = _explicit_index_order(input_named_parameters) + if ( + not index_order + and input_named_parameters + and all(style in _OCCURRENCE_KEYED_STYLE_VALUES for style in cached_styles) + ): + slots = dict.fromkeys(input_named_parameters) + if len(slots) < len(input_named_parameters): + slot_indexes = {name: index for index, name in enumerate(slots)} + index_order = tuple(slot_indexes[name] for name in input_named_parameters) + if input_named_parameters and any(style in _POSITIONAL_STYLE_VALUES for style in cached_styles): + expands_indexes = ( + bool(index_order) + and len(index_order) == cached_profile.total_count + and all(style in _OCCURRENCE_KEYED_STYLE_VALUES for style in cached_styles) + ) + processed = self._map_named_to_positional( + processed, + input_named_parameters, + is_many, + strict=config.strict_named_parameters, + index_order=index_order if expands_indexes else (), + ) + elif not input_named_parameters and any(style in _POSITIONAL_STYLE_VALUES for style in cached_styles): + processed = _mapping_rows_to_positional(processed, is_many) + elif any(style in _NAMED_STYLE_VALUES for style in cached_styles): + processed = self._map_positional_to_named(processed, cached_profile, is_many, index_order) + + return processed + def _process_internal( self, sql: str, @@ -301,9 +359,6 @@ def _process_internal( if cached_result is not None: self._cache.move_to_end(cache_key) self._cache_hits += 1 - # For static script compilation, parameters are embedded directly in SQL. - # Cache key includes parameter values, so a hit means same SQL with same values. - # Return None for parameters since the driver shouldn't receive any. if config.needs_static_script_compilation: return ParameterProcessingResult( cached_result.sql, @@ -314,8 +369,6 @@ def _process_internal( input_named_parameters=cached_result.input_named_parameters, applied_wrap_types=cached_result.applied_wrap_types, ) - # Return cached SQL transformation with NEW parameters transformed - # to match the cached SQL's placeholder format transformed_params = self.transform_cached_parameters( parameters, cached_result.parameter_profile, @@ -324,7 +377,6 @@ def _process_internal( is_many=is_many, apply_wrap_types=cached_result.applied_wrap_types, ) - # Apply output transformer if present (it may further transform params) final_sql = cached_result.sql if config.output_transformer: final_sql, transformed_params = config.output_transformer(final_sql, transformed_params) @@ -416,7 +468,7 @@ def _process_internal( applied_wrap_types = False if processed_parameters and wrap_types: - wrapped_parameters = self._wrap_parameter_types(processed_parameters) + wrapped_parameters = self._wrap_parameter_types(processed_parameters, is_many=is_many) if wrapped_parameters is not processed_parameters: processed_parameters = wrapped_parameters applied_wrap_types = True @@ -505,8 +557,37 @@ def _select_execution_style( return original_style return config.default_execution_parameter_style or config.default_parameter_style - def _wrap_parameter_types(self, parameters: "ParameterPayload") -> "ConvertedParameters": - # Fast type dispatch for common types + def _wrap_many_parameter_types(self, parameters: "ParameterPayload") -> "ConvertedParameters": + param_type = type(parameters) + if param_type is list: + many_source = cast("list[Any]", parameters) + wrapped_rows: list[Any] | None = None + for idx, row in enumerate(many_source): + wrapped_row = self._wrap_parameter_types(row, is_many=False) + if wrapped_rows is None: + if wrapped_row is row: + continue + wrapped_rows = many_source[:idx] + wrapped_rows.append(wrapped_row) + if wrapped_rows is None: + return parameters + return wrapped_rows + if param_type is tuple: + tuple_many = cast("tuple[Any, ...]", parameters) + wrapped_rows = [self._wrap_parameter_types(row, is_many=False) for row in tuple_many] + if all(wrapped is row for wrapped, row in zip(wrapped_rows, tuple_many, strict=False)): + return parameters + return wrapped_rows + if isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes)): + wrapped_rows = [self._wrap_parameter_types(row, is_many=False) for row in parameters] + if all(wrapped is row for wrapped, row in zip(wrapped_rows, parameters, strict=False)): + return cast("ConvertedParameters", parameters) + return wrapped_rows + return None + + def _wrap_parameter_types(self, parameters: "ParameterPayload", is_many: bool = False) -> "ConvertedParameters": + if is_many: + return self._wrap_many_parameter_types(parameters) param_type = type(parameters) if param_type is list: source = cast("list[Any]", parameters) @@ -526,7 +607,7 @@ def _wrap_parameter_types(self, parameters: "ParameterPayload") -> "ConvertedPar wrapped_values = [wrap_with_type(value) for value in tuple_source] if all(wrapped is value for wrapped, value in zip(wrapped_values, tuple_source, strict=False)): return parameters - return wrapped_values + return tuple(wrapped_values) if param_type is dict: source_mapping = cast("dict[str, Any]", parameters) wrapped_mapping: dict[str, Any] | None = None @@ -540,7 +621,6 @@ def _wrap_parameter_types(self, parameters: "ParameterPayload") -> "ConvertedPar if wrapped_mapping is None: return parameters return wrapped_mapping - # Fallback to ABC checks for custom types if isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes)): wrapped_values = [wrap_with_type(value) for value in parameters] if all(wrapped is value for wrapped, value in zip(wrapped_values, parameters, strict=False)): @@ -580,76 +660,6 @@ def _store_cached_result( self._cache.popitem(last=False) return result - def transform_cached_parameters( - self, - parameters: "ParameterPayload", - cached_profile: "ParameterProfile", - config: "ParameterStyleConfig", - *, - input_named_parameters: "tuple[str, ...]", - is_many: bool, - apply_wrap_types: bool, - ) -> "ConvertedParameters": - """Apply parameter transformations for a cache hit. - - Uses cached metadata to efficiently transform parameters without re-parsing SQL. - This ensures new parameter values undergo the same transformations as the original - cached request (type wrapping, coercion, named-to-positional mapping). - - Args: - parameters: New parameter payload to transform. - cached_profile: Cached ParameterProfile with execution parameter metadata. - config: Parameter style configuration. - input_named_parameters: Cached input named parameter order. - is_many: Whether this is execute_many. - apply_wrap_types: Whether to wrap parameters with type metadata. - - Returns: - Transformed parameters matching the cached SQL's placeholder format. - """ - if parameters is None: - return None - - processed = parameters - - if apply_wrap_types and processed: - processed = self._wrap_parameter_types(processed) - - if config.type_coercion_map and processed: - processed = self._coerce_parameter_types(processed, config.type_coercion_map, is_many) - - if processed: - cached_styles = cached_profile.styles - index_order = _explicit_index_order(input_named_parameters) - if ( - not index_order - and input_named_parameters - and all(style in _OCCURRENCE_KEYED_STYLE_VALUES for style in cached_styles) - ): - slots = dict.fromkeys(input_named_parameters) - if len(slots) < len(input_named_parameters): - slot_indexes = {name: index for index, name in enumerate(slots)} - index_order = tuple(slot_indexes[name] for name in input_named_parameters) - if input_named_parameters and any(style in _POSITIONAL_STYLE_VALUES for style in cached_styles): - expands_indexes = ( - bool(index_order) - and len(index_order) == cached_profile.total_count - and all(style in _OCCURRENCE_KEYED_STYLE_VALUES for style in cached_styles) - ) - processed = self._map_named_to_positional( - processed, - input_named_parameters, - is_many, - strict=config.strict_named_parameters, - index_order=index_order if expands_indexes else (), - ) - elif not input_named_parameters and any(style in _POSITIONAL_STYLE_VALUES for style in cached_styles): - processed = _mapping_rows_to_positional(processed, is_many) - elif any(style in _NAMED_STYLE_VALUES for style in cached_styles): - processed = self._map_positional_to_named(processed, cached_profile, is_many, index_order) - - return processed - def _map_positional_to_named( self, parameters: "ConvertedParameters", @@ -766,13 +776,11 @@ def _needs_mapping_normalization( if not is_many: return False - # Fast type dispatch for common types payload_type = type(payload) if payload_type is dict: return True if payload_type is list or payload_type is tuple: - # Check if any item is a dict (fast path) or Mapping (fallback) seq_payload = cast("Sequence[Any]", payload) for item in seq_payload: item_type = type(item) @@ -782,7 +790,6 @@ def _needs_mapping_normalization( return True return False - # Fallback for custom types if isinstance(payload, Mapping): return True @@ -809,7 +816,6 @@ def _normalize_sql_for_parsing( """ if not self._needs_parse_normalization(param_info, config): return sql - # Convert to the default style that sqlglot can parse for this dialect target_style = config.default_parameter_style normalized_sql, _ = self._converter.convert_placeholder_style( sql, None, target_style, is_many=False, param_info=param_info @@ -829,21 +835,26 @@ def _make_processor_cache_key( param_fingerprint: Any | None = None, ) -> tuple[Any, ...]: if param_fingerprint is None: - # For static script compilation, we must include actual values in the fingerprint if config.needs_static_script_compilation: param_fingerprint = value_fingerprint(parameters) else: - # Use structural fingerprint (keys + types, not values) for better cache hit rates param_fingerprint = structural_fingerprint(parameters, is_many) dialect_marker = dialect or "default" - # Include both input and execution parameter styles to avoid cache collisions input_style = config.default_parameter_style.value if config.default_parameter_style else "unknown" exec_style = ( config.default_execution_parameter_style.value if config.default_execution_parameter_style else input_style ) return _make_cache_key_tuple( - sql, param_fingerprint, input_style, exec_style, dialect_marker, is_many, wrap_types, normalize_for_parsing + sql, + param_fingerprint, + input_style, + exec_style, + dialect_marker, + is_many, + wrap_types, + normalize_for_parsing, + hash(config), ) def _needs_placeholder_conversion(self, param_info: "list[ParameterInfo]", config: "ParameterStyleConfig") -> bool: @@ -904,7 +915,6 @@ def _execution_placeholders( is_many: bool, ) -> "tuple[str, ConvertedParameters, list[ParameterInfo] | None]": if not needs_execution_conversion: - # Convert parameters to concrete type for return if parameters is None: return sql, None, None if isinstance(parameters, (dict, list, tuple)): @@ -979,12 +989,19 @@ def apply_type_coercion( return value +def executes_qmark(config: "ParameterStyleConfig") -> bool: + """Return True when the driver itself reads ``?`` as a placeholder.""" + if config.default_execution_parameter_style == ParameterStyle.QMARK: + return True + supported = config.supported_execution_parameter_styles + return supported is not None and ParameterStyle.QMARK in supported + + def _coerce_nested_value( value: object, type_coercion_map: "dict[type, Callable[[Any], Any]]", fallback_items: "tuple[TypeCoercionFallback, ...]", ) -> object: - # Fast type dispatch for common types value_type = type(value) if value_type is list or value_type is tuple: seq_value = cast("Sequence[Any]", value) @@ -1004,7 +1021,6 @@ def _coerce_parameter_value( return value value_type = type(value) - # Fast path: check TypedParameter by type identity if value_type is TypedParameter: typed_param = cast("TypedParameter", value) wrapped_value: object = typed_param.value @@ -1062,7 +1078,6 @@ def _coerce_parameter_set( type_coercion_map: "dict[type, Callable[[Any], Any]]", fallback_items: "tuple[TypeCoercionFallback, ...]", ) -> object: - # Fast type dispatch for common types param_type = type(param_set) if param_type is list: return _coerce_sequence_if_needed(cast("list[Any]", param_set), type_coercion_map, fallback_items) @@ -1074,7 +1089,6 @@ def _coerce_parameter_set( return tuple(cast("list[Any]", coerced_seq)) if param_type is dict: return _coerce_mapping_if_needed(cast("dict[Any, Any]", param_set), type_coercion_map, fallback_items) - # Fallback to ABC checks for custom types if isinstance(param_set, Sequence) and not isinstance(param_set, (str, bytes)): seq_fallback = param_set coerced_seq = _coerce_sequence_if_needed(seq_fallback, type_coercion_map, fallback_items) @@ -1095,7 +1109,6 @@ def _coerce_parameters_payload( fallback_items: "tuple[TypeCoercionFallback, ...]", is_many: bool, ) -> object: - # Fast type dispatch for common types param_type = type(parameters) if param_type is list: seq_params = cast("list[Any]", parameters) @@ -1121,7 +1134,6 @@ def _coerce_parameters_payload( if param_type is dict: dict_params = cast("dict[Any, Any]", parameters) return _coerce_mapping_if_needed(dict_params, type_coercion_map, fallback_items) - # Fallback to ABC checks for custom types if is_many and isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes)): return [_coerce_parameter_set(param_set, type_coercion_map, fallback_items) for param_set in parameters] if isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes)): @@ -1142,11 +1154,26 @@ def _make_cache_key_tuple( is_many: bool, wrap_types: bool | None = None, normalize_for_parsing: bool | None = None, + extra_key: Any = None, ) -> tuple[Any, ...]: """Build the shared SQL and parameter processor cache key tuple.""" if wrap_types is None and normalize_for_parsing is None: - return (sql, param_fingerprint, input_style, exec_style, dialect, is_many) - return (sql, param_fingerprint, input_style, exec_style, dialect, is_many, wrap_types, normalize_for_parsing) + if extra_key is None: + return (sql, param_fingerprint, input_style, exec_style, dialect, is_many) + return (sql, param_fingerprint, input_style, exec_style, dialect, is_many, extra_key) + if extra_key is None: + return (sql, param_fingerprint, input_style, exec_style, dialect, is_many, wrap_types, normalize_for_parsing) + return ( + sql, + param_fingerprint, + input_style, + exec_style, + dialect, + is_many, + wrap_types, + normalize_for_parsing, + extra_key, + ) def _explicit_index_order(input_named_parameters: "tuple[str, ...]") -> "tuple[int, ...]": @@ -1277,37 +1304,48 @@ def _fingerprint_execute_many(parameters: "Sequence[Any]") -> Any: """ first = parameters[0] first_type = type(first) + none_type: Any = type(None) - # Fast type dispatch for first element - if first_type is dict: + if first_type is dict or isinstance(first, Mapping): keys = tuple(first.keys()) - type_sig = tuple(type(v) for v in first.values()) - return ("many_dict", keys, type_sig) - - if first_type is list or first_type is tuple: - return ("many_seq", tuple(type(v) for v in first)) - - # Fallback to ABC checks - if isinstance(first, Mapping): - keys = tuple(first.keys()) - type_sig = tuple(type(v) for v in first.values()) - return ("many_dict", keys, type_sig) - - if isinstance(first, Sequence) and not isinstance(first, (str, bytes)): - return ("many_seq", tuple(type(v) for v in first)) - - # Scalar values in sequence for execute_many + col_types: list[type] = [type(first[k]) for k in keys] + if none_type in col_types and len(parameters) > 1: + for row in parameters[1:]: + if isinstance(row, Mapping): + for idx, key in enumerate(keys): + if col_types[idx] is none_type: + val = row.get(key) + if val is not None: + col_types[idx] = type(val) + if none_type not in col_types: + break + return ("many_dict", keys, tuple(col_types)) + + if ( + first_type is list + or first_type is tuple + or (isinstance(first, Sequence) and not isinstance(first, (str, bytes))) + ): + col_types = [type(v) for v in first] + if none_type in col_types and len(parameters) > 1: + num_cols = len(col_types) + for row in parameters[1:]: + if isinstance(row, Sequence) and not isinstance(row, (str, bytes)): + limit = min(num_cols, len(row)) + for idx in range(limit): + if col_types[idx] is none_type and row[idx] is not None: + col_types[idx] = type(row[idx]) + if none_type not in col_types: + break + return ("many_seq", tuple(col_types)) + + if first is None and len(parameters) > 1: + for item in parameters[1:]: + if item is not None: + return ("many_scalar", type(item)) return ("many_scalar", first_type) -def executes_qmark(config: "ParameterStyleConfig") -> bool: - """Return True when the driver itself reads ``?`` as a placeholder.""" - if config.default_execution_parameter_style == ParameterStyle.QMARK: - return True - supported = config.supported_execution_parameter_styles - return supported is not None and ParameterStyle.QMARK in supported - - def _is_sequence_payload(parameters: "ParameterPayload", is_many: bool) -> bool: if not parameters or isinstance(parameters, (str, bytes, bytearray, Mapping)): return False diff --git a/sqlspec/core/parameters/_types.py b/sqlspec/core/parameters/_types.py index 1e6514916..ff526d098 100644 --- a/sqlspec/core/parameters/_types.py +++ b/sqlspec/core/parameters/_types.py @@ -500,9 +500,9 @@ def __init__(self, parameters: "Sequence[ParameterInfo] | None" = None) -> None: self.reused_ordinals = tuple(reused_ordinals) self.named_parameters = tuple(named_parameters) - @classmethod - def empty(cls) -> "ParameterProfile": - return cls(()) + def __reduce__(self) -> "tuple[Any, ...]": + """Reconstruct via ``ParameterProfile(parameters)`` — fully rebuilds derived state.""" + return (ParameterProfile, (self._parameters,)) @property def parameters(self) -> "tuple[ParameterInfo, ...]": @@ -512,16 +512,16 @@ def parameters(self) -> "tuple[ParameterInfo, ...]": def total_count(self) -> int: return len(self._parameters) + @classmethod + def empty(cls) -> "ParameterProfile": + return cls(()) + def placeholder_count(self, placeholder: str) -> int: return self._placeholder_counts.get(placeholder, 0) def is_empty(self) -> bool: return not self._parameters - def __reduce__(self) -> "tuple[Any, ...]": - """Reconstruct via ``ParameterProfile(parameters)`` — fully rebuilds derived state.""" - return (ParameterProfile, (self._parameters,)) - @mypyc_attr(allow_interpreted_subclasses=False) class ParameterProcessingResult: diff --git a/sqlspec/core/parameters/_validator.py b/sqlspec/core/parameters/_validator.py index 22ee7325d..49aed96a4 100644 --- a/sqlspec/core/parameters/_validator.py +++ b/sqlspec/core/parameters/_validator.py @@ -16,7 +16,7 @@ r""" (?P"(?:[^"\\]|\\.)*") | (?P'(?:[^'\\]|\\.)*') | - (?P\$(?P\w*)?\$[\s\S]*?\$\4\$) | + (?P\$(?P\w*)\$[\s\S]*?\$(?P=dollar_quote_tag_inner)\$) | (?P--[^\r\n]*) | (?P/\*(?:[^*]|\*(?!/))*\*/) | (?P\?\?|\?\||\?&) | @@ -103,16 +103,6 @@ ]) -def _follows_operand(sql: str, start: int) -> bool: - head = sql[:start].rstrip() - if not head: - return False - if head[-1] in ")]\"'": - return True - match = _PRECEDING_WORD.search(head) - return bool(match and match.group(1).lower() not in _NON_OPERAND_KEYWORDS) - - @mypyc_attr(allow_interpreted_subclasses=False) class ParameterValidator: """Extracts placeholder metadata and dialect compatibility information.""" @@ -146,6 +136,25 @@ def cache_stats(self) -> "dict[str, int]": "max_size": self._cache_max_size, } + def extract_parameters(self, sql: str) -> "list[ParameterInfo]": + """Extract ordered parameter metadata from SQL text.""" + if self._cache_max_size <= 0: + return self._extract_parameters_uncached(sql) + + cache_key = sql + cached_result = self._parameter_cache.get(cache_key) + if cached_result is not None: + self._parameter_cache.move_to_end(cache_key) + self._cache_hits += 1 + return cached_result + self._cache_misses += 1 + + parameters = self._extract_parameters_uncached(sql) + if len(self._parameter_cache) >= self._cache_max_size: + self._parameter_cache.popitem(last=False) + self._parameter_cache[cache_key] = parameters + return parameters + @staticmethod def _extract_parameter_style(match: re.Match[str]) -> "tuple[ParameterStyle | None, str | None]": """Map a regex match to a placeholder style and optional name.""" @@ -167,25 +176,6 @@ def _extract_parameter_style(match: re.Match[str]) -> "tuple[ParameterStyle | No return ParameterStyle.NAMED_DOLLAR, match.group("dollar_param_name") return None, None - def extract_parameters(self, sql: str) -> "list[ParameterInfo]": - """Extract ordered parameter metadata from SQL text.""" - if self._cache_max_size <= 0: - return self._extract_parameters_uncached(sql) - - cache_key = sql - cached_result = self._parameter_cache.get(cache_key) - if cached_result is not None: - self._parameter_cache.move_to_end(cache_key) - self._cache_hits += 1 - return cached_result - self._cache_misses += 1 - - parameters = self._extract_parameters_uncached(sql) - if len(self._parameter_cache) >= self._cache_max_size: - self._parameter_cache.popitem(last=False) - self._parameter_cache[cache_key] = parameters - return parameters - def _extract_parameters_uncached(self, sql: str) -> "list[ParameterInfo]": parameters: list[ParameterInfo] = [] ordinal = 0 @@ -248,3 +238,13 @@ def unescape_qmark_operator( ) ) return "".join(segments), shifted + + +def _follows_operand(sql: str, start: int) -> bool: + head = sql[:start].rstrip() + if not head: + return False + if head[-1] in ")]\"'": + return True + match = _PRECEDING_WORD.search(head) + return bool(match and match.group(1).lower() not in _NON_OPERAND_KEYWORDS) diff --git a/sqlspec/core/query_modifiers.py b/sqlspec/core/query_modifiers.py index fc086f03c..eb0c9d5b5 100644 --- a/sqlspec/core/query_modifiers.py +++ b/sqlspec/core/query_modifiers.py @@ -49,7 +49,6 @@ "wrap_as_subquery", ) -# Type alias for condition factory functions ConditionFactory = Callable[[exp.Expr, exp.Placeholder], exp.Expr] _TABLE_QUALIFIED_PARTS: Final[int] = 2 @@ -478,7 +477,6 @@ def apply_column_pruning( if not isinstance(expression, exp.Select): return expression - # Check cache first if key provided if cache_key is not None: cache = get_cache() cached = cache.get_optimized(cache_key, dialect) @@ -486,20 +484,16 @@ def apply_column_pruning( cached_expr: exp.Expr = cached return cached_expr.copy() - # Apply qualification to resolve column references try: qualified = qualify_module.qualify(expression.copy(), dialect=dialect, validate_qualify_columns=False) except Exception: - # If qualification fails, return unchanged expression return expression try: pruned: exp.Expr = pushdown_projections_module.pushdown_projections(qualified) except Exception: - # If pushdown fails, return the qualified expression pruned = qualified - # Cache the result if cache_key is not None: cache = get_cache() cache.put_optimized(cache_key, pruned.copy(), dialect) diff --git a/sqlspec/core/result/_base.py b/sqlspec/core/result/_base.py index f2869ebae..bef1fe69a 100644 --- a/sqlspec/core/result/_base.py +++ b/sqlspec/core/result/_base.py @@ -117,6 +117,15 @@ def __init__( def __iter__(self) -> "Iterator[Any]": """Iterate over result rows.""" + @property + def operation_type(self) -> "OperationType": + """Get operation type from the statement. + + Returns: + The type of SQL operation that produced this result. + """ + return self._operation_type + @abstractmethod def is_success(self) -> bool: """Check if the operation was successful. @@ -154,15 +163,6 @@ def set_metadata(self, key: str, value: Any) -> None: """ self.metadata[key] = value - @property - def operation_type(self) -> "OperationType": - """Get operation type from the statement. - - Returns: - The type of SQL operation that produced this result. - """ - return self._operation_type - @mypyc_attr(allow_interpreted_subclasses=False) class SQLResult(StatementResult): @@ -273,63 +273,36 @@ def __init__( if self.total_count is None: self.total_count = len(data) if data is not None else 0 - def _get_rows(self) -> "list[dict[str, Any]]": - """Get row data as list of dicts, materializing lazily from raw format. + def __len__(self) -> int: + """Get the number of rows in the result set. Returns: - List of row dictionaries, empty list if no data. + Number of rows in the data. """ - if self._materialized_dicts is not None: - return self._materialized_dicts - if self.data is None: - return [] - if not isinstance(self.data, list): - return [] - raw = self.data - if not raw: - self._materialized_dicts = [] - return self._materialized_dicts - self._assert_row_format_fidelity(raw) - fmt = self._row_format - if fmt == "dict": - self._materialized_dicts = raw - elif fmt == "tuple": - col_names = self.column_names - if not col_names: - self._materialized_dicts = [] - elif len(col_names) == 1: - key = col_names[0] - self._materialized_dicts = [{key: row[0]} for row in raw] - elif len(col_names) == _TWO_COLUMN_THRESHOLD: - key0, key1 = col_names - self._materialized_dicts = [{key0: row[0], key1: row[1]} for row in raw] - else: - self._materialized_dicts = [dict(zip(col_names, row, strict=False)) for row in raw] - else: - # "record" — dict-like objects (asyncpg.Record, sqlite3.Row, bigquery.Row) - self._materialized_dicts = [dict(record) for record in raw] - return self._materialized_dicts + return len(self.data) if self.data is not None else 0 - def _assert_row_format_fidelity(self, rows: "list[Any]") -> None: - """Debug-only row-format shape check for first-row fidelity.""" - if not __debug__ or not rows: - return + def __getitem__(self, index: int) -> "dict[str, Any]": + """Get a row by index. - first_row = rows[0] - row_format = self._row_format - if row_format == "dict": - assert isinstance(first_row, dict), "row_format='dict' requires dict rows" - return - if row_format == "tuple": - assert isinstance(first_row, tuple), "row_format='tuple' requires tuple rows" - return - if row_format == "record": - try: - keys = first_row.keys - except AttributeError as exc: - msg = "row_format='record' requires dict-like rows" - raise AssertionError(msg) from exc - assert callable(keys), "row_format='record' requires dict-like rows" + Args: + index: Row index + + Returns: + The row at the specified index + """ + rows = self._get_rows() + if not rows: + msg = "No data available" + raise IndexError(msg) + return rows[index] + + def __iter__(self) -> "Iterator[dict[str, Any]]": + """Iterate over the rows in the result. + + Returns: + Iterator that yields each row as a dictionary + """ + return iter(self._get_rows()) @property def raw_data(self) -> "tuple[list[Any], list[str]]": @@ -340,6 +313,24 @@ def raw_data(self) -> "tuple[list[Any], list[str]]": """ return (self.data if self.data is not None else [], self.column_names) + @property + def num_rows(self) -> int: + """Get the number of rows affected (alias for get_total_rows_affected). + + Returns: + Total rows affected. + """ + return self.get_total_rows_affected() + + @property + def num_columns(self) -> int: + """Get the number of columns in the result data. + + Returns: + Number of columns. + """ + return len(self.column_names) if self.column_names else 0 + def is_success(self) -> bool: """Check if the operation was successful. @@ -354,17 +345,18 @@ def is_success(self) -> bool: if op_type == "SELECT": return self.data is not None and self.rows_affected >= 0 - if op_type in {"INSERT", "UPDATE", "DELETE", "EXECUTE"}: - return self.rows_affected >= 0 + if op_type in {"INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "EXECUTE"}: + return not self.errors and self.rows_affected >= 0 + + if op_type in {"DDL", "PRAGMA", "COPY", "COPY_FROM", "COPY_TO", "COMMAND"}: + return not self.errors and self.rows_affected >= -1 return False @overload def get_data(self, *, schema_type: "type[SchemaT]") -> "list[SchemaT]": ... - @overload def get_data(self, *, schema_type: None = None) -> "list[dict[str, Any]]": ... - def get_data(self, *, schema_type: "type[SchemaT] | None" = None) -> "list[SchemaT] | list[dict[str, Any]]": """Get the data from the result. @@ -392,38 +384,13 @@ def get_data(self, *, schema_type: "type[SchemaT] | None" = None) -> "list[Schem "total_rows_affected": self.get_total_rows_affected(), } ] - data = self._get_rows() if schema_type: - return cast("list[SchemaT]", self._get_schema_rows(schema_type, data)) - return data - - def _get_schema_rows(self, schema_type: "type[SchemaT]", rows: "list[dict[str, Any]]") -> "list[SchemaT]": - cache = self._schema_rows_cache - if cache is not None: - cached_rows = cache.get(schema_type) - if cached_rows is not None: - return cast("list[SchemaT]", cached_rows) - converted_rows = cast("list[SchemaT]", to_schema(rows, schema_type=schema_type)) - self._materialized_dicts = None - if cache is None: - self._schema_rows_cache = {schema_type: converted_rows} - else: - cache[schema_type] = converted_rows - return converted_rows - - def _get_schema_row(self, schema_type: "type[SchemaT]", row: "dict[str, Any]") -> "SchemaT": - row_cache = self._schema_row_cache - if row_cache is not None: - cached_row = row_cache.get(schema_type) - if cached_row is not None: - return cast("SchemaT", cached_row) - converted_row = to_schema(row, schema_type=schema_type) - self._materialized_dicts = None - if row_cache is None: - self._schema_row_cache = {schema_type: converted_row} - else: - row_cache[schema_type] = converted_row - return converted_row + if self._schema_rows_cache is not None and schema_type in self._schema_rows_cache: + return cast("list[SchemaT]", self._schema_rows_cache[schema_type]) + had_materialized = self._materialized_dicts is not None + data = self._get_rows() + return cast("list[SchemaT]", self._get_schema_rows(schema_type, data, preserve_dicts=had_materialized)) + return self._get_rows() def add_statement_result(self, result: "SQLResult") -> None: """Add a statement result to the script execution results. @@ -450,30 +417,10 @@ def get_total_rows_affected(self) -> int: return total return self.rows_affected if self.rows_affected and self.rows_affected > 0 else 0 - @property - def num_rows(self) -> int: - """Get the number of rows affected (alias for get_total_rows_affected). - - Returns: - Total rows affected. - """ - return self.get_total_rows_affected() - - @property - def num_columns(self) -> int: - """Get the number of columns in the result data. - - Returns: - Number of columns. - """ - return len(self.column_names) if self.column_names else 0 - @overload def get_first(self, *, schema_type: "type[SchemaT]") -> "SchemaT | None": ... - @overload def get_first(self, *, schema_type: None = None) -> "dict[str, Any] | None": ... - def get_first(self, *, schema_type: "type[SchemaT] | None" = None) -> "SchemaT | dict[str, Any] | None": """Get the first row from the result, if any. @@ -540,43 +487,10 @@ def was_deleted(self) -> bool: """ return self.operation_type == "DELETE" - def __len__(self) -> int: - """Get the number of rows in the result set. - - Returns: - Number of rows in the data. - """ - return len(self.data) if self.data is not None else 0 - - def __getitem__(self, index: int) -> "dict[str, Any]": - """Get a row by index. - - Args: - index: Row index - - Returns: - The row at the specified index - """ - rows = self._get_rows() - if not rows: - msg = "No data available" - raise IndexError(msg) - return rows[index] - - def __iter__(self) -> "Iterator[dict[str, Any]]": - """Iterate over the rows in the result. - - Returns: - Iterator that yields each row as a dictionary - """ - return iter(self._get_rows()) - @overload def all(self, *, schema_type: "type[SchemaT]") -> "list[SchemaT]": ... - @overload def all(self, *, schema_type: None = None) -> "list[dict[str, Any]]": ... - def all(self, *, schema_type: "type[SchemaT] | None" = None) -> "list[SchemaT] | list[dict[str, Any]]": """Return all rows as a list. @@ -587,17 +501,18 @@ def all(self, *, schema_type: "type[SchemaT] | None" = None) -> "list[SchemaT] | Returns: List of all rows (optionally transformed to schema_type) """ - data = self._get_rows() if schema_type: - return cast("list[SchemaT]", self._get_schema_rows(schema_type, data)) - return data + if self._schema_rows_cache is not None and schema_type in self._schema_rows_cache: + return cast("list[SchemaT]", self._schema_rows_cache[schema_type]) + had_materialized = self._materialized_dicts is not None + data = self._get_rows() + return cast("list[SchemaT]", self._get_schema_rows(schema_type, data, preserve_dicts=had_materialized)) + return self._get_rows() @overload def one(self, *, schema_type: "type[SchemaT]") -> "SchemaT": ... - @overload def one(self, *, schema_type: None = None) -> "dict[str, Any]": ... - def one(self, *, schema_type: "type[SchemaT] | None" = None) -> "SchemaT | dict[str, Any]": """Return exactly one row. @@ -628,10 +543,8 @@ def one(self, *, schema_type: "type[SchemaT] | None" = None) -> "SchemaT | dict[ @overload def one_or_none(self, *, schema_type: "type[SchemaT]") -> "SchemaT | None": ... - @overload def one_or_none(self, *, schema_type: None = None) -> "dict[str, Any] | None": ... - def one_or_none(self, *, schema_type: "type[SchemaT] | None" = None) -> "SchemaT | dict[str, Any] | None": """Return at most one row. @@ -757,6 +670,93 @@ async def write_to_storage_async( rows, destination, format_hint=format_hint, storage_options=storage_options ) + def _get_rows(self) -> "list[dict[str, Any]]": + """Get row data as list of dicts, materializing lazily from raw format. + + Returns: + List of row dictionaries, empty list if no data. + """ + if self._materialized_dicts is not None: + return self._materialized_dicts + if self.data is None: + return [] + if not isinstance(self.data, list): + return [] + raw = self.data + if not raw: + self._materialized_dicts = [] + return self._materialized_dicts + self._assert_row_format_fidelity(raw) + fmt = self._row_format + if fmt == "dict": + self._materialized_dicts = raw + elif fmt == "tuple": + col_names = self.column_names + if not col_names: + self._materialized_dicts = [] + elif len(col_names) == 1: + key = col_names[0] + self._materialized_dicts = [{key: row[0]} for row in raw] + elif len(col_names) == _TWO_COLUMN_THRESHOLD: + key0, key1 = col_names + self._materialized_dicts = [{key0: row[0], key1: row[1]} for row in raw] + else: + self._materialized_dicts = [dict(zip(col_names, row, strict=False)) for row in raw] + else: + self._materialized_dicts = [dict(record) for record in raw] + return self._materialized_dicts + + def _assert_row_format_fidelity(self, rows: "list[Any]") -> None: + """Debug-only row-format shape check for first-row fidelity.""" + if not __debug__ or not rows: + return + + first_row = rows[0] + row_format = self._row_format + if row_format == "dict": + assert isinstance(first_row, dict), "row_format='dict' requires dict rows" + return + if row_format == "tuple": + assert isinstance(first_row, tuple), "row_format='tuple' requires tuple rows" + return + if row_format == "record": + try: + keys = first_row.keys + except AttributeError as exc: + msg = "row_format='record' requires dict-like rows" + raise AssertionError(msg) from exc + assert callable(keys), "row_format='record' requires dict-like rows" + + def _get_schema_rows( + self, schema_type: "type[SchemaT]", rows: "list[dict[str, Any]]", *, preserve_dicts: bool = False + ) -> "list[SchemaT]": + cache = self._schema_rows_cache + if cache is not None: + cached_rows = cache.get(schema_type) + if cached_rows is not None: + return cast("list[SchemaT]", cached_rows) + converted_rows = cast("list[SchemaT]", to_schema(rows, schema_type=schema_type)) + if not preserve_dicts: + self._materialized_dicts = None + if cache is None: + self._schema_rows_cache = {schema_type: converted_rows} + else: + cache[schema_type] = converted_rows + return converted_rows + + def _get_schema_row(self, schema_type: "type[SchemaT]", row: "dict[str, Any]") -> "SchemaT": + row_cache = self._schema_row_cache + if row_cache is not None: + cached_row = row_cache.get(schema_type) + if cached_row is not None: + return cast("SchemaT", cached_row) + converted_row = to_schema(row, schema_type=schema_type) + if row_cache is None: + self._schema_row_cache = {schema_type: converted_row} + else: + row_cache[schema_type] = converted_row + return converted_row + @mypyc_attr(allow_interpreted_subclasses=False) class ArrowResult(StatementResult): @@ -804,35 +804,21 @@ def __init__( self.schema = schema - def is_success(self) -> bool: - """Check if the operation was successful. + def __len__(self) -> int: + """Return number of rows in the Arrow table. Returns: - True if Arrow table data is available, False otherwise. + Number of rows. """ - return self.data is not None - - def get_data(self) -> "Any": - """Get the Arrow payload from the result. + return arrow_table_num_rows(self._as_table()) - Returns the value shaped by ``arrow_table_to_return_format``: a - ``pa.Table`` for ``return_format='table'``, a ``pa.RecordBatchReader`` - for ``'reader'``, a single ``pa.RecordBatch`` for ``'batch'``, or a - ``list[pa.RecordBatch]`` for ``'batches'``. + def __iter__(self) -> "Iterator[dict[str, Any]]": + """Iterate over rows as dictionaries. - Raises: - ValueError: If no Arrow data is available. + Returns: + Iterator of row dictionaries. """ - if self.data is None: - msg = "No Arrow data available for this result" - raise ValueError(msg) - return self.data - - def _as_table(self) -> "ArrowTable": - if self.data is None: - msg = "No Arrow table available for this result" - raise ValueError(msg) - return ensure_arrow_table(self.data) + return iter(arrow_table_to_pylist(self._as_table())) @property def column_names(self) -> "list[str]": @@ -863,6 +849,30 @@ def num_columns(self) -> int: """ return arrow_table_num_columns(self._as_table()) + def is_success(self) -> bool: + """Check if the operation was successful. + + Returns: + True if Arrow table data is available, False otherwise. + """ + return self.data is not None + + def get_data(self) -> "Any": + """Get the Arrow payload from the result. + + Returns the value shaped by ``arrow_table_to_return_format``: a + ``pa.Table`` for ``return_format='table'``, a ``pa.RecordBatchReader`` + for ``'reader'``, a single ``pa.RecordBatch`` for ``'batch'``, or a + ``list[pa.RecordBatch]`` for ``'batches'``. + + Raises: + ValueError: If no Arrow data is available. + """ + if self.data is None: + msg = "No Arrow data available for this result" + raise ValueError(msg) + return self.data + def to_pandas(self) -> "PandasDataFrame": """Convert Arrow data to pandas DataFrame. @@ -917,21 +927,11 @@ async def write_to_storage_async( table, destination, format_hint=format_hint, storage_options=storage_options, compression=compression ) - def __len__(self) -> int: - """Return number of rows in the Arrow table. - - Returns: - Number of rows. - """ - return arrow_table_num_rows(self._as_table()) - - def __iter__(self) -> "Iterator[dict[str, Any]]": - """Iterate over rows as dictionaries. - - Returns: - Iterator of row dictionaries. - """ - return iter(arrow_table_to_pylist(self._as_table())) + def _as_table(self) -> "ArrowTable": + if self.data is None: + msg = "No Arrow table available for this result" + raise ValueError(msg) + return ensure_arrow_table(self.data) class EmptyResult(StatementResult): @@ -965,8 +965,6 @@ class DMLResult(SQLResult): def __init__( self, op_type: "OperationType", rows_affected: int = 0, last_inserted_id: int | str | None = None ) -> None: - # Fast-path initialization for DML results: assign slot values directly - # instead of routing through SQLResult.__init__. self.statement = _EMPTY_RESULT_STATEMENT self.data = _EMPTY_RESULT_DATA self.rows_affected = rows_affected @@ -1002,7 +1000,6 @@ def get_data(self, *, schema_type: "type[SchemaT] | None" = None) -> "list[Any]" return [] def set_metadata(self, key: str, value: Any) -> None: - # Copy-on-write to preserve low-allocation defaults for hot DML paths. if self.metadata is _DEFAULT_DML_METADATA: self.metadata = {key: value} return @@ -1029,7 +1026,6 @@ def __init__( self.rows_affected = rows_affected else: try: - # Direct access on this compiled union currently segfaults mypyc during full-graph builds. result_rows = object.__getattribute__(self.result, "rows_affected") except AttributeError: self.rows_affected = 0 @@ -1039,11 +1035,6 @@ def __init__( self.warning = warning self.metadata = dict(metadata) if metadata else None - def get_result(self) -> "StatementResult | ArrowResult": - """Return the underlying driver result.""" - - return self.result - @property def result_type(self) -> str: """Describe the underlying result type (SQL operation, Arrow, or custom).""" @@ -1054,6 +1045,11 @@ def result_type(self) -> str: return self.result.operation_type return type(self.result).__name__.upper() + def get_result(self) -> "StatementResult | ArrowResult": + """Return the underlying driver result.""" + + return self.result + def is_sql_result(self) -> bool: """Return True when the underlying result is an SQLResult.""" diff --git a/sqlspec/core/splitter.py b/sqlspec/core/splitter.py index f38e9d8d9..9b2173e61 100644 --- a/sqlspec/core/splitter.py +++ b/sqlspec/core/splitter.py @@ -210,14 +210,6 @@ def get_all_token_patterns(self) -> "list[tuple[TokenType, TokenPattern]]": return patterns - def _get_dialect_specific_patterns(self) -> "list[tuple[TokenType, TokenPattern]]": - """Get dialect-specific token patterns. - - Returns: - List of dialect-specific token patterns - """ - return [] - @staticmethod def is_real_block_ender(tokens: "list[Token]", current_pos: int) -> bool: # noqa: ARG004 """Check if END keyword represents an actual block terminator. @@ -243,6 +235,14 @@ def should_delay_semicolon_termination(self, tokens: "list[Token]", current_pos: """ return False + def _get_dialect_specific_patterns(self) -> "list[tuple[TokenType, TokenPattern]]": + """Get dialect-specific token patterns. + + Returns: + List of dialect-specific token patterns + """ + return [] + class _EagerDialectConfig(DialectConfig): """Private eager base for built-in dialect configurations.""" @@ -313,10 +313,6 @@ class OracleDialectConfig(_EagerDialectConfig): _DIALECT_NAME = "oracle" _BLOCK_STARTERS = frozenset({"BEGIN", "DECLARE", "CASE"}) - def _create_special_terminators(self) -> "dict[str, SpecialTerminatorHandler]": - """Create Oracle special terminator handlers.""" - return {"/": self._handle_slash_terminator} - def should_delay_semicolon_termination(self, tokens: "list[Token]", current_pos: int) -> bool: """Check if semicolon termination should be delayed for Oracle slash terminators. @@ -339,35 +335,6 @@ def should_delay_semicolon_termination(self, tokens: "list[Token]", current_pos: return False - def _has_upcoming_slash(self, tokens: "list[Token]", current_pos: int) -> bool: - """Check if there's a slash terminator on its own line ahead. - - Args: - tokens: List of all tokens - current_pos: Current position in token list - - Returns: - True if slash terminator found on its own line, False otherwise - """ - pos = current_pos + 1 - found_newline = False - - while pos < len(tokens): - token = tokens[pos] - if token.type == TokenType.WHITESPACE: - if "\n" in token.value: - found_newline = True - pos += 1 - continue - if token.type == TokenType.TERMINATOR and token.value == "/": - return found_newline and self._handle_slash_terminator(tokens, pos) - if token.type in _COMMENT_TOKEN_TYPES: - pos += 1 - continue - break - - return False - @staticmethod def is_real_block_ender(tokens: "list[Token]", current_pos: int) -> bool: """Check if END keyword represents a block terminator in Oracle PL/SQL. @@ -399,6 +366,39 @@ def is_real_block_ender(tokens: "list[Token]", current_pos: int) -> bool: break return True + def _create_special_terminators(self) -> "dict[str, SpecialTerminatorHandler]": + """Create Oracle special terminator handlers.""" + return {"/": self._handle_slash_terminator} + + def _has_upcoming_slash(self, tokens: "list[Token]", current_pos: int) -> bool: + """Check if there's a slash terminator on its own line ahead. + + Args: + tokens: List of all tokens + current_pos: Current position in token list + + Returns: + True if slash terminator found on its own line, False otherwise + """ + pos = current_pos + 1 + found_newline = False + + while pos < len(tokens): + token = tokens[pos] + if token.type == TokenType.WHITESPACE: + if "\n" in token.value: + found_newline = True + pos += 1 + continue + if token.type == TokenType.TERMINATOR and token.value == "/": + return found_newline and self._handle_slash_terminator(tokens, pos) + if token.type in _COMMENT_TOKEN_TYPES: + pos += 1 + continue + break + + return False + @staticmethod def _handle_slash_terminator(tokens: "list[Token]", current_pos: int) -> bool: """Check if Oracle slash terminator is properly positioned. @@ -590,6 +590,26 @@ def __init__(self, dialect: DialectConfig, strip_trailing_semicolon: bool = Fals self._compiled_patterns = self._get_or_compile_patterns() + def split(self, sql: str) -> "list[str]": + """Split SQL script into individual statements. + + Args: + sql: The SQL script to split + + Returns: + List of individual SQL statements + """ + cache_key = CacheKey(("split", self._dialect.name, sql, self._strip_trailing_semicolon)) + + cached_result = self._result_cache.get(cache_key) + if cached_result is not None: + return cast("list[str]", cached_result) + + statements = self._do_split(sql) + + self._result_cache.put(cache_key, statements) + return statements + def _get_or_compile_patterns(self) -> "list[tuple[TokenType, CompiledTokenPattern]]": """Get compiled regex patterns from cache or compile and cache them. @@ -684,26 +704,6 @@ def _tokenize(self, sql: str) -> list[Token]: ) return tokens - def split(self, sql: str) -> "list[str]": - """Split SQL script into individual statements. - - Args: - sql: The SQL script to split - - Returns: - List of individual SQL statements - """ - cache_key = CacheKey(("split", self._dialect.name, sql, self._strip_trailing_semicolon)) - - cached_result = self._result_cache.get(cache_key) - if cached_result is not None: - return cast("list[str]", cached_result) - - statements = self._do_split(sql) - - self._result_cache.put(cache_key, statements) - return statements - def _do_split(self, sql: str) -> "list[str]": """Perform the actual SQL script splitting logic. @@ -739,7 +739,6 @@ def _do_split(self, sql: str) -> "list[str]": else: current_statement_writer.write(token.value) - # Keep token-list mutation centralized for whitespace/comment and executable paths. current_statement_tokens.append(token) if token.type in _IGNORABLE_TOKEN_TYPES: continue @@ -786,7 +785,7 @@ def _do_split(self, sql: str) -> "list[str]": self._strip_trailing_semicolon and token.type == TokenType.TERMINATOR and statement.endswith(token.value) - and not is_plsql_block + and (not is_plsql_block or token.value in special_terminators) ): statement = statement[: -len(token.value)].rstrip() @@ -818,9 +817,16 @@ def _is_plsql_block(tokens: "list[Token]") -> bool: Returns: True if tokens represent a PL/SQL block, False otherwise """ - for token in tokens: - if token.type == TokenType.KEYWORD: - return token.value.upper() in {"BEGIN", "DECLARE"} + keywords = [token.value.upper() for token in tokens if token.type == TokenType.KEYWORD] + if not keywords: + return False + first = keywords[0] + if first in {"BEGIN", "DECLARE"}: + return True + if first == "CREATE": + plsql_objects = {"PROCEDURE", "FUNCTION", "TRIGGER", "PACKAGE", "TYPE"} + if any(kw in plsql_objects for kw in keywords[1:6]) and ("BEGIN" in keywords or "END" in keywords): + return True return False def _contains_executable_content(self, tokens: "list[Token]") -> bool: diff --git a/sqlspec/core/sqlcommenter.py b/sqlspec/core/sqlcommenter.py index 551d310a8..dc1d4565f 100644 --- a/sqlspec/core/sqlcommenter.py +++ b/sqlspec/core/sqlcommenter.py @@ -136,7 +136,6 @@ def parse_comment(expression: exp.Expr) -> tuple[exp.Expr, dict[str, str]]: for comment in expression.comments: stripped = comment.strip() if _is_sqlcommenter_comment(stripped): - # Parse key='value' pairs for pair in stripped.split(","): eq_idx = pair.find("='") if eq_idx == -1: @@ -207,7 +206,6 @@ def _decode(raw: str) -> str: def _is_sqlcommenter_comment(comment: str) -> bool: """Check whether a comment string looks like a sqlcommenter payload.""" stripped = comment.strip() - # sqlcommenter comments have key='value' pairs return "='" in stripped and stripped.endswith("'") diff --git a/sqlspec/core/statement.py b/sqlspec/core/statement.py index 424939a8e..0e0bba94f 100644 --- a/sqlspec/core/statement.py +++ b/sqlspec/core/statement.py @@ -245,28 +245,8 @@ class SQL: __slots__ = SQL_SLOTS - # Type annotation for mypyc compatibility _sql_param_counters: "dict[str, int]" - @classmethod - def _create_cached_direct(cls, sql: str, config: "StatementConfig", processed_state: "ProcessedState") -> "SQL": - """Create a minimal SQL object for direct (fast-path) execution. - - Bypasses standard __init__ and parameter processing. - Only for use by direct execution paths. - """ - stmt = get_sql_pool().acquire() - stmt._raw_sql = sql - stmt._statement_config = config - stmt._dialect = stmt._normalize_dialect(config.dialect) - stmt._processed_state = processed_state - stmt._pooled = True - stmt._is_cache_direct = True - stmt._is_many = False - stmt._is_script = False - stmt._rebind_processor = None - return stmt - def __init__( self, statement: "str | exp.Expr | 'SQL'", @@ -319,21 +299,6 @@ def __init__( self._original_parameters = parameters self._process_parameters(*parameters, **kwargs) - def _create_auto_config( - self, _statement: "str | exp.Expr | 'SQL'", _parameters: tuple, _kwargs: "dict[str, Any]" - ) -> "StatementConfig": - """Create default StatementConfig when none provided. - - Args: - _statement: The SQL statement (unused) - _parameters: Statement parameters (unused) - _kwargs: Additional keyword arguments (unused) - - Returns: - Default StatementConfig instance - """ - return get_default_config() - def __reduce__(self) -> "tuple[Any, ...]": """Reconstruct via the public ctor with stored raw SQL, parameters, and filters.""" return ( @@ -358,7 +323,15 @@ def __hash__(self) -> int: raw_sql = self._materialized_raw_sql() is_many = self._is_many is_script = self._is_script - self._hash = hash((raw_sql, positional_tuple, named_tuple, is_many, is_script)) + self._hash = hash(( + raw_sql, + positional_tuple, + named_tuple, + is_many, + is_script, + self._dialect, + hash_filters(self._filters), + )) return self._hash def __eq__(self, other: object) -> bool: @@ -371,6 +344,8 @@ def __eq__(self, other: object) -> bool: and self._named_parameters == other._named_parameters and self._is_many == other._is_many and self._is_script == other._is_script + and self._dialect == other._dialect + and self._filters == other._filters ) def __repr__(self) -> str: @@ -391,158 +366,6 @@ def __repr__(self) -> str: return f"SQL({self._materialized_raw_sql()!r}{params_str}{flags_str})" - def reset(self) -> None: - """Reset SQL object for reuse in pooling scenarios.""" - if self._pooled and not self._compiled_from_cache and self._processed_state is not Empty: - get_processed_state_pool().release(self._processed_state) - self._compiled_from_cache = False - self._is_cache_direct = False - self._processed_state = Empty - self._hash = None - self._filters.clear() - self._named_parameters.clear() - self._positional_parameters.clear() - self._sql_param_counters.clear() - self._original_parameters = () - self._is_many = False - self._is_script = False - self._raw_expression = None - self._raw_sql = "" - self._statement_config = get_default_config() - self._dialect = self._normalize_dialect(self._statement_config.dialect) - self._rebind_processor = None - self._declared_parameters = () - - @staticmethod - def _normalize_dialect(dialect: "DialectType") -> "str | None": - """Convert dialect to string representation. - - Args: - dialect: Dialect type, string, or None - - Returns: - String representation of the dialect or None - """ - if dialect is None: - return None - if isinstance(dialect, str): - return dialect - if isinstance(dialect, type) and issubclass(dialect, Dialect): - return dialect.__name__.lower() - return type(dialect).__name__.lower() - - def _materialized_raw_sql(self) -> str: - """Return raw SQL, materializing deferred expression SQL when needed.""" - if self._raw_sql == "" and self._raw_expression is not None: - dialect = self._dialect - self._raw_sql = self._raw_expression.sql(dialect=str(dialect) if dialect else None) - return self._raw_sql - - def _init_from_sql_object(self, sql_obj: "SQL") -> None: - """Initialize instance attributes from existing SQL object. - - Args: - sql_obj: Existing SQL object to copy from - """ - self._raw_sql = sql_obj.raw_sql - self._raw_expression = sql_obj.raw_expression - self._filters = sql_obj.filters.copy() - self._named_parameters = sql_obj.named_parameters.copy() - self._positional_parameters = sql_obj.positional_parameters.copy() - self._sql_param_counters = sql_obj._sql_param_counters.copy() - self._is_many = sql_obj.is_many - self._is_script = sql_obj.is_script - self._declared_parameters = sql_obj._declared_parameters - source_state = sql_obj.get_processed_state() - if source_state is not Empty: - self._processed_state = source_state - - @staticmethod - def _should_auto_detect_many(parameters: tuple) -> bool: - """Detect execute_many mode from parameter structure. - - Args: - parameters: Parameter tuple to analyze - - Returns: - True if parameters indicate batch execution - """ - if len(parameters) == 1 and isinstance(parameters[0], list): - param_list = parameters[0] - if not param_list: - return False - first_item = param_list[0] - if isinstance(first_item, (tuple, list, dict)): - return len(param_list) > 1 - return False - - def _process_parameters(self, *parameters: Any, dialect: str | None = None, **kwargs: Any) -> None: - """Process and organize parameters and filters. - - Args: - *parameters: Variable parameters and filters - dialect: SQL dialect override - **kwargs: Additional named parameters - """ - if dialect is not None: - self._dialect = self._normalize_dialect(dialect) - - if "is_script" in kwargs: - self._is_script = bool(kwargs.pop("is_script")) - - self._filters.extend(self._extract_filters(parameters)) - self._normalize_parameters(parameters) - self._named_parameters.update(kwargs) - - @staticmethod - def _extract_filters(parameters: "tuple[Any, ...]") -> "list[StatementFilter]": - return [p for p in parameters if is_statement_filter(p)] - - def _add_parameter(self, param: Any) -> None: - if isinstance(param, dict): - self._named_parameters.update(param) - elif isinstance(param, (list, tuple)): - if self._is_many: - self._positional_parameters = list(param) - else: - self._positional_parameters.extend(param) - else: - self._positional_parameters.append(param) - - def _normalize_parameters(self, parameters: "tuple[Any, ...]") -> None: - if not parameters: - return - - # Optimization: Fast path for single parameter (most common case) - if len(parameters) == 1: - param = parameters[0] - # Fast check for simple types before filter check - if isinstance(param, (str, int, float, bool)) or param is None: - self._positional_parameters.append(param) - return - - if is_statement_filter(param): - return - - self._add_parameter(param) - return - - # Multiple parameters: check for filters - # O(N) check only if we have more than 1 param - has_filter = any(is_statement_filter(p) for p in parameters) - if not has_filter: - self._positional_parameters.extend(parameters) - return - - actual_params = [p for p in parameters if not is_statement_filter(p)] - if not actual_params: - return - - if len(actual_params) == 1: - self._add_parameter(actual_params[0]) - else: - self._positional_parameters.extend(actual_params) - @property def sql(self) -> str: """Get the raw SQL string (alias of :attr:`raw_sql`).""" @@ -621,24 +444,12 @@ def filters(self) -> "list[StatementFilter]": """Applied filters.""" return self._filters.copy() - def get_filters_view(self) -> "FiltersView": - """Get zero-copy filters view (public API). - - Returns: - Read-only view of filters without copying - """ - return FiltersView(self._filters) - @property def is_processed(self) -> bool: """Check if SQL has been processed (public API).""" state = self._processed_state return state is not Empty - def get_processed_state(self) -> Any: - """Get processed state (public API).""" - return self._processed_state - @property def dialect(self) -> "str | None": """SQL dialect.""" @@ -684,6 +495,40 @@ def has_errors(self) -> bool: return False return bool(state.validation_errors) + def reset(self) -> None: + """Reset SQL object for reuse in pooling scenarios.""" + if self._pooled and not self._compiled_from_cache and self._processed_state is not Empty: + get_processed_state_pool().release(self._processed_state) + self._compiled_from_cache = False + self._is_cache_direct = False + self._processed_state = Empty + self._hash = None + self._filters.clear() + self._named_parameters.clear() + self._positional_parameters.clear() + self._sql_param_counters.clear() + self._original_parameters = () + self._is_many = False + self._is_script = False + self._raw_expression = None + self._raw_sql = "" + self._statement_config = get_default_config() + self._dialect = self._normalize_dialect(self._statement_config.dialect) + self._rebind_processor = None + self._declared_parameters = () + + def get_filters_view(self) -> "FiltersView": + """Get zero-copy filters view (public API). + + Returns: + Read-only view of filters without copying + """ + return FiltersView(self._filters) + + def get_processed_state(self) -> Any: + """Get processed state (public API).""" + return self._processed_state + def returns_rows(self) -> bool: """Check if statement returns rows. @@ -753,7 +598,7 @@ def compile(self) -> "tuple[str, Any]": try: config = self._statement_config - mixed = self._mixed_parameter_inputs() + mixed = self._resolve_mixed_parameter_inputs() raw_sql = mixed[0] if mixed is not None else self._materialized_raw_sql() params = mixed[1] if mixed is not None else self._named_parameters or self._positional_parameters is_many = self._is_many @@ -794,111 +639,6 @@ def compile(self) -> "tuple[str, Any]": self._compiled_from_cache = False return new_state.compiled_sql, new_state.execution_parameters - def _mixed_parameter_inputs(self) -> "tuple[str, dict[str, Any]] | None": - """Normalize coexisting positional and named values without losing either.""" - positional, named = self._positional_parameters, self._named_parameters - if not positional or not named or self._is_many: - return None - raw = self._materialized_raw_sql() - infos = self._statement_config.parameter_validator.extract_parameters(raw) - ordinal_styles = {ParameterStyle.QMARK, ParameterStyle.POSITIONAL_PYFORMAT} - index_styles = {ParameterStyle.NUMERIC, ParameterStyle.POSITIONAL_COLON} - positional_styles = ordinal_styles | index_styles - styles = {info.style for info in infos if info.style in positional_styles} - unbound = [info for info in infos if info.style not in positional_styles and info.name not in named] - if len(styles) > 1 or (styles & index_styles and unbound): - return None - selected = [info for info in infos if info.style in positional_styles or info.name not in named] - if not selected: - return None - slots: dict[str, int] = {} - indexes: list[int] = [] - counter = 0 - for info in selected: - if info.style in index_styles: - index = int(info.name or "0") - 1 - elif info.style in ordinal_styles: - index = counter - counter += 1 - elif info.name in slots: - index = slots[info.name] - else: - index = counter - slots[info.name or ""] = index - counter += 1 - if index < 0 or index >= len(positional): - return None - indexes.append(index) - if not styles & index_styles and counter != len(positional): - return None - names = {index: name for name, index in slots.items()} - used = set(named) | set(slots) - for index in indexes: - if index not in names: - name = f"param_{index}" - while name in used: - name += "_p" - names[index] = name - used.add(name) - for info, index in list(zip(selected, indexes, strict=True))[::-1]: - if info.style in positional_styles: - raw = raw[: info.position] + ":" + names[index] + raw[info.position + len(info.placeholder_text) :] - return raw, {**{name: positional[index] for index, name in names.items()}, **named} - - def _rebind_cached_parameters(self, state: "ProcessedState") -> "tuple[str, Any]": - mixed = self._mixed_parameter_inputs() - params = mixed[1] if mixed is not None else self._named_parameters or self._positional_parameters - if self._rebind_processor is None: - self._rebind_processor = ParameterProcessor( - converter=self._statement_config.parameter_converter, - validator=self._statement_config.parameter_validator, - cache_max_size=0, - ) - processor = self._rebind_processor - rebound_params = processor.transform_cached_parameters( - params, - state.parameter_profile, - self._statement_config.parameter_config, - input_named_parameters=state.input_named_parameters, - is_many=self._is_many, - apply_wrap_types=state.applied_wrap_types, - ) - compiled_sql = state.compiled_sql - output_transformer = self._statement_config.output_transformer - if output_transformer: - compiled_sql, rebound_params = output_transformer(compiled_sql, rebound_params) - self._processed_state = self._build_processed_state( - compiled_sql=compiled_sql, - execution_parameters=rebound_params, - parsed_expression=state.parsed_expression, - operation_type=state.operation_type, - input_named_parameters=state.input_named_parameters, - applied_wrap_types=state.applied_wrap_types, - filter_hash=state.filter_hash, - parameter_fingerprint=state.parameter_fingerprint, - parameter_casts=state.parameter_casts, - parameter_profile=state.parameter_profile, - operation_profile=state.operation_profile, - validation_errors=state.validation_errors.copy(), - is_many=state.is_many, - ) - self._compiled_from_cache = False - return compiled_sql, rebound_params - - def _can_reuse_cached_state(self, state: "ProcessedState") -> bool: - if self._statement_config.parameter_config.ast_transformer is not None: - return False - cached_fingerprint = state.parameter_fingerprint - if cached_fingerprint is None: - return False - if state.is_many != self._is_many: - return False - if state.filter_hash != hash_filters(self._filters): - return False - mixed = self._mixed_parameter_inputs() - params = mixed[1] if mixed is not None else self._named_parameters or self._positional_parameters - return bool(structural_fingerprint(params, is_many=self._is_many) == cached_fingerprint) - def as_script(self) -> "SQL": """Create copy marked for script execution. @@ -929,7 +669,6 @@ def copy(self, statement: "str | exp.Expr | None" = None, parameters: Any | None Returns: New SQL instance with modifications applied """ - # FAST PATH: Only parameters are changing if statement is None and not kwargs and parameters is not None and not isinstance(parameters, (str, bytes)): new_sql = self._create_empty_copy() new_sql._process_parameters(*(parameters if isinstance(parameters, tuple) else (parameters,))) @@ -952,300 +691,98 @@ def copy(self, statement: "str | exp.Expr | None" = None, parameters: Any | None new_sql._declared_parameters = self._declared_parameters return new_sql - def _create_empty_copy(self) -> "SQL": - """Create a shell copy with shared immutable state but empty mutable state.""" - new_sql = get_sql_pool().acquire() - new_sql._raw_sql = self._raw_sql - new_sql._raw_expression = self._raw_expression - new_sql._statement_config = self._statement_config - new_sql._dialect = self._dialect - new_sql._is_many = self._is_many - new_sql._is_script = self._is_script - new_sql._original_parameters = () - new_sql._pooled = True + def add_named_parameter(self, name: str, value: Any) -> "SQL": + """Add a named parameter and return a new SQL instance. - # Reset mutable state - source_state = self._processed_state - is_processed = source_state is not Empty - new_sql._compiled_from_cache = is_processed - new_sql._processed_state = source_state if is_processed else Empty - new_sql._hash = None - new_sql._filters = self._filters.copy() - new_sql._named_parameters = {} - new_sql._positional_parameters = [] - new_sql._sql_param_counters = self._sql_param_counters.copy() - new_sql._declared_parameters = self._declared_parameters + Args: + name: Parameter name + value: Parameter value + Returns: + New SQL instance with the added parameter + """ + statement_seed = self._raw_expression or self._raw_sql + new_sql = self._copy_base(statement_seed) + new_sql._named_parameters[name] = value return new_sql - def _build_processed_state( - self, - *, - compiled_sql: str, - execution_parameters: Any, - parsed_expression: "exp.Expr | None", - operation_type: "OperationType", - input_named_parameters: "tuple[str, ...] | None", - applied_wrap_types: bool, - filter_hash: int, - parameter_fingerprint: Any | None, - parameter_casts: "dict[int, str] | None", - parameter_profile: "ParameterProfile | None", - operation_profile: "OperationProfile | None", - validation_errors: "list[str] | None", - is_many: bool, - ) -> "ProcessedState": - state = get_processed_state_pool().acquire() - ProcessedState.__init__( - state, - compiled_sql=compiled_sql, - execution_parameters=execution_parameters, - parsed_expression=parsed_expression, - operation_type=operation_type, - input_named_parameters=input_named_parameters, - applied_wrap_types=applied_wrap_types, - filter_hash=filter_hash, - parameter_fingerprint=parameter_fingerprint, - parameter_casts=parameter_casts, - validation_errors=validation_errors, - parameter_profile=parameter_profile, - operation_profile=operation_profile, - is_many=is_many, - ) - return state + def where(self, condition: "str | exp.Expr") -> "SQL": + """Add WHERE condition to the SQL statement. - def _handle_compile_failure(self, error: Exception) -> ProcessedState: - logger.debug("Processing failed, using fallback: %s", error, exc_info=(type(error), error, error.__traceback__)) - mixed = self._mixed_parameter_inputs() - params = mixed[1] if mixed is not None else self._named_parameters or self._positional_parameters - return self._build_processed_state( - compiled_sql=mixed[0] if mixed is not None else self._materialized_raw_sql(), - execution_parameters=params, - parsed_expression=None, - operation_type="COMMAND", - input_named_parameters=(), - applied_wrap_types=False, - filter_hash=hash_filters(self._filters), - parameter_fingerprint=structural_fingerprint(params, is_many=self._is_many), - parameter_casts={}, - parameter_profile=ParameterProfile.empty(), - operation_profile=OperationProfile.empty(), - validation_errors=[str(error)], - is_many=self._is_many, - ) + Args: + condition: WHERE condition as string or SQLGlot expression - def _next_parameter_name(self, base_name: str) -> str: - """Generate unique parameter name with param_ prefix. + Returns: + New SQL instance with the WHERE condition applied + """ + current_expr = self._current_expression() - Uses param_ prefix to avoid collision with user-provided parameters. - Auto-generated parameters are namespaced to prevent conflicts. + condition_expr: exp.Expr + if isinstance(condition, str): + if not self._statement_config.enable_parsing: + condition_expr = exp.Paren(this=exp.Var(this=condition)) + else: + try: + condition_expr = sqlglot.parse_one(condition, dialect=self._dialect, into=exp.Condition) + except ParseError: + condition_expr = exp.Paren(this=exp.Var(this=condition)) + else: + condition_expr = condition + + if isinstance(current_expr, (exp.Select, exp.Update, exp.Delete)) or ( + not isinstance(current_expr, (exp.Query, exp.Values)) and supports_where(current_expr) + ): + new_expr = current_expr.where(condition_expr, copy=False) + else: + new_expr = wrap_as_subquery(current_expr).where(condition_expr, copy=False) + + return self._copy_with_expression(new_expr) + + def where_eq(self, column: "str | exp.Column", value: Any) -> "SQL": + """Add WHERE column = value condition. Args: - base_name: The base name for the parameter + column: Column name or expression + value: Value to compare against Returns: - A unique parameter name that doesn't exist in current parameters + New SQL instance with WHERE condition applied """ - prefixed_base = f"param_{base_name}" - current_index = self._sql_param_counters.get(prefixed_base, 0) + return self._where_comparison(column, value, expr_eq) - if prefixed_base not in self._named_parameters: - self._sql_param_counters[prefixed_base] = current_index - return prefixed_base + def where_neq(self, column: "str | exp.Column", value: Any) -> "SQL": + """Add WHERE column != value condition. - next_index = current_index + 1 - candidate = f"{prefixed_base}_{next_index}" + Args: + column: Column name or expression + value: Value to compare against - while candidate in self._named_parameters: - next_index += 1 - if next_index > _MAX_PARAM_COLLISION_ATTEMPTS: - return f"{prefixed_base}_{uuid4().hex[:8]}" - candidate = f"{prefixed_base}_{next_index}" + Returns: + New SQL instance with WHERE condition applied + """ + return self._where_comparison(column, value, expr_neq) - self._sql_param_counters[prefixed_base] = next_index - return candidate + def where_lt(self, column: "str | exp.Column", value: Any) -> "SQL": + """Add WHERE column < value condition. - def _current_expression(self) -> exp.Expr: - """Get the current expression or parse the raw SQL. + Args: + column: Column name or expression + value: Value to compare against - Preserves authoring-time parameter names for dynamic query modifiers, - then falls back to cached parsed expressions. + Returns: + New SQL instance with WHERE condition applied + """ + return self._where_comparison(column, value, expr_lt) + + def where_lte(self, column: "str | exp.Column", value: Any) -> "SQL": + """Add WHERE column <= value condition. + + Args: + column: Column name or expression + value: Value to compare against Returns: - The SQLGlot expression for this statement - """ - # Preserve authoring-time parameter names when applying dynamic query modifiers. - raw_sql = self._raw_sql - state = self._processed_state - if state is not Empty and state.input_named_parameters and self._raw_expression is None and raw_sql: - try: - parsed = sqlglot.parse_one(unescape_qmark_operator(raw_sql)[0], dialect=self._dialect) - if isinstance(parsed, exp.Expr): - return parsed - except ParseError: - pass - if state is not Empty and state.parsed_expression is not None: - return state.parsed_expression.copy() - # Then check statement_expression (from compilation) - if self.statement_expression is not None: - return self.statement_expression.copy() - # Then check raw_expression (from construction) - if self._raw_expression is not None: - return self._raw_expression.copy() - # Fall back to parsing if enabled - if not self._statement_config.enable_parsing: - return self._wrapped_raw_sql_expression(raw_sql) - try: - parsed = sqlglot.parse_one(unescape_qmark_operator(raw_sql)[0], dialect=self._dialect) - if isinstance(parsed, exp.Expr): - return parsed - return self._wrapped_raw_sql_expression(raw_sql) - except ParseError: - return self._wrapped_raw_sql_expression(raw_sql) - - def _wrapped_raw_sql_expression(self, raw_sql: str) -> exp.Expr: - if not raw_sql.strip(): - msg = "Cannot build an expression from an empty SQL statement." - raise sqlspec.exceptions.SQLParsingError(msg) - try: - return exp.Select().select("*").from_("(" + unescape_qmark_operator(raw_sql)[0] + ") AS filtered") - except ParseError as exc: - msg = f"Failed to parse SQL statement: {exc}" - raise sqlspec.exceptions.SQLParsingError(msg) from exc - - def _filter_expression(self) -> exp.Expr: - """Return a mutable expression copy for statement filters. - - Filter implementations may modify the returned expression in place. The - expression is detached from cached processed state and raw expressions. - """ - return self._current_expression() - - def _copy_with_expression(self, new_expr: "exp.Expr") -> "SQL": - """Create a new SQL instance with a modified expression. - - Args: - new_expr: The new SQLGlot expression - - Returns: - New SQL instance with the expression and copied state - """ - new_sql = self._copy_base(new_expr) - new_sql._sql_param_counters = self._sql_param_counters.copy() - return new_sql - - def _take_pending_filters(self) -> "tuple[SQL, list[StatementFilter]]": - if not self._filters: - return self, [] - copied = self._copy_base(self._raw_expression or self._raw_sql) - copied._filters = [] - return copied, self._filters.copy() - - def _copy_base(self, statement_seed: "str | exp.Expr") -> "SQL": - new_sql = SQL( - statement_seed, - *self._original_parameters, - statement_config=self._statement_config, - is_many=self._is_many, - declared_parameters=self._declared_parameters, - ) - new_sql._named_parameters.update(self._named_parameters) - new_sql._positional_parameters = self._positional_parameters.copy() - new_sql._filters = self._filters.copy() - return new_sql - - def add_named_parameter(self, name: str, value: Any) -> "SQL": - """Add a named parameter and return a new SQL instance. - - Args: - name: Parameter name - value: Parameter value - - Returns: - New SQL instance with the added parameter - """ - statement_seed = self._raw_expression or self._raw_sql - new_sql = self._copy_base(statement_seed) - new_sql._named_parameters[name] = value - return new_sql - - def where(self, condition: "str | exp.Expr") -> "SQL": - """Add WHERE condition to the SQL statement. - - Args: - condition: WHERE condition as string or SQLGlot expression - - Returns: - New SQL instance with the WHERE condition applied - """ - current_expr = self._current_expression() - - condition_expr: exp.Expr - if isinstance(condition, str): - if not self._statement_config.enable_parsing: - condition_expr = exp.Paren(this=exp.Var(this=condition)) - else: - try: - condition_expr = sqlglot.parse_one(condition, dialect=self._dialect, into=exp.Condition) - except ParseError: - condition_expr = exp.Paren(this=exp.Var(this=condition)) - else: - condition_expr = condition - - if isinstance(current_expr, (exp.Select, exp.Update, exp.Delete)) or ( - not isinstance(current_expr, (exp.Query, exp.Values)) and supports_where(current_expr) - ): - new_expr = current_expr.where(condition_expr, copy=False) - else: - new_expr = wrap_as_subquery(current_expr).where(condition_expr, copy=False) - - return self._copy_with_expression(new_expr) - - def where_eq(self, column: "str | exp.Column", value: Any) -> "SQL": - """Add WHERE column = value condition. - - Args: - column: Column name or expression - value: Value to compare against - - Returns: - New SQL instance with WHERE condition applied - """ - return self._where_comparison(column, value, expr_eq) - - def where_neq(self, column: "str | exp.Column", value: Any) -> "SQL": - """Add WHERE column != value condition. - - Args: - column: Column name or expression - value: Value to compare against - - Returns: - New SQL instance with WHERE condition applied - """ - return self._where_comparison(column, value, expr_neq) - - def where_lt(self, column: "str | exp.Column", value: Any) -> "SQL": - """Add WHERE column < value condition. - - Args: - column: Column name or expression - value: Value to compare against - - Returns: - New SQL instance with WHERE condition applied - """ - return self._where_comparison(column, value, expr_lt) - - def where_lte(self, column: "str | exp.Column", value: Any) -> "SQL": - """Add WHERE column <= value condition. - - Args: - column: Column name or expression - value: Value to compare against - - Returns: - New SQL instance with WHERE condition applied + New SQL instance with WHERE condition applied """ return self._where_comparison(column, value, expr_lte) @@ -1365,52 +902,6 @@ def where_between(self, column: "str | exp.Column", low: Any, high: Any) -> "SQL new_sql._named_parameters[high_param] = high return new_sql - def _where_condition(self, condition: exp.Expr) -> "SQL": - expression = self._current_expression() - new_expr = safe_modify_with_cte(expression, lambda e: apply_where(e, condition)) - return self._copy_with_expression(new_expr) - - def _where_comparison( - self, - column: "str | exp.Column", - value: Any, - condition_factory: "Callable[[exp.Expr, exp.Placeholder], exp.Expr]", - ) -> "SQL": - col_name = extract_column_name(column) - param_name = self._next_parameter_name(col_name) - condition = create_condition(column, param_name, condition_factory) - new_sql = self._where_condition(condition) - new_sql._named_parameters[param_name] = value - return new_sql - - def _where_sequence_membership( - self, - column: "str | exp.Column", - values: "Sequence[Any]", - parameter_suffix: str, - condition_factory: "Callable[[str | exp.Column, list[str]], exp.Expr]", - *, - empty_is_false: bool, - ) -> "SQL": - if not values: - if not empty_is_false: - return self - false_condition = exp.EQ(this=exp.Literal.number(1), expression=exp.Literal.number(0)) - return self._where_condition(false_condition) - - col_name = extract_column_name(column) - param_names: list[str] = [] - param_values: dict[str, Any] = {} - for index, value in enumerate(values): - param_name = self._next_parameter_name(f"{col_name}_{parameter_suffix}_{index}") - param_names.append(param_name) - param_values[param_name] = value - - condition = condition_factory(column, param_names) - new_sql = self._where_condition(condition) - new_sql._named_parameters.update(param_values) - return new_sql - def order_by(self, *items: "str | exp.Expr", desc: bool = False) -> "SQL": """Add ORDER BY clause to the SQL statement. @@ -1507,12 +998,10 @@ def select_only(self, *columns: "str | exp.Expr", prune_columns: bool | None = N expression = self._current_expression() new_expr = safe_modify_with_cte(expression, lambda e: apply_select_only(e, columns)) - # Determine whether to apply column pruning should_prune = prune_columns if prune_columns is not None else self._statement_config.enable_column_pruning if should_prune: if self._statement_config.enable_caching: - # Use stable content-based key instead of object id (which can be reused after GC) expr_sql = new_expr.sql(dialect=self._dialect) cache_key = f"prune:{hashlib.blake2b(expr_sql.encode(), digest_size=8).hexdigest()}" else: @@ -1541,113 +1030,639 @@ def explain(self, analyze: bool = False, verbose: bool = False, format: "str | N if format is not None: fmt = ExplainFormat(format.lower()) - options = ExplainOptions(analyze=analyze, verbose=verbose, format=fmt) + options = ExplainOptions(analyze=analyze, verbose=verbose, format=fmt) + + explain_builder = Explain(self, dialect=self._dialect, options=options) + return explain_builder.build() + + def builder(self, dialect: "DialectType | None" = None) -> "QueryBuilder": + """Create a query builder seeded from this SQL statement. + + Args: + dialect: Optional SQL dialect override for parsing and rendering. + + Returns: + QueryBuilder instance initialized with the parsed statement. + + Raises: + SQLBuilderError: If the statement cannot be parsed. + """ + if self._is_many: + msg = "QueryBuilder does not support execute_many SQL statements." + raise sqlspec.exceptions.SQLBuilderError(msg) + + from sqlspec.builder import Delete, ExpressionBuilder, Insert, Merge, Select, Update + + builder_dialect = dialect or self._dialect + converter = self._statement_config.parameter_converter or ParameterConverter( + self._statement_config.parameter_validator + ) + raw_params = self.parameters + raw_sql_for_builder = self._materialized_raw_sql() + converted_sql, converted_params = converter.convert_placeholder_style( + raw_sql_for_builder, raw_params, ParameterStyle.NAMED_COLON, is_many=False + ) + state = self._processed_state + + if ( + self._raw_expression is not None + and converted_sql == raw_sql_for_builder + and (builder_dialect == self._dialect) + ): + expression = self._raw_expression.copy() + elif ( + state is not Empty + and state.parsed_expression is not None + and converted_sql == raw_sql_for_builder + and builder_dialect == self._dialect + ): + expression = state.parsed_expression.copy() + else: + try: + expression = sqlglot.parse_one(converted_sql, dialect=builder_dialect) + except ParseError as exc: + msg = f"Failed to parse SQL for builder: {exc}" + raise sqlspec.exceptions.SQLBuilderError(msg) from exc + + base_expression = expression + ctes: list[exp.CTE] | None = None + if isinstance(expression, exp.With): + if expression.this is None: + msg = "WITH expression does not include a base statement." + raise sqlspec.exceptions.SQLBuilderError(msg) + base_expression = expression.this + ctes = list(expression.expressions) + + builder: QueryBuilder + if isinstance(base_expression, (exp.Select, exp.Union, exp.Except, exp.Intersect, exp.Values)): + builder = Select(dialect=builder_dialect) + builder.set_expression(base_expression) + elif isinstance(base_expression, exp.Insert): + builder = Insert(dialect=builder_dialect) + builder.set_expression(base_expression) + elif isinstance(base_expression, exp.Update): + builder = Update(dialect=builder_dialect) + builder.set_expression(base_expression) + elif isinstance(base_expression, exp.Delete): + builder = Delete(dialect=builder_dialect) + builder.set_expression(base_expression) + elif isinstance(base_expression, exp.Merge): + builder = Merge(dialect=builder_dialect) + builder.set_expression(base_expression) + else: + if not isinstance(base_expression, exp.Expression): + msg = f"Unsupported expression type for builder: {type(base_expression).__name__}" + raise sqlspec.exceptions.SQLBuilderError(msg) + builder = ExpressionBuilder(base_expression, dialect=builder_dialect) + + if ctes: + builder.load_ctes(ctes) + + if isinstance(converted_params, Mapping): + builder.load_parameters(converted_params) + return builder + + if ( + converted_params + and isinstance(converted_params, Sequence) + and not isinstance(converted_params, (str, bytes, bytearray)) + ): + param_info = converter.validator.extract_parameters(converted_sql) + param_map: dict[str, Any] = {} + for index, param in enumerate(param_info): + if index >= len(converted_params): + break + param_name = param.name or f"param_{param.ordinal}" + param_map[param_name] = converted_params[index] + builder.load_parameters(param_map) + + return builder + + @classmethod + def _create_cached_direct(cls, sql: str, config: "StatementConfig", processed_state: "ProcessedState") -> "SQL": + """Create a minimal SQL object for direct (fast-path) execution. + + Bypasses standard __init__ and parameter processing. + Only for use by direct execution paths. + """ + stmt = get_sql_pool().acquire() + stmt._raw_sql = sql + stmt._statement_config = config + stmt._dialect = stmt._normalize_dialect(config.dialect) + stmt._processed_state = processed_state + stmt._pooled = True + stmt._is_cache_direct = True + stmt._is_many = False + stmt._is_script = False + stmt._rebind_processor = None + return stmt + + def _create_auto_config( + self, _statement: "str | exp.Expr | 'SQL'", _parameters: tuple, _kwargs: "dict[str, Any]" + ) -> "StatementConfig": + """Create default StatementConfig when none provided. + + Args: + _statement: The SQL statement (unused) + _parameters: Statement parameters (unused) + _kwargs: Additional keyword arguments (unused) + + Returns: + Default StatementConfig instance + """ + return get_default_config() + + @staticmethod + def _normalize_dialect(dialect: "DialectType") -> "str | None": + """Convert dialect to string representation. + + Args: + dialect: Dialect type, string, or None + + Returns: + String representation of the dialect or None + """ + if dialect is None: + return None + if isinstance(dialect, str): + return dialect + if isinstance(dialect, type) and issubclass(dialect, Dialect): + return dialect.__name__.lower() + return type(dialect).__name__.lower() + + def _materialized_raw_sql(self) -> str: + """Return raw SQL, materializing deferred expression SQL when needed.""" + if self._raw_sql == "" and self._raw_expression is not None: + dialect = self._dialect + self._raw_sql = self._raw_expression.sql(dialect=str(dialect) if dialect else None) + return self._raw_sql + + def _init_from_sql_object(self, sql_obj: "SQL") -> None: + """Initialize instance attributes from existing SQL object. + + Args: + sql_obj: Existing SQL object to copy from + """ + self._raw_sql = sql_obj.raw_sql + self._raw_expression = sql_obj.raw_expression + self._filters = sql_obj.filters.copy() + self._named_parameters = sql_obj.named_parameters.copy() + self._positional_parameters = sql_obj.positional_parameters.copy() + self._sql_param_counters = sql_obj._sql_param_counters.copy() + self._is_many = sql_obj.is_many + self._is_script = sql_obj.is_script + self._declared_parameters = sql_obj._declared_parameters + source_state = sql_obj.get_processed_state() + if source_state is not Empty: + self._processed_state = source_state + + @staticmethod + def _should_auto_detect_many(parameters: tuple) -> bool: + """Detect execute_many mode from parameter structure. + + Args: + parameters: Parameter tuple to analyze + + Returns: + True if parameters indicate batch execution + """ + if len(parameters) == 1 and isinstance(parameters[0], list): + param_list = parameters[0] + if not param_list: + return False + first_item = param_list[0] + if isinstance(first_item, (tuple, list, dict)): + return len(param_list) > 1 + return False + + def _process_parameters(self, *parameters: Any, dialect: str | None = None, **kwargs: Any) -> None: + """Process and organize parameters and filters. + + Args: + *parameters: Variable parameters and filters + dialect: SQL dialect override + **kwargs: Additional named parameters + """ + if dialect is not None: + self._dialect = self._normalize_dialect(dialect) + + if "is_script" in kwargs: + self._is_script = bool(kwargs.pop("is_script")) + + self._filters.extend(self._extract_filters(parameters)) + self._normalize_parameters(parameters) + self._named_parameters.update(kwargs) + + @staticmethod + def _extract_filters(parameters: "tuple[Any, ...]") -> "list[StatementFilter]": + return [p for p in parameters if is_statement_filter(p)] + + def _add_parameter(self, param: Any) -> None: + if isinstance(param, dict): + self._named_parameters.update(param) + elif isinstance(param, (list, tuple)): + if self._is_many: + self._positional_parameters = list(param) + else: + self._positional_parameters.extend(param) + else: + self._positional_parameters.append(param) + + def _normalize_parameters(self, parameters: "tuple[Any, ...]") -> None: + if not parameters: + return + + if len(parameters) == 1: + param = parameters[0] + if isinstance(param, (str, int, float, bool)) or param is None: + self._positional_parameters.append(param) + return + + if is_statement_filter(param): + return + + self._add_parameter(param) + return + + has_filter = any(is_statement_filter(p) for p in parameters) + if not has_filter: + self._positional_parameters.extend(parameters) + return + + actual_params = [p for p in parameters if not is_statement_filter(p)] + if not actual_params: + return + + if len(actual_params) == 1: + self._add_parameter(actual_params[0]) + else: + self._positional_parameters.extend(actual_params) + + def _mixed_parameter_inputs(self) -> "tuple[str, dict[str, Any]] | None": + """Normalize coexisting positional and named values without losing either.""" + positional, named = self._positional_parameters, self._named_parameters + if not positional or not named or self._is_many: + return None + raw = self._materialized_raw_sql() + infos = self._statement_config.parameter_validator.extract_parameters(raw) + ordinal_styles = {ParameterStyle.QMARK, ParameterStyle.POSITIONAL_PYFORMAT} + index_styles = {ParameterStyle.NUMERIC, ParameterStyle.POSITIONAL_COLON} + positional_styles = ordinal_styles | index_styles + styles = {info.style for info in infos if info.style in positional_styles} + unbound = [info for info in infos if info.style not in positional_styles and info.name not in named] + if len(styles) > 1 or (styles & index_styles and unbound): + return None + selected = [info for info in infos if info.style in positional_styles or info.name not in named] + if not selected: + return None + slots: dict[str, int] = {} + indexes: list[int] = [] + counter = 0 + for info in selected: + if info.style in index_styles: + index = int(info.name or "0") - 1 + elif info.style in ordinal_styles: + index = counter + counter += 1 + elif info.name in slots: + index = slots[info.name] + else: + index = counter + slots[info.name or ""] = index + counter += 1 + if index < 0 or index >= len(positional): + return None + indexes.append(index) + if not styles & index_styles and counter != len(positional): + return None + names = {index: name for name, index in slots.items()} + used = set(named) | set(slots) + for index in indexes: + if index not in names: + name = f"param_{index}" + while name in used: + name += "_p" + names[index] = name + used.add(name) + for info, index in list(zip(selected, indexes, strict=True))[::-1]: + if info.style in positional_styles: + raw = raw[: info.position] + ":" + names[index] + raw[info.position + len(info.placeholder_text) :] + return raw, {**{name: positional[index] for index, name in names.items()}, **named} + + def _resolve_mixed_parameter_inputs(self) -> "tuple[str, dict[str, Any]] | None": + mixed = self._mixed_parameter_inputs() + if mixed is None and self._positional_parameters and self._named_parameters and not self._is_many: + msg = "Cannot combine positional and named parameters when placeholder styles or counts do not match." + raise sqlspec.exceptions.SQLSpecError(msg) + return mixed + + def _rebind_cached_parameters(self, state: "ProcessedState") -> "tuple[str, Any]": + mixed = self._resolve_mixed_parameter_inputs() + params = mixed[1] if mixed is not None else self._named_parameters or self._positional_parameters + if self._rebind_processor is None: + self._rebind_processor = ParameterProcessor( + converter=self._statement_config.parameter_converter, + validator=self._statement_config.parameter_validator, + cache_max_size=0, + ) + processor = self._rebind_processor + rebound_params = processor.transform_cached_parameters( + params, + state.parameter_profile, + self._statement_config.parameter_config, + input_named_parameters=state.input_named_parameters, + is_many=self._is_many, + apply_wrap_types=state.applied_wrap_types, + ) + compiled_sql = state.compiled_sql + output_transformer = self._statement_config.output_transformer + if output_transformer: + compiled_sql, rebound_params = output_transformer(compiled_sql, rebound_params) + self._processed_state = self._build_processed_state( + compiled_sql=compiled_sql, + execution_parameters=rebound_params, + parsed_expression=state.parsed_expression.copy() if state.parsed_expression is not None else None, + operation_type=state.operation_type, + input_named_parameters=state.input_named_parameters, + applied_wrap_types=state.applied_wrap_types, + filter_hash=state.filter_hash, + parameter_fingerprint=state.parameter_fingerprint, + parameter_casts=state.parameter_casts, + parameter_profile=state.parameter_profile, + operation_profile=state.operation_profile, + validation_errors=state.validation_errors.copy(), + is_many=state.is_many, + ) + self._compiled_from_cache = False + return compiled_sql, rebound_params + + def _can_reuse_cached_state(self, state: "ProcessedState") -> bool: + if self._statement_config.parameter_config.ast_transformer is not None: + return False + cached_fingerprint = state.parameter_fingerprint + if cached_fingerprint is None: + return False + if state.is_many != self._is_many: + return False + if state.filter_hash != hash_filters(self._filters): + return False + mixed = self._mixed_parameter_inputs() + params = mixed[1] if mixed is not None else self._named_parameters or self._positional_parameters + return bool(structural_fingerprint(params, is_many=self._is_many) == cached_fingerprint) + + def _create_empty_copy(self) -> "SQL": + """Create a shell copy with shared immutable state but empty mutable state.""" + new_sql = get_sql_pool().acquire() + new_sql._raw_sql = self._raw_sql + new_sql._raw_expression = self._raw_expression + new_sql._statement_config = self._statement_config + new_sql._dialect = self._dialect + new_sql._is_many = self._is_many + new_sql._is_script = self._is_script + new_sql._original_parameters = () + new_sql._pooled = True + + source_state = self._processed_state + is_processed = source_state is not Empty + new_sql._compiled_from_cache = is_processed + new_sql._processed_state = source_state if is_processed else Empty + new_sql._hash = None + new_sql._filters = self._filters.copy() + new_sql._named_parameters = {} + new_sql._positional_parameters = [] + new_sql._sql_param_counters = self._sql_param_counters.copy() + new_sql._declared_parameters = self._declared_parameters + + return new_sql + + def _build_processed_state( + self, + *, + compiled_sql: str, + execution_parameters: Any, + parsed_expression: "exp.Expr | None", + operation_type: "OperationType", + input_named_parameters: "tuple[str, ...] | None", + applied_wrap_types: bool, + filter_hash: int, + parameter_fingerprint: Any | None, + parameter_casts: "dict[int, str] | None", + parameter_profile: "ParameterProfile | None", + operation_profile: "OperationProfile | None", + validation_errors: "list[str] | None", + is_many: bool, + ) -> "ProcessedState": + state = get_processed_state_pool().acquire() + ProcessedState.__init__( + state, + compiled_sql=compiled_sql, + execution_parameters=execution_parameters, + parsed_expression=parsed_expression, + operation_type=operation_type, + input_named_parameters=input_named_parameters, + applied_wrap_types=applied_wrap_types, + filter_hash=filter_hash, + parameter_fingerprint=parameter_fingerprint, + parameter_casts=parameter_casts, + validation_errors=validation_errors, + parameter_profile=parameter_profile, + operation_profile=operation_profile, + is_many=is_many, + ) + return state + + def _handle_compile_failure(self, error: Exception) -> ProcessedState: + logger.debug("Processing failed, using fallback: %s", error, exc_info=(type(error), error, error.__traceback__)) + mixed = self._mixed_parameter_inputs() + params = mixed[1] if mixed is not None else self._named_parameters or self._positional_parameters + return self._build_processed_state( + compiled_sql=mixed[0] if mixed is not None else self._materialized_raw_sql(), + execution_parameters=params, + parsed_expression=None, + operation_type="COMMAND", + input_named_parameters=(), + applied_wrap_types=False, + filter_hash=hash_filters(self._filters), + parameter_fingerprint=structural_fingerprint(params, is_many=self._is_many), + parameter_casts={}, + parameter_profile=ParameterProfile.empty(), + operation_profile=OperationProfile.empty(), + validation_errors=[str(error)], + is_many=self._is_many, + ) + + def _next_parameter_name(self, base_name: str) -> str: + """Generate unique parameter name with param_ prefix. + + Uses param_ prefix to avoid collision with user-provided parameters. + Auto-generated parameters are namespaced to prevent conflicts. + + Args: + base_name: The base name for the parameter + + Returns: + A unique parameter name that doesn't exist in current parameters + """ + prefixed_base = f"param_{base_name}" + current_index = self._sql_param_counters.get(prefixed_base, 0) + + if prefixed_base not in self._named_parameters: + self._sql_param_counters[prefixed_base] = current_index + return prefixed_base + + next_index = current_index + 1 + candidate = f"{prefixed_base}_{next_index}" + + while candidate in self._named_parameters: + next_index += 1 + if next_index > _MAX_PARAM_COLLISION_ATTEMPTS: + return f"{prefixed_base}_{uuid4().hex[:8]}" + candidate = f"{prefixed_base}_{next_index}" + + self._sql_param_counters[prefixed_base] = next_index + return candidate + + def _current_expression(self) -> exp.Expr: + """Get the current expression or parse the raw SQL. + + Preserves authoring-time parameter names for dynamic query modifiers, + then falls back to cached parsed expressions. + + Returns: + The SQLGlot expression for this statement + """ + raw_sql = self._raw_sql + state = self._processed_state + if ( + state is not Empty + and ( + state.input_named_parameters + or self._positional_parameters + or self._named_parameters + or not state.parameter_profile.is_empty() + ) + and self._raw_expression is None + and raw_sql + and self._statement_config.enable_parsing + ): + try: + parsed = sqlglot.parse_one(unescape_qmark_operator(raw_sql)[0], dialect=self._dialect) + if isinstance(parsed, exp.Expr): + return parsed + except ParseError: + pass + if state is not Empty and state.parsed_expression is not None: + return state.parsed_expression.copy() + if self.statement_expression is not None: + return self.statement_expression.copy() + if self._raw_expression is not None: + return self._raw_expression.copy() + if not self._statement_config.enable_parsing: + return self._wrapped_raw_sql_expression(raw_sql) + try: + parsed = sqlglot.parse_one(unescape_qmark_operator(raw_sql)[0], dialect=self._dialect) + if isinstance(parsed, exp.Expr): + return parsed + return self._wrapped_raw_sql_expression(raw_sql) + except ParseError: + return self._wrapped_raw_sql_expression(raw_sql) + + def _wrapped_raw_sql_expression(self, raw_sql: str) -> exp.Expr: + if not raw_sql.strip(): + msg = "Cannot build an expression from an empty SQL statement." + raise sqlspec.exceptions.SQLParsingError(msg) + try: + return exp.Select().select("*").from_("(" + unescape_qmark_operator(raw_sql)[0] + ") AS filtered") + except ParseError as exc: + msg = f"Failed to parse SQL statement: {exc}" + raise sqlspec.exceptions.SQLParsingError(msg) from exc + + def _filter_expression(self) -> exp.Expr: + """Return a mutable expression copy for statement filters. - explain_builder = Explain(self, dialect=self._dialect, options=options) - return explain_builder.build() + Filter implementations may modify the returned expression in place. The + expression is detached from cached processed state and raw expressions. + """ + return self._current_expression() - def builder(self, dialect: "DialectType | None" = None) -> "QueryBuilder": - """Create a query builder seeded from this SQL statement. + def _copy_with_expression(self, new_expr: "exp.Expr") -> "SQL": + """Create a new SQL instance with a modified expression. Args: - dialect: Optional SQL dialect override for parsing and rendering. + new_expr: The new SQLGlot expression Returns: - QueryBuilder instance initialized with the parsed statement. - - Raises: - SQLBuilderError: If the statement cannot be parsed. + New SQL instance with the expression and copied state """ - if self._is_many: - msg = "QueryBuilder does not support execute_many SQL statements." - raise sqlspec.exceptions.SQLBuilderError(msg) + new_sql = self._copy_base(new_expr) + new_sql._sql_param_counters = self._sql_param_counters.copy() + return new_sql - from sqlspec.builder import Delete, ExpressionBuilder, Insert, Merge, Select, Update + def _take_pending_filters(self) -> "tuple[SQL, list[StatementFilter]]": + if not self._filters: + return self, [] + copied = self._copy_base(self._raw_expression or self._raw_sql) + copied._filters = [] + return copied, self._filters.copy() - builder_dialect = dialect or self._dialect - converter = self._statement_config.parameter_converter or ParameterConverter( - self._statement_config.parameter_validator - ) - raw_params = self.parameters - raw_sql_for_builder = self._materialized_raw_sql() - converted_sql, converted_params = converter.convert_placeholder_style( - raw_sql_for_builder, raw_params, ParameterStyle.NAMED_COLON, is_many=False + def _copy_base(self, statement_seed: "str | exp.Expr") -> "SQL": + new_sql = SQL( + statement_seed, + *self._original_parameters, + statement_config=self._statement_config, + is_many=self._is_many, + declared_parameters=self._declared_parameters, ) - state = self._processed_state - - if ( - self._raw_expression is not None - and converted_sql == raw_sql_for_builder - and (builder_dialect == self._dialect) - ): - expression = self._raw_expression.copy() - elif ( - state is not Empty - and state.parsed_expression is not None - and converted_sql == raw_sql_for_builder - and builder_dialect == self._dialect - ): - expression = state.parsed_expression.copy() - else: - try: - expression = sqlglot.parse_one(converted_sql, dialect=builder_dialect) - except ParseError as exc: - msg = f"Failed to parse SQL for builder: {exc}" - raise sqlspec.exceptions.SQLBuilderError(msg) from exc - - base_expression = expression - ctes: list[exp.CTE] | None = None - if isinstance(expression, exp.With): - if expression.this is None: - msg = "WITH expression does not include a base statement." - raise sqlspec.exceptions.SQLBuilderError(msg) - base_expression = expression.this - ctes = list(expression.expressions) + new_sql._named_parameters.update(self._named_parameters) + new_sql._positional_parameters = self._positional_parameters.copy() + new_sql._filters = self._filters.copy() + return new_sql - builder: QueryBuilder - if isinstance(base_expression, (exp.Select, exp.Union, exp.Except, exp.Intersect, exp.Values)): - builder = Select(dialect=builder_dialect) - builder.set_expression(base_expression) - elif isinstance(base_expression, exp.Insert): - builder = Insert(dialect=builder_dialect) - builder.set_expression(base_expression) - elif isinstance(base_expression, exp.Update): - builder = Update(dialect=builder_dialect) - builder.set_expression(base_expression) - elif isinstance(base_expression, exp.Delete): - builder = Delete(dialect=builder_dialect) - builder.set_expression(base_expression) - elif isinstance(base_expression, exp.Merge): - builder = Merge(dialect=builder_dialect) - builder.set_expression(base_expression) - else: - if not isinstance(base_expression, exp.Expression): - msg = f"Unsupported expression type for builder: {type(base_expression).__name__}" - raise sqlspec.exceptions.SQLBuilderError(msg) - builder = ExpressionBuilder(base_expression, dialect=builder_dialect) + def _where_condition(self, condition: exp.Expr) -> "SQL": + expression = self._current_expression() + new_expr = safe_modify_with_cte(expression, lambda e: apply_where(e, condition)) + return self._copy_with_expression(new_expr) - if ctes: - builder.load_ctes(ctes) + def _where_comparison( + self, + column: "str | exp.Column", + value: Any, + condition_factory: "Callable[[exp.Expr, exp.Placeholder], exp.Expr]", + ) -> "SQL": + col_name = extract_column_name(column) + param_name = self._next_parameter_name(col_name) + condition = create_condition(column, param_name, condition_factory) + new_sql = self._where_condition(condition) + new_sql._named_parameters[param_name] = value + return new_sql - if isinstance(converted_params, Mapping): - builder.load_parameters(converted_params) - return builder + def _where_sequence_membership( + self, + column: "str | exp.Column", + values: "Sequence[Any]", + parameter_suffix: str, + condition_factory: "Callable[[str | exp.Column, list[str]], exp.Expr]", + *, + empty_is_false: bool, + ) -> "SQL": + if not values: + if not empty_is_false: + return self + false_condition = exp.EQ(this=exp.Literal.number(1), expression=exp.Literal.number(0)) + return self._where_condition(false_condition) - if ( - converted_params - and isinstance(converted_params, Sequence) - and not isinstance(converted_params, (str, bytes, bytearray)) - ): - param_info = converter.validator.extract_parameters(converted_sql) - param_map: dict[str, Any] = {} - for index, param in enumerate(param_info): - if index >= len(converted_params): - break - param_name = param.name or f"param_{param.ordinal}" - param_map[param_name] = converted_params[index] - builder.load_parameters(param_map) + col_name = extract_column_name(column) + param_names: list[str] = [] + param_values: dict[str, Any] = {} + for index, value in enumerate(values): + param_name = self._next_parameter_name(f"{col_name}_{parameter_suffix}_{index}") + param_names.append(param_name) + param_values[param_name] = value - return builder + condition = condition_factory(column, param_names) + new_sql = self._where_condition(condition) + new_sql._named_parameters.update(param_values) + return new_sql @mypyc_attr(allow_interpreted_subclasses=False) @@ -1762,49 +1777,6 @@ def __init__( self._has_transformers = bool(self.statement_transformers) self._has_output_transformer = self.output_transformer is not None - def freeze(self) -> None: - """Mark the configuration as immutable to enable caching.""" - self._is_frozen = True - - def replace(self, **kwargs: Any) -> "StatementConfig": - """Immutable update pattern. - - Args: - **kwargs: Attributes to update - - Returns: - New StatementConfig instance with updated attributes - """ - for key in kwargs: - if key not in _PUBLIC_CONFIG_FIELDS: - msg = f"{key!r} is not a field in {type(self).__name__}" - raise TypeError(msg) - - current_kwargs: dict[str, Any] = { - "parameter_config": self.parameter_config, - "enable_parsing": self.enable_parsing, - "enable_validation": self.enable_validation, - "enable_transformations": self.enable_transformations, - "enable_analysis": self.enable_analysis, - "enable_expression_simplification": self.enable_expression_simplification, - "enable_column_pruning": self.enable_column_pruning, - "enable_parameter_type_wrapping": self.enable_parameter_type_wrapping, - "enable_caching": self.enable_caching, - "parameter_converter": self.parameter_converter, - "parameter_validator": self.parameter_validator, - "dialect": self.dialect, - "execution_mode": self.execution_mode, - "execution_args": self.execution_args, - "output_transformer": self.output_transformer, - "statement_transformers": self._user_statement_transformers, - "enable_sqlcommenter": self.enable_sqlcommenter, - "sqlcommenter_attributes": self.sqlcommenter_attributes, - "sqlcommenter_enable_traceparent": self.sqlcommenter_enable_traceparent, - "sqlcommenter_enable_context": self.sqlcommenter_enable_context, - } - current_kwargs.update(kwargs) - return type(self)(**current_kwargs) - def __hash__(self) -> int: """Hash based on configuration settings.""" if self._hash_cache is None: @@ -1899,12 +1871,67 @@ def __eq__(self, other: object) -> bool: and self.statement_transformers == other.statement_transformers ) + def freeze(self) -> None: + """Mark the configuration as immutable to enable caching.""" + self._is_frozen = True + + def replace(self, **kwargs: Any) -> "StatementConfig": + """Immutable update pattern. + + Args: + **kwargs: Attributes to update + + Returns: + New StatementConfig instance with updated attributes + """ + for key in kwargs: + if key not in _PUBLIC_CONFIG_FIELDS: + msg = f"{key!r} is not a field in {type(self).__name__}" + raise TypeError(msg) + + current_kwargs: dict[str, Any] = { + "parameter_config": self.parameter_config, + "enable_parsing": self.enable_parsing, + "enable_validation": self.enable_validation, + "enable_transformations": self.enable_transformations, + "enable_analysis": self.enable_analysis, + "enable_expression_simplification": self.enable_expression_simplification, + "enable_column_pruning": self.enable_column_pruning, + "enable_parameter_type_wrapping": self.enable_parameter_type_wrapping, + "enable_caching": self.enable_caching, + "parameter_converter": self.parameter_converter, + "parameter_validator": self.parameter_validator, + "dialect": self.dialect, + "execution_mode": self.execution_mode, + "execution_args": self.execution_args, + "output_transformer": self.output_transformer, + "statement_transformers": self._user_statement_transformers, + "enable_sqlcommenter": self.enable_sqlcommenter, + "sqlcommenter_attributes": self.sqlcommenter_attributes, + "sqlcommenter_enable_traceparent": self.sqlcommenter_enable_traceparent, + "sqlcommenter_enable_context": self.sqlcommenter_enable_context, + } + current_kwargs.update(kwargs) + return type(self)(**current_kwargs) + def _compare_parameter_configs(self, config1: Any, config2: Any) -> bool: """Compare parameter configs.""" return bool( config1.default_parameter_style == config2.default_parameter_style and config1.supported_parameter_styles == config2.supported_parameter_styles and config1.supported_execution_parameter_styles == config2.supported_execution_parameter_styles + and config1.default_execution_parameter_style == config2.default_execution_parameter_style + and config1.type_coercion_map == config2.type_coercion_map + and config1.has_native_list_expansion == config2.has_native_list_expansion + and config1.needs_static_script_compilation == config2.needs_static_script_compilation + and config1.allow_mixed_parameter_styles == config2.allow_mixed_parameter_styles + and config1.preserve_parameter_format == config2.preserve_parameter_format + and config1.preserve_original_params_for_many == config2.preserve_original_params_for_many + and config1.output_transformer == config2.output_transformer + and config1.ast_transformer == config2.ast_transformer + and config1.json_serializer == config2.json_serializer + and config1.json_deserializer == config2.json_deserializer + and config1.strict_named_parameters == config2.strict_named_parameters ) diff --git a/sqlspec/data_dictionary/_dependencies.py b/sqlspec/data_dictionary/_dependencies.py index b73e3b980..cdc8061c9 100644 --- a/sqlspec/data_dictionary/_dependencies.py +++ b/sqlspec/data_dictionary/_dependencies.py @@ -1,5 +1,6 @@ """Typed dependency graph helpers for data-dictionary DDL ordering.""" +import heapq from collections import defaultdict from enum import StrEnum from typing import TYPE_CHECKING @@ -108,19 +109,6 @@ def __init__( self.source = _coerce_source(source) self.confidence = confidence - def to_dict(self) -> "dict[str, object]": - """Serialize the edge with stable string enum values.""" - return { - "from": self.from_object.to_dict(), - "to": self.to_object.to_dict(), - "kind": self.kind.value, - "strength": self.strength.value, - "create_direction": self.create_direction.value, - "drop_direction": self.drop_direction.value, - "source": self.source.value, - "confidence": self.confidence, - } - def __repr__(self) -> str: return ( f"DependencyEdge(from_object={self.from_object!r}, to_object={self.to_object!r}, " @@ -145,6 +133,19 @@ def __hash__(self) -> int: self.confidence, )) + def to_dict(self) -> "dict[str, object]": + """Serialize the edge with stable string enum values.""" + return { + "from": self.from_object.to_dict(), + "to": self.to_object.to_dict(), + "kind": self.kind.value, + "strength": self.strength.value, + "create_direction": self.create_direction.value, + "drop_direction": self.drop_direction.value, + "source": self.source.value, + "confidence": self.confidence, + } + class DependencyCycle: """Named dependency cycle with participating objects and edge diagnostics.""" @@ -155,6 +156,17 @@ def __init__(self, objects: "tuple[ObjectIdentity, ...]", edges: "tuple[Dependen self.objects = objects self.edges = edges + def __repr__(self) -> str: + return f"DependencyCycle(objects={self.objects!r}, edges={self.edges!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, DependencyCycle): + return NotImplemented + return self.to_dict() == other.to_dict() + + def __hash__(self) -> int: + return hash((self.objects, self.edges)) + def describe(self) -> str: """Return a compact human-readable cycle diagnostic.""" if not self.edges: @@ -171,17 +183,6 @@ def to_dict(self) -> "dict[str, object]": "edges": tuple(edge.to_dict() for edge in self.edges), } - def __repr__(self) -> str: - return f"DependencyCycle(objects={self.objects!r}, edges={self.edges!r})" - - def __eq__(self, other: object) -> bool: - if not isinstance(other, DependencyCycle): - return NotImplemented - return self.to_dict() == other.to_dict() - - def __hash__(self) -> int: - return hash((self.objects, self.edges)) - class DependencyCycleError(SQLSpecError): """Raised when dependency sorting finds one or more cycles.""" @@ -203,6 +204,17 @@ def __init__(self, ordered: "tuple[ObjectIdentity, ...]", cycles: "tuple[Depende self.ordered = ordered self.cycles = cycles + def __repr__(self) -> str: + return f"DependencySortResult(ordered={self.ordered!r}, cycles={self.cycles!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, DependencySortResult): + return NotImplemented + return self.to_dict() == other.to_dict() + + def __hash__(self) -> int: + return hash((self.ordered, self.cycles)) + @property def is_acyclic(self) -> bool: """Return whether sorting completed without cycles.""" @@ -220,17 +232,6 @@ def to_dict(self) -> "dict[str, object]": "cycles": tuple(cycle.to_dict() for cycle in self.cycles), } - def __repr__(self) -> str: - return f"DependencySortResult(ordered={self.ordered!r}, cycles={self.cycles!r})" - - def __eq__(self, other: object) -> bool: - if not isinstance(other, DependencySortResult): - return NotImplemented - return self.to_dict() == other.to_dict() - - def __hash__(self) -> int: - return hash((self.ordered, self.cycles)) - def dependency_edges_from_foreign_keys( foreign_keys: "Iterable[ForeignKeyMetadata]", @@ -337,37 +338,104 @@ def sort_dependencies( *(edge.from_object for edge in edge_tuple), *(edge.to_object for edge in edge_tuple), )) + node_index = {node: idx for idx, node in enumerate(nodes)} dependencies: dict[ObjectIdentity, set[ObjectIdentity]] = {node: set() for node in nodes} - dependents: dict[ObjectIdentity, set[ObjectIdentity]] = defaultdict(set) + dependents: dict[ObjectIdentity, list[ObjectIdentity]] = defaultdict(list) for edge in edge_tuple: dependent, dependency = _edge_order(edge, order) - dependencies.setdefault(dependent, set()).add(dependency) - dependencies.setdefault(dependency, set()) - dependents[dependency].add(dependent) + if dependency not in dependencies[dependent]: + dependencies[dependent].add(dependency) + dependents[dependency].append(dependent) - ordered: list[ObjectIdentity] = [] - ready = [node for node in nodes if not dependencies[node]] - ready_seen = set(ready) + for dep_list in dependents.values(): + dep_list.sort(key=lambda item: node_index[item]) - while ready: - node = ready.pop(0) + ordered: list[ObjectIdentity] = [] + ready_heap: list[tuple[int, ObjectIdentity]] = [ + (node_index[node], node) for node in nodes if not dependencies[node] + ] + heapq.heapify(ready_heap) + ready_seen: set[ObjectIdentity] = {node for _, node in ready_heap} + + while ready_heap: + _, node = heapq.heappop(ready_heap) ordered.append(node) - for dependent in _ordered_nodes(dependents.get(node, ()), nodes): + for dependent in dependents.get(node, ()): dependencies[dependent].discard(node) - if not dependencies[dependent] and dependent not in ready_seen and dependent not in ordered: - ready.append(dependent) + if not dependencies[dependent] and dependent not in ready_seen: + heapq.heappush(ready_heap, (node_index[dependent], dependent)) ready_seen.add(dependent) - unresolved = tuple(node for node in nodes if node not in ordered) + ordered_set = set(ordered) + unresolved = tuple(node for node in nodes if node not in ordered_set) if not unresolved: return DependencySortResult(tuple(ordered)) + cycles = _extract_dependency_cycles(unresolved, edge_tuple, dependents, node_index) + return DependencySortResult(tuple(ordered), cycles) + + +def _extract_dependency_cycles( + unresolved: "tuple[ObjectIdentity, ...]", + edge_tuple: "tuple[DependencyEdge, ...]", + dependents: "dict[ObjectIdentity, list[ObjectIdentity]]", + node_index: "dict[ObjectIdentity, int]", +) -> "tuple[DependencyCycle, ...]": unresolved_set = set(unresolved) - cycle_edges = tuple( - edge for edge in edge_tuple if edge.from_object in unresolved_set and edge.to_object in unresolved_set - ) - return DependencySortResult(tuple(ordered), (DependencyCycle(unresolved, cycle_edges),)) + index = 0 + indices: dict[ObjectIdentity, int] = {} + lowlink: dict[ObjectIdentity, int] = {} + stack: list[ObjectIdentity] = [] + on_stack: set[ObjectIdentity] = set() + sccs: list[tuple[ObjectIdentity, ...]] = [] + + def strongconnect(node: ObjectIdentity) -> None: + nonlocal index + indices[node] = index + lowlink[node] = index + index += 1 + stack.append(node) + on_stack.add(node) + + for neighbor in dependents.get(node, ()): + if neighbor not in unresolved_set: + continue + if neighbor not in indices: + strongconnect(neighbor) + lowlink[node] = min(lowlink[node], lowlink[neighbor]) + elif neighbor in on_stack: + lowlink[node] = min(lowlink[node], indices[neighbor]) + + if lowlink[node] == indices[node]: + component: list[ObjectIdentity] = [] + while True: + member = stack.pop() + on_stack.discard(member) + component.append(member) + if member == node: + break + if len(component) > 1 or node in dependents.get(node, ()): + component.sort(key=lambda item: node_index[item]) + sccs.append(tuple(component)) + + for node in unresolved: + if node not in indices: + strongconnect(node) + + if not sccs: + cycle_edges = tuple( + edge for edge in edge_tuple if edge.from_object in unresolved_set and edge.to_object in unresolved_set + ) + return (DependencyCycle(unresolved, cycle_edges),) + + sccs.sort(key=lambda comp: node_index[comp[0]]) + cycles: list[DependencyCycle] = [] + for comp in sccs: + comp_set = set(comp) + comp_edges = tuple(edge for edge in edge_tuple if edge.from_object in comp_set and edge.to_object in comp_set) + cycles.append(DependencyCycle(comp, comp_edges)) + return tuple(cycles) def _edge_order(edge: DependencyEdge, order: "Literal['create', 'drop']") -> "tuple[ObjectIdentity, ObjectIdentity]": diff --git a/sqlspec/data_dictionary/_loader.py b/sqlspec/data_dictionary/_loader.py index ffdfd46f5..35435b0ba 100644 --- a/sqlspec/data_dictionary/_loader.py +++ b/sqlspec/data_dictionary/_loader.py @@ -1,3 +1,5 @@ +"""Data dictionary SQL query loader and domain resolver.""" + from importlib import resources from importlib.resources import as_file from typing import TYPE_CHECKING @@ -36,134 +38,6 @@ def __init__(self) -> None: self._domain_loaders: dict[tuple[str, str, str | None], SQLFileLoader] = {} self._loaded_domain_paths: set[tuple[str, str, str | None]] = set() - def _get_domain_loader(self, dialect: str, domain: str, mode: str | None) -> "SQLFileLoader": - """Return or create a SQL loader for a dialect/domain/mode pack.""" - key = (dialect, domain, mode) - loader = self._domain_loaders.get(key) - if loader is None: - loader = SQLFileLoader() - self._domain_loaders[key] = loader - return loader - - def _domain_path_candidates( - self, dialect: str, domain: str, mode: str | None - ) -> "tuple[tuple[str | None, Traversable], ...]": - """Return candidate resource paths for a domain query pack.""" - try: - sql_root = resources.files(f"sqlspec.data_dictionary.dialects.{dialect}").joinpath("sql") - except (ModuleNotFoundError, FileNotFoundError): - return () - if mode is None: - return ((None, sql_root.joinpath(f"{domain}.sql")), (None, sql_root.joinpath(domain))) - return ( - (mode, sql_root.joinpath(mode).joinpath(f"{domain}.sql")), - (mode, sql_root.joinpath(domain).joinpath(f"{mode}.sql")), - (mode, sql_root.joinpath(domain).joinpath(mode)), - ) - - def _ensure_domain_loaded(self, dialect: str, domain: str, mode: str | None) -> bool: - """Lazy load SQL files for a dialect/domain/mode pack. - - Args: - dialect: Canonical dialect name. - domain: Metadata domain name. - mode: Optional dialect mode name. - - Returns: - True when a matching domain path exists and has been loaded. - """ - key = (dialect, domain, mode) - if key in self._loaded_domain_paths: - return True - - for resolved_mode, domain_resource in self._domain_path_candidates(dialect, domain, mode): - if domain_resource.is_file(): - loader = self._get_domain_loader(dialect, domain, resolved_mode) - with as_file(domain_resource) as file_path: - loader.load_sql(file_path) - self._loaded_domain_paths.add((dialect, domain, resolved_mode)) - if resolved_mode == mode: - return True - elif domain_resource.is_dir(): - loader = self._get_domain_loader(dialect, domain, resolved_mode) - with as_file(domain_resource) as domain_path: - loader.load_sql(domain_path) - self._loaded_domain_paths.add((dialect, domain, resolved_mode)) - if resolved_mode == mode: - return True - return False - - def _unsupported_domain_query( - self, - dialect: str, - domain: str, - query_name: str, - *, - mode: str | None = None, - source: "MetadataSource | str" = MetadataSource.UNKNOWN, - risks: "tuple[MetadataRisk | str, ...]" = (), - warnings: "tuple[str, ...] | None" = None, - ) -> MetadataQuery: - """Return a standard unsupported query result.""" - warning_tuple = warnings - if warning_tuple is None: - warning_tuple = (f"No data-dictionary query found for {dialect}/{domain}/{query_name}",) - return MetadataQuery.unsupported( - dialect=dialect, - domain=domain, - name=query_name, - mode=mode, - source=source, - risks=risks, - warnings=warning_tuple, - ) - - def _feature_gate_query( - self, - dialect: str, - domain: str, - query_name: str, - *, - mode: str | None, - version: "VersionInfo | None", - required_features: "tuple[str, ...]", - ) -> MetadataQuery | None: - """Return an unsupported result when query feature gates fail.""" - if not required_features: - return None - config = get_dialect_config(dialect) - for feature in required_features: - flag = config.get_feature_flag(feature) - required_version = config.get_feature_version(feature) - if flag is False: - return self._unsupported_domain_query( - dialect, - domain, - query_name, - mode=mode, - risks=(MetadataRisk.VERSION_GATED,), - warnings=(f"{dialect}/{domain}/{query_name} requires {feature}",), - ) - if version is not None and required_version is not None and version < required_version: - return self._unsupported_domain_query( - dialect, - domain, - query_name, - mode=mode, - risks=(MetadataRisk.VERSION_GATED,), - warnings=(f"{dialect}/{domain}/{query_name} requires {feature} >= {required_version}",), - ) - if flag is None and required_version is None: - return self._unsupported_domain_query( - dialect, - domain, - query_name, - mode=mode, - risks=(MetadataRisk.VERSION_GATED,), - warnings=(f"{dialect}/{domain}/{query_name} requires unknown feature {feature}",), - ) - return None - def get_domain_query( self, dialect: str, @@ -287,6 +161,18 @@ def get_dialect_config(self, dialect: str) -> "DialectConfig": """ return get_dialect_config(dialect) + def has_domain_queries(self, dialect: str, domain: str, *, mode: str | None = None) -> bool: + """Return whether a dialect/domain/mode pack has any SQL queries.""" + normalized_dialect = normalize_dialect_name(dialect) + normalized_domain = _normalize_domain_key(domain) + normalized_mode = normalize_dialect_mode(normalized_dialect, mode) + if mode is not None and normalized_mode is None: + return False + if not self._ensure_domain_loaded(normalized_dialect, normalized_domain, normalized_mode): + return False + loader = self._get_domain_loader(normalized_dialect, normalized_domain, normalized_mode) + return bool(loader.list_queries()) + def list_dialects(self) -> "list[str]": """List available SQL dialects. @@ -305,6 +191,134 @@ def list_dialects(self) -> "list[str]": if path.is_dir() and path.name != "__pycache__" and path.joinpath("sql").is_dir() ]) + def _get_domain_loader(self, dialect: str, domain: str, mode: str | None) -> "SQLFileLoader": + """Return or create a SQL loader for a dialect/domain/mode pack.""" + key = (dialect, domain, mode) + loader = self._domain_loaders.get(key) + if loader is None: + loader = SQLFileLoader() + self._domain_loaders[key] = loader + return loader + + def _domain_path_candidates( + self, dialect: str, domain: str, mode: str | None + ) -> "tuple[tuple[str | None, Traversable], ...]": + """Return candidate resource paths for a domain query pack.""" + try: + sql_root = resources.files(f"sqlspec.data_dictionary.dialects.{dialect}").joinpath("sql") + except (ModuleNotFoundError, FileNotFoundError): + return () + if mode is None: + return ((None, sql_root.joinpath(f"{domain}.sql")), (None, sql_root.joinpath(domain))) + return ( + (mode, sql_root.joinpath(mode).joinpath(f"{domain}.sql")), + (mode, sql_root.joinpath(domain).joinpath(f"{mode}.sql")), + (mode, sql_root.joinpath(domain).joinpath(mode)), + ) + + def _ensure_domain_loaded(self, dialect: str, domain: str, mode: str | None) -> bool: + """Lazy load SQL files for a dialect/domain/mode pack. + + Args: + dialect: Canonical dialect name. + domain: Metadata domain name. + mode: Optional dialect mode name. + + Returns: + True when a matching domain path exists and has been loaded. + """ + key = (dialect, domain, mode) + if key in self._loaded_domain_paths: + return True + + for resolved_mode, domain_resource in self._domain_path_candidates(dialect, domain, mode): + if domain_resource.is_file(): + loader = self._get_domain_loader(dialect, domain, resolved_mode) + with as_file(domain_resource) as file_path: + loader.load_sql(file_path) + self._loaded_domain_paths.add((dialect, domain, resolved_mode)) + if resolved_mode == mode: + return True + elif domain_resource.is_dir(): + loader = self._get_domain_loader(dialect, domain, resolved_mode) + with as_file(domain_resource) as domain_path: + loader.load_sql(domain_path) + self._loaded_domain_paths.add((dialect, domain, resolved_mode)) + if resolved_mode == mode: + return True + return False + + def _unsupported_domain_query( + self, + dialect: str, + domain: str, + query_name: str, + *, + mode: str | None = None, + source: "MetadataSource | str" = MetadataSource.UNKNOWN, + risks: "tuple[MetadataRisk | str, ...]" = (), + warnings: "tuple[str, ...] | None" = None, + ) -> MetadataQuery: + """Return a standard unsupported query result.""" + warning_tuple = warnings + if warning_tuple is None: + warning_tuple = (f"No data-dictionary query found for {dialect}/{domain}/{query_name}",) + return MetadataQuery.unsupported( + dialect=dialect, + domain=domain, + name=query_name, + mode=mode, + source=source, + risks=risks, + warnings=warning_tuple, + ) + + def _feature_gate_query( + self, + dialect: str, + domain: str, + query_name: str, + *, + mode: str | None, + version: "VersionInfo | None", + required_features: "tuple[str, ...]", + ) -> MetadataQuery | None: + """Return an unsupported result when query feature gates fail.""" + if not required_features: + return None + config = get_dialect_config(dialect) + for feature in required_features: + flag = config.get_feature_flag(feature) + required_version = config.get_feature_version(feature) + if flag is False: + return self._unsupported_domain_query( + dialect, + domain, + query_name, + mode=mode, + risks=(MetadataRisk.VERSION_GATED,), + warnings=(f"{dialect}/{domain}/{query_name} requires {feature}",), + ) + if version is not None and required_version is not None and version < required_version: + return self._unsupported_domain_query( + dialect, + domain, + query_name, + mode=mode, + risks=(MetadataRisk.VERSION_GATED,), + warnings=(f"{dialect}/{domain}/{query_name} requires {feature} >= {required_version}",), + ) + if flag is None and required_version is None: + return self._unsupported_domain_query( + dialect, + domain, + query_name, + mode=mode, + risks=(MetadataRisk.VERSION_GATED,), + warnings=(f"{dialect}/{domain}/{query_name} requires unknown feature {feature}",), + ) + return None + _loader_instance: DataDictionaryLoader | None = None diff --git a/sqlspec/data_dictionary/_registry.py b/sqlspec/data_dictionary/_registry.py index b891f2919..74ba43e60 100644 --- a/sqlspec/data_dictionary/_registry.py +++ b/sqlspec/data_dictionary/_registry.py @@ -1,3 +1,5 @@ +"""Registry for data dictionary dialect configurations.""" + import importlib from typing import TYPE_CHECKING diff --git a/sqlspec/data_dictionary/_types.py b/sqlspec/data_dictionary/_types.py index 7092b53d3..84ecb6164 100644 --- a/sqlspec/data_dictionary/_types.py +++ b/sqlspec/data_dictionary/_types.py @@ -1,3 +1,5 @@ +"""Data dictionary metadata types, capabilities, and redaction policies.""" + from enum import StrEnum from typing import TYPE_CHECKING, Any, TypedDict, cast @@ -124,31 +126,6 @@ def __init__( self.quoted_name = quoted_name self.source = _coerce_source(source) - def to_dict(self) -> "dict[str, str | None]": - """Serialize the identity with stable string enum values.""" - return { - "catalog": self.catalog, - "schema": self.schema, - "name": self.name, - "object_type": self.object_type, - "dialect": self.dialect, - "quoted_name": self.quoted_name, - "source": self.source.value, - } - - @classmethod - def from_dict(cls, data: "dict[str, Any]") -> "ObjectIdentity": - """Create an identity from a serialized payload.""" - return cls( - name=str(data["name"]), - object_type=str(data["object_type"]), - catalog=cast("str | None", data.get("catalog")), - schema=cast("str | None", data.get("schema")), - dialect=cast("str | None", data.get("dialect")), - quoted_name=cast("str | None", data.get("quoted_name")), - source=cast("str", data.get("source", MetadataSource.UNKNOWN.value)), - ) - def __repr__(self) -> str: return ( f"ObjectIdentity(name={self.name!r}, object_type={self.object_type!r}, catalog={self.catalog!r}, " @@ -185,6 +162,31 @@ def __reduce__(self) -> "tuple[type[ObjectIdentity], tuple[str, str], dict[str, }, ) + def to_dict(self) -> "dict[str, str | None]": + """Serialize the identity with stable string enum values.""" + return { + "catalog": self.catalog, + "schema": self.schema, + "name": self.name, + "object_type": self.object_type, + "dialect": self.dialect, + "quoted_name": self.quoted_name, + "source": self.source.value, + } + + @classmethod + def from_dict(cls, data: "dict[str, Any]") -> "ObjectIdentity": + """Create an identity from a serialized payload.""" + return cls( + name=str(data["name"]), + object_type=str(data["object_type"]), + catalog=cast("str | None", data.get("catalog")), + schema=cast("str | None", data.get("schema")), + dialect=cast("str | None", data.get("dialect")), + quoted_name=cast("str | None", data.get("quoted_name")), + source=cast("str", data.get("source", MetadataSource.UNKNOWN.value)), + ) + class MetadataCapability: """Capability report for one metadata domain.""" @@ -208,6 +210,21 @@ def __init__( self.risks = tuple(_coerce_risk(risk) for risk in risks) self.warnings = warnings + def __repr__(self) -> str: + return ( + f"MetadataCapability(domain={self.domain!r}, support={self.support!r}, " + f"fidelity={self.fidelity!r}, source={self.source!r}, risks={self.risks!r}, " + f"warnings={self.warnings!r})" + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, MetadataCapability): + return NotImplemented + return self.to_dict() == other.to_dict() + + def __hash__(self) -> int: + return hash((self.domain, self.support, self.fidelity, self.source, self.risks, self.warnings)) + def to_dict(self) -> "dict[str, str | tuple[str, ...]]": """Serialize the capability with stable string enum values.""" return { @@ -238,21 +255,6 @@ def unsupported( """Create a standard unsupported-domain capability.""" return cls(domain=domain, support=MetadataSupport.UNSUPPORTED, source=source) - def __repr__(self) -> str: - return ( - f"MetadataCapability(domain={self.domain!r}, support={self.support!r}, " - f"fidelity={self.fidelity!r}, source={self.source!r}, risks={self.risks!r}, " - f"warnings={self.warnings!r})" - ) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, MetadataCapability): - return NotImplemented - return self.to_dict() == other.to_dict() - - def __hash__(self) -> int: - return hash((self.domain, self.support, self.fidelity, self.source, self.risks, self.warnings)) - class MetadataCapabilityProfile: """Capability report for one adapter and dialect.""" @@ -266,6 +268,20 @@ def __init__( self.adapter = adapter self.capabilities = capabilities + def __repr__(self) -> str: + return ( + f"MetadataCapabilityProfile(dialect={self.dialect!r}, adapter={self.adapter!r}, " + f"capabilities={self.capabilities!r})" + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, MetadataCapabilityProfile): + return NotImplemented + return self.to_dict() == other.to_dict() + + def __hash__(self) -> int: + return hash((self.dialect, self.adapter, self.capabilities)) + def get(self, domain: str) -> MetadataCapability: """Return capability for a domain, or unknown when it has not been reported.""" for capability in self.capabilities: @@ -290,20 +306,6 @@ def from_domains(cls, dialect: str, adapter: str | None, domains: "tuple[str, .. capabilities=tuple(MetadataCapability.unsupported(domain) for domain in domains), ) - def __repr__(self) -> str: - return ( - f"MetadataCapabilityProfile(dialect={self.dialect!r}, adapter={self.adapter!r}, " - f"capabilities={self.capabilities!r})" - ) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, MetadataCapabilityProfile): - return NotImplemented - return self.to_dict() == other.to_dict() - - def __hash__(self) -> int: - return hash((self.dialect, self.adapter, self.capabilities)) - class MetadataResult: """Uniform result envelope for metadata domain lookups.""" @@ -333,6 +335,20 @@ def __iter__(self) -> "Iterator[object]": """Iterate over the metadata items in this result envelope.""" return iter(self.items) + def __repr__(self) -> str: + return ( + f"MetadataResult(domain={self.domain!r}, capability={self.capability!r}, " + f"items={self.items!r}, warnings={self.warnings!r})" + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, MetadataResult): + return NotImplemented + return self.to_dict() == other.to_dict() + + def __hash__(self) -> int: + return hash((self.domain, self.capability, self.items, self.warnings)) + @classmethod def unsupported(cls, domain: str, *, source: "MetadataSource | str" = MetadataSource.UNKNOWN) -> "MetadataResult": """Create a standard unsupported-domain result.""" @@ -348,20 +364,6 @@ def to_dict(self) -> "dict[str, object]": "warnings": self.warnings, } - def __repr__(self) -> str: - return ( - f"MetadataResult(domain={self.domain!r}, capability={self.capability!r}, " - f"items={self.items!r}, warnings={self.warnings!r})" - ) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, MetadataResult): - return NotImplemented - return self.to_dict() == other.to_dict() - - def __hash__(self) -> int: - return hash((self.domain, self.capability, self.items, self.warnings)) - class MetadataQuery: """Loaded data-dictionary query plus capability status.""" @@ -392,6 +394,20 @@ def __init__( ) self.warnings = warnings or self.capability.warnings + def __repr__(self) -> str: + return ( + f"MetadataQuery(dialect={self.dialect!r}, domain={self.domain!r}, name={self.name!r}, " + f"mode={self.mode!r}, capability={self.capability!r}, sql={self.sql!r}, warnings={self.warnings!r})" + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, MetadataQuery): + return NotImplemented + return self.to_dict() == other.to_dict() + + def __hash__(self) -> int: + return hash((self.dialect, self.domain, self.name, self.mode, self.capability, self.query_text, self.warnings)) + @property def is_supported(self) -> bool: """Return whether the query is available and executable.""" @@ -439,20 +455,6 @@ def to_dict(self) -> "dict[str, object]": "warnings": self.warnings, } - def __repr__(self) -> str: - return ( - f"MetadataQuery(dialect={self.dialect!r}, domain={self.domain!r}, name={self.name!r}, " - f"mode={self.mode!r}, capability={self.capability!r}, sql={self.sql!r}, warnings={self.warnings!r})" - ) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, MetadataQuery): - return NotImplemented - return self.to_dict() == other.to_dict() - - def __hash__(self) -> int: - return hash((self.dialect, self.domain, self.name, self.mode, self.capability, self.query_text, self.warnings)) - class DDLResult: """DDL text and fidelity status for one database object.""" @@ -482,6 +484,24 @@ def __init__( self.context = {} if context is None else context self.warnings = warnings + def __eq__(self, other: object) -> bool: + if not isinstance(other, DDLResult): + return NotImplemented + return self.to_dict() == other.to_dict() + + def __hash__(self) -> int: + return hash(( + self.identity, + self.status, + self.fidelity, + self.source, + self.ddl, + self.dependencies, + self.redactions, + _hashable_mapping(self.context), + self.warnings, + )) + @classmethod def unsupported( cls, @@ -541,24 +561,6 @@ def to_dict(self) -> "dict[str, object]": "warnings": self.warnings, } - def __eq__(self, other: object) -> bool: - if not isinstance(other, DDLResult): - return NotImplemented - return self.to_dict() == other.to_dict() - - def __hash__(self) -> int: - return hash(( - self.identity, - self.status, - self.fidelity, - self.source, - self.ddl, - self.dependencies, - self.redactions, - _hashable_mapping(self.context), - self.warnings, - )) - class ObjectMetadata: """Generic rich metadata for a database object.""" @@ -657,6 +659,7 @@ class PartitionMetadata(ObjectMetadata): "sql_text", "statement", "statement_text", + "text", } _USER_FIELDS = { "current_user", @@ -777,6 +780,17 @@ def redact_row(self, row: "Mapping[str, object]") -> "tuple[dict[str, object], t redacted[field_name] = value return redacted, tuple(sorted(fields)) + def to_dict(self) -> "dict[str, bool]": + """Serialize redaction settings.""" + return { + "redact_sql_text": self.redact_sql_text, + "redact_users": self.redact_users, + "redact_hosts": self.redact_hosts, + "redact_settings": self.redact_settings, + "redact_connection_strings": self.redact_connection_strings, + "redact_grants": self.redact_grants, + } + def _should_redact_field(self, key: str, value: object, row: "Mapping[str, object]") -> bool: normalized = _normalize_redaction_key(key) if self.redact_sql_text and ( @@ -799,17 +813,6 @@ def _should_redact_field(self, key: str, value: object, row: "Mapping[str, objec return True return self.redact_grants and normalized in _GRANT_FIELDS - def to_dict(self) -> "dict[str, bool]": - """Serialize redaction settings.""" - return { - "redact_sql_text": self.redact_sql_text, - "redact_users": self.redact_users, - "redact_hosts": self.redact_hosts, - "redact_settings": self.redact_settings, - "redact_connection_strings": self.redact_connection_strings, - "redact_grants": self.redact_grants, - } - class SystemMetadataRequest: """Opt-in request for system and performance metadata. @@ -957,6 +960,42 @@ def __init__( self.redaction_fields = redaction_fields self.warnings = warnings + def __repr__(self) -> str: + return ( + f"SystemMetadataCapability(domain={self.domain!r}, support={self.support!r}, " + f"source={self.source!r}, risks={self.risks!r})" + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SystemMetadataCapability): + return NotImplemented + return self.to_dict() == other.to_dict() + + def __hash__(self) -> int: + return hash(( + self.domain, + self.support, + self.fidelity, + self.source, + self.risks, + self.required_privileges, + self.cost_implications, + self.license_gate, + self.managed_service_restricted, + self.redaction_fields, + self.warnings, + )) + + @property + def requires_billed_opt_in(self) -> bool: + """Return whether this domain requires billed metadata opt-in.""" + return MetadataRisk.BILLED in self.risks + + @property + def requires_license_opt_in(self) -> bool: + """Return whether this domain requires license-gated diagnostics opt-in.""" + return MetadataRisk.LICENSE_GATED in self.risks + @classmethod def unsupported( cls, domain: str, *, source: "MetadataSource | str" = MetadataSource.SYSTEM_VIEW @@ -971,16 +1010,6 @@ def unsupported( warnings=("No system metadata query pack is registered for this domain.",), ) - @property - def requires_billed_opt_in(self) -> bool: - """Return whether this domain requires billed metadata opt-in.""" - return MetadataRisk.BILLED in self.risks - - @property - def requires_license_opt_in(self) -> bool: - """Return whether this domain requires license-gated diagnostics opt-in.""" - return MetadataRisk.LICENSE_GATED in self.risks - def with_support( self, support: "MetadataSupport | str", @@ -1019,32 +1048,6 @@ def to_dict(self) -> "dict[str, object]": "warnings": self.warnings, } - def __repr__(self) -> str: - return ( - f"SystemMetadataCapability(domain={self.domain!r}, support={self.support!r}, " - f"source={self.source!r}, risks={self.risks!r})" - ) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, SystemMetadataCapability): - return NotImplemented - return self.to_dict() == other.to_dict() - - def __hash__(self) -> int: - return hash(( - self.domain, - self.support, - self.fidelity, - self.source, - self.risks, - self.required_privileges, - self.cost_implications, - self.license_gate, - self.managed_service_restricted, - self.redaction_fields, - self.warnings, - )) - class SystemMetadataResult: """Result envelope for system and performance metadata. @@ -1077,6 +1080,28 @@ def __init__( self.warnings = capability.warnings + warnings self.redactions = redactions + def __repr__(self) -> str: + return ( + f"SystemMetadataResult(request={self.request!r}, capability={self.capability!r}, " + f"rows={self.rows!r}, warnings={self.warnings!r}, redactions={self.redactions!r})" + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SystemMetadataResult): + return NotImplemented + return self.to_dict() == other.to_dict() + + def __hash__(self) -> int: + rows_hash = tuple(tuple(sorted((key, repr(value)) for key, value in row.items())) for row in self.rows) + return hash(( + repr(self.request.to_dict()), + self.capability, + rows_hash, + self.source, + self.warnings, + self.redactions, + )) + @classmethod def from_rows( cls, @@ -1114,28 +1139,6 @@ def to_dict(self) -> "dict[str, object]": "redactions": self.redactions, } - def __repr__(self) -> str: - return ( - f"SystemMetadataResult(request={self.request!r}, capability={self.capability!r}, " - f"rows={self.rows!r}, warnings={self.warnings!r}, redactions={self.redactions!r})" - ) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, SystemMetadataResult): - return NotImplemented - return self.to_dict() == other.to_dict() - - def __hash__(self) -> int: - rows_hash = tuple(tuple(sorted((key, repr(value)) for key, value in row.items())) for row in self.rows) - return hash(( - repr(self.request.to_dict()), - self.capability, - rows_hash, - self.source, - self.warnings, - self.redactions, - )) - class SystemMetadata(ObjectMetadata): """Opt-in system or performance metadata.""" @@ -1335,11 +1338,6 @@ def __init__(self, major: int, minor: int = 0, patch: int = 0) -> None: self.minor = minor self.patch = patch - @property - def version_tuple(self) -> "tuple[int, int, int]": - """Get version as tuple for comparison.""" - return (self.major, self.minor, self.patch) - def __str__(self) -> str: """String representation of version info.""" return f"{self.major}.{self.minor}.{self.patch}" @@ -1377,6 +1375,11 @@ def __hash__(self) -> int: def __reduce__(self) -> "tuple[type[VersionInfo], tuple[int, int, int]]": return (self.__class__, self.version_tuple) + @property + def version_tuple(self) -> "tuple[int, int, int]": + """Get version as tuple for comparison.""" + return (self.major, self.minor, self.patch) + VersionCacheResult = tuple[bool, VersionInfo | None] """Return type for version cache lookup methods.""" diff --git a/sqlspec/data_dictionary/dialects/bigquery/config.py b/sqlspec/data_dictionary/dialects/bigquery/config.py index 2292ae3dc..ecda4b621 100644 --- a/sqlspec/data_dictionary/dialects/bigquery/config.py +++ b/sqlspec/data_dictionary/dialects/bigquery/config.py @@ -1,8 +1,18 @@ +"""BigQuery dialect static configuration and reflection helpers.""" + import re from sqlspec.data_dictionary import DialectConfig, FeatureFlags, FeatureVersions, register_dialect -__all__ = ("format_bigquery_information_schema_tables", "format_bigquery_schema_prefix") +__all__ = ( + "BIGQUERY_CONFIG", + "BIGQUERY_FEATURE_FLAGS", + "BIGQUERY_FEATURE_VERSIONS", + "BIGQUERY_TYPE_MAPPINGS", + "BIGQUERY_VERSION_PATTERN", + "format_bigquery_information_schema_tables", + "format_bigquery_schema_prefix", +) BIGQUERY_VERSION_PATTERN = re.compile(r".*") diff --git a/sqlspec/data_dictionary/dialects/bigquery/sql/constraints.sql b/sqlspec/data_dictionary/dialects/bigquery/sql/constraints.sql index eed9b3a24..3bbdc1bb7 100644 --- a/sqlspec/data_dictionary/dialects/bigquery/sql/constraints.sql +++ b/sqlspec/data_dictionary/dialects/bigquery/sql/constraints.sql @@ -8,8 +8,8 @@ SELECT tc.constraint_type, kcu.column_name, kcu.ordinal_position, - ccu.table_name AS referenced_table_name, - ccu.column_name AS referenced_column_name, + COALESCE(pk_kcu.table_name, ccu.table_name) AS referenced_table_name, + COALESCE(pk_kcu.column_name, ccu.column_name) AS referenced_column_name, rc.match_option, rc.update_rule, rc.delete_rule @@ -18,14 +18,20 @@ LEFT JOIN {key_column_usage_table} AS kcu ON tc.constraint_catalog = kcu.constraint_catalog AND tc.constraint_schema = kcu.constraint_schema AND tc.constraint_name = kcu.constraint_name -LEFT JOIN {constraint_column_usage_table} AS ccu - ON tc.constraint_catalog = ccu.constraint_catalog - AND tc.constraint_schema = ccu.constraint_schema - AND tc.constraint_name = ccu.constraint_name LEFT JOIN {referential_constraints_table} AS rc ON tc.constraint_catalog = rc.constraint_catalog AND tc.constraint_schema = rc.constraint_schema AND tc.constraint_name = rc.constraint_name +LEFT JOIN {key_column_usage_table} AS pk_kcu + ON rc.unique_constraint_catalog = pk_kcu.constraint_catalog + AND rc.unique_constraint_schema = pk_kcu.constraint_schema + AND rc.unique_constraint_name = pk_kcu.constraint_name + AND COALESCE(kcu.position_in_unique_constraint, kcu.ordinal_position) = pk_kcu.ordinal_position +LEFT JOIN {constraint_column_usage_table} AS ccu + ON tc.constraint_catalog = ccu.constraint_catalog + AND tc.constraint_schema = ccu.constraint_schema + AND tc.constraint_name = ccu.constraint_name + AND ccu.column_name = kcu.column_name WHERE (:schema_name IS NULL OR tc.constraint_schema = :schema_name) AND (:table_name IS NULL OR tc.table_name = :table_name) ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position; diff --git a/sqlspec/data_dictionary/dialects/bigquery/sql/tables.sql b/sqlspec/data_dictionary/dialects/bigquery/sql/tables.sql index 9bb0ef815..16cc4f253 100644 --- a/sqlspec/data_dictionary/dialects/bigquery/sql/tables.sql +++ b/sqlspec/data_dictionary/dialects/bigquery/sql/tables.sql @@ -62,8 +62,15 @@ WITH RECURSIVE dependency_tree AS ( AND NOT EXISTS ( SELECT 1 FROM {kcu_table} kcu - JOIN {rc_table} rc ON kcu.constraint_name = rc.constraint_name + JOIN {rc_table} rc + ON kcu.constraint_name = rc.constraint_name + AND kcu.constraint_schema = rc.constraint_schema + JOIN {kcu_table} pk_kcu + ON rc.unique_constraint_name = pk_kcu.constraint_name + AND rc.unique_constraint_schema = pk_kcu.constraint_schema + AND kcu.ordinal_position = pk_kcu.ordinal_position WHERE kcu.table_name = t.table_name + AND pk_kcu.table_name <> t.table_name AND (:schema_name IS NULL OR kcu.table_schema = :schema_name) ) @@ -74,14 +81,31 @@ WITH RECURSIVE dependency_tree AS ( dt.level + 1, ARRAY_CONCAT(dt.path, [kcu.table_name]) FROM {kcu_table} kcu - JOIN {rc_table} rc ON kcu.constraint_name = rc.constraint_name + JOIN {rc_table} rc + ON kcu.constraint_name = rc.constraint_name + AND kcu.constraint_schema = rc.constraint_schema JOIN {kcu_table} pk_kcu ON rc.unique_constraint_name = pk_kcu.constraint_name + AND rc.unique_constraint_schema = pk_kcu.constraint_schema AND kcu.ordinal_position = pk_kcu.ordinal_position JOIN dependency_tree dt ON pk_kcu.table_name = dt.table_name - WHERE kcu.table_name NOT IN UNNEST(dt.path) + WHERE pk_kcu.table_name <> kcu.table_name + AND kcu.table_name NOT IN UNNEST(dt.path) AND (:schema_name IS NULL OR kcu.table_schema = :schema_name) +), +dependency_levels AS ( + SELECT + table_name, + MAX(level) AS dependency_level + FROM dependency_tree + GROUP BY table_name ) -SELECT DISTINCT table_name -FROM dependency_tree -ORDER BY level, table_name; +SELECT + t.table_name, + COALESCE(dl.dependency_level, 0) AS dependency_level, + COALESCE(dl.dependency_level, 0) AS level +FROM {tables_table} t +LEFT JOIN dependency_levels dl ON dl.table_name = t.table_name +WHERE t.table_type = 'BASE TABLE' + AND (:schema_name IS NULL OR t.table_schema = :schema_name) +ORDER BY dependency_level, t.table_name; diff --git a/sqlspec/data_dictionary/dialects/cockroachdb/config.py b/sqlspec/data_dictionary/dialects/cockroachdb/config.py index 2c61546d5..5e278943b 100644 --- a/sqlspec/data_dictionary/dialects/cockroachdb/config.py +++ b/sqlspec/data_dictionary/dialects/cockroachdb/config.py @@ -1,8 +1,17 @@ +"""CockroachDB dialect static configuration and reflection helpers.""" + import re from sqlspec.data_dictionary import DialectConfig, FeatureFlags, FeatureVersions, VersionInfo, register_dialect -__all__ = ("resolve_cockroachdb_json_type",) +__all__ = ( + "COCKROACHDB_CONFIG", + "COCKROACHDB_FEATURE_FLAGS", + "COCKROACHDB_FEATURE_VERSIONS", + "COCKROACHDB_TYPE_MAPPINGS", + "COCKROACHDB_VERSION_PATTERN", + "resolve_cockroachdb_json_type", +) COCKROACHDB_VERSION_PATTERN = re.compile(r"CockroachDB (?:CCL )?v(\d+)\.(\d+)\.(\d+)") diff --git a/sqlspec/data_dictionary/dialects/cockroachdb/sql/constraints.sql b/sqlspec/data_dictionary/dialects/cockroachdb/sql/constraints.sql index 3f9131866..8622bb1bc 100644 --- a/sqlspec/data_dictionary/dialects/cockroachdb/sql/constraints.sql +++ b/sqlspec/data_dictionary/dialects/cockroachdb/sql/constraints.sql @@ -20,24 +20,24 @@ SELECT ORDER BY kcu.ordinal_position )::text[] AS columns, rc.unique_constraint_schema::text AS referenced_schema, - ccu.table_name::text AS referenced_table, + pk_tc.table_name::text AS referenced_table, ARRAY( - SELECT ccu_inner.column_name::text - FROM information_schema.constraint_column_usage ccu_inner - WHERE ccu_inner.constraint_catalog = tc.constraint_catalog - AND ccu_inner.constraint_schema = tc.constraint_schema - AND ccu_inner.constraint_name = tc.constraint_name - ORDER BY ccu_inner.column_name + SELECT pk_kcu.column_name::text + FROM information_schema.key_column_usage pk_kcu + WHERE pk_kcu.constraint_catalog = rc.unique_constraint_catalog + AND pk_kcu.constraint_schema = rc.unique_constraint_schema + AND pk_kcu.constraint_name = rc.unique_constraint_name + ORDER BY pk_kcu.ordinal_position )::text[] AS referenced_columns FROM information_schema.table_constraints tc LEFT JOIN information_schema.referential_constraints rc ON rc.constraint_catalog = tc.constraint_catalog AND rc.constraint_schema = tc.constraint_schema AND rc.constraint_name = tc.constraint_name -LEFT JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_catalog = tc.constraint_catalog - AND ccu.constraint_schema = tc.constraint_schema - AND ccu.constraint_name = tc.constraint_name +LEFT JOIN information_schema.table_constraints pk_tc + ON pk_tc.constraint_catalog = rc.unique_constraint_catalog + AND pk_tc.constraint_schema = rc.unique_constraint_schema + AND pk_tc.constraint_name = rc.unique_constraint_name WHERE tc.table_schema = :schema_name AND (:table_name::text IS NULL OR tc.table_name = :table_name) ORDER BY tc.table_schema, tc.table_name, tc.constraint_name; diff --git a/sqlspec/data_dictionary/dialects/cockroachdb/sql/foreign_keys.sql b/sqlspec/data_dictionary/dialects/cockroachdb/sql/foreign_keys.sql index 6878b94fe..6b303487d 100644 --- a/sqlspec/data_dictionary/dialects/cockroachdb/sql/foreign_keys.sql +++ b/sqlspec/data_dictionary/dialects/cockroachdb/sql/foreign_keys.sql @@ -3,38 +3,56 @@ SELECT tc.constraint_name::text AS constraint_name, kcu.column_name::text AS column_name, - ccu.table_name::text AS referenced_table, - ccu.column_name::text AS referenced_column, + pk_kcu.table_name::text AS referenced_table, + pk_kcu.column_name::text AS referenced_column, tc.table_name::text AS table_name, - tc.table_schema::text AS table_schema + tc.table_schema::text AS table_schema, + pk_kcu.table_schema::text AS referenced_schema, + pk_kcu.table_schema::text AS referenced_table_schema FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema -JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name - AND ccu.table_schema = tc.table_schema + ON tc.constraint_catalog = kcu.constraint_catalog + AND tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name +JOIN information_schema.referential_constraints rc + ON tc.constraint_catalog = rc.constraint_catalog + AND tc.constraint_schema = rc.constraint_schema + AND tc.constraint_name = rc.constraint_name +JOIN information_schema.key_column_usage pk_kcu + ON rc.unique_constraint_catalog = pk_kcu.constraint_catalog + AND rc.unique_constraint_schema = pk_kcu.constraint_schema + AND rc.unique_constraint_name = pk_kcu.constraint_name + AND COALESCE(kcu.position_in_unique_constraint, kcu.ordinal_position) = pk_kcu.ordinal_position WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = :schema_name AND tc.table_name = :table_name -ORDER BY tc.constraint_name, kcu.column_name; +ORDER BY tc.constraint_name, kcu.ordinal_position; -- name: by_schema -- dialect: cockroachdb SELECT tc.constraint_name::text AS constraint_name, kcu.column_name::text AS column_name, - ccu.table_name::text AS referenced_table, - ccu.column_name::text AS referenced_column, + pk_kcu.table_name::text AS referenced_table, + pk_kcu.column_name::text AS referenced_column, tc.table_name::text AS table_name, - tc.table_schema::text AS table_schema + tc.table_schema::text AS table_schema, + pk_kcu.table_schema::text AS referenced_schema, + pk_kcu.table_schema::text AS referenced_table_schema FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema -JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name - AND ccu.table_schema = tc.table_schema + ON tc.constraint_catalog = kcu.constraint_catalog + AND tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name +JOIN information_schema.referential_constraints rc + ON tc.constraint_catalog = rc.constraint_catalog + AND tc.constraint_schema = rc.constraint_schema + AND tc.constraint_name = rc.constraint_name +JOIN information_schema.key_column_usage pk_kcu + ON rc.unique_constraint_catalog = pk_kcu.constraint_catalog + AND rc.unique_constraint_schema = pk_kcu.constraint_schema + AND rc.unique_constraint_name = pk_kcu.constraint_name + AND COALESCE(kcu.position_in_unique_constraint, kcu.ordinal_position) = pk_kcu.ordinal_position WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = :schema_name -ORDER BY tc.table_name, tc.constraint_name, kcu.column_name; +ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position; diff --git a/sqlspec/data_dictionary/dialects/cockroachdb/sql/indexes.sql b/sqlspec/data_dictionary/dialects/cockroachdb/sql/indexes.sql index b3ead0eef..767f86f0d 100644 --- a/sqlspec/data_dictionary/dialects/cockroachdb/sql/indexes.sql +++ b/sqlspec/data_dictionary/dialects/cockroachdb/sql/indexes.sql @@ -4,23 +4,23 @@ SELECT table_schema::text AS schema_name, table_name::text AS table_name, index_name::text AS index_name, - column_name::text AS column_name, - non_unique = false AS is_unique, - index_name = 'primary' AS is_primary, - direction::text AS direction, - storing::text AS storing + array_agg(column_name::text ORDER BY seq_in_index::int) AS columns, + (non_unique = false OR non_unique::text IN ('0', 'f', 'false', 'NO')) AS is_unique, + index_name = 'primary' AS is_primary FROM information_schema.statistics WHERE table_schema = :schema_name AND (:table_name::text IS NULL OR table_name = :table_name) -ORDER BY table_schema, table_name, index_name, seq_in_index; +GROUP BY table_schema, table_name, index_name, non_unique +ORDER BY table_schema, table_name, index_name; -- name: by_table -- dialect: cockroachdb SELECT i.relname::text AS index_name, - a.attname::text AS column_name, + t.relname::text AS table_name, ix.indisunique AS is_unique, - ix.indisprimary AS is_primary + ix.indisprimary AS is_primary, + array_agg(a.attname::text ORDER BY array_position(ix.indkey, a.attnum)) AS columns FROM pg_catalog.pg_class t JOIN pg_catalog.pg_namespace n ON t.relnamespace = n.oid JOIN pg_catalog.pg_index ix ON t.oid = ix.indrelid @@ -28,4 +28,5 @@ JOIN pg_catalog.pg_class i ON i.oid = ix.indexrelid JOIN pg_catalog.pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) WHERE t.relname = :table_name AND n.nspname = :schema_name -ORDER BY i.relname, a.attnum; +GROUP BY t.relname, i.relname, ix.indisunique, ix.indisprimary +ORDER BY i.relname; diff --git a/sqlspec/data_dictionary/dialects/db2/sql/tables.sql b/sqlspec/data_dictionary/dialects/db2/sql/tables.sql index 20fa9c4cc..c47e6e3e1 100644 --- a/sqlspec/data_dictionary/dialects/db2/sql/tables.sql +++ b/sqlspec/data_dictionary/dialects/db2/sql/tables.sql @@ -18,6 +18,7 @@ WITH dependency_tree (schema_name, table_name, level, path) AS ( FROM SYSCAT.REFERENCES r WHERE r.TABSCHEMA = t.TABSCHEMA AND r.TABNAME = t.TABNAME + AND (r.REFTABSCHEMA <> t.TABSCHEMA OR r.REFTABNAME <> t.TABNAME) ) UNION ALL @@ -32,6 +33,7 @@ WITH dependency_tree (schema_name, table_name, level, path) AS ( AND r.TABNAME = child.TABNAME AND r.REFTABSCHEMA = dt.schema_name AND r.REFTABNAME = dt.table_name + AND (r.REFTABSCHEMA <> child.TABSCHEMA OR r.REFTABNAME <> child.TABNAME) AND child.TYPE = 'T' AND child.TABSCHEMA NOT LIKE 'SYS%' AND child.TABSCHEMA NOT LIKE 'NULLID%' diff --git a/sqlspec/data_dictionary/dialects/duckdb/config.py b/sqlspec/data_dictionary/dialects/duckdb/config.py index ecb88e677..9213b7d84 100644 --- a/sqlspec/data_dictionary/dialects/duckdb/config.py +++ b/sqlspec/data_dictionary/dialects/duckdb/config.py @@ -1,7 +1,17 @@ +"""DuckDB dialect static configuration and reflection helpers.""" + import re from sqlspec.data_dictionary import DialectConfig, FeatureFlags, FeatureVersions, VersionInfo, register_dialect +__all__ = ( + "DUCKDB_CONFIG", + "DUCKDB_FEATURE_FLAGS", + "DUCKDB_FEATURE_VERSIONS", + "DUCKDB_TYPE_MAPPINGS", + "DUCKDB_VERSION_PATTERN", +) + DUCKDB_VERSION_PATTERN = re.compile(r"v?(\d+)\.(\d+)\.(\d+)") DUCKDB_FEATURE_VERSIONS: "FeatureVersions" = { diff --git a/sqlspec/data_dictionary/dialects/duckdb/sql/foreign_keys.sql b/sqlspec/data_dictionary/dialects/duckdb/sql/foreign_keys.sql index 4eace98c0..505933280 100644 --- a/sqlspec/data_dictionary/dialects/duckdb/sql/foreign_keys.sql +++ b/sqlspec/data_dictionary/dialects/duckdb/sql/foreign_keys.sql @@ -11,8 +11,10 @@ SELECT FROM information_schema.key_column_usage kcu JOIN information_schema.referential_constraints rc ON kcu.constraint_name = rc.constraint_name + AND kcu.constraint_schema = rc.constraint_schema JOIN information_schema.key_column_usage pk_kcu ON rc.unique_constraint_name = pk_kcu.constraint_name + AND rc.unique_constraint_schema = pk_kcu.constraint_schema AND kcu.ordinal_position = pk_kcu.ordinal_position WHERE kcu.table_schema = COALESCE(:schema_name, current_schema()) AND kcu.table_name = :table_name; @@ -30,7 +32,9 @@ SELECT FROM information_schema.key_column_usage kcu JOIN information_schema.referential_constraints rc ON kcu.constraint_name = rc.constraint_name + AND kcu.constraint_schema = rc.constraint_schema JOIN information_schema.key_column_usage pk_kcu ON rc.unique_constraint_name = pk_kcu.constraint_name + AND rc.unique_constraint_schema = pk_kcu.constraint_schema AND kcu.ordinal_position = pk_kcu.ordinal_position WHERE kcu.table_schema = COALESCE(:schema_name, current_schema()); diff --git a/sqlspec/data_dictionary/dialects/duckdb/sql/tables.sql b/sqlspec/data_dictionary/dialects/duckdb/sql/tables.sql index b0989a7bc..63b774447 100644 --- a/sqlspec/data_dictionary/dialects/duckdb/sql/tables.sql +++ b/sqlspec/data_dictionary/dialects/duckdb/sql/tables.sql @@ -2,20 +2,24 @@ -- dialect: duckdb WITH RECURSIVE dependency_tree AS ( SELECT - table_name, + t.table_name, 0 AS level, - [table_name] AS path + [t.table_name] AS path FROM information_schema.tables t WHERE t.table_type = 'BASE TABLE' AND t.table_schema = COALESCE(:schema_name, current_schema()) AND NOT EXISTS ( SELECT 1 FROM information_schema.key_column_usage kcu + JOIN information_schema.referential_constraints rc + ON kcu.constraint_name = rc.constraint_name + AND kcu.constraint_schema = rc.constraint_schema + JOIN information_schema.key_column_usage pk_kcu + ON rc.unique_constraint_name = pk_kcu.constraint_name + AND rc.unique_constraint_schema = pk_kcu.constraint_schema WHERE kcu.table_name = t.table_name AND kcu.table_schema = t.table_schema - AND kcu.constraint_name IN ( - SELECT constraint_name FROM information_schema.referential_constraints - ) + AND pk_kcu.table_name <> t.table_name ) UNION ALL @@ -25,14 +29,30 @@ WITH RECURSIVE dependency_tree AS ( dt.level + 1, list_append(dt.path, kcu.table_name) FROM information_schema.key_column_usage kcu - JOIN information_schema.referential_constraints rc ON kcu.constraint_name = rc.constraint_name + JOIN information_schema.referential_constraints rc + ON kcu.constraint_name = rc.constraint_name + AND kcu.constraint_schema = rc.constraint_schema JOIN information_schema.key_column_usage pk_kcu ON rc.unique_constraint_name = pk_kcu.constraint_name AND rc.unique_constraint_schema = pk_kcu.constraint_schema JOIN dependency_tree dt ON dt.table_name = pk_kcu.table_name WHERE kcu.table_schema = COALESCE(:schema_name, current_schema()) + AND pk_kcu.table_name <> kcu.table_name AND NOT list_contains(dt.path, kcu.table_name) +), +dependency_levels AS ( + SELECT + table_name, + MAX(level) AS dependency_level + FROM dependency_tree + GROUP BY table_name ) -SELECT DISTINCT table_name, level -FROM dependency_tree -ORDER BY level, table_name; +SELECT + t.table_name, + COALESCE(dl.dependency_level, 0) AS level, + COALESCE(dl.dependency_level, 0) AS dependency_level +FROM information_schema.tables t +LEFT JOIN dependency_levels dl ON dl.table_name = t.table_name +WHERE t.table_type = 'BASE TABLE' + AND t.table_schema = COALESCE(:schema_name, current_schema()) +ORDER BY level, t.table_name; diff --git a/sqlspec/data_dictionary/dialects/mariadb/sql/columns.sql b/sqlspec/data_dictionary/dialects/mariadb/sql/columns.sql index 68f1402d7..ead43d9b5 100644 --- a/sqlspec/data_dictionary/dialects/mariadb/sql/columns.sql +++ b/sqlspec/data_dictionary/dialects/mariadb/sql/columns.sql @@ -22,3 +22,19 @@ SELECT FROM information_schema.columns WHERE table_schema = COALESCE(:schema_name, DATABASE()) ORDER BY table_schema, table_name, ordinal_position; + +-- name: by_table +-- dialect: mysql +SELECT + column_name AS `column_name`, + data_type AS `data_type`, + column_type AS `column_type`, + is_nullable AS `is_nullable`, + column_default AS `column_default`, + column_key AS `column_key`, + column_key = 'PRI' AS `is_primary`, + extra AS `extra` +FROM information_schema.columns +WHERE table_name = :table_name + AND table_schema = COALESCE(:schema_name, DATABASE()) +ORDER BY ordinal_position; diff --git a/sqlspec/data_dictionary/dialects/mariadb/sql/foreign_keys.sql b/sqlspec/data_dictionary/dialects/mariadb/sql/foreign_keys.sql new file mode 100644 index 000000000..791c455c1 --- /dev/null +++ b/sqlspec/data_dictionary/dialects/mariadb/sql/foreign_keys.sql @@ -0,0 +1,28 @@ +-- name: by_table +-- dialect: mysql +SELECT + table_name AS `table_name`, + column_name AS `column_name`, + referenced_table_name AS `referenced_table`, + referenced_column_name AS `referenced_column`, + constraint_name AS `constraint_name`, + table_schema AS `schema`, + referenced_table_schema AS `referenced_schema` +FROM information_schema.key_column_usage +WHERE referenced_table_name IS NOT NULL + AND table_name = :table_name + AND table_schema = COALESCE(:schema_name, DATABASE()); + +-- name: by_schema +-- dialect: mysql +SELECT + table_name AS `table_name`, + column_name AS `column_name`, + referenced_table_name AS `referenced_table`, + referenced_column_name AS `referenced_column`, + constraint_name AS `constraint_name`, + table_schema AS `schema`, + referenced_table_schema AS `referenced_schema` +FROM information_schema.key_column_usage +WHERE referenced_table_name IS NOT NULL + AND table_schema = COALESCE(:schema_name, DATABASE()); diff --git a/sqlspec/data_dictionary/dialects/mariadb/sql/indexes.sql b/sqlspec/data_dictionary/dialects/mariadb/sql/indexes.sql index 1e4e84149..454e8863d 100644 --- a/sqlspec/data_dictionary/dialects/mariadb/sql/indexes.sql +++ b/sqlspec/data_dictionary/dialects/mariadb/sql/indexes.sql @@ -17,3 +17,17 @@ WHERE table_schema = COALESCE(:schema_name, DATABASE()) AND (:table_name IS NULL OR table_name = :table_name) GROUP BY table_schema, table_name, index_name, non_unique, index_type, ignored, index_comment ORDER BY table_schema, table_name, index_name; + +-- name: by_table +-- dialect: mysql +SELECT + s.index_name AS index_name, + s.table_name AS table_name, + CASE WHEN s.non_unique = 0 THEN 1 ELSE 0 END AS is_unique, + CASE WHEN s.index_name = 'PRIMARY' THEN 1 ELSE 0 END AS is_primary, + GROUP_CONCAT(s.column_name ORDER BY s.seq_in_index) AS columns +FROM information_schema.statistics s +WHERE s.table_schema = COALESCE(:schema_name, DATABASE()) + AND s.table_name = :table_name +GROUP BY s.index_name, s.table_name, s.non_unique +ORDER BY s.index_name; diff --git a/sqlspec/data_dictionary/dialects/mariadb/sql/tables.sql b/sqlspec/data_dictionary/dialects/mariadb/sql/tables.sql index a1e847650..c87538f79 100644 --- a/sqlspec/data_dictionary/dialects/mariadb/sql/tables.sql +++ b/sqlspec/data_dictionary/dialects/mariadb/sql/tables.sql @@ -1,27 +1,64 @@ -- name: by_schema -- dialect: mysql +WITH RECURSIVE dependency_tree AS ( + SELECT + table_name, + 0 AS level, + CAST(table_name AS CHAR(4000)) AS path + FROM information_schema.tables AS root_table + WHERE root_table.table_type = 'BASE TABLE' + AND root_table.table_schema = COALESCE(:schema_name, DATABASE()) + AND NOT EXISTS ( + SELECT 1 + FROM information_schema.key_column_usage AS root_key + WHERE root_key.table_name = root_table.table_name + AND root_key.table_schema = root_table.table_schema + AND root_key.referenced_table_name IS NOT NULL + AND root_key.referenced_table_name <> root_table.table_name + ) + + UNION ALL + + SELECT + child_key.table_name, + dependency_tree.level + 1, + CONCAT(dependency_tree.path, ',', child_key.table_name) + FROM information_schema.key_column_usage AS child_key + JOIN dependency_tree ON child_key.referenced_table_name = dependency_tree.table_name + WHERE child_key.table_schema = COALESCE(:schema_name, DATABASE()) + AND child_key.referenced_table_name IS NOT NULL + AND child_key.referenced_table_name <> child_key.table_name + AND NOT FIND_IN_SET(child_key.table_name, dependency_tree.path) +), dependency_levels AS ( + SELECT table_name, MAX(level) AS dependency_level + FROM dependency_tree + GROUP BY table_name +) SELECT - table_schema AS schema_name, - table_name, - table_type, - engine, - version, - row_format, - table_rows, - avg_row_length, - data_length, - max_data_length, - index_length, - data_free, - auto_increment, - create_time, - update_time, - check_time, - table_collation, - checksum, - create_options, - table_comment -FROM information_schema.tables -WHERE table_schema = COALESCE(:schema_name, DATABASE()) - AND (:table_name IS NULL OR table_name = :table_name) -ORDER BY table_schema, table_name; + catalog_table.table_schema AS schema_name, + CAST(catalog_table.table_name AS CHAR) AS table_name, + catalog_table.table_type AS table_type, + catalog_table.engine AS engine, + catalog_table.version AS version, + catalog_table.row_format AS row_format, + catalog_table.table_rows AS table_rows, + catalog_table.avg_row_length AS avg_row_length, + catalog_table.data_length AS data_length, + catalog_table.max_data_length AS max_data_length, + catalog_table.index_length AS index_length, + catalog_table.data_free AS data_free, + catalog_table.auto_increment AS auto_increment, + catalog_table.create_time AS create_time, + catalog_table.update_time AS update_time, + catalog_table.check_time AS check_time, + catalog_table.table_collation AS table_collation, + catalog_table.checksum AS checksum, + catalog_table.create_options AS create_options, + catalog_table.table_comment AS table_comment, + COALESCE(dependency_levels.dependency_level, 0) AS dependency_level, + COALESCE(dependency_levels.dependency_level, 0) AS level +FROM information_schema.tables AS catalog_table +LEFT JOIN dependency_levels ON dependency_levels.table_name = catalog_table.table_name +WHERE catalog_table.table_schema = COALESCE(:schema_name, DATABASE()) + AND (:table_name IS NULL OR catalog_table.table_name = :table_name) +ORDER BY dependency_level, catalog_table.table_name; diff --git a/sqlspec/data_dictionary/dialects/mariadb/sql/version.sql b/sqlspec/data_dictionary/dialects/mariadb/sql/version.sql new file mode 100644 index 000000000..f506a2a98 --- /dev/null +++ b/sqlspec/data_dictionary/dialects/mariadb/sql/version.sql @@ -0,0 +1,7 @@ +-- name: current +-- dialect: mysql +SELECT VERSION() AS version; + +-- name: version +-- dialect: mysql +SELECT VERSION() AS version; diff --git a/sqlspec/data_dictionary/dialects/mssql/config.py b/sqlspec/data_dictionary/dialects/mssql/config.py index ad910a403..0bf0d5dc8 100644 --- a/sqlspec/data_dictionary/dialects/mssql/config.py +++ b/sqlspec/data_dictionary/dialects/mssql/config.py @@ -1,3 +1,5 @@ +"""SQL Server dialect static configuration and reflection helpers.""" + import re from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Final, cast @@ -27,6 +29,29 @@ from sqlspec.data_dictionary import TableMetadata __all__ = ( + "MSSQL_CONFIG", + "MSSQL_DDL_WARNING", + "MSSQL_DYNAMIC_FEATURES", + "MSSQL_ENGINE_EDITION_AZURE_SET", + "MSSQL_FEATURE_FLAGS", + "MSSQL_FEATURE_VERSIONS", + "MSSQL_MIN_GREATEST_LEAST_VERSION", + "MSSQL_MIN_JSON_FUNCTIONS_VERSION", + "MSSQL_MIN_NATIVE_JSON_VERSION", + "MSSQL_MIN_STRING_AGG_VERSION", + "MSSQL_PERMISSIONS_WARNING", + "MSSQL_PRODUCT_VERSION_PATTERN", + "MSSQL_QUERY_STORE_PERMISSION_WARNING", + "MSSQL_REDACTED_VALUE", + "MSSQL_REPLACEMENT_DOMAINS", + "MSSQL_SENSITIVE_SYSTEM_KEYS", + "MSSQL_SYSTEM_METADATA_DOMAINS", + "MSSQL_SYSTEM_OPT_IN_WARNING", + "MSSQL_SYSTEM_PERMISSION_WARNING", + "MSSQL_SYSTEM_WARNING", + "MSSQL_TYPE_MAPPINGS", + "MSSQL_VERSION_PARTS_COUNT", + "MSSQL_VERSION_PATTERN", "MssqlVersionInfo", "build_mssql_metadata_capability_profile", "build_mssql_system_metadata_capability", @@ -161,6 +186,8 @@ class MssqlVersionInfo(VersionInfo): """MSSQL database version info with build, revision, and Azure SQL detection.""" + __slots__ = ("build", "edition", "engine_edition", "is_azure_sql", "revision") + def __init__( self, major: int, @@ -177,15 +204,6 @@ def __init__( self.engine_edition = engine_edition self.is_azure_sql = is_mssql_azure_sql(engine_edition) - def supports_native_json(self) -> bool: - """Return whether this server supports the native JSON type.""" - return mssql_supports_native_json(self.major, is_azure_sql=self.is_azure_sql) - - @property - def version_tuple(self) -> "tuple[int, int, int]": - """Get version tuple using the MSSQL build number as the third component.""" - return (self.major, self.minor, self.build) - def __str__(self) -> str: """String representation of version info.""" version_str = f"{self.major}.{self.minor}.{self.build}.{self.revision}" @@ -195,6 +213,15 @@ def __str__(self) -> str: version_str += " [Azure]" return version_str + @property + def version_tuple(self) -> "tuple[int, int, int]": + """Get version tuple using the MSSQL build number as the third component.""" + return (self.major, self.minor, self.build) + + def supports_native_json(self) -> bool: + """Return whether this server supports the native JSON type.""" + return mssql_supports_native_json(self.major, is_azure_sql=self.is_azure_sql) + def extract_mssql_version_value(row: object) -> "str | None": """Extract a SQL Server version string from a row-like object.""" diff --git a/sqlspec/data_dictionary/dialects/mssql/sql/foreign_keys.sql b/sqlspec/data_dictionary/dialects/mssql/sql/foreign_keys.sql index 3e27eb1c2..21872287a 100644 --- a/sqlspec/data_dictionary/dialects/mssql/sql/foreign_keys.sql +++ b/sqlspec/data_dictionary/dialects/mssql/sql/foreign_keys.sql @@ -2,6 +2,7 @@ -- dialect: mssql SELECT OBJECT_SCHEMA_NAME(fk.parent_object_id) AS schema_name, + OBJECT_SCHEMA_NAME(fk.parent_object_id) AS table_schema, fk.name AS constraint_name, OBJECT_NAME(fk.parent_object_id) AS table_name, cpa.name AS column_name, @@ -22,6 +23,7 @@ ORDER BY fk.name, fkc.constraint_column_id; -- dialect: mssql SELECT OBJECT_SCHEMA_NAME(fk.parent_object_id) AS schema_name, + OBJECT_SCHEMA_NAME(fk.parent_object_id) AS table_schema, fk.name AS constraint_name, OBJECT_NAME(fk.parent_object_id) AS table_name, cpa.name AS column_name, diff --git a/sqlspec/data_dictionary/dialects/mssql/sql/tables.sql b/sqlspec/data_dictionary/dialects/mssql/sql/tables.sql index ac5809577..cfa79a044 100644 --- a/sqlspec/data_dictionary/dialects/mssql/sql/tables.sql +++ b/sqlspec/data_dictionary/dialects/mssql/sql/tables.sql @@ -31,6 +31,7 @@ WITH dependency_tree AS ( SELECT 1 FROM sys.foreign_keys AS fk WHERE fk.parent_object_id = t.object_id + AND fk.referenced_object_id <> t.object_id ) UNION ALL @@ -45,7 +46,8 @@ WITH dependency_tree AS ( INNER JOIN sys.tables AS child_table ON fk.parent_object_id = child_table.object_id INNER JOIN sys.schemas AS child_schema ON child_table.schema_id = child_schema.schema_id INNER JOIN dependency_tree ON fk.referenced_object_id = dependency_tree.object_id - WHERE (:schema_name IS NULL OR child_schema.name = :schema_name) + WHERE fk.referenced_object_id <> fk.parent_object_id + AND (:schema_name IS NULL OR child_schema.name = :schema_name) AND CHARINDEX(CONCAT('|', child_table.object_id, '|'), dependency_tree.path) = 0 ) SELECT diff --git a/sqlspec/data_dictionary/dialects/mysql/config.py b/sqlspec/data_dictionary/dialects/mysql/config.py index 969319abc..70c762966 100644 --- a/sqlspec/data_dictionary/dialects/mysql/config.py +++ b/sqlspec/data_dictionary/dialects/mysql/config.py @@ -1,3 +1,5 @@ +"""MySQL and MariaDB dialect static configuration and reflection helpers.""" + import re from sqlspec.data_dictionary import ( @@ -20,7 +22,14 @@ __all__ = ( "MARIADB_CONFIG", + "MARIADB_FEATURE_FLAGS", + "MARIADB_FEATURE_VERSIONS", "MYSQL_CONFIG", + "MYSQL_FEATURE_FLAGS", + "MYSQL_FEATURE_VERSIONS", + "MYSQL_METADATA_DOMAINS", + "MYSQL_TYPE_MAPPINGS", + "MYSQL_VERSION_PATTERN", "MySQLEngineVersion", "build_mysql_metadata_capability_profile", "build_mysql_show_create_statement", diff --git a/sqlspec/data_dictionary/dialects/mysql/dictionary.py b/sqlspec/data_dictionary/dialects/mysql/dictionary.py index 74752c62a..5756cdb27 100644 --- a/sqlspec/data_dictionary/dialects/mysql/dictionary.py +++ b/sqlspec/data_dictionary/dialects/mysql/dictionary.py @@ -39,10 +39,7 @@ __all__ = ("MySQLAsyncDataDictionary", "MySQLSyncDataDictionary") - -def _get_driver_cache_key(driver: Any) -> int: - """Resolve a cache key for engine version caching.""" - return id(driver) +_MAX_ENGINE_VERSION_CACHE_SIZE = 256 @mypyc_attr(allow_interpreted_subclasses=True, native_class=False) @@ -61,21 +58,15 @@ async def get_version(self, driver: "AsyncDriverAdapterBase") -> "VersionInfo | if cache_key in self._version_fetch_attempted: return self._version_cache.get(cache_key) - version_value = await driver.select_value_or_none(self.get_query("version", "current")) - if not version_value: + engine_version = await self._get_engine_version(driver) + if engine_version is None: self._log_version_unavailable(type(self).dialect, "missing") self.cache_version(cache_key, None) return None - version_info = self.parse_version_with_pattern(self.get_dialect_config().version_pattern, str(version_value)) - if version_info is None: - self._log_version_unavailable(type(self).dialect, "parse_failed") - self.cache_version(cache_key, None) - return None - - self._log_version_detected(type(self).dialect, version_info) - self.cache_version(cache_key, version_info) - return version_info + self._log_version_detected(type(self).dialect, engine_version.version) + self.cache_version(cache_key, engine_version.version) + return engine_version.version async def get_feature_flag(self, driver: "AsyncDriverAdapterBase", feature: str) -> bool: """Check if MySQL database supports a specific feature.""" @@ -277,12 +268,14 @@ async def _get_engine_version(self, driver: "AsyncDriverAdapterBase") -> "MySQLE cache_key = _get_driver_cache_key(driver) if cache_key in self._engine_version_cache: return self._engine_version_cache[cache_key] + if len(self._engine_version_cache) >= _MAX_ENGINE_VERSION_CACHE_SIZE: + self._engine_version_cache.pop(next(iter(self._engine_version_cache))) try: version_value = await driver.select_value_or_none(self.get_query_text("version", "current")) except Exception: self._engine_version_cache[cache_key] = None return None - if version_value is None: + if not version_value: self._engine_version_cache[cache_key] = None return None engine_version = parse_mysql_engine_version(str(version_value)) @@ -330,21 +323,15 @@ def get_version(self, driver: "SyncDriverAdapterBase") -> "VersionInfo | None": if cache_key in self._version_fetch_attempted: return self._version_cache.get(cache_key) - version_value = driver.select_value_or_none(self.get_query("version", "current")) - if not version_value: + engine_version = self._get_engine_version(driver) + if engine_version is None: self._log_version_unavailable(type(self).dialect, "missing") self.cache_version(cache_key, None) return None - version_info = self.parse_version_with_pattern(self.get_dialect_config().version_pattern, str(version_value)) - if version_info is None: - self._log_version_unavailable(type(self).dialect, "parse_failed") - self.cache_version(cache_key, None) - return None - - self._log_version_detected(type(self).dialect, version_info) - self.cache_version(cache_key, version_info) - return version_info + self._log_version_detected(type(self).dialect, engine_version.version) + self.cache_version(cache_key, engine_version.version) + return engine_version.version def get_feature_flag(self, driver: "SyncDriverAdapterBase", feature: str) -> bool: """Check if MySQL database supports a specific feature.""" @@ -546,12 +533,14 @@ def _get_engine_version(self, driver: "SyncDriverAdapterBase") -> "MySQLEngineVe cache_key = _get_driver_cache_key(driver) if cache_key in self._engine_version_cache: return self._engine_version_cache[cache_key] + if len(self._engine_version_cache) >= _MAX_ENGINE_VERSION_CACHE_SIZE: + self._engine_version_cache.pop(next(iter(self._engine_version_cache))) try: version_value = driver.select_value_or_none(self.get_query_text("version", "current")) except Exception: self._engine_version_cache[cache_key] = None return None - if version_value is None: + if not version_value: self._engine_version_cache[cache_key] = None return None engine_version = parse_mysql_engine_version(str(version_value)) @@ -581,3 +570,8 @@ def _select_domain_result( else: rows = driver.select(query.sql, schema_name=schema_name, table_name=table_name) return MetadataResult(domain, capability=capability, items=tuple(rows)) + + +def _get_driver_cache_key(driver: Any) -> int: + """Resolve a cache key for engine version caching.""" + return id(driver) diff --git a/sqlspec/data_dictionary/dialects/mysql/sql/tables.sql b/sqlspec/data_dictionary/dialects/mysql/sql/tables.sql index 2af31015e..c87538f79 100644 --- a/sqlspec/data_dictionary/dialects/mysql/sql/tables.sql +++ b/sqlspec/data_dictionary/dialects/mysql/sql/tables.sql @@ -14,6 +14,7 @@ WITH RECURSIVE dependency_tree AS ( WHERE root_key.table_name = root_table.table_name AND root_key.table_schema = root_table.table_schema AND root_key.referenced_table_name IS NOT NULL + AND root_key.referenced_table_name <> root_table.table_name ) UNION ALL @@ -26,9 +27,10 @@ WITH RECURSIVE dependency_tree AS ( JOIN dependency_tree ON child_key.referenced_table_name = dependency_tree.table_name WHERE child_key.table_schema = COALESCE(:schema_name, DATABASE()) AND child_key.referenced_table_name IS NOT NULL + AND child_key.referenced_table_name <> child_key.table_name AND NOT FIND_IN_SET(child_key.table_name, dependency_tree.path) ), dependency_levels AS ( - SELECT table_name, MIN(level) AS dependency_level + SELECT table_name, MAX(level) AS dependency_level FROM dependency_tree GROUP BY table_name ) diff --git a/sqlspec/data_dictionary/dialects/oracle/config.py b/sqlspec/data_dictionary/dialects/oracle/config.py index 5848b5ec9..f1dc95a36 100644 --- a/sqlspec/data_dictionary/dialects/oracle/config.py +++ b/sqlspec/data_dictionary/dialects/oracle/config.py @@ -1,3 +1,5 @@ +"""Oracle dialect static configuration and reflection helpers.""" + import re from collections.abc import Mapping from typing import TYPE_CHECKING, Final @@ -8,9 +10,21 @@ from sqlspec.data_dictionary import TableMetadata, VersionInfo __all__ = ( + "ORACLE_CONFIG", + "ORACLE_DYNAMIC_FEATURES", + "ORACLE_FEATURE_FLAGS", + "ORACLE_FEATURE_VERSIONS", "ORACLE_JSON_STORAGE_BLOB_JSON", "ORACLE_JSON_STORAGE_BLOB_PLAIN", "ORACLE_JSON_STORAGE_NATIVE", + "ORACLE_MIN_JSON_BLOB_VERSION", + "ORACLE_MIN_JSON_NATIVE_COMPATIBLE", + "ORACLE_MIN_JSON_NATIVE_VERSION", + "ORACLE_MIN_OSON_VERSION", + "ORACLE_MIN_UPDATE_FROM_VERSION", + "ORACLE_TYPE_MAPPINGS", + "ORACLE_VERSION_PARTS_COUNT", + "ORACLE_VERSION_PATTERN", "extract_oracle_version_value", "list_oracle_available_features", "merge_oracle_table_lists", diff --git a/sqlspec/data_dictionary/dialects/postgres/config.py b/sqlspec/data_dictionary/dialects/postgres/config.py index f088a51fd..405844603 100644 --- a/sqlspec/data_dictionary/dialects/postgres/config.py +++ b/sqlspec/data_dictionary/dialects/postgres/config.py @@ -1,8 +1,17 @@ +"""PostgreSQL dialect static configuration and reflection helpers.""" + import re from sqlspec.data_dictionary import DialectConfig, FeatureFlags, FeatureVersions, VersionInfo, register_dialect -__all__ = ("resolve_postgres_json_type",) +__all__ = ( + "POSTGRES_CONFIG", + "POSTGRES_FEATURE_FLAGS", + "POSTGRES_FEATURE_VERSIONS", + "POSTGRES_TYPE_MAPPINGS", + "POSTGRES_VERSION_PATTERN", + "resolve_postgres_json_type", +) POSTGRES_VERSION_PATTERN = re.compile(r"PostgreSQL (\d+)\.(\d+)(?:\.(\d+))?") diff --git a/sqlspec/data_dictionary/dialects/postgres/sql/foreign_keys.sql b/sqlspec/data_dictionary/dialects/postgres/sql/foreign_keys.sql index 5c91ca764..8b605d196 100644 --- a/sqlspec/data_dictionary/dialects/postgres/sql/foreign_keys.sql +++ b/sqlspec/data_dictionary/dialects/postgres/sql/foreign_keys.sql @@ -1,40 +1,44 @@ -- name: by_table -- dialect: postgres SELECT - kcu.table_name, - kcu.column_name, - ccu.table_name AS referenced_table_name, - ccu.column_name AS referenced_column_name, - tc.constraint_name, - tc.table_schema, - ccu.table_schema AS referenced_table_schema -FROM information_schema.table_constraints AS tc -JOIN information_schema.key_column_usage AS kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema -JOIN information_schema.constraint_column_usage AS ccu - ON ccu.constraint_name = tc.constraint_name - AND ccu.table_schema = tc.table_schema -WHERE tc.constraint_type = 'FOREIGN KEY' - AND (:schema_name::text IS NULL OR tc.table_schema = :schema_name) - AND (:table_name::text IS NULL OR tc.table_name = :table_name); + c.relname::text AS table_name, + att.attname::text AS column_name, + ref.relname::text AS referenced_table_name, + ref_att.attname::text AS referenced_column_name, + con.conname::text AS constraint_name, + n.nspname::text AS table_schema, + ref_ns.nspname::text AS referenced_table_schema +FROM pg_catalog.pg_constraint AS con +JOIN pg_catalog.pg_class AS c ON c.oid = con.conrelid +JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace +JOIN pg_catalog.pg_class AS ref ON ref.oid = con.confrelid +JOIN pg_catalog.pg_namespace AS ref_ns ON ref_ns.oid = ref.relnamespace +CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS keys(attnum, ref_attnum, ord) +JOIN pg_catalog.pg_attribute AS att ON att.attrelid = c.oid AND att.attnum = keys.attnum +JOIN pg_catalog.pg_attribute AS ref_att ON ref_att.attrelid = ref.oid AND ref_att.attnum = keys.ref_attnum +WHERE con.contype = 'f' + AND (:schema_name::text IS NULL OR n.nspname = :schema_name) + AND (:table_name::text IS NULL OR c.relname = :table_name) +ORDER BY c.relname, con.conname, keys.ord; -- name: by_schema -- dialect: postgres SELECT - kcu.table_name, - kcu.column_name, - ccu.table_name AS referenced_table_name, - ccu.column_name AS referenced_column_name, - tc.constraint_name, - tc.table_schema, - ccu.table_schema AS referenced_table_schema -FROM information_schema.table_constraints AS tc -JOIN information_schema.key_column_usage AS kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema -JOIN information_schema.constraint_column_usage AS ccu - ON ccu.constraint_name = tc.constraint_name - AND ccu.table_schema = tc.table_schema -WHERE tc.constraint_type = 'FOREIGN KEY' - AND (:schema_name::text IS NULL OR tc.table_schema = :schema_name); + c.relname::text AS table_name, + att.attname::text AS column_name, + ref.relname::text AS referenced_table_name, + ref_att.attname::text AS referenced_column_name, + con.conname::text AS constraint_name, + n.nspname::text AS table_schema, + ref_ns.nspname::text AS referenced_table_schema +FROM pg_catalog.pg_constraint AS con +JOIN pg_catalog.pg_class AS c ON c.oid = con.conrelid +JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace +JOIN pg_catalog.pg_class AS ref ON ref.oid = con.confrelid +JOIN pg_catalog.pg_namespace AS ref_ns ON ref_ns.oid = ref.relnamespace +CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS keys(attnum, ref_attnum, ord) +JOIN pg_catalog.pg_attribute AS att ON att.attrelid = c.oid AND att.attnum = keys.attnum +JOIN pg_catalog.pg_attribute AS ref_att ON ref_att.attrelid = ref.oid AND ref_att.attnum = keys.ref_attnum +WHERE con.contype = 'f' + AND (:schema_name::text IS NULL OR n.nspname = :schema_name) +ORDER BY c.relname, con.conname, keys.ord; diff --git a/sqlspec/data_dictionary/dialects/postgres/sql/tables.sql b/sqlspec/data_dictionary/dialects/postgres/sql/tables.sql index aa5e2b245..35c85f292 100644 --- a/sqlspec/data_dictionary/dialects/postgres/sql/tables.sql +++ b/sqlspec/data_dictionary/dialects/postgres/sql/tables.sql @@ -17,6 +17,7 @@ WITH RECURSIVE dependency_tree AS ( FROM pg_catalog.pg_constraint fk WHERE fk.conrelid = c.oid AND fk.contype = 'f' + AND fk.confrelid <> c.oid ) UNION ALL @@ -32,11 +33,19 @@ WITH RECURSIVE dependency_tree AS ( JOIN pg_catalog.pg_namespace child_ns ON child_ns.oid = child.relnamespace JOIN dependency_tree parent ON parent.oid = fk.confrelid WHERE fk.contype = 'f' + AND fk.confrelid <> fk.conrelid AND child_ns.nspname = :schema_name AND (:table_name::text IS NULL OR child.relname = :table_name) AND NOT child.oid = ANY(parent.path) +), +dependency_levels AS ( + SELECT + oid, + MAX(dependency_level) AS dependency_level + FROM dependency_tree + GROUP BY oid ) -SELECT DISTINCT ON (t.oid) +SELECT current_database()::text AS table_catalog, n.nspname::text AS table_schema, n.nspname::text AS schema_name, @@ -51,8 +60,8 @@ SELECT DISTINCT ON (t.oid) FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_catalog.pg_roles r ON r.oid = c.relowner -LEFT JOIN dependency_tree t ON t.oid = c.oid +LEFT JOIN dependency_levels t ON t.oid = c.oid WHERE n.nspname = :schema_name AND (:table_name::text IS NULL OR c.relname = :table_name) AND c.relkind IN ('r', 'p') -ORDER BY t.oid, dependency_level, c.relname; +ORDER BY dependency_level, c.relname; diff --git a/sqlspec/data_dictionary/dialects/spanner/config.py b/sqlspec/data_dictionary/dialects/spanner/config.py index 9d4ced865..be14fba4a 100644 --- a/sqlspec/data_dictionary/dialects/spanner/config.py +++ b/sqlspec/data_dictionary/dialects/spanner/config.py @@ -1,7 +1,17 @@ +"""Cloud Spanner dialect static configuration and reflection helpers.""" + import re from sqlspec.data_dictionary import DialectConfig, FeatureFlags, FeatureVersions, register_dialect +__all__ = ( + "SPANNER_CONFIG", + "SPANNER_FEATURE_FLAGS", + "SPANNER_FEATURE_VERSIONS", + "SPANNER_TYPE_MAPPINGS", + "SPANNER_VERSION_PATTERN", +) + SPANNER_VERSION_PATTERN = re.compile(r".*") SPANNER_FEATURE_VERSIONS: "FeatureVersions" = {} diff --git a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/change_streams.sql b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/change_streams.sql index 89832486f..a549584b1 100644 --- a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/change_streams.sql +++ b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/change_streams.sql @@ -1,11 +1,11 @@ -- name: list -- dialect: spanner SELECT - CHANGE_STREAM_CATALOG, - CHANGE_STREAM_SCHEMA, - CHANGE_STREAM_NAME, - ALL_TABLES, - CHANGE_STREAM_FOR_CLAUSE + CHANGE_STREAM_CATALOG AS change_stream_catalog, + CHANGE_STREAM_SCHEMA AS change_stream_schema, + CHANGE_STREAM_NAME AS change_stream_name, + ALL_TABLES AS all_tables, + CHANGE_STREAM_FOR_CLAUSE AS change_stream_for_clause FROM INFORMATION_SCHEMA.CHANGE_STREAMS WHERE (CAST(:schema_name AS STRING) IS NULL OR CHANGE_STREAM_SCHEMA = :schema_name) AND (CAST(:change_stream_name AS STRING) IS NULL OR CHANGE_STREAM_NAME = :change_stream_name) @@ -14,14 +14,14 @@ ORDER BY CHANGE_STREAM_SCHEMA, CHANGE_STREAM_NAME; -- name: tables -- dialect: spanner SELECT - CHANGE_STREAM_CATALOG, - CHANGE_STREAM_SCHEMA, - CHANGE_STREAM_NAME, - TABLE_CATALOG, - TABLE_SCHEMA, - TABLE_NAME, - ALL_COLUMNS, - COLUMN_NAMES + CHANGE_STREAM_CATALOG AS change_stream_catalog, + CHANGE_STREAM_SCHEMA AS change_stream_schema, + CHANGE_STREAM_NAME AS change_stream_name, + TABLE_CATALOG AS table_catalog, + TABLE_SCHEMA AS table_schema, + TABLE_NAME AS table_name, + ALL_COLUMNS AS all_columns, + COLUMN_NAMES AS column_names FROM INFORMATION_SCHEMA.CHANGE_STREAM_TABLES WHERE (CAST(:schema_name AS STRING) IS NULL OR CHANGE_STREAM_SCHEMA = :schema_name) AND (CAST(:change_stream_name AS STRING) IS NULL OR CHANGE_STREAM_NAME = :change_stream_name) diff --git a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/constraints.sql b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/constraints.sql index b6e3300b4..eb4de6dc3 100644 --- a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/constraints.sql +++ b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/constraints.sql @@ -1,18 +1,18 @@ -- name: by_schema -- dialect: spanner SELECT - tc.CONSTRAINT_CATALOG, - tc.CONSTRAINT_SCHEMA, - tc.CONSTRAINT_NAME, - tc.TABLE_NAME, - tc.CONSTRAINT_TYPE, - kcu.COLUMN_NAME, - kcu.ORDINAL_POSITION, - rc.UNIQUE_CONSTRAINT_NAME, - rc.MATCH_OPTION, - rc.UPDATE_RULE, - rc.DELETE_RULE, - cc.CHECK_CLAUSE + tc.CONSTRAINT_CATALOG AS constraint_catalog, + tc.CONSTRAINT_SCHEMA AS constraint_schema, + tc.CONSTRAINT_NAME AS constraint_name, + tc.TABLE_NAME AS table_name, + tc.CONSTRAINT_TYPE AS constraint_type, + kcu.COLUMN_NAME AS column_name, + kcu.ORDINAL_POSITION AS ordinal_position, + rc.UNIQUE_CONSTRAINT_NAME AS unique_constraint_name, + rc.MATCH_OPTION AS match_option, + rc.UPDATE_RULE AS update_rule, + rc.DELETE_RULE AS delete_rule, + cc.CHECK_CLAUSE AS check_clause FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS kcu ON tc.CONSTRAINT_CATALOG = kcu.CONSTRAINT_CATALOG @@ -33,18 +33,18 @@ ORDER BY tc.TABLE_NAME, tc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION; -- name: by_table -- dialect: spanner SELECT - tc.CONSTRAINT_CATALOG, - tc.CONSTRAINT_SCHEMA, - tc.CONSTRAINT_NAME, - tc.TABLE_NAME, - tc.CONSTRAINT_TYPE, - kcu.COLUMN_NAME, - kcu.ORDINAL_POSITION, - rc.UNIQUE_CONSTRAINT_NAME, - rc.MATCH_OPTION, - rc.UPDATE_RULE, - rc.DELETE_RULE, - cc.CHECK_CLAUSE + tc.CONSTRAINT_CATALOG AS constraint_catalog, + tc.CONSTRAINT_SCHEMA AS constraint_schema, + tc.CONSTRAINT_NAME AS constraint_name, + tc.TABLE_NAME AS table_name, + tc.CONSTRAINT_TYPE AS constraint_type, + kcu.COLUMN_NAME AS column_name, + kcu.ORDINAL_POSITION AS ordinal_position, + rc.UNIQUE_CONSTRAINT_NAME AS unique_constraint_name, + rc.MATCH_OPTION AS match_option, + rc.UPDATE_RULE AS update_rule, + rc.DELETE_RULE AS delete_rule, + cc.CHECK_CLAUSE AS check_clause FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS kcu ON tc.CONSTRAINT_CATALOG = kcu.CONSTRAINT_CATALOG diff --git a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/database.sql b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/database.sql index ad5960bab..d08b2da22 100644 --- a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/database.sql +++ b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/database.sql @@ -1,9 +1,9 @@ -- name: options -- dialect: spanner SELECT - CATALOG_NAME, - OPTION_NAME, - OPTION_TYPE, - OPTION_VALUE + CATALOG_NAME AS catalog_name, + OPTION_NAME AS option_name, + OPTION_TYPE AS option_type, + OPTION_VALUE AS option_value FROM INFORMATION_SCHEMA.DATABASE_OPTIONS ORDER BY OPTION_NAME; diff --git a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/objects.sql b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/objects.sql index 08ad79dce..72ac018fd 100644 --- a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/objects.sql +++ b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/objects.sql @@ -1,10 +1,10 @@ -- name: by_schema -- dialect: spanner SELECT - TABLE_CATALOG, - TABLE_SCHEMA, - TABLE_NAME, - TABLE_TYPE + TABLE_CATALOG AS table_catalog, + TABLE_SCHEMA AS table_schema, + TABLE_NAME AS table_name, + TABLE_TYPE AS table_type FROM INFORMATION_SCHEMA.TABLES -WHERE TABLE_SCHEMA = @schema_name +WHERE (CAST(:schema_name AS STRING) IS NULL OR TABLE_SCHEMA = :schema_name) ORDER BY TABLE_SCHEMA, TABLE_NAME; diff --git a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/privileges.sql b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/privileges.sql index 162055bdb..8c977008b 100644 --- a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/privileges.sql +++ b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/privileges.sql @@ -1,12 +1,12 @@ -- name: by_schema -- dialect: spanner SELECT - TABLE_CATALOG, - TABLE_SCHEMA, - TABLE_NAME, - PRIVILEGE_TYPE, - GRANTEE, - IS_GRANTABLE + TABLE_CATALOG AS table_catalog, + TABLE_SCHEMA AS table_schema, + TABLE_NAME AS table_name, + PRIVILEGE_TYPE AS privilege_type, + GRANTEE AS grantee, + IS_GRANTABLE AS is_grantable FROM INFORMATION_SCHEMA.TABLE_PRIVILEGES WHERE (CAST(:schema_name AS STRING) IS NULL OR TABLE_SCHEMA = :schema_name) AND (CAST(:table_name AS STRING) IS NULL OR TABLE_NAME = :table_name) diff --git a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/property_graphs.sql b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/property_graphs.sql index de565caef..9ce37623a 100644 --- a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/property_graphs.sql +++ b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/property_graphs.sql @@ -1,10 +1,10 @@ -- name: list -- dialect: spanner SELECT - PROPERTY_GRAPH_CATALOG, - PROPERTY_GRAPH_SCHEMA, - PROPERTY_GRAPH_NAME, - PROPERTY_GRAPH_DEFINITION + PROPERTY_GRAPH_CATALOG AS property_graph_catalog, + PROPERTY_GRAPH_SCHEMA AS property_graph_schema, + PROPERTY_GRAPH_NAME AS property_graph_name, + PROPERTY_GRAPH_DEFINITION AS property_graph_definition FROM INFORMATION_SCHEMA.PROPERTY_GRAPHS WHERE (CAST(:schema_name AS STRING) IS NULL OR PROPERTY_GRAPH_SCHEMA = :schema_name) AND (CAST(:property_graph_name AS STRING) IS NULL OR PROPERTY_GRAPH_NAME = :property_graph_name) diff --git a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/routines.sql b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/routines.sql index ac36b58ae..d801db58f 100644 --- a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/routines.sql +++ b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/routines.sql @@ -1,16 +1,16 @@ -- name: by_schema -- dialect: spanner SELECT - ROUTINE_CATALOG, - ROUTINE_SCHEMA, - ROUTINE_NAME, - ROUTINE_TYPE, - DATA_TYPE, - ROUTINE_BODY, - ROUTINE_DEFINITION, - EXTERNAL_LANGUAGE, - IS_DETERMINISTIC, - SECURITY_TYPE + ROUTINE_CATALOG AS routine_catalog, + ROUTINE_SCHEMA AS routine_schema, + ROUTINE_NAME AS routine_name, + ROUTINE_TYPE AS routine_type, + DATA_TYPE AS data_type, + ROUTINE_BODY AS routine_body, + ROUTINE_DEFINITION AS routine_definition, + EXTERNAL_LANGUAGE AS external_language, + IS_DETERMINISTIC AS is_deterministic, + SECURITY_TYPE AS security_type FROM INFORMATION_SCHEMA.ROUTINES WHERE (CAST(:schema_name AS STRING) IS NULL OR ROUTINE_SCHEMA = :schema_name) AND (CAST(:routine_name AS STRING) IS NULL OR ROUTINE_NAME = :routine_name) @@ -19,14 +19,14 @@ ORDER BY ROUTINE_SCHEMA, ROUTINE_NAME; -- name: parameters_by_schema -- dialect: spanner SELECT - SPECIFIC_CATALOG, - SPECIFIC_SCHEMA, - SPECIFIC_NAME, - ORDINAL_POSITION, - PARAMETER_MODE, - IS_RESULT, - PARAMETER_NAME, - DATA_TYPE + SPECIFIC_CATALOG AS specific_catalog, + SPECIFIC_SCHEMA AS specific_schema, + SPECIFIC_NAME AS specific_name, + ORDINAL_POSITION AS ordinal_position, + PARAMETER_MODE AS parameter_mode, + IS_RESULT AS is_result, + PARAMETER_NAME AS parameter_name, + DATA_TYPE AS data_type FROM INFORMATION_SCHEMA.PARAMETERS WHERE (CAST(:schema_name AS STRING) IS NULL OR SPECIFIC_SCHEMA = :schema_name) AND (CAST(:routine_name AS STRING) IS NULL OR SPECIFIC_NAME = :routine_name) diff --git a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/sequences.sql b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/sequences.sql index 82a413d6c..78da34beb 100644 --- a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/sequences.sql +++ b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/sequences.sql @@ -1,13 +1,13 @@ -- name: by_schema -- dialect: spanner SELECT - SEQUENCE_CATALOG, - SEQUENCE_SCHEMA, - SEQUENCE_NAME, - DATA_TYPE, - START_WITH_COUNTER, - SKIP_RANGE_MIN, - SKIP_RANGE_MAX + SEQUENCE_CATALOG AS sequence_catalog, + SEQUENCE_SCHEMA AS sequence_schema, + SEQUENCE_NAME AS sequence_name, + DATA_TYPE AS data_type, + START_WITH_COUNTER AS start_with_counter, + SKIP_RANGE_MIN AS skip_range_min, + SKIP_RANGE_MAX AS skip_range_max FROM INFORMATION_SCHEMA.SEQUENCES WHERE (CAST(:schema_name AS STRING) IS NULL OR SEQUENCE_SCHEMA = :schema_name) AND (CAST(:sequence_name AS STRING) IS NULL OR SEQUENCE_NAME = :sequence_name) diff --git a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/system.sql b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/system.sql index be7c6d5d3..4d003764f 100644 --- a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/system.sql +++ b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/system.sql @@ -1,14 +1,15 @@ -- name: query_stats_top -- dialect: spanner SELECT - INTERVAL_END, - TEXT, - TEXT_FINGERPRINT, - EXECUTION_COUNT, - AVG_LATENCY_SECONDS, - AVG_ROWS, - AVG_BYTES, - AVG_CPU_SECONDS + INTERVAL_END AS interval_end, + TEXT AS text, + TEXT AS query_text, + TEXT_FINGERPRINT AS text_fingerprint, + EXECUTION_COUNT AS execution_count, + AVG_LATENCY_SECONDS AS avg_latency_seconds, + AVG_ROWS AS avg_rows, + AVG_BYTES AS avg_bytes, + AVG_CPU_SECONDS AS avg_cpu_seconds FROM SPANNER_SYS.QUERY_STATS_TOP_MINUTE WHERE (CAST(:interval_end_after AS TIMESTAMP) IS NULL OR INTERVAL_END >= :interval_end_after) ORDER BY INTERVAL_END DESC, EXECUTION_COUNT DESC @@ -17,9 +18,9 @@ LIMIT :limit; -- name: table_sizes -- dialect: spanner SELECT - INTERVAL_END, - TABLE_NAME, - USED_BYTES + INTERVAL_END AS interval_end, + TABLE_NAME AS table_name, + USED_BYTES AS used_bytes FROM SPANNER_SYS.TABLE_SIZES_STATS_1HOUR WHERE (CAST(:table_name AS STRING) IS NULL OR TABLE_NAME = :table_name) AND (CAST(:interval_end_after AS TIMESTAMP) IS NULL OR INTERVAL_END >= :interval_end_after) diff --git a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/views.sql b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/views.sql index c2fe28142..ccf4b45e7 100644 --- a/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/views.sql +++ b/sqlspec/data_dictionary/dialects/spanner/sql/googlesql/views.sql @@ -1,12 +1,12 @@ -- name: by_schema -- dialect: spanner SELECT - TABLE_CATALOG, - TABLE_SCHEMA, - TABLE_NAME, - VIEW_DEFINITION, - CHECK_OPTION, - SECURITY_TYPE + TABLE_CATALOG AS table_catalog, + TABLE_SCHEMA AS table_schema, + TABLE_NAME AS table_name, + VIEW_DEFINITION AS view_definition, + CHECK_OPTION AS check_option, + SECURITY_TYPE AS security_type FROM INFORMATION_SCHEMA.VIEWS WHERE (CAST(:schema_name AS STRING) IS NULL OR TABLE_SCHEMA = :schema_name) AND (CAST(:view_name AS STRING) IS NULL OR TABLE_NAME = :view_name) diff --git a/sqlspec/data_dictionary/dialects/spanner/sql/postgresql/system.sql b/sqlspec/data_dictionary/dialects/spanner/sql/postgresql/system.sql index fe57027c0..eb6f82b7e 100644 --- a/sqlspec/data_dictionary/dialects/spanner/sql/postgresql/system.sql +++ b/sqlspec/data_dictionary/dialects/spanner/sql/postgresql/system.sql @@ -3,6 +3,7 @@ SELECT interval_end, text, + text AS query_text, text_fingerprint, execution_count, avg_latency_seconds, diff --git a/sqlspec/data_dictionary/dialects/sqlite/config.py b/sqlspec/data_dictionary/dialects/sqlite/config.py index b6acf3782..c3ac735c8 100644 --- a/sqlspec/data_dictionary/dialects/sqlite/config.py +++ b/sqlspec/data_dictionary/dialects/sqlite/config.py @@ -1,8 +1,18 @@ +"""SQLite dialect static configuration and reflection helpers.""" + import re from sqlspec.data_dictionary import DialectConfig, FeatureFlags, FeatureVersions, VersionInfo, register_dialect -__all__ = ("list_sqlite_available_features", "resolve_sqlite_json_type") +__all__ = ( + "SQLITE_CONFIG", + "SQLITE_FEATURE_FLAGS", + "SQLITE_FEATURE_VERSIONS", + "SQLITE_TYPE_MAPPINGS", + "SQLITE_VERSION_PATTERN", + "list_sqlite_available_features", + "resolve_sqlite_json_type", +) SQLITE_VERSION_PATTERN = re.compile(r"(\d+)\.(\d+)\.(\d+)") diff --git a/sqlspec/data_dictionary/dialects/sqlite/sql/constraints.sql b/sqlspec/data_dictionary/dialects/sqlite/sql/constraints.sql index e69de29bb..977b9ff26 100644 --- a/sqlspec/data_dictionary/dialects/sqlite/sql/constraints.sql +++ b/sqlspec/data_dictionary/dialects/sqlite/sql/constraints.sql @@ -0,0 +1,26 @@ +-- name: by_schema +-- dialect: sqlite +SELECT + COALESCE(:schema_name, 'main') AS schema_name, + tl.name AS table_name, + il.name AS constraint_name, + CASE WHEN il.origin = 'pk' THEN 'PRIMARY KEY' ELSE 'UNIQUE' END AS constraint_type +FROM pragma_table_list AS tl +JOIN pragma_index_list(tl.name, COALESCE(:schema_name, 'main')) AS il +WHERE tl.schema = COALESCE(:schema_name, 'main') + AND tl.type IN ('table', 'virtual') + AND tl.name NOT LIKE 'sqlite_%' + AND il.origin IN ('pk', 'u') + AND (:table_name IS NULL OR tl.name = :table_name) +ORDER BY tl.name, il.name; + +-- name: by_table +-- dialect: sqlite +SELECT + COALESCE(:schema_name, 'main') AS schema_name, + :table_name AS table_name, + il.name AS constraint_name, + CASE WHEN il.origin = 'pk' THEN 'PRIMARY KEY' ELSE 'UNIQUE' END AS constraint_type +FROM pragma_index_list(:table_name, COALESCE(:schema_name, 'main')) AS il +WHERE il.origin IN ('pk', 'u') +ORDER BY il.name; diff --git a/sqlspec/data_dictionary/dialects/sqlite/sql/foreign_keys.sql b/sqlspec/data_dictionary/dialects/sqlite/sql/foreign_keys.sql index d786f8db3..aea69c59a 100644 --- a/sqlspec/data_dictionary/dialects/sqlite/sql/foreign_keys.sql +++ b/sqlspec/data_dictionary/dialects/sqlite/sql/foreign_keys.sql @@ -2,6 +2,7 @@ -- dialect: sqlite SELECT tl.schema AS schema_name, + tl.schema AS table_schema, tl.name AS table_name, fk."from" AS column_name, fk."table" AS referenced_table_name, @@ -21,7 +22,8 @@ ORDER BY tl.name, fk.id, fk.seq; -- name: by_table -- dialect: sqlite SELECT - :schema_name AS schema_name, + COALESCE(:schema_name, 'main') AS schema_name, + COALESCE(:schema_name, 'main') AS table_schema, :table_name AS table_name, fk."from" AS column_name, fk."table" AS referenced_table_name, diff --git a/sqlspec/data_dictionary/dialects/sqlite/sql/tables.sql b/sqlspec/data_dictionary/dialects/sqlite/sql/tables.sql index 9798a8a7b..50553247c 100644 --- a/sqlspec/data_dictionary/dialects/sqlite/sql/tables.sql +++ b/sqlspec/data_dictionary/dialects/sqlite/sql/tables.sql @@ -21,7 +21,8 @@ dependency_tree AS ( FROM table_inventory AS ti WHERE NOT EXISTS ( SELECT 1 - FROM pragma_foreign_key_list(ti.table_name, COALESCE(:schema_name, 'main')) + FROM pragma_foreign_key_list(ti.table_name, COALESCE(:schema_name, 'main')) AS fk + WHERE fk."table" <> ti.table_name ) UNION ALL @@ -33,17 +34,25 @@ dependency_tree AS ( FROM table_inventory AS ti JOIN pragma_foreign_key_list(ti.table_name, COALESCE(:schema_name, 'main')) AS fk JOIN dependency_tree AS dt ON fk."table" = dt.table_name - WHERE instr(dt.path, '/' || ti.table_name || '/') = 0 + WHERE fk."table" <> ti.table_name + AND instr(dt.path, '/' || ti.table_name || '/') = 0 +), +dependency_levels AS ( + SELECT + table_name, + MAX(level) AS dependency_level + FROM dependency_tree + GROUP BY table_name ) -SELECT DISTINCT +SELECT ti.schema_name, ti.table_name, ti.table_type, - COALESCE(dt.level, 0) AS dependency_level, - COALESCE(dt.level, 0) AS level, + COALESCE(dl.dependency_level, 0) AS dependency_level, + COALESCE(dl.dependency_level, 0) AS level, ti.ncol, ti.wr, ti.strict FROM table_inventory AS ti -LEFT JOIN dependency_tree AS dt ON dt.table_name = ti.table_name -ORDER BY COALESCE(dt.level, 0), ti.table_name; +LEFT JOIN dependency_levels AS dl ON dl.table_name = ti.table_name +ORDER BY COALESCE(dl.dependency_level, 0), ti.table_name; diff --git a/sqlspec/dialects/db2/__init__.py b/sqlspec/dialects/db2/__init__.py index a5afcc4eb..b01ad7069 100644 --- a/sqlspec/dialects/db2/__init__.py +++ b/sqlspec/dialects/db2/__init__.py @@ -36,6 +36,7 @@ class DB2Tokenizer(tokens.Tokenizer): KEYWORDS = { **tokens.Tokenizer.KEYWORDS, "BLOB": TokenType.BLOB, + "XML": TokenType.XML, "VARCHAR_FORMAT": TokenType.VAR, "POSSTR": TokenType.VAR, "CURRENT TIMESTAMP": TokenType.CURRENT_TIMESTAMP, diff --git a/sqlspec/dialects/db2/_generators.py b/sqlspec/dialects/db2/_generators.py index 2467a5e18..6d796bbe9 100644 --- a/sqlspec/dialects/db2/_generators.py +++ b/sqlspec/dialects/db2/_generators.py @@ -49,6 +49,7 @@ "set_operation_sql", "str_position_sql", "time_to_str_sql", + "version_sql", ) DB2_TYPE_MAPPING: dict[exp.DType, str] = { @@ -79,6 +80,7 @@ exp.DType.DECFLOAT: "DECFLOAT", exp.DType.BINARY: "BINARY", exp.DType.VARBINARY: "VARBINARY", + exp.DType.XML: "XML", } DB2_DEFAULT_TYPE_LENGTHS: Final[dict[exp.DType, str]] = { @@ -88,18 +90,7 @@ } _ZONED_TIMESTAMP_TYPES: Final[frozenset[exp.DType]] = frozenset({exp.DType.TIMESTAMPTZ, exp.DType.TIMESTAMPLTZ}) - - -def _detach_statement_tail(expression: "exp.Query") -> "tuple[exp.Query, list[exp.Lock], dict[str, object]]": - if not expression.args.get("locks") and not any(key in expression.args for key in DB2_TAIL_ARG_KEYS): - return expression, [], {} - expression = expression.copy() - locks = list(expression.args.get("locks") or []) - tail_args: dict[str, object] = {key: expression.args.get(key) for key in DB2_TAIL_ARG_KEYS} - expression.set("locks", None) - for key in DB2_TAIL_ARG_KEYS: - expression.set(key, None) - return expression, locks, tail_args +_TEMPORAL_RANGE_ARITY: Final[int] = 2 def render_statement_tail( @@ -153,20 +144,6 @@ def render_statement_tail( return " ".join(parts) -def _with_statement_tail(sql: str, tail: str) -> str: - return f"{sql} {tail}" if tail else sql - - -def _convert_limit_to_fetch(query: exp.Expr) -> exp.Expr: - """Convert an exp.Limit clause to an exp.Fetch clause for Db2.""" - limit = query.args.get("limit") - if isinstance(limit, exp.Limit): - direction = "NEXT" if query.args.get("offset") else "FIRST" - fetch = exp.Fetch(direction=direction, count=limit.expression) - query.set("limit", fetch) - return query - - def select_sql(generator: "generator.Generator", expression: exp.Select) -> str: """Render a SELECT with a Db2 dummy table, FETCH pagination and statement tail.""" detached, locks, tail_args = _detach_statement_tail(expression) @@ -298,6 +275,26 @@ def ilike_sql(generator: "generator.Generator", expression: exp.ILike) -> str: return render_ilike(generator, expression) +def version_sql(generator: "generator.Generator", expression: exp.Version) -> str: + """Render a Db2 temporal table period specification (FOR SYSTEM_TIME / FOR BUSINESS_TIME).""" + raw_period = str(expression.this).upper() + if raw_period in {"TIMESTAMP", "SYSTEM_TIME"}: + period = "SYSTEM_TIME" + elif raw_period in {"VERSION", "BUSINESS_TIME"}: + period = "BUSINESS_TIME" + else: + period = raw_period + kind = expression.text("kind").upper() + expr = expression.expression + if kind in {"FROM", "BETWEEN"} and isinstance(expr, exp.Tuple) and len(expr.expressions) == _TEMPORAL_RANGE_ARITY: + separator = "TO" if kind == "FROM" else "AND" + start_sql = generator.sql(expr.expressions[0]) + end_sql = generator.sql(expr.expressions[1]) + return f"FOR {period} {kind} {start_sql} {separator} {end_sql}" + expr_sql = generator.sql(expr) if expr is not None else "" + return f"FOR {period} {kind} {expr_sql}".rstrip() + + DB2_TRANSFORMS: Final[dict[type[exp.Expr], Callable[[Any, Any], str]]] = { exp.Select: select_sql, exp.Union: set_operation_sql, @@ -324,6 +321,7 @@ def ilike_sql(generator: "generator.Generator", expression: exp.ILike) -> str: exp.CurrentUser: current_user_sql, exp.CurrentSchema: current_schema_sql, exp.Column: column_sql, + exp.Version: version_sql, } _overlay_cache: "tuple[dict[type[exp.Expr], Callable[..., str]], dict[type[exp.Expr], Callable[..., str]]] | None" = ( @@ -350,3 +348,29 @@ def db2_dispatch(base: "dict[type[exp.Expr], Callable[..., str]]") -> "dict[type overlay = {**base, **DB2_TRANSFORMS} _overlay_cache = (base, overlay) return overlay + + +def _detach_statement_tail(expression: "exp.Query") -> "tuple[exp.Query, list[exp.Lock], dict[str, object]]": + if not expression.args.get("locks") and not any(key in expression.args for key in DB2_TAIL_ARG_KEYS): + return expression, [], {} + expression = expression.copy() + locks = list(expression.args.get("locks") or []) + tail_args: dict[str, object] = {key: expression.args.get(key) for key in DB2_TAIL_ARG_KEYS} + expression.set("locks", None) + for key in DB2_TAIL_ARG_KEYS: + expression.set(key, None) + return expression, locks, tail_args + + +def _with_statement_tail(sql: str, tail: str) -> str: + return f"{sql} {tail}" if tail else sql + + +def _convert_limit_to_fetch(query: exp.Expr) -> exp.Expr: + """Convert an exp.Limit clause to an exp.Fetch clause for Db2.""" + limit = query.args.get("limit") + if isinstance(limit, exp.Limit): + direction = "NEXT" if query.args.get("offset") else "FIRST" + fetch = exp.Fetch(direction=direction, count=limit.expression) + query.set("limit", fetch) + return query diff --git a/sqlspec/dialects/db2/_parsers.py b/sqlspec/dialects/db2/_parsers.py index cb5cdf80d..1e37f2071 100644 --- a/sqlspec/dialects/db2/_parsers.py +++ b/sqlspec/dialects/db2/_parsers.py @@ -134,6 +134,7 @@ def __init__(self) -> None: frozenset({"LOCKED"}), frozenset({"DATA"}), ) + _ISOLATION_WITH_LOCK_REQUEST: Final[tuple[frozenset[str] | None, ...]] = ( frozenset({"WITH"}), _LOCK_REQUEST_ISOLATION_LEVELS, @@ -143,13 +144,16 @@ def __init__(self) -> None: _LOCK_REQUESTS, frozenset({"LOCKS"}), ) + _ISOLATION: Final[tuple[frozenset[str] | None, ...]] = (frozenset({"WITH"}), _ISOLATION_LEVELS) + _OPTIMIZE_FOR_ROWS: Final[tuple[frozenset[str] | None, ...]] = ( frozenset({"OPTIMIZE"}), frozenset({"FOR"}), None, frozenset({"ROW", "ROWS"}), ) + _READ_ONLY: Final[tuple[frozenset[str] | None, ...]] = ( frozenset({"FOR"}), frozenset({"READ", "FETCH"}), @@ -157,24 +161,31 @@ def __init__(self) -> None: ) -def _ends_with(words: "list[str]", end: int, pattern: "tuple[frozenset[str] | None, ...]") -> bool: - start = end - len(pattern) - if start < 0: - return False - return all(options is None or words[start + offset] in options for offset, options in enumerate(pattern)) - +_FETCH_FIRST_COUNT: Final[tuple[frozenset[str] | None, ...]] = ( + frozenset({"FETCH"}), + frozenset({"FIRST", "NEXT"}), + None, + frozenset({"ROW", "ROWS"}), + frozenset({"ONLY"}), +) -def _words(tokens: "list[Token]") -> "list[str]": - return ["" if token.token_type in _QUOTED_TOKEN_TYPES else token.text.upper() for token in tokens] +_FETCH_FIRST_NO_COUNT: Final[tuple[frozenset[str] | None, ...]] = ( + frozenset({"FETCH"}), + frozenset({"FIRST", "NEXT"}), + frozenset({"ROW", "ROWS"}), + frozenset({"ONLY"}), +) def split_statement_tail(tokens: "list[Token]") -> "tuple[list[Token], Db2StatementTail | None]": """Remove the Db2 select-statement tail from the tokens of one statement. - Clauses are matched from the end in Db2 order: ``SKIP LOCKED DATA``, then - ``WITH RR|RS|CS|UR`` (optionally ``USE AND KEEP SHARE|UPDATE|EXCLUSIVE LOCKS`` - after ``RR`` or ``RS``), then ``OPTIMIZE FOR n ROW|ROWS``, then - ``FOR READ ONLY`` or ``FOR FETCH ONLY``. + Clauses are matched iteratively from the end in any Db2 permutation order: + ``SKIP LOCKED DATA``, ``WITH RR|RS|CS|UR`` (optionally + ``USE AND KEEP SHARE|UPDATE|EXCLUSIVE LOCKS`` after ``RR`` or ``RS``), + ``OPTIMIZE FOR n ROW|ROWS``, and ``FOR READ ONLY`` or ``FOR FETCH ONLY``, + even when ``FETCH FIRST|NEXT ... ROW|ROWS ONLY`` appears after a tail + clause. Args: tokens: Tokens of a single statement. @@ -187,38 +198,79 @@ def split_statement_tail(tokens: "list[Token]") -> "tuple[list[Token], Db2Statem end = len(tokens) tail = Db2StatementTail() matched = False + fetch_tokens: list[Token] = [] + stripped_comments: list[str] = [] + + while end > 0: + if _ends_with(words, end, _SKIP_LOCKED_DATA): + start = end - len(_SKIP_LOCKED_DATA) + tail.skip_locked = True + stripped_comments = _collect_token_comments(tokens[start:end]) + stripped_comments + end = start + matched = True + continue - if _ends_with(words, end, _SKIP_LOCKED_DATA): - tail.skip_locked = True - end -= len(_SKIP_LOCKED_DATA) - matched = True - - if _ends_with(words, end, _ISOLATION_WITH_LOCK_REQUEST): - start = end - len(_ISOLATION_WITH_LOCK_REQUEST) - tail.isolation = words[start + 1] - tail.lock_request = words[start + 5] - end = start - matched = True - elif _ends_with(words, end, _ISOLATION): - tail.isolation = words[end - 1] - end -= len(_ISOLATION) - matched = True - - if _ends_with(words, end, _OPTIMIZE_FOR_ROWS): - rows_token = tokens[end - 2] - if rows_token.token_type == TokenType.NUMBER and rows_token.text.isdigit(): - tail.optimize_rows = int(rows_token.text) - end -= len(_OPTIMIZE_FOR_ROWS) + if _ends_with(words, end, _ISOLATION_WITH_LOCK_REQUEST): + start = end - len(_ISOLATION_WITH_LOCK_REQUEST) + tail.isolation = words[start + 1] + tail.lock_request = words[start + 5] + stripped_comments = _collect_token_comments(tokens[start:end]) + stripped_comments + end = start matched = True + continue - if _ends_with(words, end, _READ_ONLY): - tail.read_only = True - end -= len(_READ_ONLY) - matched = True + if _ends_with(words, end, _ISOLATION): + start = end - len(_ISOLATION) + tail.isolation = words[end - 1] + stripped_comments = _collect_token_comments(tokens[start:end]) + stripped_comments + end = start + matched = True + continue + + if _ends_with(words, end, _OPTIMIZE_FOR_ROWS): + rows_token = tokens[end - 2] + if rows_token.token_type == TokenType.NUMBER and rows_token.text.isdigit(): + start = end - len(_OPTIMIZE_FOR_ROWS) + tail.optimize_rows = int(rows_token.text) + stripped_comments = _collect_token_comments(tokens[start:end]) + stripped_comments + end = start + matched = True + continue + + if _ends_with(words, end, _READ_ONLY): + start = end - len(_READ_ONLY) + tail.read_only = True + stripped_comments = _collect_token_comments(tokens[start:end]) + stripped_comments + end = start + matched = True + continue + + if not fetch_tokens and _ends_with(words, end, _FETCH_FIRST_COUNT): + count_token = tokens[end - 3] + if count_token.token_type == TokenType.NUMBER and count_token.text.isdigit(): + start = end - len(_FETCH_FIRST_COUNT) + fetch_tokens = tokens[start:end] + end = start + continue + + if not fetch_tokens and _ends_with(words, end, _FETCH_FIRST_NO_COUNT): + start = end - len(_FETCH_FIRST_NO_COUNT) + fetch_tokens = tokens[start:end] + end = start + continue + + break if not matched: return tokens, None - return tokens[:end], tail + + kept = [*tokens[:end], *fetch_tokens] + if stripped_comments and kept: + last = kept[-1] + kept[-1] = Token( + last.token_type, last.text, last.line, last.col, last.start, last.end, [*last.comments, *stripped_comments] + ) + return kept, tail def split_statement_tails(tokens: "list[Token]") -> "tuple[list[Token], list[Db2StatementTail | None]]": @@ -320,7 +372,69 @@ def mark_native_locks(expression: exp.Expr) -> None: lock.set("sqlspec_db2_native", True) +def normalize_db2_expression(expression: exp.Expr) -> exp.Expr: + """Rewrite Db2-only spellings into canonical sqlglot expressions. + + ``POSSTR(haystack, needle)`` becomes ``exp.StrPosition``, + ``VARCHAR_FORMAT(value, 'format')`` becomes ``exp.TimeToStr`` with the Db2 + format elements translated, and the ``CURRENT SCHEMA`` special register + becomes ``exp.CurrentSchema``. + + Args: + expression: Expression parsed from Db2 SQL. + + Returns: + The normalised expression. + """ + return expression.transform(_canonical_db2_node, copy=False) + + +def normalize_db2_tokens(tokens: "list[Token]") -> "list[Token]": + """Rewrite Db2-only token sequences into forms sqlglot's parser accepts. + + Special registers without a sqlglot node (``CURRENT TIME ZONE``, + ``CURRENT SERVER``, ...) become one identifier token carrying the register + name; LOB sizes such as ``CLOB(1M)`` become one size token; labeled + durations such as ``a + 1 DAYS`` gain an ``INTERVAL`` token; and a + statement-level ``VALUES`` list without parentheses gets one parenthesised + row per item. + + Args: + tokens: Tokens produced by the Db2 tokenizer. + + Returns: + The normalised token list. + """ + tokens = _merge_special_registers(tokens) + tokens = _merge_lob_sizes(tokens) + tokens = _normalize_temporal_periods(tokens) + tokens = _mark_labeled_durations(tokens) + return _wrap_statement_values(tokens) + + +def _collect_token_comments(tokens: "list[Token]") -> "list[str]": + return [comment for token in tokens for comment in token.comments] + + +def _ends_with(words: "list[str]", end: int, pattern: "tuple[frozenset[str] | None, ...]") -> bool: + start = end - len(pattern) + if start < 0: + return False + return all(options is None or words[start + offset] in options for offset, options in enumerate(pattern)) + + +def _words(tokens: "list[Token]") -> "list[str]": + return ["" if token.token_type in _QUOTED_TOKEN_TYPES else token.text.upper() for token in tokens] + + def _canonical_db2_node(node: exp.Expr) -> exp.Expr: + if isinstance(node, exp.Version): + period = str(node.this).upper() + if period == "TIMESTAMP": + node.set("this", "SYSTEM_TIME") + elif period == "VERSION": + node.set("this", "BUSINESS_TIME") + return node if ( isinstance(node, exp.Column) and not node.table @@ -349,29 +463,36 @@ def _canonical_db2_node(node: exp.Expr) -> exp.Expr: return node -def normalize_db2_expression(expression: exp.Expr) -> exp.Expr: - """Rewrite Db2-only spellings into canonical sqlglot expressions. - - ``POSSTR(haystack, needle)`` becomes ``exp.StrPosition``, - ``VARCHAR_FORMAT(value, 'format')`` becomes ``exp.TimeToStr`` with the Db2 - format elements translated, and the ``CURRENT SCHEMA`` special register - becomes ``exp.CurrentSchema``. - - Args: - expression: Expression parsed from Db2 SQL. - - Returns: - The normalised expression. - """ - return expression.transform(_canonical_db2_node, copy=False) - - def _merged_token(token_type: TokenType, text: str, parts: "list[Token]") -> Token: first = parts[0] comments = [comment for part in parts for comment in part.comments] return Token(token_type, text, first.line, first.col, first.start, parts[-1].end, comments) +def _normalize_temporal_periods(tokens: "list[Token]") -> "list[Token]": + result: list[Token] = [] + index = 0 + total = len(tokens) + while index < total: + token = tokens[index] + result.append(token) + index += 1 + if token.token_type == TokenType.FOR and index < total and tokens[index].token_type not in _QUOTED_TOKEN_TYPES: + next_tok = tokens[index] + if next_tok.text.upper() == "BUSINESS_TIME": + result.append(_merged_token(TokenType.VAR, "VERSION", [next_tok])) + index += 1 + elif ( + next_tok.text.upper() == "BUSINESS" + and index + 1 < total + and tokens[index + 1].token_type not in _QUOTED_TOKEN_TYPES + and tokens[index + 1].text.upper() == "TIME" + ): + result.append(_merged_token(TokenType.VAR, "VERSION", [next_tok, tokens[index + 1]])) + index += 2 + return result + + def _merge_special_registers(tokens: "list[Token]") -> "list[Token]": result: list[Token] = [] index = 0 @@ -425,7 +546,7 @@ def _merge_lob_sizes(tokens: "list[Token]") -> "list[Token]": ): number, unit = tokens[index + 1], tokens[index + 2] result.append(tokens[index]) - result.append(_merged_token(TokenType.VAR, f"{number.text}{unit.text.upper()}", [number, unit])) + result.append(_merged_token(TokenType.NUMBER, f"{number.text}{unit.text.upper()}", [number, unit])) index += 3 return result @@ -534,25 +655,3 @@ def _wrap_statement_values(tokens: "list[Token]") -> "list[Token]": statement.append(token) result.extend(_wrap_values_rows(statement)) return result - - -def normalize_db2_tokens(tokens: "list[Token]") -> "list[Token]": - """Rewrite Db2-only token sequences into forms sqlglot's parser accepts. - - Special registers without a sqlglot node (``CURRENT TIME ZONE``, - ``CURRENT SERVER``, ...) become one identifier token carrying the register - name; LOB sizes such as ``CLOB(1M)`` become one size token; labeled - durations such as ``a + 1 DAYS`` gain an ``INTERVAL`` token; and a - statement-level ``VALUES`` list without parentheses gets one parenthesised - row per item. - - Args: - tokens: Tokens produced by the Db2 tokenizer. - - Returns: - The normalised token list. - """ - tokens = _merge_special_registers(tokens) - tokens = _merge_lob_sizes(tokens) - tokens = _mark_labeled_durations(tokens) - return _wrap_statement_values(tokens) diff --git a/sqlspec/dialects/db2/_transforms.py b/sqlspec/dialects/db2/_transforms.py index b9a3dc4e6..b773bf9d0 100644 --- a/sqlspec/dialects/db2/_transforms.py +++ b/sqlspec/dialects/db2/_transforms.py @@ -82,15 +82,20 @@ def render_duration_amount(generator: Any, node: exp.Expr) -> "tuple[str, str | def render_anonymous(generator: Any, expression: exp.Anonymous) -> str: """Transform anonymous functions such as DATEADD into Db2 syntax.""" - if expression.this.upper() == "DATEADD" and len(expression.expressions) == _EXPECTED_DATEADD_ARGS_LEN: - unit = generator.sql(expression.expressions[0]).upper() - amount = generator.sql(expression.expressions[1]) + if expression.name.upper() == "DATEADD" and len(expression.expressions) == _EXPECTED_DATEADD_ARGS_LEN: + unit_node = expression.expressions[0] + amount_node = expression.expressions[1] this = generator.sql(expression.expressions[2]) op = "+" - if amount.startswith("-"): + if isinstance(amount_node, exp.Neg): + amount_node = amount_node.this op = "-" - amount = amount[1:].lstrip() - return f"{this} {op} {amount} {unit}" + amount_sql, embedded_unit = render_duration_amount(generator, amount_node) + if amount_sql.startswith("-"): + op = "+" if op == "-" else "-" + amount_sql = amount_sql[1:].lstrip() + unit = _render_duration_unit(generator, unit_node) or embedded_unit or "DAY" + return f"{this} {op} {amount_sql} {unit}" return str(generator.anonymous_sql(expression)) @@ -112,7 +117,7 @@ def render_date_add(generator: Any, expression: exp.DateAdd | exp.DateSub | exp. amount = amount.this op = "+" if op == "-" else "-" amount_sql, embedded_unit = render_duration_amount(generator, amount) - unit_str = generator.sql(unit_node).upper() if unit_node else embedded_unit or "DAY" + unit_str = _render_duration_unit(generator, unit_node) if unit_node else embedded_unit or "DAY" if amount_sql.startswith("-"): amount_sql = amount_sql[1:].lstrip() op = "+" if op == "-" else "-" @@ -162,3 +167,9 @@ def render_varchar_format(generator: Any, expression: exp.TimeToStr | exp.ToChar if fmt: return f"VARCHAR_FORMAT({this}, {fmt})" return f"VARCHAR_FORMAT({this})" + + +def _render_duration_unit(generator: Any, unit_node: exp.Expr) -> str: + if isinstance(unit_node, exp.Literal) and unit_node.is_string: + return unit_node.name.upper() + return str(generator.sql(unit_node)).upper() diff --git a/sqlspec/dialects/postgres/_generators.py b/sqlspec/dialects/postgres/_generators.py index da8454161..e580059ef 100644 --- a/sqlspec/dialects/postgres/_generators.py +++ b/sqlspec/dialects/postgres/_generators.py @@ -7,7 +7,7 @@ interpreted subclasses. """ -from typing import Final +from typing import Any, Final from sqlglot import exp from sqlglot.dialects.postgres import Postgres @@ -16,6 +16,7 @@ from sqlspec.builder._generation import invalidate_generator_dispatch from sqlspec.builder._vector_distance import ( is_vector_distance_expression, + render_vector_distance_bigquery, render_vector_distance_postgres, vector_distance_metric, ) @@ -23,7 +24,8 @@ __all__ = ("PGTextSearchGenerator", "PGVectorGenerator", "ParadeDBGenerator") -_BASE_OPERATOR_TRANSFORM = Postgres.Generator.TRANSFORMS[exp.Operator] +_GENERATOR_TRANSFORMS_REGISTERED_ATTR: Final[str] = "_sqlspec_postgres_generator_transforms" +_ORIGINAL_OPERATOR_TRANSFORM_ATTR: Final[str] = "_sqlspec_postgres_original_operator_transform" _POSTGRES_EXTENSION_DIALECT_NAMES: Final[frozenset[str]] = frozenset({ "Postgres", "PGVector", @@ -31,12 +33,22 @@ "PGTextSearch", }) +if not hasattr(PostgresGenerator, _ORIGINAL_OPERATOR_TRANSFORM_ATTR): + setattr(PostgresGenerator, _ORIGINAL_OPERATOR_TRANSFORM_ATTR, Postgres.Generator.TRANSFORMS[exp.Operator]) +_BASE_OPERATOR_TRANSFORM: Any = getattr(PostgresGenerator, _ORIGINAL_OPERATOR_TRANSFORM_ATTR) + def _postgres_extension_operator_sql(generator: PostgresGenerator, expression: exp.Operator) -> str: dialect = generator.dialect dialect_class = getattr(dialect, "__class__", None) dialect_name = dialect_class.__name__ if dialect_class else None - if isinstance(dialect, Postgres) or (dialect_name and dialect_name in _POSTGRES_EXTENSION_DIALECT_NAMES): + if dialect_name == "Spangres" and is_vector_distance_expression(expression): + return render_vector_distance_bigquery( + generator.sql(expression, "this"), + generator.sql(expression, "expression"), + vector_distance_metric(expression), + ) + if dialect_name and dialect_name in _POSTGRES_EXTENSION_DIALECT_NAMES: if is_vector_distance_expression(expression): return render_vector_distance_postgres( generator.sql(expression, "this"), @@ -51,15 +63,18 @@ def _postgres_extension_operator_sql(generator: PostgresGenerator, expression: e f"{generator.sql(expression, 'expression')}" ) - return _BASE_OPERATOR_TRANSFORM(generator, expression) + return str(_BASE_OPERATOR_TRANSFORM(generator, expression)) + +def _register_postgres_generator_transforms() -> None: + if getattr(PostgresGenerator, _GENERATOR_TRANSFORMS_REGISTERED_ATTR, False): + return + PostgresGenerator.TRANSFORMS[exp.Operator] = _postgres_extension_operator_sql + setattr(PostgresGenerator, _GENERATOR_TRANSFORMS_REGISTERED_ATTR, True) + invalidate_generator_dispatch(PostgresGenerator) -# sqlglot[c] and pure-Python: patch TRANSFORMS on the base class and alias. -# Compiled classes reject interpreted subclasses, so we use a dialect-aware -# patch on the base class to support both environments consistently. -PostgresGenerator.TRANSFORMS[exp.Operator] = _postgres_extension_operator_sql -invalidate_generator_dispatch(PostgresGenerator) +_register_postgres_generator_transforms() PGTextSearchGenerator = PostgresGenerator # pyright: ignore[reportAssignmentType] PGVectorGenerator = PostgresGenerator # pyright: ignore[reportAssignmentType] diff --git a/sqlspec/dialects/postgres/_operators.py b/sqlspec/dialects/postgres/_operators.py index de30aa57a..3c30dfeee 100644 --- a/sqlspec/dialects/postgres/_operators.py +++ b/sqlspec/dialects/postgres/_operators.py @@ -22,9 +22,14 @@ ) _CUSTOM_OPERATOR_META_KEY: Final[str] = "sqlspec_postgres_extension_operator" +_OPERATOR_PARSERS_REGISTERED_ATTR: Final[str] = "_sqlspec_postgres_operator_parsers" +_POSTGRES_EXTENSION_DIALECT_NAMES: Final[frozenset[str]] = frozenset({ + "Postgres", + "PGVector", + "ParadeDB", + "PGTextSearch", +}) -# These token slots are not emitted by the base Postgres tokenizer and are only -# produced by the extension tokenizers below. PGVECTOR_OPERATOR_TOKENS: Final[dict[str, TokenType]] = { "<#>": TokenType.GEOGRAPHYPOINT, "<=>": TokenType.HLLSKETCH, @@ -43,26 +48,19 @@ } PG_TEXTSEARCH_OPERATOR_TOKENS: Final[dict[str, TokenType]] = {"<@>": TokenType.RING} -_REGISTERED = False - def register_postgres_extension_operators() -> None: """Patch the compiled Postgres parser with PostgreSQL extension operators.""" - global _REGISTERED - - if _REGISTERED: + if getattr(PostgresParser, _OPERATOR_PARSERS_REGISTERED_ATTR, False): return - factor: dict[TokenType, Any] = dict(PostgresParser.FACTOR) - extension_tokens = {**PGVECTOR_OPERATOR_TOKENS, **PARADEDB_OPERATOR_TOKENS} + operators: dict[TokenType, Any] = dict(PostgresParser.JSON_OPERATORS) + extension_tokens = {**PGVECTOR_OPERATOR_TOKENS, **PARADEDB_OPERATOR_TOKENS, **PG_TEXTSEARCH_OPERATOR_TOKENS} for operator, token in extension_tokens.items(): - factor[token] = _build_operator_factory(operator) + operators[token] = _build_operator_factory(operator, operators.get(token)) - setattr(PostgresParser, "FACTOR", factor) - operators = dict(PostgresParser.JSON_OPERATORS) - operators[PG_TEXTSEARCH_OPERATOR_TOKENS["<@>"]] = _parse_pg_textsearch_operator setattr(PostgresParser, "JSON_OPERATORS", operators) - _REGISTERED = True + setattr(PostgresParser, _OPERATOR_PARSERS_REGISTERED_ATTR, True) def is_postgres_extension_operator(expression: object) -> bool: @@ -78,18 +76,20 @@ def postgres_extension_operator(expression: exp.Operator) -> str: return expression.text("operator") -def _build_operator_factory(operator: str) -> Callable[[exp.Expr | None, exp.Expr | None], exp.Operator]: - def _factory(this: exp.Expr | None, expression: exp.Expr | None) -> exp.Operator: +def _build_operator_factory( + operator: str, original: Any = None +) -> Callable[[PostgresParser | None, exp.Expr | None, exp.Expr | None], exp.Expr]: + def _factory(parser: PostgresParser | None, this: exp.Expr | None, expression: exp.Expr | None) -> exp.Expr: + if parser is not None: + dialect = getattr(parser, "dialect", None) + if ( + dialect is not None + and type(dialect).__name__ not in _POSTGRES_EXTENSION_DIALECT_NAMES + and original is not None + ): + return original(parser, this, expression) # type: ignore[no-any-return] node = exp.Operator(this=this, expression=expression, operator=operator) node.meta[_CUSTOM_OPERATOR_META_KEY] = operator return node return _factory - - -def _parse_pg_textsearch_operator( - _parser: PostgresParser, this: exp.Expr | None, expression: exp.Expr | None -) -> exp.Operator: - node = exp.Operator(this=this, expression=expression, operator="<@>") - node.meta[_CUSTOM_OPERATOR_META_KEY] = "<@>" - return node diff --git a/sqlspec/dialects/spanner/_expressions.py b/sqlspec/dialects/spanner/_expressions.py index e77796bdf..b6919647b 100644 --- a/sqlspec/dialects/spanner/_expressions.py +++ b/sqlspec/dialects/spanner/_expressions.py @@ -71,6 +71,7 @@ def __instancecheck__(cls, instance: Any) -> bool: class SpannerPropertyGraph(_SpannerAnonymousBase, metaclass=_SpannerAnonymousNodeMeta): """AST node builder and type matcher for a Cloud Spanner PROPERTY GRAPH definition.""" + __slots__ = () _anonymous_name = "SPANNER_PROPERTY_GRAPH" _required_arg = "node_tables" @@ -84,6 +85,7 @@ def __new__(cls, *, this: Any, node_tables: Any, edge_tables: Any = None) -> "Se class SpannerGraphTable(_SpannerAnonymousBase, metaclass=_SpannerAnonymousNodeMeta): """AST node builder and type matcher for a Cloud Spanner GRAPH_TABLE(...) query expression.""" + __slots__ = () _anonymous_name = "GRAPH_TABLE" _required_arg = "match" @@ -91,6 +93,96 @@ def __new__(cls, *, this: Any, match: Any, columns: Any, where: Any = None) -> " return cast("Self", exp.Anonymous(this="GRAPH_TABLE", graph=this, match=match, where=where, columns=columns)) +class ApproxCosineDistance(_SpannerAnonymousBase, metaclass=_SpannerAnonymousNodeMeta): + """AST node builder and type matcher for APPROX_COSINE_DISTANCE.""" + + __slots__ = () + _anonymous_name = "APPROX_COSINE_DISTANCE" + + def __new__(cls, this: Any, expression: Any, options: Any = None) -> "Self": + return cast("Self", approx_cosine_distance(this, expression, options)) + + +class SearchSubstring(_SpannerAnonymousBase, metaclass=_SpannerAnonymousNodeMeta): + """AST node builder and type matcher for SEARCH_SUBSTRING.""" + + __slots__ = () + _anonymous_name = "SEARCH_SUBSTRING" + + def __new__(cls, this: Any, expression: Any) -> "Self": + return cast("Self", search_substring(this, expression)) + + +class Score(_SpannerAnonymousBase, metaclass=_SpannerAnonymousNodeMeta): + """AST node builder and type matcher for SCORE.""" + + __slots__ = () + _anonymous_name = "SCORE" + + def __new__(cls, this: Any, expression: Any) -> "Self": + return cast("Self", score(this, expression)) + + +class ScoreNgrams(_SpannerAnonymousBase, metaclass=_SpannerAnonymousNodeMeta): + """AST node builder and type matcher for SCORE_NGRAMS.""" + + __slots__ = () + _anonymous_name = "SCORE_NGRAMS" + + def __new__(cls, this: Any, expression: Any, *expressions: Any) -> "Self": + return cast("Self", score_ngrams(this, expression, *expressions)) + + +class Snippet(_SpannerAnonymousBase, metaclass=_SpannerAnonymousNodeMeta): + """AST node builder and type matcher for SNIPPET.""" + + __slots__ = () + _anonymous_name = "SNIPPET" + + def __new__(cls, this: Any, expression: Any, *expressions: Any) -> "Self": + return cast("Self", snippet(this, expression, *expressions)) + + +class TokenizeFulltext(_SpannerAnonymousBase, metaclass=_SpannerAnonymousNodeMeta): + """AST node builder and type matcher for TOKENIZE_FULLTEXT.""" + + __slots__ = () + _anonymous_name = "TOKENIZE_FULLTEXT" + + def __new__(cls, this: Any, *expressions: Any) -> "Self": + return cast("Self", tokenize_fulltext(this, *expressions)) + + +class TokenizeSubstring(_SpannerAnonymousBase, metaclass=_SpannerAnonymousNodeMeta): + """AST node builder and type matcher for TOKENIZE_SUBSTRING.""" + + __slots__ = () + _anonymous_name = "TOKENIZE_SUBSTRING" + + def __new__(cls, this: Any) -> "Self": + return cast("Self", tokenize_substring(this)) + + +class TokenizeNgrams(_SpannerAnonymousBase, metaclass=_SpannerAnonymousNodeMeta): + """AST node builder and type matcher for TOKENIZE_NGRAMS.""" + + __slots__ = () + _anonymous_name = "TOKENIZE_NGRAMS" + + def __new__(cls, this: Any) -> "Self": + return cast("Self", tokenize_ngrams(this)) + + +class GetNextSequenceValue(_SpannerAnonymousBase, metaclass=_SpannerAnonymousNodeMeta): + """AST node builder and type matcher for GET_NEXT_SEQUENCE_VALUE.""" + + __slots__ = () + _anonymous_name = "GET_NEXT_SEQUENCE_VALUE" + + def __new__(cls, this: Any) -> "Self": + return cast("Self", get_next_sequence_value(this)) + + def approx_cosine_distance(this: Any, expression: Any, options: Any = None) -> exp.Anonymous: """Build an APPROX_COSINE_DISTANCE function call.""" exprs = [this, expression] @@ -137,7 +229,8 @@ def tokenize_ngrams(this: Any) -> exp.Anonymous: def get_next_sequence_value(this: Any) -> exp.Anonymous: """Build a GET_NEXT_SEQUENCE_VALUE function call.""" - return exp.Anonymous(this="GET_NEXT_SEQUENCE_VALUE", expressions=[this]) + seq_expr = exp.var(this) if isinstance(this, str) else this + return exp.Anonymous(this="GET_NEXT_SEQUENCE_VALUE", expressions=[seq_expr]) def graph_table( @@ -152,14 +245,3 @@ def graph_table( where_expr = parse_one(where, read="spanner") if isinstance(where, str) else where column_exprs = [parse_one(col, read="spanner") if isinstance(col, str) else col for col in columns] return SpannerGraphTable(this=graph_expr, match=match_expr, where=where_expr, columns=column_exprs) - - -ApproxCosineDistance = approx_cosine_distance -SearchSubstring = search_substring -Score = score -ScoreNgrams = score_ngrams -Snippet = snippet -TokenizeFulltext = tokenize_fulltext -TokenizeSubstring = tokenize_substring -TokenizeNgrams = tokenize_ngrams -GetNextSequenceValue = get_next_sequence_value diff --git a/sqlspec/dialects/spanner/_generators.py b/sqlspec/dialects/spanner/_generators.py index 9652735d2..5dfdcae37 100644 --- a/sqlspec/dialects/spanner/_generators.py +++ b/sqlspec/dialects/spanner/_generators.py @@ -202,7 +202,9 @@ def _spanner_properties_sql(self: Any, expression: exp.Properties) -> str: ) if not spanner_block: return rendered - return f"{rendered}, {spanner_block}" if rendered else f", {spanner_block}" + if rendered: + return f"{rendered}, {spanner_block}" + return f" {spanner_block}" if isinstance(expression.parent, exp.Create) else spanner_block def _spangres_property_sql(self: Any, expression: exp.Property) -> str: @@ -250,13 +252,19 @@ def _spangres_properties_sql(self: Any, expression: exp.Properties) -> str: _original_bq_returning_transform = BigQueryGenerator.TRANSFORMS.get(exp.Returning) _original_bq_select_transform = BigQueryGenerator.TRANSFORMS.get(exp.Select) _original_bq_table_transform = BigQueryGenerator.TRANSFORMS.get(exp.Table) +_original_bq_join_transform = BigQueryGenerator.TRANSFORMS.get(exp.Join) _original_bq_anonymous_transform = BigQueryGenerator.TRANSFORMS.get(exp.Anonymous) _original_pg_property_transform = PostgresGenerator.TRANSFORMS.get(exp.Property) _original_pg_properties_transform = PostgresGenerator.TRANSFORMS.get(exp.Properties) +_original_pg_create_transform = PostgresGenerator.TRANSFORMS.get(exp.Create) +_original_pg_index_transform = PostgresGenerator.TRANSFORMS.get(exp.Index) +_original_pg_alter_transform = PostgresGenerator.TRANSFORMS.get(exp.Alter) +_original_pg_drop_transform = PostgresGenerator.TRANSFORMS.get(exp.Drop) _original_pg_hint_transform = PostgresGenerator.TRANSFORMS.get(exp.Hint) _original_pg_select_transform = PostgresGenerator.TRANSFORMS.get(exp.Select) _original_pg_table_transform = PostgresGenerator.TRANSFORMS.get(exp.Table) +_original_pg_join_transform = PostgresGenerator.TRANSFORMS.get(exp.Join) _original_pg_anonymous_transform = PostgresGenerator.TRANSFORMS.get(exp.Anonymous) @@ -363,8 +371,9 @@ def _spanner_index_sql(generator: Any, expression: exp.Index) -> str: opts_str = ", ".join(opts_list) parts.append(f"OPTIONS ({opts_str})") return " ".join(parts) - if _original_bq_index_transform is not None: - return str(_original_bq_index_transform(generator, expression)) + orig = _original_pg_index_transform if _get_dialect_name(generator) == "Spangres" else _original_bq_index_transform + if orig is not None: + return str(orig(generator, expression)) return str(generator.index_sql(expression)) @@ -397,8 +406,8 @@ def _spanner_graph_table_sql(generator: Any, expression: exp.Expr) -> str: return f"GRAPH_TABLE({inner})" -def _spanner_create_transform(generator: Any, expression: exp.Create) -> str: - """Transform CREATE statements for Spanner including TABLE, SEQUENCE, CHANGE STREAM, and PROPERTY GRAPH.""" +def _spanner_ddl_create_sql(generator: Any, expression: exp.Create) -> "str | None": + """Render Spanner/Spangres CREATE DDL for PROPERTY GRAPH, SEQUENCE, and CHANGE STREAM.""" if expression.kind == "PROPERTY GRAPH": replace = " OR REPLACE" if expression.args.get("replace") else "" exists = " IF NOT EXISTS" if expression.args.get("exists") else "" @@ -420,7 +429,15 @@ def _spanner_create_transform(generator: Any, expression: exp.Create) -> str: if len(exprs) == 1 and isinstance(exprs[0], exp.Var) and exprs[0].name.upper() == "ALL": parts.append("FOR ALL") else: - target_str = ", ".join(generator.sql(x) for x in exprs) + target_items: list[str] = [] + for item in exprs: + if isinstance(item, exp.Anonymous): + tbl_sql = generator.sql(item, "this") + cols_sql = ", ".join(generator.sql(col) for col in item.expressions) + target_items.append(f"{tbl_sql}({cols_sql})") + else: + target_items.append(generator.sql(item)) + target_str = ", ".join(target_items) parts.append(f"FOR {target_str}") props = expression.args.get("properties") if props: @@ -428,15 +445,27 @@ def _spanner_create_transform(generator: Any, expression: exp.Create) -> str: opts_str = ", ".join(opts_list) parts.append(f"OPTIONS ({opts_str})") return " ".join(parts) + return None + + +def _spanner_create_transform(generator: Any, expression: exp.Create) -> str: + """Transform CREATE statements for Spanner including TABLE, SEQUENCE, CHANGE STREAM, and PROPERTY GRAPH.""" + ddl_sql = _spanner_ddl_create_sql(generator, expression) + if ddl_sql is not None: + return ddl_sql + target = expression if expression.this and expression.kind == "TABLE": properties = expression.args.get("properties") if properties: spanner_props = [p for p in properties.expressions if _is_post_schema_spanner_property(p)] other_props = [p for p in properties.expressions if not _is_post_schema_spanner_property(p)] - properties.set("expressions", other_props + spanner_props) + reordered = other_props + spanner_props + if reordered != list(properties.expressions): + target = expression.copy() + target.args["properties"].set("expressions", reordered) if _original_bq_create_transform is not None: - return str(_original_bq_create_transform(generator, expression)) - return str(generator.create_sql(expression)) + return str(_original_bq_create_transform(generator, target)) + return str(generator.create_sql(target)) def _bq_create_transform(self: Any, expression: exp.Create) -> str: @@ -449,6 +478,17 @@ def _bq_create_transform(self: Any, expression: exp.Create) -> str: return str(self.create_sql(expression)) +def _pg_create_transform(self: Any, expression: exp.Create) -> str: + """Transform CREATE statements for Spangres or delegate to original Postgres transform.""" + if _get_dialect_name(self) == "Spangres": + ddl_sql = _spanner_ddl_create_sql(self, expression) + if ddl_sql is not None: + return ddl_sql + if _original_pg_create_transform is not None: + return str(_original_pg_create_transform(self, expression)) + return str(self.create_sql(expression)) + + def _spanner_alter_sql(generator: Any, expression: exp.Alter) -> str: """Render ALTER statements for Spanner including SEQUENCE and CHANGE STREAM.""" kind = expression.args.get("kind") @@ -468,8 +508,9 @@ def _spanner_alter_sql(generator: Any, expression: exp.Alter) -> str: opts_str = ", ".join(opts_list) return f"ALTER CHANGE STREAM {name} SET OPTIONS ({opts_str})" return f"ALTER CHANGE STREAM {name}" - if _original_bq_alter_transform is not None: - return str(_original_bq_alter_transform(generator, expression)) + orig = _original_pg_alter_transform if _get_dialect_name(generator) == "Spangres" else _original_bq_alter_transform + if orig is not None: + return str(orig(generator, expression)) return str(generator.alter_sql(expression)) @@ -480,8 +521,9 @@ def _spanner_drop_sql(generator: Any, expression: exp.Drop) -> str: exists_sql = " IF EXISTS" if expression.args.get("exists") else "" name = generator.sql(expression, "this") return f"DROP {kind}{exists_sql} {name}" - if _original_bq_drop_transform is not None: - return str(_original_bq_drop_transform(generator, expression)) + orig = _original_pg_drop_transform if _get_dialect_name(generator) == "Spangres" else _original_bq_drop_transform + if orig is not None: + return str(orig(generator, expression)) return str(generator.drop_sql(expression)) @@ -545,42 +587,41 @@ def _spangres_select_sql(generator: Any, expression: exp.Select) -> str: return PostgresGenerator.select_sql(generator, expression) -def _spanner_table_sql(generator: Any, expression: exp.Table) -> str: - """Render table reference followed by @{...} table hint if present.""" +def _render_table_with_hints(generator: Any, expression: exp.Table, original_transform: Any, default_fn: Any) -> str: hints = expression.args.get("hints") if hints: expr_copy = expression.copy() expr_copy.set("hints", None) - base_sql = ( - _original_bq_table_transform(generator, expr_copy) - if _original_bq_table_transform is not None - else BigQueryGenerator.table_sql(generator, expr_copy) + bare_copy = expr_copy.copy() + bare_copy.set("alias", None) + bare_copy.set("pivots", None) + bare_copy.set("joins", None) + table_path_sql = str( + original_transform(generator, bare_copy) + if original_transform is not None + else default_fn(generator, bare_copy) ) - hint_strs = [generator.sql(h) for h in hints] - joined_hints = " ".join(hint_strs) - return f"{base_sql} {joined_hints}" - if _original_bq_table_transform is not None: - return str(_original_bq_table_transform(generator, expression)) - return BigQueryGenerator.table_sql(generator, expression) + full_sql = str( + original_transform(generator, expr_copy) + if original_transform is not None + else default_fn(generator, expr_copy) + ) + suffix = full_sql[len(table_path_sql) :] if full_sql.startswith(table_path_sql) else "" + joined_hints = " ".join(generator.sql(h) for h in hints) + return f"{table_path_sql} {joined_hints}{suffix}" + if original_transform is not None: + return str(original_transform(generator, expression)) + return str(default_fn(generator, expression)) + + +def _spanner_table_sql(generator: Any, expression: exp.Table) -> str: + """Render table reference followed by @{...} table hint before any table alias.""" + return _render_table_with_hints(generator, expression, _original_bq_table_transform, BigQueryGenerator.table_sql) def _spangres_table_sql(generator: Any, expression: exp.Table) -> str: - """Render table reference followed by /*@ ... */ table hint if present.""" - hints = expression.args.get("hints") - if hints: - expr_copy = expression.copy() - expr_copy.set("hints", None) - base_sql = ( - _original_pg_table_transform(generator, expr_copy) - if _original_pg_table_transform is not None - else PostgresGenerator.table_sql(generator, expr_copy) - ) - hint_strs = [generator.sql(h) for h in hints] - joined_hints = " ".join(hint_strs) - return f"{base_sql} {joined_hints}" - if _original_pg_table_transform is not None: - return str(_original_pg_table_transform(generator, expression)) - return PostgresGenerator.table_sql(generator, expression) + """Render table reference followed by /*@ ... */ table hint before any table alias.""" + return _render_table_with_hints(generator, expression, _original_pg_table_transform, PostgresGenerator.table_sql) def _bq_index_transform(generator: Any, expression: exp.Index) -> str: @@ -610,6 +651,33 @@ def _bq_drop_transform(generator: Any, expression: exp.Drop) -> str: return str(generator.drop_sql(expression)) +def _pg_index_transform(generator: Any, expression: exp.Index) -> str: + """Transform INDEX statements for Spangres or delegate to original Postgres transform.""" + if _get_dialect_name(generator) == "Spangres": + return _spanner_index_sql(generator, expression) + if _original_pg_index_transform is not None: + return str(_original_pg_index_transform(generator, expression)) + return str(generator.index_sql(expression)) + + +def _pg_alter_transform(generator: Any, expression: exp.Alter) -> str: + """Transform ALTER statements for Spangres or delegate to original Postgres transform.""" + if _get_dialect_name(generator) == "Spangres": + return _spanner_alter_sql(generator, expression) + if _original_pg_alter_transform is not None: + return str(_original_pg_alter_transform(generator, expression)) + return str(generator.alter_sql(expression)) + + +def _pg_drop_transform(generator: Any, expression: exp.Drop) -> str: + """Transform DROP statements for Spangres or delegate to original Postgres transform.""" + if _get_dialect_name(generator) == "Spangres": + return _spanner_drop_sql(generator, expression) + if _original_pg_drop_transform is not None: + return str(_original_pg_drop_transform(generator, expression)) + return str(generator.drop_sql(expression)) + + def _bq_computed_column_transform(generator: Any, expression: exp.ComputedColumnConstraint) -> str: """Transform COMPUTED COLUMN statements for Spanner or delegate to original BigQuery transform.""" if _get_dialect_name(generator) == "Spanner": @@ -747,25 +815,31 @@ def _pg_table_transform(generator: Any, expression: exp.Table) -> str: return str(PostgresGenerator.table_sql(generator, expression)) +def _render_spanner_custom_anonymous(generator: Any, expression: exp.Anonymous) -> "str | None": + name = str(expression.this).upper() + if name == "SPANNER_PROPERTY_GRAPH": + return _spanner_property_graph_sql(generator, expression) + if name == "GRAPH_TABLE" and "match" in expression.args: + return _spanner_graph_table_sql(generator, expression) + if name == "GET_NEXT_SEQUENCE_VALUE" and expression.expressions: + seq = generator.sql(expression.expressions[0]) + return f"GET_NEXT_SEQUENCE_VALUE(SEQUENCE {seq})" + if name == "APPROX_COSINE_DISTANCE": + return _render_approx_cosine_distance(generator, expression) + if name == "TOKENIZE_FULLTEXT": + return _render_tokenize_fulltext(generator, expression) + if name in {"SEARCH_SUBSTRING", "SCORE", "SCORE_NGRAMS", "SNIPPET", "TOKENIZE_SUBSTRING", "TOKENIZE_NGRAMS"}: + args = ", ".join(generator.sql(e) for e in expression.expressions) + return f"{name}({args})" + return None + + def _spanner_anonymous_transform(generator: Any, expression: exp.Anonymous) -> str: """Transform Anonymous function calls for Spanner.""" - dialect_name = _get_dialect_name(generator) - if dialect_name == "Spanner": - name = str(expression.this).upper() - if name == "SPANNER_PROPERTY_GRAPH": - return _spanner_property_graph_sql(generator, expression) - if name == "GRAPH_TABLE" and "match" in expression.args: - return _spanner_graph_table_sql(generator, expression) - if name == "GET_NEXT_SEQUENCE_VALUE" and expression.expressions: - seq = generator.sql(expression.expressions[0]) - return f"GET_NEXT_SEQUENCE_VALUE(SEQUENCE {seq})" - if name == "APPROX_COSINE_DISTANCE": - return _render_approx_cosine_distance(generator, expression) - if name == "TOKENIZE_FULLTEXT": - return _render_tokenize_fulltext(generator, expression) - if name in {"SEARCH_SUBSTRING", "SCORE", "SCORE_NGRAMS", "SNIPPET", "TOKENIZE_SUBSTRING", "TOKENIZE_NGRAMS"}: - args = ", ".join(generator.sql(e) for e in expression.expressions) - return f"{name}({args})" + if _get_dialect_name(generator) == "Spanner": + rendered = _render_spanner_custom_anonymous(generator, expression) + if rendered is not None: + return rendered if _original_bq_anonymous_transform is not None: return str(_original_bq_anonymous_transform(generator, expression)) return str(generator.anonymous_sql(expression)) @@ -773,17 +847,10 @@ def _spanner_anonymous_transform(generator: Any, expression: exp.Anonymous) -> s def _spangres_anonymous_transform(generator: Any, expression: exp.Anonymous) -> str: """Transform Anonymous function calls for Spangres.""" - dialect_name = _get_dialect_name(generator) - if dialect_name == "Spangres": - name = str(expression.this).upper() - if name == "GET_NEXT_SEQUENCE_VALUE" and expression.expressions: - seq = generator.sql(expression.expressions[0]) - return f"GET_NEXT_SEQUENCE_VALUE(SEQUENCE {seq})" - if name == "APPROX_COSINE_DISTANCE": - return _render_approx_cosine_distance(generator, expression) - if name in {"SEARCH_SUBSTRING", "SCORE", "SCORE_NGRAMS", "SNIPPET", "TOKENIZE_SUBSTRING", "TOKENIZE_NGRAMS"}: - args = ", ".join(generator.sql(e) for e in expression.expressions) - return f"{name}({args})" + if _get_dialect_name(generator) == "Spangres": + rendered = _render_spanner_custom_anonymous(generator, expression) + if rendered is not None: + return rendered if _original_pg_anonymous_transform is not None: return str(_original_pg_anonymous_transform(generator, expression)) return str(generator.anonymous_sql(expression)) @@ -791,11 +858,13 @@ def _spangres_anonymous_transform(generator: Any, expression: exp.Anonymous) -> def _spanner_join_sql(generator: Any, expression: exp.Join) -> str: """Preserve native join hints in their position after JOIN.""" - sql = str(generator.join_sql(expression)) + dialect_name = _get_dialect_name(generator) + orig = _original_pg_join_transform if isinstance(generator, PostgresGenerator) else _original_bq_join_transform + sql = str(orig(generator, expression)) if orig is not None else str(generator.join_sql(expression)) hint = expression.args.get("spanner_hint") - if hint is not None and _get_dialect_name(generator) in {"Spanner", "Spangres"}: - prefix, separator, suffix = sql.partition("JOIN ") - return f"{prefix}{separator}{generator.sql(hint)} {suffix}" + if hint is not None and dialect_name in {"Spanner", "Spangres"}: + hint_sql = generator.sql(hint) + return re.sub(r"\b(JOIN)\s+", rf"\1 {hint_sql} ", sql, count=1, flags=re.IGNORECASE) return sql @@ -818,6 +887,7 @@ def _transform(generator: Any, expression: exp.Expr) -> str: _original_pg_cosine_distance_transform = PostgresGenerator.TRANSFORMS.get(CosineDistance) _original_pg_euclidean_distance_transform = PostgresGenerator.TRANSFORMS.get(EuclideanDistance) _original_pg_dot_product_transform = PostgresGenerator.TRANSFORMS.get(DotProduct) +_original_pg_search_transform = PostgresGenerator.TRANSFORMS.get(Search) def _register_spanner_generator_transforms() -> None: @@ -857,6 +927,10 @@ def _register_spanner_generator_transforms() -> None: if not getattr(PostgresGenerator, _GENERATOR_TRANSFORMS_REGISTERED_ATTR, False): PostgresGenerator.TRANSFORMS[exp.Property] = _pg_property_transform PostgresGenerator.TRANSFORMS[exp.Properties] = _pg_properties_transform + PostgresGenerator.TRANSFORMS[exp.Create] = _pg_create_transform + PostgresGenerator.TRANSFORMS[exp.Index] = _pg_index_transform + PostgresGenerator.TRANSFORMS[exp.Alter] = _pg_alter_transform + PostgresGenerator.TRANSFORMS[exp.Drop] = _pg_drop_transform PostgresGenerator.TRANSFORMS[exp.Hint] = _pg_hint_transform PostgresGenerator.TRANSFORMS[exp.Select] = _pg_select_transform PostgresGenerator.TRANSFORMS[exp.Table] = _pg_table_transform @@ -871,6 +945,9 @@ def _register_spanner_generator_transforms() -> None: PostgresGenerator.TRANSFORMS[DotProduct] = _build_function_fallback_transform( "Spangres", _original_pg_dot_product_transform ) + PostgresGenerator.TRANSFORMS[Search] = _build_function_fallback_transform( + "Spangres", _original_pg_search_transform + ) setattr(PostgresGenerator, _GENERATOR_TRANSFORMS_REGISTERED_ATTR, True) updated = True diff --git a/sqlspec/dialects/spanner/_parsers.py b/sqlspec/dialects/spanner/_parsers.py index 21126fc19..1a568ef7a 100644 --- a/sqlspec/dialects/spanner/_parsers.py +++ b/sqlspec/dialects/spanner/_parsers.py @@ -48,6 +48,7 @@ _WITH_ACTION_SENTINEL: Final[str] = "__sqlspec_then_return_with_action__" _DML_STATEMENT_TOKENS: Final[frozenset[TokenType]] = frozenset({TokenType.INSERT, TokenType.UPDATE, TokenType.DELETE}) _SPANNER_DIALECT_NAMES: Final[frozenset[str]] = frozenset({"Spangres", "Spanner"}) +_BINARY_VECTOR_ARITY: Final[int] = 2 def build_interleave_property(parent: exp.Expr, on_delete: "str | None" = None, in_parent: bool = True) -> exp.Property: @@ -60,6 +61,178 @@ def build_interleave_property(parent: exp.Expr, on_delete: "str | None" = None, return exp.Property(this=exp.Literal.string(_INTERLEAVE_NAME), value=exp.Tuple(expressions=values)) +_original_bq_function_graph_table: Any = BigQueryParser.FUNCTION_PARSERS.get("GRAPH_TABLE") + + +def normalize_spanner_tokens(tokens: "list[Token]", sql: str = "") -> "list[Token]": + """Convert Spanner GoogleSQL ``@{...}`` hint token sequences into token comments. + + Token-level normalization ensures ``@{...}`` inside string literals or SQL + comments is left untouched while statement, table, and join hints attach to + the appropriate token's ``comments`` list for ``attach_hints`` to consume. + + Args: + tokens: Tokens produced by the base BigQuery tokenizer. + sql: Original SQL text used to extract raw hint body substrings. + + Returns: + Normalized token list with ``@{...}`` sequences folded into comments. + """ + result: list[Token] = [] + pending_comments: list[str] = [] + index = 0 + total = len(tokens) + while index < total: + token = tokens[index] + if ( + token.token_type == TokenType.PARAMETER + and token.text == "@" + and index + 1 < total + and tokens[index + 1].token_type == TokenType.L_BRACE + and (not sql or token.end + 1 == tokens[index + 1].start) + ): + l_brace = tokens[index + 1] + end_idx = _closing_brace_index(tokens, index + 2) + if end_idx >= 0: + r_brace = tokens[end_idx] + if sql and r_brace.start > l_brace.end: + hint_body = sql[l_brace.end + 1 : r_brace.start].strip() + else: + hint_body = "".join( + ", " if part.token_type == TokenType.COMMA else part.text + for part in tokens[index + 2 : end_idx] + ).strip() + collected_comments = [comment for part in tokens[index : end_idx + 1] for comment in part.comments] + if hint_body: + collected_comments.append(f"@ {hint_body}") + if result and result[-1].token_type not in {TokenType.SEMICOLON, TokenType.L_PAREN}: + result[-1].comments.extend(collected_comments) + else: + pending_comments.extend(collected_comments) + index = end_idx + 1 + continue + if pending_comments: + token.comments = [*pending_comments, *token.comments] + pending_comments = [] + result.append(token) + index += 1 + return _collapse_then_return(result) + + +def parse_hint_expression(raw_hint: str) -> exp.Hint: + """Parse hint string into canonical exp.Hint.""" + raw = raw_hint.strip() + if raw.startswith("@"): + raw = raw[1:].strip() + pairs = [p.strip() for p in raw.split(",") if p.strip()] + exprs: list[exp.Expr] = [] + for pair in pairs: + if "=" in pair: + k, v = pair.split("=", 1) + v_str = v.strip() + val_expr: exp.Expr = exp.Literal.number(int(v_str)) if v_str.isdigit() else exp.var(v_str) + exprs.append(exp.EQ(this=exp.to_identifier(k.strip()), expression=val_expr)) + else: + exprs.append(exp.to_identifier(pair.strip())) + return exp.Hint(expressions=exprs) + + +def attach_hints(expression: exp.Expr) -> None: + """Attach parsed hints from node comments to AST nodes and resolve ``THEN RETURN WITH ACTION``.""" + for node in expression.walk(): + if isinstance(node, exp.Returning): + _attach_then_return_action(node) + elif isinstance(node, exp.ColumnDef): + _attach_hidden_column_constraint(node) + elif ( + isinstance(node, exp.DataType) and node.this == exp.DataType.Type.USERDEFINED and not node.args.get("kind") + ): + node.set("kind", "TOKENLIST") + comments = getattr(node, "comments", None) + if not comments: + continue + hint_comments = [c for c in comments if c.strip().startswith("@") and "=" in c.strip()[1:]] + for hc in hint_comments: + hint = parse_hint_expression(hc) + target_table: exp.Table | None = None + if isinstance(node, exp.Table): + target_table = node + elif isinstance(node.parent, exp.Table) and ( + isinstance(node, exp.TableAlias) or (isinstance(node, exp.Identifier) and node is node.parent.this) + ): + target_table = node.parent + if target_table is not None: + comments.remove(hc) + existing_hints = list(target_table.args.get("hints") or []) + existing_hints.append(hint) + target_table.set("hints", existing_hints) + elif isinstance(node, (exp.Select, exp.Query)): + comments.remove(hc) + node.set("hint", hint) + elif isinstance(node, exp.Join): + comments.remove(hc) + node.set("spanner_hint", hint) + + +_original_bq_function_cosine_distance: Any = BigQueryParser.FUNCTIONS.get("COSINE_DISTANCE") +_original_bq_function_euclidean_distance: Any = BigQueryParser.FUNCTIONS.get("EUCLIDEAN_DISTANCE") +_original_bq_function_dot_product: Any = BigQueryParser.FUNCTIONS.get("DOT_PRODUCT") +_original_bq_function_search: Any = BigQueryParser.FUNCTIONS.get("SEARCH") + +_original_pg_function_cosine_distance: Any = PostgresParser.FUNCTIONS.get("COSINE_DISTANCE") +_original_pg_function_euclidean_distance: Any = PostgresParser.FUNCTIONS.get("EUCLIDEAN_DISTANCE") +_original_pg_function_dot_product: Any = PostgresParser.FUNCTIONS.get("DOT_PRODUCT") +_original_pg_function_search: Any = PostgresParser.FUNCTIONS.get("SEARCH") +_original_pg_function_graph_table: Any = PostgresParser.FUNCTION_PARSERS.get("GRAPH_TABLE") + +_original_bq_statement_create: Any = BigQueryParser.STATEMENT_PARSERS.get(TokenType.CREATE) +_original_bq_statement_alter: Any = BigQueryParser.STATEMENT_PARSERS.get(TokenType.ALTER) +_original_bq_statement_drop: Any = BigQueryParser.STATEMENT_PARSERS.get(TokenType.DROP) +_original_bq_constraint_hidden: Any = BigQueryParser.CONSTRAINT_PARSERS.get("HIDDEN") + +_original_pg_statement_create: Any = PostgresParser.STATEMENT_PARSERS.get(TokenType.CREATE) +_original_pg_statement_alter: Any = PostgresParser.STATEMENT_PARSERS.get(TokenType.ALTER) +_original_pg_statement_drop: Any = PostgresParser.STATEMENT_PARSERS.get(TokenType.DROP) + + +def register_spanner_property_parsers() -> None: + """Install Spanner parser extensions on the BigQuery and Postgres parser classes.""" + for parser_class in (BigQueryParser, PostgresParser): + if getattr(parser_class, _PROPERTY_PARSERS_REGISTERED_ATTR, False): + continue + property_parsers: dict[str, Any] = dict(parser_class.PROPERTY_PARSERS) + for key, handler in ( + ("INTERLEAVE", _parse_interleave), + ("PRIMARY KEY", _parse_primary_key_property), + ("ROW", _parse_row_deletion_policy), + ("TTL", _parse_ttl), + ): + property_parsers[key] = _build_property_entry(handler, property_parsers.get(key)) + setattr(parser_class, "PROPERTY_PARSERS", property_parsers) + if parser_class is BigQueryParser: + BigQueryParser.FUNCTIONS["COSINE_DISTANCE"] = _build_cosine_distance + BigQueryParser.FUNCTIONS["EUCLIDEAN_DISTANCE"] = _build_euclidean_distance + BigQueryParser.FUNCTIONS["DOT_PRODUCT"] = _build_dot_product + BigQueryParser.FUNCTIONS["SEARCH"] = _build_search + BigQueryParser.FUNCTION_PARSERS["GET_NEXT_SEQUENCE_VALUE"] = _parse_get_next_sequence_value + BigQueryParser.FUNCTION_PARSERS["GRAPH_TABLE"] = _parse_graph_table + BigQueryParser.CONSTRAINT_PARSERS["HIDDEN"] = _parse_hidden_column_constraint + BigQueryParser.STATEMENT_PARSERS[TokenType.CREATE] = _bq_parse_create + BigQueryParser.STATEMENT_PARSERS[TokenType.ALTER] = _bq_parse_alter + BigQueryParser.STATEMENT_PARSERS[TokenType.DROP] = _bq_parse_drop + elif parser_class is PostgresParser: + PostgresParser.FUNCTIONS["COSINE_DISTANCE"] = _build_cosine_distance + PostgresParser.FUNCTIONS["EUCLIDEAN_DISTANCE"] = _build_euclidean_distance + PostgresParser.FUNCTIONS["DOT_PRODUCT"] = _build_dot_product + PostgresParser.FUNCTIONS["SEARCH"] = _build_search + PostgresParser.FUNCTION_PARSERS["GET_NEXT_SEQUENCE_VALUE"] = _parse_get_next_sequence_value + PostgresParser.FUNCTION_PARSERS["GRAPH_TABLE"] = _parse_graph_table + PostgresParser.STATEMENT_PARSERS[TokenType.CREATE] = _pg_parse_create + PostgresParser.STATEMENT_PARSERS[TokenType.ALTER] = _pg_parse_alter + PostgresParser.STATEMENT_PARSERS[TokenType.DROP] = _pg_parse_drop + setattr(parser_class, _PROPERTY_PARSERS_REGISTERED_ATTR, True) + + def _normalize_on_delete_value(on_delete: str) -> str: return " ".join(on_delete.upper().split()) @@ -165,41 +338,62 @@ def _parse_get_next_sequence_value(parser: Any) -> exp.Anonymous: return exp.Anonymous(this="GET_NEXT_SEQUENCE_VALUE", expressions=parser._parse_csv(parser._parse_lambda)) +def _has_kwarg_arg(args: list[Any]) -> bool: + return any(isinstance(arg, exp.Kwarg) for arg in args) + + def _build_search(args: list[Any], dialect: Any) -> exp.Expr: - """Build a Spanner Search node or fall back to Anonymous for BigQuery.""" - if dialect is not None and type(dialect).__name__ == "Spanner": - return Search.from_arg_list(args) + """Build a Spanner/Spangres Search node or delegate to the base dialect.""" + dialect_name = type(dialect).__name__ if dialect is not None else "" + if not _has_kwarg_arg(args): + if dialect_name in _SPANNER_DIALECT_NAMES: + return Search.from_arg_list(args) + if dialect_name == "BigQuery" and _original_bq_function_search is not None: + return cast("exp.Expr", _original_bq_function_search(args)) + if dialect_name and _original_pg_function_search is not None: + return cast("exp.Expr", _original_pg_function_search(args)) return exp.Anonymous(this="SEARCH", expressions=args) def _build_cosine_distance(args: list[Any], dialect: Any) -> exp.Expr: - """Build a Spanner/Spangres CosineDistance node or fall back to Anonymous.""" - if dialect is not None and type(dialect).__name__ in _SPANNER_DIALECT_NAMES: - return CosineDistance.from_arg_list(args) + """Build a CosineDistance node or delegate to the base dialect.""" + dialect_name = type(dialect).__name__ if dialect is not None else "" + if len(args) <= _BINARY_VECTOR_ARITY and not _has_kwarg_arg(args): + if dialect_name in _SPANNER_DIALECT_NAMES: + return CosineDistance.from_arg_list(args) + if dialect_name == "BigQuery" and _original_bq_function_cosine_distance is not None: + return cast("exp.Expr", _original_bq_function_cosine_distance(args)) + if dialect_name and _original_pg_function_cosine_distance is not None: + return cast("exp.Expr", _original_pg_function_cosine_distance(args)) return exp.Anonymous(this="COSINE_DISTANCE", expressions=args) def _build_euclidean_distance(args: list[Any], dialect: Any) -> exp.Expr: - """Build a Spanner/Spangres EuclideanDistance node or fall back to Anonymous.""" - if dialect is not None and type(dialect).__name__ in _SPANNER_DIALECT_NAMES: - return EuclideanDistance.from_arg_list(args) + """Build a EuclideanDistance node or delegate to the base dialect.""" + dialect_name = type(dialect).__name__ if dialect is not None else "" + if len(args) <= _BINARY_VECTOR_ARITY and not _has_kwarg_arg(args): + if dialect_name in _SPANNER_DIALECT_NAMES: + return EuclideanDistance.from_arg_list(args) + if dialect_name == "BigQuery" and _original_bq_function_euclidean_distance is not None: + return cast("exp.Expr", _original_bq_function_euclidean_distance(args)) + if dialect_name and _original_pg_function_euclidean_distance is not None: + return cast("exp.Expr", _original_pg_function_euclidean_distance(args)) return exp.Anonymous(this="EUCLIDEAN_DISTANCE", expressions=args) def _build_dot_product(args: list[Any], dialect: Any) -> exp.Expr: - """Build a Spanner/Spangres DotProduct node or fall back to Anonymous.""" - if dialect is not None and type(dialect).__name__ in _SPANNER_DIALECT_NAMES: - return DotProduct.from_arg_list(args) + """Build a DotProduct node or delegate to the base dialect.""" + dialect_name = type(dialect).__name__ if dialect is not None else "" + if len(args) <= _BINARY_VECTOR_ARITY and not _has_kwarg_arg(args): + if dialect_name in _SPANNER_DIALECT_NAMES: + return DotProduct.from_arg_list(args) + if dialect_name == "BigQuery" and _original_bq_function_dot_product is not None: + return cast("exp.Expr", _original_bq_function_dot_product(args)) + if dialect_name and _original_pg_function_dot_product is not None: + return cast("exp.Expr", _original_pg_function_dot_product(args)) return exp.Anonymous(this="DOT_PRODUCT", expressions=args) -def _convert_userdefined_type(data_type: exp.DataType) -> exp.DataType: - """Assign explicit TOKENLIST kind to unparameterized USERDEFINED data types in Spanner.""" - if not data_type.args.get("kind"): - data_type.set("kind", "TOKENLIST") - return data_type - - def _parse_options_properties(parser: Any) -> exp.Properties | None: """Parse an OPTIONS (key = value, ...) property block.""" if not parser._match_text_seq("OPTIONS"): @@ -213,9 +407,9 @@ def _parse_options_properties(parser: Any) -> exp.Properties | None: def _parse_create_vector_index(parser: Any) -> exp.Index: """Parse CREATE VECTOR INDEX [IF NOT EXISTS] name ON table (cols) [STORING (cols)] [WHERE ...] [OPTIONS (...)].""" exists = parser._parse_exists(not_=True) - name = parser._parse_id_var() + name = parser._parse_table_parts(schema=True) parser._match_text_seq("ON") - table = exp.Table(this=parser._parse_id_var()) + table = parser._parse_table_parts(schema=True) parser._match(TokenType.L_PAREN) cols = parser._parse_csv(parser._parse_column) parser._match(TokenType.R_PAREN) @@ -240,9 +434,9 @@ def _parse_create_vector_index(parser: Any) -> exp.Index: def _parse_create_search_index(parser: Any) -> exp.Index: """Parse CREATE SEARCH INDEX [IF NOT EXISTS] name ON table (cols) [STORING (cols)] [PARTITION BY cols] [ORDER BY order] [OPTIONS (...)].""" exists = parser._parse_exists(not_=True) - name = parser._parse_id_var() + name = parser._parse_table_parts(schema=True) parser._match_text_seq("ON") - table = exp.Table(this=parser._parse_id_var()) + table = parser._parse_table_parts(schema=True) parser._match(TokenType.L_PAREN) cols = parser._parse_csv(parser._parse_column) parser._match(TokenType.R_PAREN) @@ -291,7 +485,7 @@ def _parse_alter_sequence(parser: Any) -> exp.Alter: def _parse_create_change_stream(parser: Any) -> exp.Create: """Parse CREATE CHANGE STREAM name [FOR ...] [OPTIONS (...)].""" - name = parser._parse_id_var() + name = parser._parse_table_parts(schema=True) for_expressions: list[exp.Expr] = [] if parser._match_text_seq("FOR"): if parser._match_text_seq("ALL"): @@ -299,11 +493,11 @@ def _parse_create_change_stream(parser: Any) -> exp.Create: else: def _parse_stream_target() -> exp.Expr: - target_table = cast("exp.Expr", parser._parse_id_var()) + target_table = cast("exp.Expr", parser._parse_table_parts(schema=True)) if parser._match(TokenType.L_PAREN): cols = parser._parse_csv(parser._parse_column) parser._match(TokenType.R_PAREN) - return exp.Anonymous(this=target_table.name, expressions=cols) + return exp.Anonymous(this=target_table, expressions=cols) return target_table targets = parser._parse_csv(_parse_stream_target) @@ -314,7 +508,7 @@ def _parse_stream_target() -> exp.Expr: def _parse_alter_change_stream(parser: Any) -> exp.Alter: """Parse ALTER CHANGE STREAM name [SET ...] [OPTIONS (...)].""" - name = parser._parse_id_var() + name = parser._parse_table_parts(schema=True) options: exp.Properties | None = None if parser._match_text_seq("SET", "OPTIONS") or parser._match_text_seq("OPTIONS"): parser._retreat(parser._index - 1) @@ -329,6 +523,43 @@ def _parse_drop_spanner_object(parser: Any, kind: str) -> exp.Drop: return exp.Drop(this=name, kind=kind, exists=exists) +def _collapse_unquoted_whitespace(sql_fragment: str) -> str: + """Collapse runs of whitespace outside quoted literals into single spaces.""" + chars: list[str] = [] + quote_char: str | None = None + in_space = False + index = 0 + total = len(sql_fragment) + while index < total: + ch = sql_fragment[index] + if quote_char is not None: + chars.append(ch) + if ch == "\\" and index + 1 < total: + index += 1 + chars.append(sql_fragment[index]) + elif ch == quote_char: + if index + 1 < total and sql_fragment[index + 1] == quote_char: + index += 1 + chars.append(sql_fragment[index]) + else: + quote_char = None + elif ch in {"'", '"', "`"}: + if in_space and chars: + chars.append(" ") + in_space = False + quote_char = ch + chars.append(ch) + elif ch.isspace(): + in_space = True + else: + if in_space and chars: + chars.append(" ") + in_space = False + chars.append(ch) + index += 1 + return "".join(chars) + + def _parse_graph_element_list(parser: Any) -> list[exp.Expr]: """Parse comma-separated graph node/edge table definitions inside NODE/EDGE TABLES (...).""" parser._match(TokenType.L_PAREN) @@ -350,7 +581,7 @@ def _parse_graph_element_list(parser: Any) -> list[exp.Expr]: parser._advance() raw_elem = parser._find_sql(start_token, end_token).strip() if raw_elem: - elements.append(exp.var(" ".join(raw_elem.split()))) + elements.append(exp.var(_collapse_unquoted_whitespace(raw_elem))) if not parser._match(TokenType.COMMA): break parser._match(TokenType.R_PAREN) @@ -360,7 +591,7 @@ def _parse_graph_element_list(parser: Any) -> list[exp.Expr]: def _parse_create_property_graph(parser: Any, *, replace: bool = False) -> exp.Create: """Parse CREATE [OR REPLACE] PROPERTY GRAPH [IF NOT EXISTS] name NODE TABLES (...) [EDGE TABLES (...)].""" exists = parser._parse_exists(not_=True) - name = parser._parse_id_var() + name = parser._parse_table_parts(schema=True) node_tables: list[exp.Expr] = [] if parser._match_text_seq("NODE", "TABLES"): node_tables = _parse_graph_element_list(parser) @@ -371,16 +602,16 @@ def _parse_create_property_graph(parser: Any, *, replace: bool = False) -> exp.C return exp.Create(this=graph, kind="PROPERTY GRAPH", exists=exists, replace=replace) -_original_bq_function_graph_table: Any = BigQueryParser.FUNCTION_PARSERS.get("GRAPH_TABLE") - - def _parse_graph_table(parser: Any) -> SpannerGraphTable | exp.Expr: """Parse GRAPH_TABLE(graph MATCH pattern [WHERE expr] COLUMNS (col_expr [AS alias], ...)).""" if not _is_spanner_parser(parser): - if _original_bq_function_graph_table is not None: + dialect_name = type(getattr(parser, "dialect", None)).__name__ + if dialect_name == "BigQuery" and _original_bq_function_graph_table is not None: return cast("exp.Expr", _original_bq_function_graph_table(parser)) + if _original_pg_function_graph_table is not None: + return cast("exp.Expr", _original_pg_function_graph_table(parser)) return exp.Anonymous(this="GRAPH_TABLE", expressions=parser._parse_csv(parser._parse_lambda)) - graph = cast("exp.Expr", parser._parse_id_var()) + graph = cast("exp.Expr", parser._parse_table_parts(schema=True)) parser._match_text_seq("MATCH") start_token = parser._curr end_token = start_token @@ -397,7 +628,7 @@ def _parse_graph_table(parser: Any) -> SpannerGraphTable | exp.Expr: end_token = curr parser._advance() match_sql = parser._find_sql(start_token, end_token).strip() if start_token and end_token else "" - match_expr = exp.var(" ".join(match_sql.split())) + match_expr = exp.var(_collapse_unquoted_whitespace(match_sql)) where_expr: exp.Expr | None = None if parser._match(TokenType.WHERE) or parser._match_text_seq("WHERE"): where_expr = cast("exp.Expr", parser._parse_conjunction()) @@ -421,61 +652,6 @@ def _closing_brace_index(tokens: "list[Token]", start: int) -> int: return -1 -def normalize_spanner_tokens(tokens: "list[Token]", sql: str = "") -> "list[Token]": - """Convert Spanner GoogleSQL ``@{...}`` hint token sequences into token comments. - - Token-level normalization ensures ``@{...}`` inside string literals or SQL - comments is left untouched while statement, table, and join hints attach to - the appropriate token's ``comments`` list for ``attach_hints`` to consume. - - Args: - tokens: Tokens produced by the base BigQuery tokenizer. - sql: Original SQL text used to extract raw hint body substrings. - - Returns: - Normalized token list with ``@{...}`` sequences folded into comments. - """ - result: list[Token] = [] - pending_comments: list[str] = [] - index = 0 - total = len(tokens) - while index < total: - token = tokens[index] - if ( - token.token_type == TokenType.PARAMETER - and token.text == "@" - and index + 1 < total - and tokens[index + 1].token_type == TokenType.L_BRACE - and (not sql or token.end + 1 == tokens[index + 1].start) - ): - l_brace = tokens[index + 1] - end_idx = _closing_brace_index(tokens, index + 2) - if end_idx >= 0: - r_brace = tokens[end_idx] - if sql and r_brace.start > l_brace.end: - hint_body = sql[l_brace.end + 1 : r_brace.start].strip() - else: - hint_body = "".join( - ", " if part.token_type == TokenType.COMMA else part.text - for part in tokens[index + 2 : end_idx] - ).strip() - collected_comments = [comment for part in tokens[index : end_idx + 1] for comment in part.comments] - if hint_body: - collected_comments.append(f"@ {hint_body}") - if result and result[-1].token_type not in {TokenType.SEMICOLON, TokenType.L_PAREN}: - result[-1].comments.extend(collected_comments) - else: - pending_comments.extend(collected_comments) - index = end_idx + 1 - continue - if pending_comments: - token.comments = [*pending_comments, *token.comments] - pending_comments = [] - result.append(token) - index += 1 - return _collapse_then_return(result) - - def _is_word(token: "Token", word: str) -> bool: return token.token_type in {TokenType.VAR, TokenType.IDENTIFIER} and token.text.upper() == word @@ -582,61 +758,6 @@ def _attach_hidden_column_constraint(column_def: exp.ColumnDef) -> None: column_def.set("constraints", remaining) -def parse_hint_expression(raw_hint: str) -> exp.Hint: - """Parse hint string into canonical exp.Hint.""" - raw = raw_hint.strip() - if raw.startswith("@"): - raw = raw[1:].strip() - pairs = [p.strip() for p in raw.split(",") if p.strip()] - exprs: list[exp.Expr] = [] - for pair in pairs: - if "=" in pair: - k, v = pair.split("=", 1) - v_str = v.strip() - val_expr: exp.Expr = exp.Literal.number(int(v_str)) if v_str.isdigit() else exp.var(v_str) - exprs.append(exp.EQ(this=exp.to_identifier(k.strip()), expression=val_expr)) - else: - exprs.append(exp.to_identifier(pair.strip())) - return exp.Hint(expressions=exprs) - - -def attach_hints(expression: exp.Expr) -> None: - """Attach parsed hints from node comments to AST nodes and resolve ``THEN RETURN WITH ACTION``.""" - for node in expression.walk(): - if isinstance(node, exp.Returning): - _attach_then_return_action(node) - elif isinstance(node, exp.ColumnDef): - _attach_hidden_column_constraint(node) - comments = getattr(node, "comments", None) - if not comments: - continue - hint_comments = [c for c in comments if c.strip().startswith("@") and "=" in c.strip()[1:]] - for hc in hint_comments: - hint = parse_hint_expression(hc) - target_table: exp.Table | None = None - if isinstance(node, exp.Table): - target_table = node - elif isinstance(node, exp.TableAlias) and isinstance(node.parent, exp.Table): - target_table = node.parent - if target_table is not None: - comments.remove(hc) - existing_hints = list(target_table.args.get("hints") or []) - existing_hints.append(hint) - target_table.set("hints", existing_hints) - elif isinstance(node, (exp.Select, exp.Query)): - comments.remove(hc) - node.set("hint", hint) - elif isinstance(node, exp.Join): - comments.remove(hc) - node.set("spanner_hint", hint) - - -_original_bq_statement_create: Any = BigQueryParser.STATEMENT_PARSERS.get(TokenType.CREATE) -_original_bq_statement_alter: Any = BigQueryParser.STATEMENT_PARSERS.get(TokenType.ALTER) -_original_bq_statement_drop: Any = BigQueryParser.STATEMENT_PARSERS.get(TokenType.DROP) -_original_bq_constraint_hidden: Any = BigQueryParser.CONSTRAINT_PARSERS.get("HIDDEN") - - def _parse_hidden_column_constraint(parser: Any) -> exp.Expr | None: """Parse trailing HIDDEN modifier on Spanner generated/tokenlist columns.""" if _is_spanner_parser(parser): @@ -697,44 +818,54 @@ def _bq_parse_drop(self: Any) -> exp.Drop | exp.Command: return cast("exp.Drop | exp.Command", self._parse_drop()) -def register_spanner_property_parsers() -> None: - """Install Spanner parser extensions on the BigQuery and Postgres parser classes.""" - for parser_class in (BigQueryParser, PostgresParser): - if getattr(parser_class, _PROPERTY_PARSERS_REGISTERED_ATTR, False): - continue - property_parsers: dict[str, Any] = dict(parser_class.PROPERTY_PARSERS) - for key, handler in ( - ("INTERLEAVE", _parse_interleave), - ("PRIMARY KEY", _parse_primary_key_property), - ("ROW", _parse_row_deletion_policy), - ("TTL", _parse_ttl), - ): - property_parsers[key] = _build_property_entry(handler, property_parsers.get(key)) - setattr(parser_class, "PROPERTY_PARSERS", property_parsers) - if parser_class is BigQueryParser: - BigQueryParser.FUNCTIONS["COSINE_DISTANCE"] = _build_cosine_distance - BigQueryParser.FUNCTIONS["EUCLIDEAN_DISTANCE"] = _build_euclidean_distance - BigQueryParser.FUNCTIONS["DOT_PRODUCT"] = _build_dot_product - BigQueryParser.FUNCTIONS["SEARCH"] = _build_search - BigQueryParser.FUNCTION_PARSERS["GET_NEXT_SEQUENCE_VALUE"] = _parse_get_next_sequence_value - BigQueryParser.FUNCTION_PARSERS["GRAPH_TABLE"] = _parse_graph_table - BigQueryParser.CONSTRAINT_PARSERS["HIDDEN"] = _parse_hidden_column_constraint - BigQueryParser.TYPE_CONVERTERS = { - **BigQueryParser.TYPE_CONVERTERS, - exp.DataType.Type.USERDEFINED: _convert_userdefined_type, - } - BigQueryParser.STATEMENT_PARSERS[TokenType.CREATE] = _bq_parse_create - BigQueryParser.STATEMENT_PARSERS[TokenType.ALTER] = _bq_parse_alter - BigQueryParser.STATEMENT_PARSERS[TokenType.DROP] = _bq_parse_drop - elif parser_class is PostgresParser: - PostgresParser.FUNCTIONS["COSINE_DISTANCE"] = _build_cosine_distance - PostgresParser.FUNCTIONS["EUCLIDEAN_DISTANCE"] = _build_euclidean_distance - PostgresParser.FUNCTIONS["DOT_PRODUCT"] = _build_dot_product - PostgresParser.FUNCTION_PARSERS["GET_NEXT_SEQUENCE_VALUE"] = _parse_get_next_sequence_value - setattr(parser_class, _PROPERTY_PARSERS_REGISTERED_ATTR, True) +def _pg_parse_create(self: Any) -> exp.Create | exp.Index | exp.Command: + """Parse Spangres CREATE statements including VECTOR INDEX, SEARCH INDEX, CHANGE STREAM, and PROPERTY GRAPH.""" + dialect = getattr(self, "dialect", None) + if dialect is not None and type(dialect).__name__ == "Spangres": + if self._match_text_seq("VECTOR", "INDEX"): + return _parse_create_vector_index(self) + if self._match_text_seq("SEARCH", "INDEX"): + return _parse_create_search_index(self) + if self._match_text_seq("CHANGE", "STREAM"): + return _parse_create_change_stream(self) + if self._match_text_seq("PROPERTY", "GRAPH"): + return _parse_create_property_graph(self, replace=False) + if self._match_text_seq("OR", "REPLACE", "PROPERTY", "GRAPH"): + return _parse_create_property_graph(self, replace=True) + if _original_pg_statement_create is not None: + return cast("exp.Create | exp.Index | exp.Command", _original_pg_statement_create(self)) + return cast("exp.Create | exp.Index | exp.Command", self._parse_create()) + + +def _pg_parse_alter(self: Any) -> exp.Alter | exp.Command: + """Parse Spangres ALTER statements including CHANGE STREAM.""" + dialect = getattr(self, "dialect", None) + if dialect is not None and type(dialect).__name__ == "Spangres" and self._match_text_seq("CHANGE", "STREAM"): + return _parse_alter_change_stream(self) + if _original_pg_statement_alter is not None: + return cast("exp.Alter | exp.Command", _original_pg_statement_alter(self)) + return cast("exp.Alter | exp.Command", self._parse_alter()) + + +def _pg_parse_drop(self: Any) -> exp.Drop | exp.Command: + """Parse Spangres DROP statements including CHANGE STREAM, PROPERTY GRAPH, VECTOR INDEX, and SEARCH INDEX.""" + dialect = getattr(self, "dialect", None) + if dialect is not None and type(dialect).__name__ == "Spangres": + if self._match_text_seq("CHANGE", "STREAM"): + return _parse_drop_spanner_object(self, "CHANGE STREAM") + if self._match_text_seq("PROPERTY", "GRAPH"): + return _parse_drop_spanner_object(self, "PROPERTY GRAPH") + if self._match_text_seq("VECTOR", "INDEX"): + return _parse_drop_spanner_object(self, "VECTOR INDEX") + if self._match_text_seq("SEARCH", "INDEX"): + return _parse_drop_spanner_object(self, "SEARCH INDEX") + if _original_pg_statement_drop is not None: + return cast("exp.Drop | exp.Command", _original_pg_statement_drop(self)) + return cast("exp.Drop | exp.Command", self._parse_drop()) register_spanner_property_parsers() SpannerParser = BigQueryParser + SpangresParser = PostgresParser diff --git a/sqlspec/driver/_async.py b/sqlspec/driver/_async.py index 2888b0cb9..fc1dcb4d0 100644 --- a/sqlspec/driver/_async.py +++ b/sqlspec/driver/_async.py @@ -11,6 +11,17 @@ from sqlspec.core import SQL, StackResult, create_arrow_result from sqlspec.core.result import DMLResult from sqlspec.core.stack import StackOperation, StatementStack +from sqlspec.data_dictionary import ( + DEFAULT_SYSTEM_METADATA_DOMAINS, + DDLResult, + MetadataCapabilityProfile, + MetadataResult, + ObjectIdentity, + ensure_system_metadata_request, + system_metadata_capabilities_from_domains, + system_metadata_gated_result, + unsupported_system_metadata_capability, +) from sqlspec.driver._common import ( AsyncExceptionHandler, CommonDriverAttributesMixin, @@ -47,11 +58,8 @@ from sqlspec.core import ArrowResult, SQLResult, Statement, StatementConfig, StatementFilter from sqlspec.data_dictionary import ( ColumnMetadata, - DDLResult, ForeignKeyMetadata, IndexMetadata, - MetadataCapabilityProfile, - MetadataResult, SystemMetadataCapability, SystemMetadataRequest, SystemMetadataResult, @@ -78,17 +86,22 @@ class AsyncPoolConnectionContext: adapter-specific pool acquisition and release logic. """ - __slots__ = ("_config", "_connection") + __slots__ = ("_config", "_connection", "_pool") def __init__(self, config: Any) -> None: self._config = config self._connection: Any = None + self._pool: Any = None async def __aenter__(self) -> Any: - pool = self._config.connection_instance - if pool is None: - pool = await self._config.create_pool() - self._config.connection_instance = pool + if hasattr(self._config, "provide_pool"): + pool = await self._config.provide_pool() + else: + pool = self._config.connection_instance + if pool is None: + pool = await self._config.create_pool() + self._config.connection_instance = pool + self._pool = pool self._connection = await pool.acquire() return self._connection @@ -96,9 +109,11 @@ async def __aexit__( self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: Any ) -> "bool | None": if self._connection is not None: - if self._config.connection_instance: - await self._config.connection_instance.release(self._connection) + pool = self._pool or getattr(self._config, "connection_instance", None) + if pool is not None: + await pool.release(self._connection) self._connection = None + self._pool = None return None @@ -110,24 +125,32 @@ class AsyncPoolSessionFactory: for adapter-specific pool acquisition and release logic. """ - __slots__ = ("_config", "_connection") + __slots__ = ("_config", "_connection", "_pool") def __init__(self, config: Any) -> None: self._config = config self._connection: Any = None + self._pool: Any = None async def acquire_connection(self) -> Any: - pool = self._config.connection_instance - if pool is None: - pool = await self._config.create_pool() - self._config.connection_instance = pool + if hasattr(self._config, "provide_pool"): + pool = await self._config.provide_pool() + else: + pool = self._config.connection_instance + if pool is None: + pool = await self._config.create_pool() + self._config.connection_instance = pool + self._pool = pool self._connection = await pool.acquire() return self._connection async def release_connection(self, _conn: Any, **kwargs: Any) -> None: - if self._connection is not None and self._config.connection_instance is not None: - await self._config.connection_instance.release(self._connection) + if self._connection is not None: + pool = self._pool or getattr(self._config, "connection_instance", None) + if pool is not None: + await pool.release(self._connection) self._connection = None + self._pool = None @mypyc_attr(allow_interpreted_subclasses=True) @@ -206,22 +229,6 @@ async def has_schema(self, schema: str) -> bool: ) return True - @staticmethod - def _check_pending_exception(exc_handler: AsyncExceptionHandler) -> None: - """Raise any pending mapped exception after context manager exit.""" - if exc_handler.pending_exception is not None: - raise exc_handler.pending_exception from None - - async def _run_with_exception_handler( - self, - exc_handler: AsyncExceptionHandler, - operation: "Callable[..., Awaitable[_AsyncResultT]]", - *args: Any, - **kwargs: Any, - ) -> "_AsyncResultT | None": - """Run an async operation without inheriting the caller's active exception state.""" - return await _run_with_async_exception_handler(exc_handler, operation, *args, **kwargs) - @final async def dispatch_statement_execution(self, statement: "SQL", connection: "Any") -> "SQLResult": """Central execution dispatcher using the Template Method Pattern. @@ -238,7 +245,6 @@ async def dispatch_statement_execution(self, statement: "SQL", connection: "Any" compiled_sql, execution_parameters = statement.compile() result: SQLResult | None = None - # FAST PATH: Skip all instrumentation if runtime is absent or idle. if runtime is None or runtime.is_idle: exc_handler = self.handle_database_exceptions() result = await self._run_with_exception_handler( @@ -297,7 +303,7 @@ async def dispatch_statement_execution(self, statement: "SQL", connection: "Any" await runtime.emit_error_async(mapped_exc, **query_context) _raise_database_exception(exc_handler, None) - assert result is not None # Guaranteed: no exception means result was assigned + assert result is not None runtime.span_manager.end_span(span) duration = perf_counter() - started @@ -369,7 +375,10 @@ async def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> Execut for stmt in statements: single_stmt = self._sub_statement(stmt, prepared_parameters) - await self.dispatch_execute(cursor, single_stmt) + try: + await self.dispatch_execute(cursor, single_stmt) + finally: + self._release_pooled_statement(single_stmt) successful_count += 1 return self.create_execution_result( @@ -430,69 +439,6 @@ def resolve_rowcount(self, cursor: Any) -> int: msg = "Adapter must implement resolve_rowcount() for direct execution path" raise NotImplementedError(msg) - async def _execute_cache_hit( - self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: CachedQuery - ) -> "SQLResult": - """Execute pre-compiled query via ultra-fast path (async). - - Uses DBAPI-style async cursor methods when available. Async adapters with - connection-level execution APIs fall back to dispatch_execute so they can - preserve adapter-specific parameter binding semantics. For DML operations, - returns DMLResult to bypass full SQLResult construction. - - Args: - sql: Raw SQL string (original, not compiled). - params: Query parameters. - cached: Cached query metadata. - - Returns: - SQLResult or DMLResult. - """ - exc_handler = self.handle_database_exceptions() - result: SQLResult | None = None - result = await self._run_with_exception_handler( - exc_handler, self._execute_cache_hit_with_cursor, sql, params, cached - ) - self._check_pending_exception(exc_handler) - assert result is not None - return result - - async def _cached_execution( - self, statement: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]" - ) -> "SQLResult | None": - """Attempt fast-path execution for cached query (async). - - Args: - statement: Raw SQL string. - params: Query parameters. - - Returns: - SQLResult if cache hit and execution succeeds, None otherwise. - """ - result = super()._cached_execution(statement, params) - if result is None: - return None - return await cast("Awaitable[SQLResult | None]", result) - - async def _execute_cached_statement(self, statement: "SQL") -> "SQLResult": - """Execute pre-compiled query via fast path (async). - - The statement is already compiled by _cached_execution, so dispatch_execute - will hit the fast path in _compiled_statement (is_processed check). - """ - exc_handler = self.handle_database_exceptions() - result: SQLResult | None = None - try: - result = await self._run_with_exception_handler( - exc_handler, self._execute_cached_statement_with_cursor, statement - ) - - self._check_pending_exception(exc_handler) - assert result is not None - return result - finally: - self._release_pooled_statement(statement) - @abstractmethod async def begin(self) -> None: """Begin a database transaction on the current connection.""" @@ -570,39 +516,7 @@ async def execute( **kwargs: Any, ) -> "SQLResult": """Execute a statement with parameter handling.""" - exc_handler = self.handle_database_exceptions() - result = await self._run_with_exception_handler( - exc_handler, self._execute, statement, parameters, statement_config, kwargs - ) - self._check_pending_exception(exc_handler) - assert result is not None - return result - - async def _execute( - self, - statement: "SQL | Statement | QueryBuilder", - parameters: "tuple[StatementParameters | StatementFilter, ...]", - statement_config: "StatementConfig | None", - kwargs: "dict[str, Any]", - ) -> "SQLResult": - fast_params: tuple[Any, ...] | list[Any] | dict[str, Any] | None = None - if not kwargs and len(parameters) == 1 and isinstance(parameters[0], (tuple, list, dict)): - fast_params = cast("tuple[Any, ...] | list[Any] | dict[str, Any]", parameters[0]) - elif not parameters and kwargs: - fast_params = kwargs - if ( - self._stmt_cache_enabled - and (statement_config is None or statement_config is self.statement_config) - and isinstance(statement, str) - and fast_params is not None - ): - fast_result = await self._cached_execution(statement, fast_params) - if fast_result is not None: - return fast_result - sql_statement = self.prepare_statement( - statement, parameters, statement_config=statement_config or self.statement_config, kwargs=kwargs - ) - return await self.dispatch_statement_execution(statement=sql_statement, connection=self.connection) + return await self._execute(statement, parameters, statement_config, kwargs) async def execute_many( self, @@ -617,33 +531,7 @@ async def execute_many( Parameters passed will be used as the batch execution sequence. """ - exc_handler = self.handle_database_exceptions() - result = await self._run_with_exception_handler( - exc_handler, self._execute_many, statement, parameters, filters, statement_config, kwargs - ) - self._check_pending_exception(exc_handler) - assert result is not None - return result - - async def _execute_many( - self, - statement: "SQL | Statement | QueryBuilder", - parameters: "Sequence[StatementParameters]", - filters: "tuple[StatementParameters | StatementFilter, ...]", - statement_config: "StatementConfig | None", - kwargs: "dict[str, Any]", - ) -> "SQLResult": - config = statement_config or self.statement_config - if isinstance(statement, str) and not filters and not kwargs: - sql_statement = SQL(statement, parameters, statement_config=config, is_many=True) - elif isinstance(statement, SQL): - statement_seed = statement.raw_expression or statement.raw_sql - sql_statement = SQL(statement_seed, parameters, statement_config=config, is_many=True, **kwargs) - else: - base_statement = self.prepare_statement(statement, filters, statement_config=config, kwargs=kwargs) - statement_seed = base_statement.raw_expression or base_statement.raw_sql - sql_statement = SQL(statement_seed, parameters, statement_config=config, is_many=True, **kwargs) - return await self.dispatch_statement_execution(statement=sql_statement, connection=self.connection) + return await self._execute_many(statement, parameters, filters, statement_config, kwargs) async def execute_script( self, @@ -658,24 +546,7 @@ async def execute_script( By default, validates each statement and logs warnings for dangerous operations. Use suppress_warnings=True for migrations and admin scripts. """ - exc_handler = self.handle_database_exceptions() - result = await self._run_with_exception_handler( - exc_handler, self._execute_script, statement, parameters, statement_config, kwargs - ) - self._check_pending_exception(exc_handler) - assert result is not None - return result - - async def _execute_script( - self, - statement: "str | SQL", - parameters: "tuple[StatementParameters | StatementFilter, ...]", - statement_config: "StatementConfig | None", - kwargs: "dict[str, Any]", - ) -> "SQLResult": - config = statement_config or self.statement_config - sql_statement = self.prepare_statement(statement, parameters, statement_config=config, kwargs=kwargs) - return await self.dispatch_statement_execution(statement=sql_statement.as_script(), connection=self.connection) + return await self._execute_script(statement, parameters, statement_config, kwargs) @overload async def select( @@ -687,7 +558,6 @@ async def select( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "list[SchemaT]": ... - @overload async def select( self, @@ -698,7 +568,6 @@ async def select( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "list[dict[str, Any]]": ... - async def select( self, statement: "Statement | QueryBuilder", @@ -722,7 +591,6 @@ async def fetch( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "list[SchemaT]": ... - @overload async def fetch( self, @@ -733,7 +601,6 @@ async def fetch( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "list[dict[str, Any]]": ... - async def fetch( self, statement: "Statement | QueryBuilder", @@ -765,7 +632,6 @@ async def select_one( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "SchemaT": ... - @overload async def select_one( self, @@ -776,7 +642,6 @@ async def select_one( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "dict[str, Any]": ... - async def select_one( self, statement: "Statement | QueryBuilder", @@ -806,7 +671,6 @@ async def fetch_one( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "SchemaT": ... - @overload async def fetch_one( self, @@ -817,7 +681,6 @@ async def fetch_one( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "dict[str, Any]": ... - async def fetch_one( self, statement: "Statement | QueryBuilder", @@ -851,7 +714,6 @@ async def select_one_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "SchemaT | None": ... - @overload async def select_one_or_none( self, @@ -862,7 +724,6 @@ async def select_one_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "dict[str, Any] | None": ... - async def select_one_or_none( self, statement: "Statement | QueryBuilder", @@ -892,7 +753,6 @@ async def fetch_one_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "SchemaT | None": ... - @overload async def fetch_one_or_none( self, @@ -903,7 +763,6 @@ async def fetch_one_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "dict[str, Any] | None": ... - async def fetch_one_or_none( self, statement: "Statement | QueryBuilder", @@ -938,7 +797,6 @@ async def select_value( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "ValueT": ... - @overload async def select_value( self, @@ -949,7 +807,6 @@ async def select_value( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> Any: ... - async def select_value( self, statement: "Statement | QueryBuilder", @@ -997,7 +854,6 @@ async def fetch_value( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "ValueT": ... - @overload async def fetch_value( self, @@ -1008,7 +864,6 @@ async def fetch_value( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> Any: ... - async def fetch_value( self, statement: "Statement | QueryBuilder", @@ -1043,7 +898,6 @@ async def select_value_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "ValueT | None": ... - @overload async def select_value_or_none( self, @@ -1054,7 +908,6 @@ async def select_value_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> Any: ... - async def select_value_or_none( self, statement: "Statement | QueryBuilder", @@ -1102,7 +955,6 @@ async def fetch_value_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "ValueT | None": ... - @overload async def fetch_value_or_none( self, @@ -1113,7 +965,6 @@ async def fetch_value_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> Any: ... - async def fetch_value_or_none( self, statement: "Statement | QueryBuilder", @@ -1150,7 +1001,6 @@ async def select_with_total( count_with_window: bool = False, **kwargs: Any, ) -> "tuple[list[SchemaT], int]": ... - @overload async def select_with_total( self, @@ -1162,7 +1012,6 @@ async def select_with_total( count_with_window: bool = False, **kwargs: Any, ) -> "tuple[list[dict[str, Any]], int]": ... - async def select_with_total( self, statement: "Statement | QueryBuilder", @@ -1199,7 +1048,7 @@ async def select_with_total( if count_with_window: modified_sql = self._with_total_count(sql_statement) - result = await self.dispatch_statement_execution(modified_sql, self.connection) + result = await self.execute(modified_sql) rows = result.all() data, total = self._extract_total_from_rows(rows) @@ -1207,8 +1056,8 @@ async def select_with_total( return (cast("list[SchemaT]", self.to_schema(data, schema_type=schema_type)), total) return (data, total) - count_result = await self.dispatch_statement_execution(self._count_query(sql_statement), self.connection) - select_result = await self.dispatch_statement_execution(sql_statement, self.connection) + count_result = await self.execute(self._count_query(sql_statement)) + select_result = await self.execute(sql_statement) return (select_result.get_data(schema_type=schema_type), count_result.scalar()) @@ -1223,7 +1072,6 @@ async def fetch_with_total( count_with_window: bool = False, **kwargs: Any, ) -> "tuple[list[SchemaT], int]": ... - @overload async def fetch_with_total( self, @@ -1235,7 +1083,6 @@ async def fetch_with_total( count_with_window: bool = False, **kwargs: Any, ) -> "tuple[list[dict[str, Any]], int]": ... - async def fetch_with_total( self, statement: "Statement | QueryBuilder", @@ -1370,7 +1217,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "AsyncRowStream[SchemaT]": ... - @overload def select_stream( self, @@ -1383,7 +1229,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "AsyncRowStream[dict[str, Any]]": ... - def select_stream( self, statement: "SQL | Statement | QueryBuilder", @@ -1426,7 +1271,6 @@ def fetch_stream( native_only: bool = False, **kwargs: Any, ) -> "AsyncRowStream[SchemaT]": ... - @overload def fetch_stream( self, @@ -1439,7 +1283,6 @@ def fetch_stream( native_only: bool = False, **kwargs: Any, ) -> "AsyncRowStream[dict[str, Any]]": ... - def fetch_stream( self, statement: "SQL | Statement | QueryBuilder", @@ -1490,9 +1333,13 @@ async def execute_stack( with StackExecutionObserver(self, stack, continue_on_error, native_pipeline=False) as observer: started_transaction = False + try: + caller_in_transaction = self._connection_in_transaction() + except NotImplementedError: + caller_in_transaction = False try: - if single_transaction and not self._connection_in_transaction(): + if single_transaction and not caller_in_transaction: await self.begin() started_transaction = True @@ -1516,7 +1363,8 @@ async def execute_stack( started_transaction = False if continue_on_error: - await self._rollback_failed_stack() + if not caller_in_transaction: + await self._rollback_failed_stack() observer.record_operation_error(stack_error) results.append(StackResult.from_error(stack_error)) continue @@ -1525,7 +1373,7 @@ async def execute_stack( results.append(StackResult(result=result)) - if continue_on_error: + if continue_on_error and not caller_in_transaction: await self._commit_stack_success() if started_transaction: @@ -1566,7 +1414,6 @@ async def select_to_storage( StorageBridgeJob with execution telemetry. """ self._raise_storage_not_implemented("select_to_storage") - raise NotImplementedError async def load_from_arrow( self, @@ -1591,7 +1438,6 @@ async def load_from_arrow( NotImplementedError: If not implemented. """ self._raise_storage_not_implemented("load_from_arrow") - raise NotImplementedError async def load_from_storage( self, @@ -1615,7 +1461,6 @@ async def load_from_storage( StorageBridgeJob with execution telemetry. """ self._raise_storage_not_implemented("load_from_storage") - raise NotImplementedError async def load_from_records( self, @@ -1660,6 +1505,145 @@ def convert_to_dialect( """ return _convert_to_dialect_impl(statement, self.dialect, to_dialect, pretty) + @staticmethod + def _check_pending_exception(exc_handler: AsyncExceptionHandler) -> None: + """Raise any pending mapped exception after context manager exit.""" + pending = exc_handler.pending_exception + if pending is not None: + if pending.__cause__ is not None: + raise pending + raise pending from None + + async def _run_with_exception_handler( + self, + exc_handler: AsyncExceptionHandler, + operation: "Callable[..., Awaitable[_AsyncResultT]]", + *args: Any, + **kwargs: Any, + ) -> "_AsyncResultT | None": + """Run an async operation without inheriting the caller's active exception state.""" + return await _run_with_async_exception_handler(exc_handler, operation, *args, **kwargs) + + async def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: CachedQuery + ) -> "SQLResult": + """Execute pre-compiled query via ultra-fast path (async). + + Uses DBAPI-style async cursor methods when available. Async adapters with + connection-level execution APIs fall back to dispatch_execute so they can + preserve adapter-specific parameter binding semantics. For DML operations, + returns DMLResult to bypass full SQLResult construction. + + Args: + sql: Raw SQL string (original, not compiled). + params: Query parameters. + cached: Cached query metadata. + + Returns: + SQLResult or DMLResult. + """ + exc_handler = self.handle_database_exceptions() + result: SQLResult | None = None + result = await self._run_with_exception_handler( + exc_handler, self._execute_cache_hit_with_cursor, sql, params, cached + ) + self._check_pending_exception(exc_handler) + assert result is not None + return result + + async def _cached_execution( + self, statement: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]" + ) -> "SQLResult | None": + """Attempt fast-path execution for cached query (async). + + Args: + statement: Raw SQL string. + params: Query parameters. + + Returns: + SQLResult if cache hit and execution succeeds, None otherwise. + """ + result = super()._cached_execution(statement, params) + if result is None: + return None + return await cast("Awaitable[SQLResult | None]", result) + + async def _execute_cached_statement(self, statement: "SQL") -> "SQLResult": + """Execute pre-compiled query via fast path (async). + + The statement is already compiled by _cached_execution, so dispatch_execute + will hit the fast path in _compiled_statement (is_processed check). + """ + exc_handler = self.handle_database_exceptions() + result: SQLResult | None = None + try: + result = await self._run_with_exception_handler( + exc_handler, self._execute_cached_statement_with_cursor, statement + ) + + self._check_pending_exception(exc_handler) + assert result is not None + return result + finally: + self._release_pooled_statement(statement) + + async def _execute( + self, + statement: "SQL | Statement | QueryBuilder", + parameters: "tuple[StatementParameters | StatementFilter, ...]", + statement_config: "StatementConfig | None", + kwargs: "dict[str, Any]", + ) -> "SQLResult": + fast_params: tuple[Any, ...] | list[Any] | dict[str, Any] | None = None + if not kwargs and len(parameters) == 1 and isinstance(parameters[0], (tuple, list, dict)): + fast_params = cast("tuple[Any, ...] | list[Any] | dict[str, Any]", parameters[0]) + elif not parameters and kwargs: + fast_params = kwargs + if ( + self._stmt_cache_enabled + and (statement_config is None or statement_config is self.statement_config) + and isinstance(statement, str) + and fast_params is not None + ): + fast_result = await self._cached_execution(statement, fast_params) + if fast_result is not None: + return fast_result + sql_statement = self.prepare_statement( + statement, parameters, statement_config=statement_config or self.statement_config, kwargs=kwargs + ) + return await self.dispatch_statement_execution(statement=sql_statement, connection=self.connection) + + async def _execute_many( + self, + statement: "SQL | Statement | QueryBuilder", + parameters: "Sequence[StatementParameters]", + filters: "tuple[StatementParameters | StatementFilter, ...]", + statement_config: "StatementConfig | None", + kwargs: "dict[str, Any]", + ) -> "SQLResult": + config = statement_config or self.statement_config + if isinstance(statement, str) and not filters and not kwargs: + sql_statement = SQL(statement, parameters, statement_config=config, is_many=True) + elif isinstance(statement, SQL): + statement_seed = statement.raw_expression or statement.raw_sql + sql_statement = SQL(statement_seed, parameters, statement_config=config, is_many=True, **kwargs) + else: + base_statement = self.prepare_statement(statement, filters, statement_config=config, kwargs=kwargs) + statement_seed = base_statement.raw_expression or base_statement.raw_sql + sql_statement = SQL(statement_seed, parameters, statement_config=config, is_many=True, **kwargs) + return await self.dispatch_statement_execution(statement=sql_statement, connection=self.connection) + + async def _execute_script( + self, + statement: "str | SQL", + parameters: "tuple[StatementParameters | StatementFilter, ...]", + statement_config: "StatementConfig | None", + kwargs: "dict[str, Any]", + ) -> "SQLResult": + config = statement_config or self.statement_config + sql_statement = self.prepare_statement(statement, parameters, statement_config=config, kwargs=kwargs) + return await self.dispatch_statement_execution(statement=sql_statement.as_script(), connection=self.connection) + def _connection_in_transaction(self) -> bool: """Check if the connection is inside a transaction. @@ -1925,8 +1909,6 @@ async def get_metadata_capabilities( Returns: Capability profile. Base implementations report requested domains as unsupported. """ - from sqlspec.data_dictionary import MetadataCapabilityProfile - requested_domains: tuple[str, ...] = ( "schemas", "objects", @@ -1956,8 +1938,6 @@ async def get_system_metadata_capabilities( Returns: System metadata capabilities. Base implementations report requested domains as unsupported. """ - from sqlspec.data_dictionary import DEFAULT_SYSTEM_METADATA_DOMAINS, system_metadata_capabilities_from_domains - requested_domains = DEFAULT_SYSTEM_METADATA_DOMAINS if domains is None else tuple(domains) return system_metadata_capabilities_from_domains(requested_domains) @@ -2053,12 +2033,6 @@ async def get_system_metadata( self, driver: Any, request: "SystemMetadataRequest | str | None" = None, **kwargs: Any ) -> "SystemMetadataResult": """Get opt-in system metadata or a gated/unsupported result.""" - from sqlspec.data_dictionary import ( - ensure_system_metadata_request, - system_metadata_gated_result, - unsupported_system_metadata_capability, - ) - metadata_request = ensure_system_metadata_request(request, **kwargs) capability = unsupported_system_metadata_capability(metadata_request.domain) return system_metadata_gated_result(metadata_request, capability) @@ -2156,19 +2130,16 @@ async def get_foreign_keys( """ def _unsupported_metadata(self, domain: str) -> "MetadataResult": - from sqlspec.data_dictionary import MetadataResult - return MetadataResult.unsupported(domain) def _unsupported_ddl(self, object_name: str, *, schema: "str | None", object_type: str) -> "DDLResult": - from sqlspec.data_dictionary import DDLResult, ObjectIdentity - return DDLResult.unsupported( ObjectIdentity(object_name, object_type, schema=schema, dialect=self.dialect), warnings=(f"{self.dialect} DDL extraction is not implemented",), ) +@mypyc_attr(allow_interpreted_subclasses=False) class _AsyncDriverTransaction(Generic[_AsyncDriverT]): """Context manager that commits on success and rolls back on error. diff --git a/sqlspec/driver/_common.py b/sqlspec/driver/_common.py index 905458353..14149b3cb 100644 --- a/sqlspec/driver/_common.py +++ b/sqlspec/driver/_common.py @@ -149,14 +149,12 @@ class SyncExceptionHandler(Protocol): handlers store mapped exceptions in pending_exception for the caller to raise. """ - @property - def pending_exception(self) -> Exception | None: ... - def __enter__(self) -> Self: ... - def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: "TracebackType | None" ) -> bool: ... + @property + def pending_exception(self) -> Exception | None: ... class AsyncExceptionHandler(Protocol): @@ -167,14 +165,12 @@ class AsyncExceptionHandler(Protocol): handlers store mapped exceptions in pending_exception for the caller to raise. """ - @property - def pending_exception(self) -> Exception | None: ... - async def __aenter__(self) -> Self: ... - async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: "TracebackType | None" ) -> bool: ... + @property + def pending_exception(self) -> Exception | None: ... class ExecutionResult(NamedTuple): @@ -234,6 +230,7 @@ def hash_stack_operations(stack: "StatementStack") -> "tuple[str, ...]": return tuple(hashes) +@mypyc_attr(allow_interpreted_subclasses=False) class StackExecutionObserver: """Context manager that aggregates telemetry for stack execution.""" @@ -563,6 +560,8 @@ class CommonDriverAttributesMixin: statement_config: "StatementConfig" driver_features: "dict[str, Any]" + storage_pipeline_factory: "ClassVar[type[SyncStoragePipeline | AsyncStoragePipeline] | None]" = None + def __init__( self, connection: "Any", @@ -598,11 +597,6 @@ def __init__( self._processed_state_pool = get_processed_state_pool() self._refresh_statement_cache_state() - def attach_observability(self, runtime: "ObservabilityRuntime") -> None: - """Attach or replace the observability runtime.""" - self._observability = runtime - self._refresh_statement_cache_state() - @property def observability(self) -> "ObservabilityRuntime": """Return the observability runtime, creating a disabled instance when absent.""" @@ -624,7 +618,10 @@ def stack_native_disabled(self) -> bool: """Return True when native stack execution is disabled for this driver.""" return bool(self.driver_features.get("stack_native_disabled", False)) - storage_pipeline_factory: "ClassVar[type[SyncStoragePipeline | AsyncStoragePipeline] | None]" = None + def attach_observability(self, runtime: "ObservabilityRuntime") -> None: + """Attach or replace the observability runtime.""" + self._observability = runtime + self._refresh_statement_cache_state() def storage_capabilities(self) -> "StorageCapabilities": """Return cached storage capabilities for the active driver. @@ -663,15 +660,6 @@ def stmt_cache_rebind( apply_wrap_types=cached.applied_wrap_types, ) - def _statement_cache_size(self) -> int: - """Return the configured statement-cache capacity.""" - cache_size = self.driver_features.get("sqlspec_statement_cache_size", STMT_CACHE_MAX_SIZE) - try: - resolved_size = int(cache_size) - except (TypeError, ValueError): - return STMT_CACHE_MAX_SIZE - return max(0, resolved_size) - @overload @staticmethod def to_schema(data: "list[dict[str, Any]]", *, schema_type: "type[SchemaT]") -> "list[SchemaT]": ... @@ -690,7 +678,6 @@ def to_schema(data: Any, *, schema_type: "type[SchemaT]") -> Any: ... @overload @staticmethod def to_schema(data: Any, *, schema_type: None = None) -> Any: ... - @staticmethod def to_schema(data: Any, *, schema_type: "type[Any] | None" = None) -> Any: """Convert data to a specified schema type. @@ -751,7 +738,6 @@ def create_execution_result( Returns: ExecutionResult configured for the specified operation type """ - # Positional arguments are slightly faster for NamedTuple return ExecutionResult( cursor_result, rowcount_override, @@ -779,6 +765,7 @@ def build_statement_result(self, statement: "SQL", execution_result: ExecutionRe Returns: SQLResult with complete execution data """ + statement._pooled = False if execution_result.is_script_result: return SQLResult( statement=statement, @@ -805,8 +792,6 @@ def build_statement_result(self, statement: "SQL", execution_result: ExecutionRe last_inserted_id=execution_result.last_inserted_id, ) - # DML path (INSERT/UPDATE/DELETE): use _EMPTY_DML_DATA sentinel to avoid - # allocating a new empty list on every DML execution. return SQLResult( statement=statement, data=_EMPTY_DML_DATA, @@ -840,16 +825,12 @@ def prepare_statement( if statement_config is None: statement_config = self.statement_config - # FAST PATH: String statement with simple parameters if isinstance(statement, str): cached_sql = self._statement_cache.get(statement) if cached_sql is not None and not kwargs: self._statement_cache.move_to_end(statement) - # Check if parameters contain filters has_filters = any(is_statement_filter(p) for p in parameters) if not has_filters: - # Reuse cached SQL object and just update its parameters - # This avoids SQL.__init__ overhead return cached_sql.copy(parameters=parameters) kwargs = kwargs or {} @@ -864,7 +845,6 @@ def prepare_statement( filters = [*pending_filters, *filters] else: sql_statement = self._prepare_from_string(statement, data_parameters, statement_config, kwargs) - # Cache the newly created SQL object for future use if not filters and not kwargs and isinstance(statement, str) and self._stmt_cache_max_size > 0: if len(self._statement_cache) >= self._stmt_cache_max_size: self._statement_cache.popitem(last=False) @@ -978,9 +958,19 @@ def find_filter( """ return _find_filter_impl(filter_type, filters) + def _statement_cache_size(self) -> int: + """Return the configured statement-cache capacity.""" + cache_size = self.driver_features.get("sqlspec_statement_cache_size", STMT_CACHE_MAX_SIZE) + try: + resolved_size = int(cache_size) + except (TypeError, ValueError): + return STMT_CACHE_MAX_SIZE + return max(0, resolved_size) + def _refresh_statement_cache_state(self) -> None: + is_observability_idle = self._observability is None or self._observability.is_idle self._stmt_cache_enabled = bool( - self._stmt_cache_max_size > 0 and not self.statement_config._has_transformers and self.observability.is_idle + self._stmt_cache_max_size > 0 and not self.statement_config._has_transformers and is_observability_idle ) def _require_capability(self, capability_flag: str) -> None: @@ -1000,7 +990,7 @@ def _require_capability(self, capability_flag: str) -> None: msg = f"{human_label} is not available for this adapter" raise StorageCapabilityError(msg, capability=capability_flag, remediation=remediation) - def _raise_storage_not_implemented(self, capability: str) -> None: + def _raise_storage_not_implemented(self, capability: str) -> NoReturn: """Raise NotImplementedError for storage operations. Args: @@ -1027,8 +1017,6 @@ def _cached_statement( params_are_simple: bool = False, compiled_sql: str | None = None, ) -> "SQL": - # Pre-process parameters for the driver so _compiled_statement can skip - # prepare_driver_parameters entirely on the direct fast path. if params_are_simple: prepared_parameters = execution_parameters else: @@ -1054,7 +1042,6 @@ def _cached_statement( operation_profile=cached_state.operation_profile, is_many=False, ) - # Fast-path: directly set internal attributes to avoid constructor overhead. return SQL._create_cached_direct(sql, self.statement_config, direct_state) def _sub_statement(self, sql: str, execution_parameters: "ConvertedParameters") -> "SQL": @@ -1083,14 +1070,11 @@ def _cached_execution( param_values = _cache_param_values(params) - # AST transformer fallback config = self.statement_config param_config = config.parameter_config if param_config.ast_transformer is not None and any(p is None for p in param_values): return None - # Check if we can use the pre-compiled direct execute path - # This bypasses SQL object construction entirely. needs_rebind = bool(cached.input_named_parameters or cached.applied_wrap_types) if not needs_rebind: needs_rebind = parameter_values_need_processing(param_values, param_config.type_coercion_map) @@ -1101,7 +1085,6 @@ def _cached_execution( if not needs_rebind and not config._has_output_transformer: return self._execute_cache_hit(statement, params, cached) - # Fallback to standard path (builds SQL object) if needs_rebind: rebound_params = self.stmt_cache_rebind(params, cached) params_are_simple = False @@ -1168,8 +1151,6 @@ def _cache_statement(self, statement: "SQL") -> None: return if not statement.is_processed: return - # When an AST transformer is configured, compiled SQL may depend on which - # Don't cache these results as they'd corrupt the cache for non-None calls. if statement.statement_config.parameter_config.ast_transformer is not None: params = statement.positional_parameters if any(p is None for p in params): @@ -1179,9 +1160,6 @@ def _cache_statement(self, statement: "SQL") -> None: processed = cast("ProcessedState", statement.get_processed_state()) param_profile = processed.parameter_profile - # Store a stable snapshot for cache metadata. Some call paths execute - # pooled SQL objects that are reset/recycled after dispatch, and the - # cache must not hold references to a mutable pooled ProcessedState. cached_state = _clone_processed_state(processed) cached = CachedQuery( compiled_sql=processed.compiled_sql, @@ -1330,6 +1308,8 @@ def _prepare_from_sql( ) rebuilt._named_parameters.update(sql_statement.named_parameters) return rebuilt + if declared: + return sql_statement.copy() return sql_statement def _prepare_from_string( @@ -1482,8 +1462,6 @@ def _compiled_statement( its ProcessedState directly. This eliminates redundant compilation when dispatch_statement_execution() has already triggered compile(). """ - # FAST PATH: Statement already compiled - reuse its processed state - # This is the key optimization: avoid double compilation if statement.is_processed: if statement._compiled_from_cache: compiled_sql, execution_parameters = statement.compile() @@ -1503,9 +1481,6 @@ def _compiled_statement( return cached_statement, prepared_parameters processed = statement.get_processed_state() - # DIRECT FAST PATH: When _is_cache_direct is set, execution_parameters - # already went through prepare_driver_parameters. - # Skip the redundant parameter processing and store entirely. if statement._is_cache_direct: prepared_parameters = processed.execution_parameters cached_statement = CachedStatement( @@ -1535,17 +1510,13 @@ def _compiled_statement( self._cache_statement(statement) return cached_statement, prepared_parameters - # Materialize iterators before cache key generation to prevent exhaustion. - # will consume it during cache key generation, leaving empty parameters for execution. params = statement.parameters if params is not None and not isinstance(params, (list, tuple, dict)): try: materialized = list(params) - # Create a copy of the statement with materialized parameters - # to avoid consuming the iterator during cache key generation statement = statement.copy(parameters=materialized) except TypeError: - pass # Not iterable, proceed normally + pass cache_config = get_cache_config() dialect_key = str(statement.dialect) if statement.dialect else None @@ -1563,8 +1534,6 @@ def _compiled_statement( if not isinstance(cached_result, CachedStatement): cached_result = None if cached_result is not None: - # Structural fingerprinting means same SQL structure = same cache entry, - # but we must still use the caller's actual parameter values. parameter_profile = cached_result.parameter_profile if parameter_profile is not None: prepared_parameters = self._stmt_cache_rebind_processor.transform_cached_parameters( @@ -1579,8 +1548,6 @@ def _compiled_statement( prepared_parameters = self.prepare_driver_parameters( statement.parameters, statement_config, is_many=statement.is_many, prepared_statement=statement ) - # Return cached SQL metadata but with newly processed parameters - # Preserve list type for execute_many operations (some drivers require list, not tuple) updated_cached = CachedStatement( compiled_sql=cached_result.compiled_sql, parameters=cast("tuple[Any, ...] | list[Any] | dict[str, Any] | None", prepared_parameters), @@ -1591,7 +1558,6 @@ def _compiled_statement( ) return updated_cached, prepared_parameters - # Compile the statement directly (no need for prepare_statement indirection) compiled_sql, execution_parameters = statement.compile() processed = statement.get_processed_state() @@ -1653,9 +1619,6 @@ def _compile_cache_key(self, statement: "SQL", config: "StatementConfig", flatte except TypeError: pass - # For static script compilation, parameter VALUES are embedded in the SQL string, - # so different values produce different compiled SQL. Must use value_fingerprint - # to avoid returning cached SQL with stale embedded values. if config.parameter_config.needs_static_script_compilation: params_fingerprint = value_fingerprint(params) else: @@ -1667,14 +1630,11 @@ def _count_query(self, original_sql: "SQL") -> "SQL": """Create a COUNT query from the original SQL statement. Transforms the original SELECT statement to count total rows while preserving - WHERE, HAVING, and GROUP BY clauses but removing ORDER BY, LIMIT, and OFFSET. - Copies any existing ``WITH`` clause (sqlglot stores it under ``with_``) and falls back to inferred tables if the FROM clause is missing. - When GROUP BY, JOINs, or a WITH clause exist we wrap the payload in a subquery before counting. + WHERE, HAVING, and GROUP BY clauses but removing ORDER BY, LIMIT, OFFSET, and FETCH. + Copies any existing ``WITH`` clause (sqlglot stores it under ``with_``). + When GROUP BY, HAVING, JOINs, DISTINCT, QUALIFY, LATERALs, window/aggregate + projections, or a WITH clause exist, wraps the payload in a subquery before counting. """ - # Extract pagination placeholders BEFORE compile() transforms them - # Uses statement_expression if available, falls back to parsing raw_sql - pagination_params = _pagination_names(original_sql) - if not original_sql.expression: original_sql.compile() @@ -1699,80 +1659,80 @@ def _count_query(self, original_sql: "SQL") -> "SQL": ) raise ImproperConfigurationError(msg) - has_group = expr.args.get("group") - has_joins = expr.args.get("joins") - needs_subquery = has_group or has_joins or cte is not None + has_group = bool(expr.args.get("group")) + has_joins = bool(expr.args.get("joins")) + has_distinct = bool(expr.args.get("distinct")) + has_qualify = bool(expr.args.get("qualify")) + has_laterals = bool(expr.args.get("laterals")) + has_having = bool(expr.args.get("having")) + has_agg_or_window = any( + isinstance(node, (exp.AggFunc, exp.Window)) + for select_expr in expr.expressions + for node in select_expr.walk() + ) + needs_subquery = ( + has_group + or has_joins + or cte is not None + or has_distinct + or has_qualify + or has_laterals + or has_having + or has_agg_or_window + ) + leading_stripped = ( + 0 + if needs_subquery + else sum(_count_anonymous_placeholders(select_expr) for select_expr in expr.expressions) + ) + trailing_stripped = sum( + _count_anonymous_placeholders(expr.args.get(clause)) for clause in ("order", "limit", "offset", "fetch") + ) + pagination_params = _pagination_names(original_sql, include_select_expressions=not needs_subquery) if needs_subquery: subquery_expr = expr.copy() subquery_expr.set("order", None) subquery_expr.set("limit", None) subquery_expr.set("offset", None) + subquery_expr.set("fetch", None) subquery = subquery_expr.subquery(alias="grouped_data", copy=False) count_expr = exp.select(exp.Count(this=exp.Star())).from_(subquery, copy=False) else: - # Direct count from source count_expr = exp.select(exp.Count(this=exp.Star())) - - # Copy FROM clause(s) - # handle current sqlglot's 'from_' and keep 'from' as defensive compatibility - from_arg = expr.args.get("from_") or expr.args.get("from") - if from_arg: - count_expr.set("from_", from_arg.copy()) - else: - # Fallback: extract table from expression if FROM arg is missing - # This happens if sqlglot structure differs or builder used implicit FROM - tables = list(expr.find_all(exp.Table)) - if tables: - first_table = tables[0] - # Create new FROM clause with the found table - count_expr = count_expr.from_(first_table.copy(), copy=False) - - # Copy JOIN clauses - joins = expr.args.get("joins") - if joins: - count_expr.set("joins", [join.copy() for join in joins]) - + count_expr.set("from_", from_clause.copy()) if expr.args.get("where"): count_expr = count_expr.where(cast("exp.Expression", expr.args.get("where")).copy(), copy=False) - if expr.args.get("having"): - count_expr = count_expr.having(cast("exp.Expression", expr.args.get("having")).copy(), copy=False) if cte is not None: count_expr.set("with_", cte.copy()) - # Filter out pagination parameters (limit/offset) captured before compile() - filtered_named_params = { - k: v for k, v in original_sql.named_parameters.items() if k not in pagination_params - } - materialized_parameters = _parameters_for_repeated_names(original_sql, pagination_params) - if materialized_parameters is not None: - return SQL(count_expr, *materialized_parameters, statement_config=original_sql.statement_config) - return SQL( + return _build_rewritten_sql( count_expr, - *original_sql.positional_parameters, - statement_config=original_sql.statement_config, - **filtered_named_params, + original_sql, + pagination_params=pagination_params, + leading_stripped=leading_stripped, + trailing_stripped=trailing_stripped, ) - # Strip ORDER BY, LIMIT, OFFSET before wrapping for count - # (they don't affect the total count and cause incorrect results) + leading_stripped = 0 + trailing_stripped = sum( + _count_anonymous_placeholders(expr.args.get(clause)) for clause in ("order", "limit", "offset", "fetch") + ) + pagination_params = _pagination_names(original_sql, include_select_expressions=False) count_source = cast("exp.Query", expr.copy()) count_source.set("order", None) count_source.set("limit", None) count_source.set("offset", None) + count_source.set("fetch", None) subquery = count_source.subquery(alias="total_query", copy=False) count_expr = exp.select(exp.Count(this=exp.Star())).from_(subquery, copy=False) if cte is not None: count_expr.set("with_", cte.copy()) - # Filter out pagination parameters (limit/offset) captured before compile() - filtered_named_params = {k: v for k, v in original_sql.named_parameters.items() if k not in pagination_params} - materialized_parameters = _parameters_for_repeated_names(original_sql, pagination_params) - if materialized_parameters is not None: - return SQL(count_expr, *materialized_parameters, statement_config=original_sql.statement_config) - return SQL( + return _build_rewritten_sql( count_expr, - *original_sql.positional_parameters, - statement_config=original_sql.statement_config, - **filtered_named_params, + original_sql, + pagination_params=pagination_params, + leading_stripped=leading_stripped, + trailing_stripped=trailing_stripped, ) def _with_total_count(self, original_sql: "SQL", alias: str = "_total_count") -> "SQL": @@ -1801,12 +1761,10 @@ def _with_total_count(self, original_sql: "SQL", alias: str = "_total_count") -> expr = original_sql.expression if isinstance(expr, exp.SetOperation): - # Extract CTE before wrapping — CTE must stay at outermost level cte = expr.args.get("with_") expr_copy = expr.copy() if cte: expr_copy.set("with_", None) - # Wrap set operation in subquery, then select all columns + count window subquery = expr_copy.subquery(alias="__set_op_subq", copy=False) modified_expr = exp.select(exp.Column(this=exp.Star())).from_(subquery, copy=False) count_window = exp.Window(this=exp.Count(this=exp.Star())) @@ -1823,14 +1781,8 @@ def _with_total_count(self, original_sql: "SQL", alias: str = "_total_count") -> msg = "COUNT(*) OVER() can only be added to SELECT or set operation statements" raise ImproperConfigurationError(msg) - materialized_parameters = _parameters_for_repeated_names(original_sql, set()) - if materialized_parameters is not None: - return SQL(modified_expr, *materialized_parameters, statement_config=original_sql.statement_config) - return SQL( - modified_expr, - *original_sql.positional_parameters, - statement_config=original_sql.statement_config, - **original_sql.named_parameters, + return _build_rewritten_sql( + modified_expr, original_sql, pagination_params=set(), leading_stripped=0, trailing_stripped=0 ) def _extract_total_from_rows(self, rows: "list[Any]") -> "tuple[list[dict[str, Any]], int]": @@ -1838,18 +1790,28 @@ def _extract_total_from_rows(self, rows: "list[Any]") -> "tuple[list[dict[str, A if not rows: return ([], 0) - first_row = rows[0] - if is_dict_row(first_row): - total = cast("int", first_row.get("_total_count", 0)) - return ([{k: v for k, v in row.items() if k != "_total_count"} for row in rows], total) - if is_mapping_like(first_row): - total = cast("int", dict(first_row).get("_total_count", 0)) - return ([{k: v for k, v in dict(row).items() if k != "_total_count"} for row in rows], total) - if has_asdict_method(first_row): - total = cast("int", getattr(first_row, "_total_count", 0)) - return ([{k: v for k, v in row._asdict().items() if k != "_total_count"} for row in rows], total) - - return ([{} for _ in rows], 0) + clean_rows: list[dict[str, Any]] = [] + total = 0 + total_found = False + for row in rows: + if is_dict_row(row): + row_dict: dict[str, Any] = row + elif is_mapping_like(row): + row_dict = dict(row) + elif has_asdict_method(row): + row_dict = row._asdict() + else: + clean_rows.append({}) + continue + if not total_found: + total_val = row_dict.get("_total_count") + if total_val is None: + total_val = row_dict.get("_TOTAL_COUNT") + if total_val is not None: + total = int(total_val) + total_found = True + clean_rows.append({k: v for k, v in row_dict.items() if k not in {"_total_count", "_TOTAL_COUNT"}}) + return (clean_rows, total) def _coerce_arrow_table(self, source: "ArrowResult | Any") -> "ArrowTable": """Coerce various sources to a PyArrow Table.""" @@ -1934,9 +1896,11 @@ def _raise_database_exception( """Raise any mapped database exception captured by a deferred exception handler.""" pending_exception = exc_handler.pending_exception if pending_exception is not None: - if exc is None: - raise pending_exception from None - raise pending_exception from exc + if exc is not None: + raise pending_exception from exc + if pending_exception.__cause__ is not None: + raise pending_exception + raise pending_exception from None if exc is not None: raise exc @@ -1995,79 +1959,87 @@ def _clone_processed_state(processed: "ProcessedState") -> "ProcessedState": ) -def _pagination_names_in_expression(expression: "exp.Expr") -> "set[str]": - """Extract named placeholder names from LIMIT and OFFSET clauses of an expression. +def _count_anonymous_placeholders(node: "exp.Expr | None") -> int: + """Count anonymous positional placeholders within a SQLGlot expression subtree.""" + if node is None: + return 0 + return sum(1 for child in node.walk() if isinstance(child, exp.Placeholder) and child.this is None) + + +def _pagination_names_in_expression(expression: "exp.Expr", *, include_select_expressions: bool = False) -> "set[str]": + """Extract named placeholder names from stripped clauses of an expression that do not appear in retained clauses. Args: - expression: A SQLGlot SELECT expression to scan. + expression: A SQLGlot expression to scan. + include_select_expressions: Whether top-level SELECT expressions are also stripped. Returns: - Set of placeholder names found in LIMIT/OFFSET clauses. + Set of placeholder names that only appear in stripped clauses. """ - pagination_placeholders: set[str] = set() + stripped_placeholders: set[str] = set() + stripped_nodes: list[exp.Expr] = [] - # Extract from LIMIT clause - limit_clause = expression.args.get("limit") - if limit_clause: - for node in limit_clause.walk(): - if isinstance(node, exp.Placeholder) and node.this is not None: - pagination_placeholders.add(str(node.this)) + if include_select_expressions and isinstance(expression, exp.Select): + stripped_nodes.extend(expression.expressions) - # Extract from OFFSET clause - offset_clause = expression.args.get("offset") - if offset_clause: - for node in offset_clause.walk(): + for clause_name in ("order", "limit", "offset", "fetch"): + clause = expression.args.get(clause_name) + if isinstance(clause, exp.Expr): + stripped_nodes.append(clause) + + for clause_node in stripped_nodes: + for node in clause_node.walk(): if isinstance(node, exp.Placeholder) and node.this is not None: - pagination_placeholders.add(str(node.this)) + stripped_placeholders.add(str(node.this)) - return pagination_placeholders + if not stripped_placeholders: + return set() + retained_expr = expression.copy() + if include_select_expressions and isinstance(retained_expr, exp.Select): + retained_expr.set("expressions", []) + for clause_name in ("order", "limit", "offset", "fetch"): + retained_expr.set(clause_name, None) -def _pagination_names(original_sql: "SQL") -> "set[str]": - """Extract placeholder names from LIMIT and OFFSET clauses. + retained_placeholders = { + str(node.this) for node in retained_expr.walk() if isinstance(node, exp.Placeholder) and node.this is not None + } + return stripped_placeholders - retained_placeholders - These are the placeholders that should be EXCLUDED from count queries, - since count queries remove LIMIT/OFFSET clauses. - First tries to use statement_expression if it has named placeholders. - For raw SQL strings where statement_expression is None or has positional - placeholders, parses the raw_sql once to extract the names. +def _pagination_names(original_sql: "SQL", *, include_select_expressions: bool = False) -> "set[str]": + """Extract placeholder names from stripped pagination/projection clauses. Args: original_sql: The SQL object to extract pagination placeholders from. + include_select_expressions: Whether top-level SELECT expressions are also stripped. Returns: - Set of placeholder names found in LIMIT/OFFSET clauses. + Set of placeholder names found exclusively in stripped clauses. """ - # First try: use statement_expression if available and has named placeholders stmt_expr = original_sql.statement_expression if stmt_expr is not None: - placeholders = _pagination_names_in_expression(stmt_expr) + placeholders = _pagination_names_in_expression(stmt_expr, include_select_expressions=include_select_expressions) if placeholders: return placeholders - # Check if it has any named placeholders at all - if not, fall through has_named = any(isinstance(n, exp.Placeholder) and n.this is not None for n in stmt_expr.walk()) if has_named: - # Expression has named placeholders but none in LIMIT/OFFSET return set() - # Fallback: parse raw_sql to extract LIMIT/OFFSET placeholder names - # This is necessary for raw SQL strings before compile() or after - # compile() converts placeholders to positional style raw_sql = original_sql.raw_sql if not raw_sql: return set() try: - parsed = sqlglot.parse_one(raw_sql) - return _pagination_names_in_expression(parsed) + dialect = str(original_sql.statement_config.dialect) if original_sql.statement_config.dialect else None + parsed = sqlglot.parse_one(raw_sql, read=dialect) + return _pagination_names_in_expression(parsed, include_select_expressions=include_select_expressions) except Exception: - # If parsing fails, return empty set (conservative - don't filter anything) return set() def _parameters_for_repeated_names(original_sql: "SQL", excluded_names: "set[str]") -> "tuple[Any, ...] | None": - """Return occurrence-ordered parameters when a rewritten expression lost repeated names.""" + """Return occurrence-ordered parameters when a rewritten expression uses repeated named parameters.""" processed_state = original_sql._processed_state # pyright: ignore[reportPrivateUsage] if not isinstance(processed_state, ProcessedState): return None @@ -2081,9 +2053,7 @@ def _parameters_for_repeated_names(original_sql: "SQL", excluded_names: "set[str return None execution_parameters = processed_state.execution_parameters - if not isinstance(execution_parameters, (list, tuple)): - return None - if len(execution_parameters) != len(input_names): + if not isinstance(execution_parameters, (list, tuple)) or len(execution_parameters) != len(input_names): return None return tuple( @@ -2091,6 +2061,48 @@ def _parameters_for_repeated_names(original_sql: "SQL", excluded_names: "set[str ) +def _build_rewritten_sql( + rewritten_expr: "exp.Expr", + original_sql: "SQL", + *, + pagination_params: "set[str]", + leading_stripped: int, + trailing_stripped: int, +) -> "SQL": + """Construct a rewritten SQL object with stripped positional or named parameters.""" + positional = original_sql.positional_parameters + retained_positional = positional + if positional and (leading_stripped or trailing_stripped): + end_idx = len(positional) - trailing_stripped if trailing_stripped else len(positional) + retained_positional = positional[leading_stripped:end_idx] + + if original_sql.named_parameters: + materialized_parameters = _parameters_for_repeated_names(original_sql, pagination_params) + if materialized_parameters is not None: + return SQL(rewritten_expr, *materialized_parameters, statement_config=original_sql.statement_config) + processed_state = original_sql._processed_state # pyright: ignore[reportPrivateUsage] + input_names = processed_state.input_named_parameters if isinstance(processed_state, ProcessedState) else () + filtered_named_params: dict[str, Any] = {} + for name in input_names: + if ( + name not in pagination_params + and name in original_sql.named_parameters + and name not in filtered_named_params + ): + filtered_named_params[name] = original_sql.named_parameters[name] + for key, value in original_sql.named_parameters.items(): + if key not in pagination_params and key not in filtered_named_params: + filtered_named_params[key] = value + return SQL( + rewritten_expr, + *retained_positional, + statement_config=original_sql.statement_config, + **filtered_named_params, + ) + + return SQL(rewritten_expr, *retained_positional, statement_config=original_sql.statement_config) + + def _callable_cache_key(func: Any) -> Any: """Return a stable cache key component for callables. @@ -2137,11 +2149,11 @@ def _check_declared_named_row(declared: "tuple[ParameterDeclaration, ...]", supp def _check_declared_parameters(sql_statement: "SQL") -> None: - """Enforce declared-parameter contracts on the original user params. + """Enforce declared-parameter contracts on a copy of the user params. No-op unless the statement carries declarations and is not a script. Runs before driver style conversion, so declared names and raw values are intact. Named binding - is validated for presence and type; ``execute_many`` checks the first row only; + is validated for presence and type across all rows without mutating caller dicts; positional binding is skipped (arity is validated at load time). """ declared = sql_statement.declared_parameters @@ -2150,11 +2162,18 @@ def _check_declared_parameters(sql_statement: "SQL") -> None: if sql_statement.is_many: rows = sql_statement.positional_parameters if rows and isinstance(rows[0], dict): + copied_rows: list[Any] = [] for row in rows: if isinstance(row, dict): - _apply_declared_optional_defaults(declared, row) - _check_declared_named_row(declared, rows[0]) + row_copy = dict(row) + _check_declared_named_row(declared, row_copy) + copied_rows.append(row_copy) + else: + copied_rows.append(row) + sql_statement._positional_parameters = copied_rows return if sql_statement.positional_parameters: return - _check_declared_named_row(declared, sql_statement.named_parameters) + named_copy = dict(sql_statement.named_parameters) + _check_declared_named_row(declared, named_copy) + sql_statement._named_parameters = named_copy diff --git a/sqlspec/driver/_exception_handler.py b/sqlspec/driver/_exception_handler.py index 7c1ae9a59..3059779c6 100644 --- a/sqlspec/driver/_exception_handler.py +++ b/sqlspec/driver/_exception_handler.py @@ -37,14 +37,15 @@ from sqlspec.exceptions import SQLSpecError if TYPE_CHECKING: - from collections.abc import Awaitable, Callable + from collections.abc import Awaitable, Callable, Iterator from types import TracebackType - from sqlspec.driver._common import AsyncExceptionHandler + from sqlspec.driver._common import AsyncExceptionHandler, SyncExceptionHandler __all__ = ("BaseAsyncExceptionHandler", "BaseSyncExceptionHandler") _AsyncResultT = TypeVar("_AsyncResultT") +_SyncResultT = TypeVar("_SyncResultT") @mypyc_attr(allow_interpreted_subclasses=True) @@ -66,11 +67,18 @@ async def __aexit__( if exc_val is None: return False - # Do not re-map if already a SQLSpecError if isinstance(exc_val, SQLSpecError): return False - return self._handle_exception(exc_type, exc_val) + handled = self._handle_exception(exc_type, exc_val) + if ( + handled + and self.pending_exception is not None + and self.pending_exception.__cause__ is None + and self.pending_exception is not exc_val + ): + self.pending_exception.__cause__ = exc_val + return handled def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "BaseException") -> bool: """Handle an adapter exception. @@ -100,11 +108,18 @@ def __exit__( if exc_val is None: return False - # Do not re-map if already a SQLSpecError if isinstance(exc_val, SQLSpecError): return False - return self._handle_exception(exc_type, exc_val) + handled = self._handle_exception(exc_type, exc_val) + if ( + handled + and self.pending_exception is not None + and self.pending_exception.__cause__ is None + and self.pending_exception is not exc_val + ): + self.pending_exception.__cause__ = exc_val + return handled def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "BaseException") -> bool: """Handle an adapter exception. @@ -115,6 +130,68 @@ def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "Ba return False +def _iter_sync_exception_handler( + exc_handler: "SyncExceptionHandler", operation: "Callable[..., _SyncResultT]", *args: Any, **kwargs: Any +) -> "Iterator[tuple[_SyncResultT | None, BaseException | None, TracebackType | None]]": + """Execute a sync operation and exit handler inside an isolated generator frame. + + Yields: + Tuple of (result, error, traceback) after running the operation and handler exit. + """ + result: _SyncResultT | None = None + + error: BaseException | None = None + traceback: TracebackType | None = None + try: + result = operation(*args, **kwargs) + except BaseException as caught: + error = caught + traceback = caught.__traceback__ + + if error is None: + exc_handler.__exit__(None, None, None) + yield (result, None, None) + return + + suppressed = exc_handler.__exit__(type(error), error, traceback) + if suppressed: + yield (None, None, None) + return + yield (None, error, traceback) + + +def _run_with_sync_exception_handler( + exc_handler: "SyncExceptionHandler", operation: "Callable[..., _SyncResultT]", *args: Any, **kwargs: Any +) -> "_SyncResultT | None": + """Run a sync operation without inheriting an active exception state.""" + exc_handler.__enter__() + if sys.exc_info()[1] is not None: + isolated_result, isolated_error, isolated_tb = next( + _iter_sync_exception_handler(exc_handler, operation, *args, **kwargs) + ) + if isolated_error is not None: + raise isolated_error.with_traceback(isolated_tb) + return isolated_result + + result: _SyncResultT | None = None + error: BaseException | None = None + traceback: TracebackType | None = None + try: + result = operation(*args, **kwargs) + except BaseException as caught: + error = caught + traceback = caught.__traceback__ + + if error is None: + exc_handler.__exit__(None, None, None) + return result + + suppressed = exc_handler.__exit__(type(error), error, traceback) + if not suppressed: + raise error.with_traceback(traceback) + return None + + async def _run_with_async_exception_handler( exc_handler: "AsyncExceptionHandler", operation: "Callable[..., Awaitable[_AsyncResultT]]", diff --git a/sqlspec/driver/_query_cache.py b/sqlspec/driver/_query_cache.py index cacdf96cc..0ce0d3cfc 100644 --- a/sqlspec/driver/_query_cache.py +++ b/sqlspec/driver/_query_cache.py @@ -76,6 +76,9 @@ def __init__(self, max_size: int = STMT_CACHE_MAX_SIZE) -> None: self._cache: OrderedDict[str, CachedQuery] = OrderedDict() self._max_size = max_size + def __len__(self) -> int: + return len(self._cache) + def get(self, sql: str) -> "CachedQuery | None": entry = self._cache.get(sql) if entry is None: @@ -95,6 +98,3 @@ def set(self, sql: str, entry: "CachedQuery") -> None: def clear(self) -> None: """Clear all cached entries.""" self._cache.clear() - - def __len__(self) -> int: - return len(self._cache) diff --git a/sqlspec/driver/_storage_helpers.py b/sqlspec/driver/_storage_helpers.py index e504e7867..12b0d397d 100644 --- a/sqlspec/driver/_storage_helpers.py +++ b/sqlspec/driver/_storage_helpers.py @@ -68,7 +68,6 @@ def coerce_arrow_table(source: "ArrowResult | Any") -> "ArrowTable": Returns: PyArrow Table. """ - # mypyc boundary: compiled _common.py cannot safely call uncompiled arrow_helpers directly; this wrapper is intentional. return _coerce_arrow_table_impl(source) @@ -84,13 +83,11 @@ def arrow_table_to_rows( Returns: Tuple of (column_names, list of row tuples). """ - # mypyc boundary: compiled _common.py cannot safely call uncompiled arrow_helpers directly; this wrapper is intentional. return _arrow_table_to_rows_impl(table, columns) def arrow_table_needs_parameter_preparation(table: "ArrowTable") -> bool: """Return whether Arrow rows may contain nested values needing preparation.""" - # mypyc boundary: compiled _common.py cannot safely call uncompiled arrow_helpers directly; this wrapper is intentional. return _arrow_rows_need_preparation(table) diff --git a/sqlspec/driver/_stream.py b/sqlspec/driver/_stream.py index 0080856d4..d19325e1b 100644 --- a/sqlspec/driver/_stream.py +++ b/sqlspec/driver/_stream.py @@ -7,13 +7,19 @@ list[dict[str, Any]]`` returns the next chunk (empty list signals exhaustion), ``close() -> None`` is idempotent and safe at any state including pre-start. - async source: same names, all coroutines. + +``_StopAsync`` dynamically subclasses ``StopAsyncIteration`` via ``builtins`` to +bypass mypyc's coroutine transformation of literal ``raise StopAsyncIteration`` +statements inside compiled ``__anext__`` methods. """ import builtins -import contextlib from typing import TYPE_CHECKING, Any, Generic, Protocol, Self, TypeVar, cast, overload +from mypy_extensions import mypyc_attr + from sqlspec.exceptions import SQLSpecError +from sqlspec.utils.logging import get_logger from sqlspec.utils.schema import to_schema if TYPE_CHECKING: @@ -31,6 +37,8 @@ "rows_to_dicts", ) +logger = get_logger("sqlspec.driver") + _StopAsyncBase = getattr(builtins, "Stop" + "Async" + "Iteration") _StopAsync = type("_StopAsync", (_StopAsyncBase,), {}) RowT = TypeVar("RowT") @@ -81,6 +89,7 @@ def rows_to_dicts(rows: "list[Any]", column_names: "list[str]") -> "list[dict[st return [dict(zip(column_names, row, strict=False)) for row in rows] +@mypyc_attr(allow_interpreted_subclasses=False) class SyncRowStream(Generic[RowT]): """Bounded-memory iterator backed by a chunk source.""" @@ -94,20 +103,6 @@ def __init__(self, source: SyncRowSource, schema_type: "type[RowT] | None" = Non self._closed = False self._started = False - @overload - def _with_schema_type(self, schema_type: "type[SchemaRowT]") -> "SyncRowStream[SchemaRowT]": ... - - @overload - def _with_schema_type(self, schema_type: None = None) -> "SyncRowStream[dict[str, Any]]": ... - - def _with_schema_type( - self, schema_type: "type[SchemaRowT] | None" = None - ) -> "SyncRowStream[SchemaRowT] | SyncRowStream[dict[str, Any]]": - self._schema_type = schema_type - if schema_type is None: - return cast("SyncRowStream[dict[str, Any]]", self) - return cast("SyncRowStream[SchemaRowT]", self) - def __enter__(self) -> Self: return self @@ -144,25 +139,40 @@ def __next__(self) -> RowT: self._buffer_index += 1 return row + def close(self) -> None: + self._close(error=False) + + @overload + def _with_schema_type(self, schema_type: "type[SchemaRowT]") -> "SyncRowStream[SchemaRowT]": ... + @overload + def _with_schema_type(self, schema_type: None = None) -> "SyncRowStream[dict[str, Any]]": ... + def _with_schema_type( + self, schema_type: "type[SchemaRowT] | None" = None + ) -> "SyncRowStream[SchemaRowT] | SyncRowStream[dict[str, Any]]": + self._schema_type = schema_type + if schema_type is None: + return cast("SyncRowStream[dict[str, Any]]", self) + return cast("SyncRowStream[SchemaRowT]", self) + def _coerce_chunk(self, chunk: "list[dict[str, Any]]") -> "list[RowT]": schema_type = self._schema_type if schema_type is None: return cast("list[RowT]", chunk) return cast("list[RowT]", to_schema(chunk, schema_type=schema_type)) - def close(self) -> None: - self._close(error=False) - def _close(self, error: bool = False) -> None: if self._closed: return self._closed = True self._buffer = [] self._buffer_index = 0 - with contextlib.suppress(Exception): + try: self._source.close(error=error) + except Exception as exc: + logger.debug("SyncRowStream source close failed: %s", exc) +@mypyc_attr(allow_interpreted_subclasses=False) class AsyncRowStream(Generic[RowT]): """Async bounded-memory iterator backed by an async chunk source.""" @@ -176,20 +186,6 @@ def __init__(self, source: AsyncRowSource, schema_type: "type[RowT] | None" = No self._closed = False self._started = False - @overload - def _with_schema_type(self, schema_type: "type[SchemaRowT]") -> "AsyncRowStream[SchemaRowT]": ... - - @overload - def _with_schema_type(self, schema_type: None = None) -> "AsyncRowStream[dict[str, Any]]": ... - - def _with_schema_type( - self, schema_type: "type[SchemaRowT] | None" = None - ) -> "AsyncRowStream[SchemaRowT] | AsyncRowStream[dict[str, Any]]": - self._schema_type = schema_type - if schema_type is None: - return cast("AsyncRowStream[dict[str, Any]]", self) - return cast("AsyncRowStream[SchemaRowT]", self) - def __aiter__(self) -> "AsyncRowStream[RowT]": return self @@ -226,25 +222,40 @@ async def __anext__(self) -> RowT: self._buffer_index += 1 return row + async def aclose(self) -> None: + await self._aclose(error=False) + + @overload + def _with_schema_type(self, schema_type: "type[SchemaRowT]") -> "AsyncRowStream[SchemaRowT]": ... + @overload + def _with_schema_type(self, schema_type: None = None) -> "AsyncRowStream[dict[str, Any]]": ... + def _with_schema_type( + self, schema_type: "type[SchemaRowT] | None" = None + ) -> "AsyncRowStream[SchemaRowT] | AsyncRowStream[dict[str, Any]]": + self._schema_type = schema_type + if schema_type is None: + return cast("AsyncRowStream[dict[str, Any]]", self) + return cast("AsyncRowStream[SchemaRowT]", self) + def _coerce_chunk(self, chunk: "list[dict[str, Any]]") -> "list[RowT]": schema_type = self._schema_type if schema_type is None: return cast("list[RowT]", chunk) return cast("list[RowT]", to_schema(chunk, schema_type=schema_type)) - async def aclose(self) -> None: - await self._aclose(error=False) - async def _aclose(self, error: bool = False) -> None: if self._closed: return self._closed = True self._buffer = [] self._buffer_index = 0 - with contextlib.suppress(Exception): + try: await self._source.close(error=error) + except Exception as exc: + logger.debug("AsyncRowStream source close failed: %s", exc) +@mypyc_attr(allow_interpreted_subclasses=False) class EagerSyncRowSource: """Chunk source over pre-materialized rows (eager fallback; not bounded-memory).""" @@ -267,6 +278,7 @@ def close(self, error: bool = False) -> None: self._rows = [] +@mypyc_attr(allow_interpreted_subclasses=False) class EagerAsyncRowSource: """Async chunk source over pre-materialized rows (eager fallback; not bounded-memory).""" @@ -289,6 +301,33 @@ async def close(self, error: bool = False) -> None: self._rows = [] +@mypyc_attr(allow_interpreted_subclasses=False) +class _LazyEagerSyncRowSource: + """Sync eager fallback source that materializes via the driver on first fetch.""" + + __slots__ = ("_chunk_size", "_driver", "_position", "_rows", "_statement") + + def __init__(self, driver: Any, statement: "SQL", chunk_size: int) -> None: + self._driver = driver + self._statement = statement + self._chunk_size = chunk_size + self._rows: list[dict[str, Any]] = [] + self._position = 0 + + def start(self) -> None: + result = self._driver.execute(self._statement) + self._rows = result.get_data() + + def fetch_chunk(self) -> "list[dict[str, Any]]": + chunk = self._rows[self._position : self._position + self._chunk_size] + self._position += len(chunk) + return chunk + + def close(self, error: bool = False) -> None: + self._rows = [] + + +@mypyc_attr(allow_interpreted_subclasses=False) class _LazyEagerAsyncRowSource: """Async eager fallback source that materializes via the driver on first fetch.""" diff --git a/sqlspec/driver/_sync.py b/sqlspec/driver/_sync.py index de4bf86f3..52c26d6f6 100644 --- a/sqlspec/driver/_sync.py +++ b/sqlspec/driver/_sync.py @@ -10,6 +10,17 @@ from sqlspec.core import SQL, StackResult, create_arrow_result from sqlspec.core.result import DMLResult from sqlspec.core.stack import StackOperation, StatementStack +from sqlspec.data_dictionary import ( + DEFAULT_SYSTEM_METADATA_DOMAINS, + DDLResult, + MetadataCapabilityProfile, + MetadataResult, + ObjectIdentity, + ensure_system_metadata_request, + system_metadata_capabilities_from_domains, + system_metadata_gated_result, + unsupported_system_metadata_capability, +) from sqlspec.driver._common import ( CommonDriverAttributesMixin, DataDictionaryDialectMixin, @@ -22,11 +33,12 @@ handle_single_row_error, validate_savepoint_name, ) +from sqlspec.driver._exception_handler import _run_with_sync_exception_handler from sqlspec.driver._query_cache import CachedQuery from sqlspec.driver._sql_helpers import DEFAULT_PRETTY from sqlspec.driver._sql_helpers import convert_to_dialect as _convert_to_dialect_impl from sqlspec.driver._storage_helpers import stringify_storage_target -from sqlspec.driver._stream import EagerSyncRowSource, SyncRowStream +from sqlspec.driver._stream import SyncRowStream, _LazyEagerSyncRowSource from sqlspec.exceptions import ImproperConfigurationError, StackExecutionError from sqlspec.observability import _runtime as observability_runtime from sqlspec.storage import StorageBridgeJob, StorageDestination, StorageFormat, StorageTelemetry, SyncStoragePipeline @@ -36,7 +48,7 @@ from sqlspec.utils.type_guards import resolve_row_format if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Callable, Mapping, Sequence from types import TracebackType from sqlglot.dialects.dialect import DialectType @@ -45,11 +57,8 @@ from sqlspec.core import ArrowResult, SQLResult, Statement, StatementConfig, StatementFilter from sqlspec.data_dictionary import ( ColumnMetadata, - DDLResult, ForeignKeyMetadata, IndexMetadata, - MetadataCapabilityProfile, - MetadataResult, SystemMetadataCapability, SystemMetadataRequest, SystemMetadataResult, @@ -62,6 +71,7 @@ _LOGGER_NAME: Final[str] = "sqlspec.driver" logger = get_logger(_LOGGER_NAME) +_SyncResultT = TypeVar("_SyncResultT") _SyncDriverT = TypeVar("_SyncDriverT", bound="SyncDriverAdapterBase") @@ -183,12 +193,6 @@ def has_schema(self, schema: str) -> bool: ) return True - @staticmethod - def _check_pending_exception(exc_handler: SyncExceptionHandler) -> None: - """Raise any pending mapped exception after context manager exit.""" - if exc_handler.pending_exception is not None: - raise exc_handler.pending_exception from None - @final def dispatch_statement_execution(self, statement: "SQL", connection: "Any") -> "SQLResult": """Central execution dispatcher using the Template Method Pattern. @@ -202,34 +206,18 @@ def dispatch_statement_execution(self, statement: "SQL", connection: "Any") -> " """ try: runtime = self._observability - # Pre-compile the statement so dispatch methods can reuse the processed state - # via the fast path in _compiled_statement(). This ensures compile() - # is called exactly once per statement execution. compiled_sql, execution_parameters = statement.compile() result: SQLResult | None = None - # FAST PATH: Skip all instrumentation if runtime is idle if runtime is None or runtime.is_idle: exc_handler = self.handle_database_exceptions() - with exc_handler, self.with_cursor(connection) as cursor: - # Logic mirrors the instrumentation path below but without telemetry - special_result = self.dispatch_special_handling(cursor, statement) - if special_result is not None: - result = special_result - elif statement.is_script: - execution_result = self.dispatch_execute_script(cursor, statement) - result = self.build_statement_result(statement, execution_result) - elif statement.is_many: - if execution_parameters: - execution_result = self.dispatch_execute_many(cursor, statement) - else: - execution_result = self.create_execution_result( - cursor, rowcount_override=0, is_many_result=True - ) - result = self.build_statement_result(statement, execution_result) - else: - execution_result = self.dispatch_execute(cursor, statement) - result = self.build_statement_result(statement, execution_result) + result = self._run_with_exception_handler( + exc_handler, + self._dispatch_statement_with_cursor, + connection, + statement, + has_execution_parameters=bool(execution_parameters), + ) self._check_pending_exception(exc_handler) assert result is not None return result @@ -253,24 +241,13 @@ def dispatch_statement_execution(self, statement: "SQL", connection: "Any") -> " started = perf_counter() exc_handler = self.handle_database_exceptions() try: - with exc_handler, self.with_cursor(connection) as cursor: - special_result = self.dispatch_special_handling(cursor, statement) - if special_result is not None: - result = special_result - elif statement.is_script: - execution_result = self.dispatch_execute_script(cursor, statement) - result = self.build_statement_result(statement, execution_result) - elif statement.is_many: - if execution_parameters: - execution_result = self.dispatch_execute_many(cursor, statement) - else: - execution_result = self.create_execution_result( - cursor, rowcount_override=0, is_many_result=True - ) - result = self.build_statement_result(statement, execution_result) - else: - execution_result = self.dispatch_execute(cursor, statement) - result = self.build_statement_result(statement, execution_result) + result = self._run_with_exception_handler( + exc_handler, + self._dispatch_statement_with_cursor, + connection, + statement, + has_execution_parameters=bool(execution_parameters), + ) except Exception as exc: # pragma: no cover pending_exception = exc_handler.pending_exception if pending_exception is not None: @@ -289,7 +266,7 @@ def dispatch_statement_execution(self, statement: "SQL", connection: "Any") -> " runtime.emit_error_sync(mapped_exc, **query_context) _raise_database_exception(exc_handler, None) - assert result is not None # Guaranteed: no exception means result was assigned + assert result is not None runtime.span_manager.end_span(span) duration = perf_counter() - started @@ -361,7 +338,10 @@ def dispatch_execute_script(self, cursor: Any, statement: "SQL") -> ExecutionRes for stmt in statements: single_stmt = self._sub_statement(stmt, prepared_parameters) - self.dispatch_execute(cursor, single_stmt) + try: + self.dispatch_execute(cursor, single_stmt) + finally: + self._release_pooled_statement(single_stmt) successful_count += 1 return self.create_execution_result( @@ -422,97 +402,6 @@ def resolve_rowcount(self, cursor: Any) -> int: msg = "Adapter must implement resolve_rowcount() for direct execution path" raise NotImplementedError(msg) - def _execute_cache_hit( - self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: CachedQuery - ) -> "SQLResult": - """Execute pre-compiled query via ultra-fast path (sync). - - Uses a DB-API direct path when available (`cursor.execute`) and falls - back to dispatch_execute() for adapters with non-standard cursor APIs. - For DML operations, returns DMLResult to avoid full SQLResult costs. - - Args: - sql: Raw SQL string (original, not compiled). - params: Query parameters. - cached: Cached query metadata. - - Returns: - SQLResult or DMLResult. - """ - direct_statement: SQL | None = None - exc_handler = self.handle_database_exceptions() - result: SQLResult | None = None - try: - with exc_handler, self.with_cursor(self.connection) as cursor: - execute = getattr(cursor, "execute", None) - fetchall = getattr(cursor, "fetchall", None) - returns_rows = cached.operation_profile.returns_rows - can_use_cursor_fast_path = execute is not None and ( - (returns_rows and fetchall is not None) or (not returns_rows and hasattr(cursor, "rowcount")) - ) - if can_use_cursor_fast_path: - assert execute is not None - execute(cached.compiled_sql, params) - if returns_rows: - assert fetchall is not None - fetched_data = fetchall() - data, column_names, row_count = self.collect_rows(cursor, fetched_data) - execution_result = self.create_execution_result( - cursor, - selected_data=data, - column_names=column_names, - data_row_count=row_count, - is_select_result=True, - row_format=resolve_row_format(data), - ) - direct_statement = self._cached_statement( - sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql - ) - result = self.build_statement_result(direct_statement, execution_result) - else: - affected_rows = self.resolve_rowcount(cursor) - result = DMLResult(cached.operation_type, affected_rows) - else: - direct_statement = self._cached_statement( - sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql - ) - execution_result = self.dispatch_execute(cursor, direct_statement) - if returns_rows: - result = self.build_statement_result(direct_statement, execution_result) - else: - affected_rows = ( - execution_result.rowcount_override - if execution_result.rowcount_override is not None - and execution_result.rowcount_override >= 0 - else 0 - ) - result = DMLResult(cached.operation_type, affected_rows) - - self._check_pending_exception(exc_handler) - assert result is not None - return result - finally: - if direct_statement is not None: - self._release_pooled_statement(direct_statement) - - def _execute_cached_statement(self, statement: "SQL") -> "SQLResult": - """Execute pre-compiled query via fast path. - - The statement is already compiled by _cached_execution, so dispatch_execute - will hit the fast path in _compiled_statement (is_processed check). - """ - exc_handler = self.handle_database_exceptions() - result: SQLResult | None = None - try: - with exc_handler, self.with_cursor(self.connection) as cursor: - execution_result = self.dispatch_execute(cursor, statement) - result = self.build_statement_result(statement, execution_result) - self._check_pending_exception(exc_handler) - assert result is not None - return result - finally: - self._release_pooled_statement(statement) - @abstractmethod def begin(self) -> None: """Begin a database transaction on the current connection.""" @@ -590,31 +479,24 @@ def execute( **kwargs: Any, ) -> "SQLResult": """Execute a statement with parameter handling.""" - exc_handler = self.handle_database_exceptions() - result: SQLResult | None = None - with exc_handler: - fast_params: tuple[Any, ...] | list[Any] | dict[str, Any] | None = None - if not kwargs and len(parameters) == 1 and isinstance(parameters[0], (tuple, list, dict)): - fast_params = cast("tuple[Any, ...] | list[Any] | dict[str, Any]", parameters[0]) - elif not parameters and kwargs: - fast_params = kwargs - if ( - self._stmt_cache_enabled - and (statement_config is None or statement_config is self.statement_config) - and isinstance(statement, str) - and fast_params is not None - ): - fast_result = self._cached_execution(statement, fast_params) - if fast_result is not None: - result = cast("SQLResult", fast_result) - if result is None: - sql_statement = self.prepare_statement( - statement, parameters, statement_config=statement_config or self.statement_config, kwargs=kwargs - ) - result = self.dispatch_statement_execution(statement=sql_statement, connection=self.connection) - self._check_pending_exception(exc_handler) - assert result is not None - return result + fast_params: tuple[Any, ...] | list[Any] | dict[str, Any] | None = None + if not kwargs and len(parameters) == 1 and isinstance(parameters[0], (tuple, list, dict)): + fast_params = cast("tuple[Any, ...] | list[Any] | dict[str, Any]", parameters[0]) + elif not parameters and kwargs: + fast_params = kwargs + if ( + self._stmt_cache_enabled + and (statement_config is None or statement_config is self.statement_config) + and isinstance(statement, str) + and fast_params is not None + ): + fast_result = self._cached_execution(statement, fast_params) + if fast_result is not None: + return cast("SQLResult", fast_result) + sql_statement = self.prepare_statement( + statement, parameters, statement_config=statement_config or self.statement_config, kwargs=kwargs + ) + return self.dispatch_statement_execution(statement=sql_statement, connection=self.connection) def execute_many( self, @@ -629,25 +511,19 @@ def execute_many( Parameters passed will be used as the batch execution sequence. """ - exc_handler = self.handle_database_exceptions() - result: SQLResult | None = None - with exc_handler: - config = statement_config or self.statement_config - - if isinstance(statement, str) and not filters and not kwargs: - sql_statement = SQL(statement, parameters, statement_config=config, is_many=True) - elif isinstance(statement, SQL): - statement_seed = statement.raw_expression or statement.raw_sql - sql_statement = SQL(statement_seed, parameters, statement_config=config, is_many=True, **kwargs) - else: - base_statement = self.prepare_statement(statement, filters, statement_config=config, kwargs=kwargs) - statement_seed = base_statement.raw_expression or base_statement.raw_sql - sql_statement = SQL(statement_seed, parameters, statement_config=config, is_many=True, **kwargs) + config = statement_config or self.statement_config - result = self.dispatch_statement_execution(statement=sql_statement, connection=self.connection) - self._check_pending_exception(exc_handler) - assert result is not None - return result + if isinstance(statement, str) and not filters and not kwargs: + sql_statement = SQL(statement, parameters, statement_config=config, is_many=True) + elif isinstance(statement, SQL): + statement_seed = statement.raw_expression or statement.raw_sql + sql_statement = SQL(statement_seed, parameters, statement_config=config, is_many=True, **kwargs) + else: + base_statement = self.prepare_statement(statement, filters, statement_config=config, kwargs=kwargs) + statement_seed = base_statement.raw_expression or base_statement.raw_sql + sql_statement = SQL(statement_seed, parameters, statement_config=config, is_many=True, **kwargs) + + return self.dispatch_statement_execution(statement=sql_statement, connection=self.connection) def execute_script( self, @@ -662,15 +538,9 @@ def execute_script( By default, validates each statement and logs warnings for dangerous operations. Use suppress_warnings=True for migrations and admin scripts. """ - exc_handler = self.handle_database_exceptions() - result: SQLResult | None = None - with exc_handler: - config = statement_config or self.statement_config - sql_statement = self.prepare_statement(statement, parameters, statement_config=config, kwargs=kwargs) - result = self.dispatch_statement_execution(statement=sql_statement.as_script(), connection=self.connection) - self._check_pending_exception(exc_handler) - assert result is not None - return result + config = statement_config or self.statement_config + sql_statement = self.prepare_statement(statement, parameters, statement_config=config, kwargs=kwargs) + return self.dispatch_statement_execution(statement=sql_statement.as_script(), connection=self.connection) @overload def select( @@ -682,7 +552,6 @@ def select( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "list[SchemaT]": ... - @overload def select( self, @@ -693,7 +562,6 @@ def select( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "list[dict[str, Any]]": ... - def select( self, statement: "Statement | QueryBuilder", @@ -717,7 +585,6 @@ def fetch( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "list[SchemaT]": ... - @overload def fetch( self, @@ -728,7 +595,6 @@ def fetch( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "list[dict[str, Any]]": ... - def fetch( self, statement: "Statement | QueryBuilder", @@ -758,7 +624,6 @@ def select_one( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "SchemaT": ... - @overload def select_one( self, @@ -769,7 +634,6 @@ def select_one( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "dict[str, Any]": ... - def select_one( self, statement: "Statement | QueryBuilder", @@ -799,7 +663,6 @@ def fetch_one( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "SchemaT": ... - @overload def fetch_one( self, @@ -810,7 +673,6 @@ def fetch_one( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "dict[str, Any]": ... - def fetch_one( self, statement: "Statement | QueryBuilder", @@ -844,7 +706,6 @@ def select_one_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "SchemaT | None": ... - @overload def select_one_or_none( self, @@ -855,7 +716,6 @@ def select_one_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "dict[str, Any] | None": ... - def select_one_or_none( self, statement: "Statement | QueryBuilder", @@ -885,7 +745,6 @@ def fetch_one_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "SchemaT | None": ... - @overload def fetch_one_or_none( self, @@ -896,7 +755,6 @@ def fetch_one_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "dict[str, Any] | None": ... - def fetch_one_or_none( self, statement: "Statement | QueryBuilder", @@ -931,7 +789,6 @@ def select_value( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "ValueT": ... - @overload def select_value( self, @@ -942,7 +799,6 @@ def select_value( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> Any: ... - def select_value( self, statement: "Statement | QueryBuilder", @@ -990,7 +846,6 @@ def fetch_value( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "ValueT": ... - @overload def fetch_value( self, @@ -1001,7 +856,6 @@ def fetch_value( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> Any: ... - def fetch_value( self, statement: "Statement | QueryBuilder", @@ -1036,7 +890,6 @@ def select_value_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "ValueT | None": ... - @overload def select_value_or_none( self, @@ -1047,7 +900,6 @@ def select_value_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> Any: ... - def select_value_or_none( self, statement: "Statement | QueryBuilder", @@ -1095,7 +947,6 @@ def fetch_value_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> "ValueT | None": ... - @overload def fetch_value_or_none( self, @@ -1106,7 +957,6 @@ def fetch_value_or_none( statement_config: "StatementConfig | None" = None, **kwargs: Any, ) -> Any: ... - def fetch_value_or_none( self, statement: "Statement | QueryBuilder", @@ -1143,7 +993,6 @@ def select_with_total( count_with_window: bool = False, **kwargs: Any, ) -> "tuple[list[SchemaT], int]": ... - @overload def select_with_total( self, @@ -1155,7 +1004,6 @@ def select_with_total( count_with_window: bool = False, **kwargs: Any, ) -> "tuple[list[dict[str, Any]], int]": ... - def select_with_total( self, statement: "Statement | QueryBuilder", @@ -1192,7 +1040,7 @@ def select_with_total( if count_with_window: modified_sql = self._with_total_count(sql_statement) - result = self.dispatch_statement_execution(modified_sql, self.connection) + result = self.execute(modified_sql) rows = result.all() data, total = self._extract_total_from_rows(rows) @@ -1200,8 +1048,8 @@ def select_with_total( return (cast("list[SchemaT]", self.to_schema(data, schema_type=schema_type)), total) return (data, total) - count_result = self.dispatch_statement_execution(self._count_query(sql_statement), self.connection) - select_result = self.dispatch_statement_execution(sql_statement, self.connection) + count_result = self.execute(self._count_query(sql_statement)) + select_result = self.execute(sql_statement) return (select_result.get_data(schema_type=schema_type), count_result.scalar()) @@ -1216,7 +1064,6 @@ def fetch_with_total( count_with_window: bool = False, **kwargs: Any, ) -> "tuple[list[SchemaT], int]": ... - @overload def fetch_with_total( self, @@ -1228,7 +1075,6 @@ def fetch_with_total( count_with_window: bool = False, **kwargs: Any, ) -> "tuple[list[dict[str, Any]], int]": ... - def fetch_with_total( self, statement: "Statement | QueryBuilder", @@ -1364,7 +1210,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "SyncRowStream[SchemaT]": ... - @overload def select_stream( self, @@ -1377,7 +1222,6 @@ def select_stream( native_only: bool = False, **kwargs: Any, ) -> "SyncRowStream[dict[str, Any]]": ... - def select_stream( self, statement: "SQL | Statement | QueryBuilder", @@ -1406,8 +1250,7 @@ def select_stream( "or switch to an adapter with native row streaming support." ) raise ImproperConfigurationError(msg) - result = self.execute(sql_statement) - return SyncRowStream(EagerSyncRowSource(result.get_data(), chunk_size), schema_type=schema_type) + return SyncRowStream(_LazyEagerSyncRowSource(self, sql_statement, chunk_size), schema_type=schema_type) @overload def fetch_stream( @@ -1421,7 +1264,6 @@ def fetch_stream( native_only: bool = False, **kwargs: Any, ) -> "SyncRowStream[SchemaT]": ... - @overload def fetch_stream( self, @@ -1434,7 +1276,6 @@ def fetch_stream( native_only: bool = False, **kwargs: Any, ) -> "SyncRowStream[dict[str, Any]]": ... - def fetch_stream( self, statement: "SQL | Statement | QueryBuilder", @@ -1483,9 +1324,13 @@ def execute_stack(self, stack: "StatementStack", *, continue_on_error: bool = Fa with StackExecutionObserver(self, stack, continue_on_error, native_pipeline=False) as observer: started_transaction = False + try: + caller_in_transaction = self._connection_in_transaction() + except NotImplementedError: + caller_in_transaction = False try: - if single_transaction and not self._connection_in_transaction(): + if single_transaction and not caller_in_transaction: self.begin() started_transaction = True @@ -1509,7 +1354,8 @@ def execute_stack(self, stack: "StatementStack", *, continue_on_error: bool = Fa started_transaction = False if continue_on_error: - self._rollback_failed_stack() + if not caller_in_transaction: + self._rollback_failed_stack() observer.record_operation_error(stack_error) results.append(StackResult.from_error(stack_error)) continue @@ -1518,7 +1364,7 @@ def execute_stack(self, stack: "StatementStack", *, continue_on_error: bool = Fa results.append(StackResult(result=result)) - if continue_on_error: + if continue_on_error and not caller_in_transaction: self._commit_stack_success() if started_transaction: @@ -1559,7 +1405,6 @@ def select_to_storage( StorageBridgeJob with execution telemetry. """ self._raise_storage_not_implemented("select_to_storage") - raise NotImplementedError def load_from_arrow( self, @@ -1581,7 +1426,6 @@ def load_from_arrow( StorageBridgeJob with execution telemetry. """ self._raise_storage_not_implemented("load_from_arrow") - raise NotImplementedError def load_from_storage( self, @@ -1605,7 +1449,6 @@ def load_from_storage( StorageBridgeJob with execution telemetry. """ self._raise_storage_not_implemented("load_from_storage") - raise NotImplementedError def load_from_records( self, @@ -1650,6 +1493,165 @@ def convert_to_dialect( """ return _convert_to_dialect_impl(statement, self.dialect, to_dialect, pretty) + @staticmethod + def _check_pending_exception(exc_handler: SyncExceptionHandler) -> None: + """Raise any pending mapped exception after context manager exit.""" + pending = exc_handler.pending_exception + if pending is not None: + if pending.__cause__ is not None: + raise pending + raise pending from None + + def _run_with_exception_handler( + self, exc_handler: SyncExceptionHandler, operation: "Callable[..., _SyncResultT]", *args: Any, **kwargs: Any + ) -> "_SyncResultT | None": + """Run a sync operation without inheriting the caller's active exception state.""" + return _run_with_sync_exception_handler(exc_handler, operation, *args, **kwargs) + + def _execute_cache_hit( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: CachedQuery + ) -> "SQLResult": + """Execute pre-compiled query via ultra-fast path (sync). + + Uses a DB-API direct path when available (`cursor.execute`) and falls + back to dispatch_execute() for adapters with non-standard cursor APIs. + For DML operations, returns DMLResult to avoid full SQLResult costs. + + Args: + sql: Raw SQL string (original, not compiled). + params: Query parameters. + cached: Cached query metadata. + + Returns: + SQLResult or DMLResult. + """ + exc_handler = self.handle_database_exceptions() + result = self._run_with_exception_handler(exc_handler, self._execute_cache_hit_with_cursor, sql, params, cached) + self._check_pending_exception(exc_handler) + assert result is not None + return result + + def _execute_cached_statement(self, statement: "SQL") -> "SQLResult": + """Execute pre-compiled query via fast path. + + The statement is already compiled by _cached_execution, so dispatch_execute + will hit the fast path in _compiled_statement (is_processed check). + """ + exc_handler = self.handle_database_exceptions() + try: + result = self._run_with_exception_handler( + exc_handler, self._execute_cached_statement_with_cursor, statement + ) + self._check_pending_exception(exc_handler) + assert result is not None + return result + finally: + self._release_pooled_statement(statement) + + def _dispatch_statement_with_cursor( + self, connection: Any, statement: "SQL", *, has_execution_parameters: bool + ) -> "SQLResult": + """Execute a statement while owning only the cursor context.""" + cursor_manager = self.with_cursor(connection) + cursor_entered = False + exit_suppressed = False + error: Exception | None = None + execution_result: ExecutionResult | None = None + result: SQLResult | None = None + try: + cursor = cursor_manager.__enter__() + cursor_entered = True + special_result = self.dispatch_special_handling(cursor, statement) + if special_result is not None: + result = special_result + elif statement.is_script: + execution_result = self.dispatch_execute_script(cursor, statement) + elif statement.is_many: + if has_execution_parameters: + execution_result = self.dispatch_execute_many(cursor, statement) + else: + execution_result = self.create_execution_result(cursor, rowcount_override=0, is_many_result=True) + else: + execution_result = self.dispatch_execute(cursor, statement) + if special_result is None: + assert execution_result is not None + result = self.build_statement_result(statement, execution_result) + except Exception as exc: + error = exc + finally: + if cursor_entered: + if error is None: + cursor_manager.__exit__(None, None, None) + else: + exit_suppressed = bool(cursor_manager.__exit__(type(error), error, error.__traceback__)) + if error is not None and not exit_suppressed: + raise error + assert result is not None + return result + + def _execute_cache_hit_with_cursor( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: CachedQuery + ) -> "SQLResult": + """Execute a cached query while owning only the cursor context.""" + direct_statement: SQL | None = None + result: SQLResult | None = None + try: + with self.with_cursor(self.connection) as cursor: + execute = getattr(cursor, "execute", None) + fetchall = getattr(cursor, "fetchall", None) + returns_rows = cached.operation_profile.returns_rows + can_use_cursor_fast_path = execute is not None and ( + (returns_rows and fetchall is not None) or (not returns_rows and hasattr(cursor, "rowcount")) + ) + if can_use_cursor_fast_path: + assert execute is not None + execute(cached.compiled_sql, params) + if returns_rows: + assert fetchall is not None + fetched_data = fetchall() + data, column_names, row_count = self.collect_rows(cursor, fetched_data) + execution_result = self.create_execution_result( + cursor, + selected_data=data, + column_names=column_names, + data_row_count=row_count, + is_select_result=True, + row_format=resolve_row_format(data), + ) + direct_statement = self._cached_statement( + sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql + ) + result = self.build_statement_result(direct_statement, execution_result) + else: + affected_rows = self.resolve_rowcount(cursor) + result = DMLResult(cached.operation_type, affected_rows) + else: + direct_statement = self._cached_statement( + sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql + ) + execution_result = self.dispatch_execute(cursor, direct_statement) + if returns_rows: + result = self.build_statement_result(direct_statement, execution_result) + else: + affected_rows = ( + execution_result.rowcount_override + if execution_result.rowcount_override is not None + and execution_result.rowcount_override >= 0 + else 0 + ) + result = DMLResult(cached.operation_type, affected_rows) + assert result is not None + return result + finally: + if direct_statement is not None: + self._release_pooled_statement(direct_statement) + + def _execute_cached_statement_with_cursor(self, statement: "SQL") -> "SQLResult": + """Execute a prepared statement while owning only the cursor context.""" + with self.with_cursor(self.connection) as cursor: + execution_result = self.dispatch_execute(cursor, statement) + return self.build_statement_result(statement, execution_result) + def _connection_in_transaction(self) -> bool: """Check if the connection is inside a transaction. @@ -1805,8 +1807,6 @@ def get_metadata_capabilities( Returns: Capability profile. Base implementations report requested domains as unsupported. """ - from sqlspec.data_dictionary import MetadataCapabilityProfile - requested_domains: tuple[str, ...] = ( "schemas", "objects", @@ -1836,8 +1836,6 @@ def get_system_metadata_capabilities( Returns: System metadata capabilities. Base implementations report requested domains as unsupported. """ - from sqlspec.data_dictionary import DEFAULT_SYSTEM_METADATA_DOMAINS, system_metadata_capabilities_from_domains - requested_domains = DEFAULT_SYSTEM_METADATA_DOMAINS if domains is None else tuple(domains) return system_metadata_capabilities_from_domains(requested_domains) @@ -1931,12 +1929,6 @@ def get_system_metadata( self, driver: Any, request: "SystemMetadataRequest | str | None" = None, **kwargs: Any ) -> "SystemMetadataResult": """Get opt-in system metadata or a gated/unsupported result.""" - from sqlspec.data_dictionary import ( - ensure_system_metadata_request, - system_metadata_gated_result, - unsupported_system_metadata_capability, - ) - metadata_request = ensure_system_metadata_request(request, **kwargs) capability = unsupported_system_metadata_capability(metadata_request.domain) return system_metadata_gated_result(metadata_request, capability) @@ -2034,19 +2026,16 @@ def get_foreign_keys( """ def _unsupported_metadata(self, domain: str) -> "MetadataResult": - from sqlspec.data_dictionary import MetadataResult - return MetadataResult.unsupported(domain) def _unsupported_ddl(self, object_name: str, *, schema: "str | None", object_type: str) -> "DDLResult": - from sqlspec.data_dictionary import DDLResult, ObjectIdentity - return DDLResult.unsupported( ObjectIdentity(object_name, object_type, schema=schema, dialect=self.dialect), warnings=(f"{self.dialect} DDL extraction is not implemented",), ) +@mypyc_attr(allow_interpreted_subclasses=False) class _SyncDriverTransaction(Generic[_SyncDriverT]): """Context manager that commits on success and rolls back on error. diff --git a/sqlspec/exceptions.py b/sqlspec/exceptions.py index faacb7b61..389017b77 100644 --- a/sqlspec/exceptions.py +++ b/sqlspec/exceptions.py @@ -314,15 +314,20 @@ def __init__(self, path: str, root: str | None = None) -> None: class SQLFileNotFoundError(SQLSpecError): """Raised when a SQL file cannot be found.""" - def __init__(self, name: str, path: "str | None" = None) -> None: + def __init__(self, name: str, path: "str | None" = None, *, message: "str | None" = None) -> None: """Initialize the error. Args: name: Name of the SQL file. path: Optional path where the file was expected. + message: Optional pre-formatted error message override. """ - message = f"SQL file '{name}' not found at path: {path}" if path else f"SQL file '{name}' not found" - super().__init__(message) + resolved_message = ( + message + if message is not None + else (f"SQL file '{name}' not found at path: {path}" if path else f"SQL file '{name}' not found") + ) + super().__init__(resolved_message) self.name = name self.path = path @@ -330,24 +335,25 @@ def __init__(self, name: str, path: "str | None" = None) -> None: class SQLStatementNotFoundError(SQLFileNotFoundError): """Raised when a named SQL statement is not loaded.""" - def __init__(self, name: str, normalized_name: str, query_count: int) -> None: + def __init__(self, name: str, normalized_name: str, query_count: int, *, message: "str | None" = None) -> None: """Initialize the error. Args: name: Name requested by the caller. normalized_name: Normalized statement name used for lookup. query_count: Number of SQL statements loaded in the registry. + message: Optional pre-formatted error message override. """ - if query_count == 0: - message = f"SQL statement '{name}' not found. No SQL statements are loaded." + if message is not None: + resolved_message = message + elif query_count == 0: + resolved_message = f"SQL statement '{name}' not found. No SQL statements are loaded." else: - message = ( + resolved_message = ( f"SQL statement '{name}' not found. {query_count} SQL statements are loaded. " "Use list_queries() to inspect available statement names." ) - SQLSpecError.__init__(self, message) - self.name = name - self.path = None + super().__init__(name, None, message=resolved_message) self.normalized_name = normalized_name self.query_count = query_count @@ -370,11 +376,7 @@ def __init__(self, name: str, normalized_name: str, fragment_count: int) -> None f"SQL fragment '{name}' not found. {fragment_count} SQL fragments are loaded. " "Use list_fragments() to inspect available fragment names." ) - SQLSpecError.__init__(self, message) - self.name = name - self.path = None - self.normalized_name = normalized_name - self.query_count = fragment_count + super().__init__(name, normalized_name, fragment_count, message=message) class SQLFileParseError(SQLSpecError): @@ -436,12 +438,9 @@ class SquashValidationError(MigrationError): """ -# SQLSTATE class code length (first 2 characters of 5-character SQLSTATE) SQLSTATE_CLASS_CODE_LEN: Final[int] = 2 -# SQLSTATE to exception mapping for database-agnostic error translation SQLSTATE_EXCEPTION_MAP: Final[dict[str, type[SQLSpecError]]] = { - # Exact SQLSTATE matches (5 characters) - most specific "23505": UniqueViolationError, "23503": ForeignKeyViolationError, "23502": NotNullViolationError, @@ -449,7 +448,6 @@ class SquashValidationError(MigrationError): "40001": SerializationConflictError, "40P01": DeadlockError, "57014": OperationalError, - # Class-level matches (2 characters) - broader categories "02": NotFoundError, "08": DatabaseConnectionError, "22": DataError, @@ -481,14 +479,11 @@ def map_sqlstate_to_exception(sqlstate: str | None) -> type[SQLSpecError] | None if not sqlstate: return None - # Cache global in local for faster access in mypyc exc_map = SQLSTATE_EXCEPTION_MAP - # Single lookup instead of in + [] if exc_class := exc_map.get(sqlstate): return exc_class - # Class prefix lookup if len(sqlstate) >= SQLSTATE_CLASS_CODE_LEN and (exc_class := exc_map.get(sqlstate[:SQLSTATE_CLASS_CODE_LEN])): return exc_class diff --git a/sqlspec/extensions/__init__.py b/sqlspec/extensions/__init__.py index e69de29bb..07f210c56 100644 --- a/sqlspec/extensions/__init__.py +++ b/sqlspec/extensions/__init__.py @@ -0,0 +1,3 @@ +"""SQLSpec framework and observability extensions.""" + +__all__ = () diff --git a/sqlspec/extensions/adk/__init__.py b/sqlspec/extensions/adk/__init__.py index f3c22aa57..bbbc0ab08 100644 --- a/sqlspec/extensions/adk/__init__.py +++ b/sqlspec/extensions/adk/__init__.py @@ -23,36 +23,40 @@ - StoredArtifact: TypedDict for artifact metadata database records """ -from sqlspec.config import ADKConfig -from sqlspec.extensions.adk._types import SessionOrderBy, StoredEvent, StoredSession -from sqlspec.extensions.adk.artifact import ( - BaseAsyncADKArtifactStore, - BaseSyncADKArtifactStore, - SQLSpecArtifactService, - StoredArtifact, -) -from sqlspec.extensions.adk.maintenance import ( - PruneReport, - prune_artifacts, - prune_artifacts_sync, - prune_events, - prune_events_sync, - prune_memory, - prune_memory_sync, - prune_sessions, - prune_sessions_sync, - prune_user_state, - prune_user_state_sync, -) -from sqlspec.extensions.adk.memory import ( - BaseAsyncADKMemoryStore, - BaseSyncADKMemoryStore, - SQLSpecMemoryService, - SQLSpecSyncMemoryService, - StoredMemory, -) -from sqlspec.extensions.adk.service import SQLSpecSessionService -from sqlspec.extensions.adk.store import BaseAsyncADKStore, BaseSyncADKStore, normalize_session_list_options +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from sqlspec.config import ADKConfig + from sqlspec.extensions.adk._types import SessionOrderBy, StoredEvent, StoredSession + from sqlspec.extensions.adk.artifact import ( + BaseAsyncADKArtifactStore, + BaseSyncADKArtifactStore, + SQLSpecArtifactService, + StoredArtifact, + ) + from sqlspec.extensions.adk.maintenance import ( + PruneReport, + prune_artifacts, + prune_artifacts_sync, + prune_events, + prune_events_sync, + prune_memory, + prune_memory_sync, + prune_sessions, + prune_sessions_sync, + prune_user_state, + prune_user_state_sync, + ) + from sqlspec.extensions.adk.memory import ( + BaseAsyncADKMemoryStore, + BaseSyncADKMemoryStore, + SQLSpecMemoryService, + SQLSpecSyncMemoryService, + StoredMemory, + ) + from sqlspec.extensions.adk.service import SQLSpecSessionService + from sqlspec.extensions.adk.store import BaseAsyncADKStore, BaseSyncADKStore, normalize_session_list_options __all__ = ( "ADKConfig", @@ -84,3 +88,49 @@ "prune_user_state", "prune_user_state_sync", ) + +_EXPORTS: dict[str, tuple[str, str]] = { + "ADKConfig": ("sqlspec.config", "ADKConfig"), + "BaseAsyncADKArtifactStore": ("sqlspec.extensions.adk.artifact", "BaseAsyncADKArtifactStore"), + "BaseAsyncADKMemoryStore": ("sqlspec.extensions.adk.memory", "BaseAsyncADKMemoryStore"), + "BaseAsyncADKStore": ("sqlspec.extensions.adk.store", "BaseAsyncADKStore"), + "BaseSyncADKArtifactStore": ("sqlspec.extensions.adk.artifact", "BaseSyncADKArtifactStore"), + "BaseSyncADKMemoryStore": ("sqlspec.extensions.adk.memory", "BaseSyncADKMemoryStore"), + "BaseSyncADKStore": ("sqlspec.extensions.adk.store", "BaseSyncADKStore"), + "PruneReport": ("sqlspec.extensions.adk.maintenance", "PruneReport"), + "SQLSpecArtifactService": ("sqlspec.extensions.adk.artifact", "SQLSpecArtifactService"), + "SQLSpecMemoryService": ("sqlspec.extensions.adk.memory", "SQLSpecMemoryService"), + "SQLSpecSessionService": ("sqlspec.extensions.adk.service", "SQLSpecSessionService"), + "SQLSpecSyncMemoryService": ("sqlspec.extensions.adk.memory", "SQLSpecSyncMemoryService"), + "SessionOrderBy": ("sqlspec.extensions.adk._types", "SessionOrderBy"), + "StoredArtifact": ("sqlspec.extensions.adk.artifact", "StoredArtifact"), + "StoredEvent": ("sqlspec.extensions.adk._types", "StoredEvent"), + "StoredMemory": ("sqlspec.extensions.adk.memory", "StoredMemory"), + "StoredSession": ("sqlspec.extensions.adk._types", "StoredSession"), + "normalize_session_list_options": ("sqlspec.extensions.adk.store", "normalize_session_list_options"), + "prune_artifacts": ("sqlspec.extensions.adk.maintenance", "prune_artifacts"), + "prune_artifacts_sync": ("sqlspec.extensions.adk.maintenance", "prune_artifacts_sync"), + "prune_events": ("sqlspec.extensions.adk.maintenance", "prune_events"), + "prune_events_sync": ("sqlspec.extensions.adk.maintenance", "prune_events_sync"), + "prune_memory": ("sqlspec.extensions.adk.maintenance", "prune_memory"), + "prune_memory_sync": ("sqlspec.extensions.adk.maintenance", "prune_memory_sync"), + "prune_sessions": ("sqlspec.extensions.adk.maintenance", "prune_sessions"), + "prune_sessions_sync": ("sqlspec.extensions.adk.maintenance", "prune_sessions_sync"), + "prune_user_state": ("sqlspec.extensions.adk.maintenance", "prune_user_state"), + "prune_user_state_sync": ("sqlspec.extensions.adk.maintenance", "prune_user_state_sync"), +} + + +def __getattr__(name: str) -> Any: + if name not in _EXPORTS: + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + module_path, attr_name = _EXPORTS[name] + module = import_module(module_path) + value = getattr(module, attr_name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_EXPORTS)) diff --git a/sqlspec/extensions/adk/artifact/service.py b/sqlspec/extensions/adk/artifact/service.py index 4b3da0efd..6b0182b63 100644 --- a/sqlspec/extensions/adk/artifact/service.py +++ b/sqlspec/extensions/adk/artifact/service.py @@ -10,23 +10,23 @@ starting from 0. """ -import json +import inspect import logging import re from datetime import UTC, datetime from typing import TYPE_CHECKING, Any -from google.adk.artifacts.base_artifact_service import BaseArtifactService +from google.adk.artifacts.base_artifact_service import ArtifactVersion, BaseArtifactService, ensure_part +from google.genai import types from sqlspec.extensions.adk.artifact._types import StoredArtifact from sqlspec.storage.registry import StorageRegistry, storage_registry from sqlspec.utils.logging import get_logger, log_with_context +from sqlspec.utils.serializers import from_json, to_json +from sqlspec.utils.sync_tools import async_ if TYPE_CHECKING: - from google.adk.artifacts.base_artifact_service import ArtifactVersion - from google.genai import types - - from sqlspec.extensions.adk.artifact.store import BaseAsyncADKArtifactStore + from sqlspec.extensions.adk.artifact.store import BaseAsyncADKArtifactStore, BaseSyncADKArtifactStore __all__ = ("SQLSpecArtifactService",) @@ -45,21 +45,24 @@ class SQLSpecArtifactService(BaseArtifactService): via the storage registry. Args: - store: Artifact metadata store implementation. + store: Artifact metadata store implementation (async or sync). artifact_storage_uri: Base URI for content storage. Can also be a registered alias in the storage registry. registry: Storage registry to use. Defaults to the global singleton. """ def __init__( - self, store: "BaseAsyncADKArtifactStore", artifact_storage_uri: str, registry: "StorageRegistry | None" = None + self, + store: "BaseAsyncADKArtifactStore | BaseSyncADKArtifactStore", + artifact_storage_uri: str, + registry: "StorageRegistry | None" = None, ) -> None: self._store = store self._artifact_storage_uri = artifact_storage_uri.rstrip("/") self._registry = registry or storage_registry @property - def store(self) -> "BaseAsyncADKArtifactStore": + def store(self) -> "BaseAsyncADKArtifactStore | BaseSyncADKArtifactStore": """Return the artifact metadata store.""" return self._store @@ -96,11 +99,9 @@ async def save_artifact( Returns: The version number (0-based, incrementing). """ - from google.adk.artifacts.base_artifact_service import ensure_part - artifact_part: types.Part = ensure_part(artifact) - version = await self._store.get_next_version( - app_name=app_name, user_id=user_id, filename=filename, session_id=session_id + version = await self._call_store( + "get_next_version", app_name=app_name, user_id=user_id, filename=filename, session_id=session_id ) content_path = _build_content_path( app_name=app_name, user_id=user_id, filename=filename, version=version, session_id=session_id @@ -121,7 +122,7 @@ async def save_artifact( custom_metadata=custom_metadata, created_at=datetime.now(tz=UTC), ) - await self._store.insert_artifact(record) + await self._call_store("insert_artifact", record) log_with_context( logger, @@ -134,7 +135,7 @@ async def save_artifact( session_id=session_id, mime_type=mime_type, ) - return version + return int(version) async def load_artifact( self, @@ -157,8 +158,13 @@ async def load_artifact( Returns: Deserialized Part, or None if not found. """ - record = await self._store.get_artifact( - app_name=app_name, user_id=user_id, filename=filename, session_id=session_id, version=version + record = await self._call_store( + "get_artifact", + app_name=app_name, + user_id=user_id, + filename=filename, + session_id=session_id, + version=version, ) if record is None: log_with_context( @@ -202,7 +208,9 @@ async def list_artifact_keys(self, *, app_name: str, user_id: str, session_id: " Returns: List of artifact filenames. """ - keys = await self._store.list_artifact_keys(app_name=app_name, user_id=user_id, session_id=session_id) + keys: list[str] = list( + await self._call_store("list_artifact_keys", app_name=app_name, user_id=user_id, session_id=session_id) + ) log_with_context( logger, logging.DEBUG, @@ -228,8 +236,8 @@ async def delete_artifact( filename: Artifact filename. session_id: Session identifier (None for user-scoped). """ - deleted_records = await self._store.delete_artifact( - app_name=app_name, user_id=user_id, filename=filename, session_id=session_id + deleted_records = await self._call_store( + "delete_artifact", app_name=app_name, user_id=user_id, filename=filename, session_id=session_id ) await self._delete_content(deleted_records) @@ -261,7 +269,7 @@ async def delete_artifacts_older_than(self, before: "datetime", app_name: "str | Returns: Number of artifact version rows deleted from metadata storage. """ - deleted_records = await self._store.delete_artifacts_older_than(before, app_name=app_name) + deleted_records = await self._call_store("delete_artifacts_older_than", before, app_name=app_name) await self._delete_content(deleted_records) log_with_context( @@ -288,8 +296,8 @@ async def list_versions( Returns: Sorted list of version numbers. """ - records = await self._store.list_artifact_versions( - app_name=app_name, user_id=user_id, filename=filename, session_id=session_id + records = await self._call_store( + "list_artifact_versions", app_name=app_name, user_id=user_id, filename=filename, session_id=session_id ) return [r["version"] for r in records] @@ -307,8 +315,8 @@ async def list_artifact_versions( Returns: List of ArtifactVersion objects ordered by version ascending. """ - records = await self._store.list_artifact_versions( - app_name=app_name, user_id=user_id, filename=filename, session_id=session_id + records = await self._call_store( + "list_artifact_versions", app_name=app_name, user_id=user_id, filename=filename, session_id=session_id ) return [_record_to_artifact_version(r) for r in records] @@ -333,13 +341,25 @@ async def get_artifact_version( Returns: ArtifactVersion if found, None otherwise. """ - record = await self._store.get_artifact( - app_name=app_name, user_id=user_id, filename=filename, session_id=session_id, version=version + record = await self._call_store( + "get_artifact", + app_name=app_name, + user_id=user_id, + filename=filename, + session_id=session_id, + version=version, ) if record is None: return None return _record_to_artifact_version(record) + async def _call_store(self, method_name: str, *args: Any, **kwargs: Any) -> Any: + """Invoke an async or sync artifact store method, bridging sync stores via async_().""" + method = getattr(self._store, method_name) + if inspect.iscoroutinefunction(method): + return await method(*args, **kwargs) + return await async_(method)(*args, **kwargs) + async def _delete_content(self, records: "list[StoredArtifact]") -> None: """Remove stored content for deleted metadata records on a best-effort basis. @@ -368,14 +388,14 @@ async def _delete_content(self, records: "list[StoredArtifact]") -> None: ) -_UNSAFE_PATH_CHARS = re.compile(r"(?:^|/)\.\.(?:/|$)|[\x00]") +_UNSAFE_PATH_CHARS = re.compile(r"(?:^|/)\.\.(?:/|$)|[/\\\x00]") def _sanitize_path_component(value: str) -> str: - """Sanitize a path component to prevent directory traversal. + """Sanitize a path component to prevent directory traversal and namespace injection. - Removes leading/trailing slashes, rejects ``..`` traversals, and - replaces NUL bytes. + Removes leading/trailing slashes and rejects empty values, ``..`` traversals, + internal slashes/backslashes, and NUL bytes. Args: value: Raw path component. @@ -384,10 +404,10 @@ def _sanitize_path_component(value: str) -> str: Sanitized path component. Raises: - ValueError: If the value contains path traversal sequences. + ValueError: If the value is empty or contains unsafe path sequences. """ value = value.strip("/") - if _UNSAFE_PATH_CHARS.search(value): + if not value or _UNSAFE_PATH_CHARS.search(value): msg = f"Unsafe path component: {value!r}" raise ValueError(msg) return value @@ -450,9 +470,9 @@ def _extract_mime_type(artifact: "types.Part | dict[str, Any]") -> "str | None": def _serialize_artifact(artifact: "types.Part | dict[str, Any]") -> bytes: """Serialize an artifact Part to bytes for content storage. - The artifact is serialized as JSON via ``model_dump(exclude_none=True)``. - This preserves the full Part structure including text, inline_data, - file_data, and any future Part fields. + The artifact is serialized as JSON via ``model_dump(exclude_none=True, mode='json')``. + This preserves the full Part structure including binary ``inline_data.data``, + text, file_data, and any future Part fields. Args: artifact: ADK Part or dict representation. @@ -461,13 +481,13 @@ def _serialize_artifact(artifact: "types.Part | dict[str, Any]") -> bytes: JSON-encoded bytes. """ if isinstance(artifact, dict): - return json.dumps(artifact, default=str).encode("utf-8") + return to_json(artifact, as_bytes=True) if hasattr(artifact, "model_dump"): - data = artifact.model_dump(exclude_none=True) - return json.dumps(data, default=str).encode("utf-8") + data = artifact.model_dump(exclude_none=True, mode="json") + return to_json(data, as_bytes=True) - return json.dumps({"text": str(artifact)}).encode("utf-8") + return to_json({"text": str(artifact)}, as_bytes=True) def _deserialize_artifact(data: bytes) -> "types.Part": @@ -479,9 +499,7 @@ def _deserialize_artifact(data: bytes) -> "types.Part": Returns: Reconstructed Part object. """ - from google.genai import types - - parsed = json.loads(data.decode("utf-8")) + parsed = from_json(data) return types.Part.model_validate(parsed) @@ -494,8 +512,6 @@ def _record_to_artifact_version(record: "StoredArtifact") -> "ArtifactVersion": Returns: ArtifactVersion model instance. """ - from google.adk.artifacts.base_artifact_service import ArtifactVersion - return ArtifactVersion( version=record["version"], canonical_uri=record["canonical_uri"], @@ -512,4 +528,4 @@ async def _call_storage_backend( async_method = getattr(backend, async_method_name, None) if async_method is not None: return await async_method(*args, **kwargs) - return getattr(backend, sync_method_name)(*args, **kwargs) + return await async_(getattr(backend, sync_method_name))(*args, **kwargs) diff --git a/sqlspec/extensions/adk/converters.py b/sqlspec/extensions/adk/converters.py index 4d2e0024e..2fc626b98 100644 --- a/sqlspec/extensions/adk/converters.py +++ b/sqlspec/extensions/adk/converters.py @@ -46,7 +46,6 @@ def session_to_record(session: "Session") -> StoredSession: app_name=session.app_name, user_id=session.user_id, state=filter_temp_state(session.state), - # create_time is not exposed by ADK Session; a re-upsert of a restored session will reset this timestamp. create_time=datetime.now(UTC), update_time=datetime.fromtimestamp(session.last_update_time, tz=UTC), ) diff --git a/sqlspec/extensions/adk/memory/converters.py b/sqlspec/extensions/adk/memory/converters.py index 4cbe6a46e..3b01fc1da 100644 --- a/sqlspec/extensions/adk/memory/converters.py +++ b/sqlspec/extensions/adk/memory/converters.py @@ -160,6 +160,8 @@ def memory_entry_to_record( if entry.timestamp: try: timestamp = datetime.fromisoformat(entry.timestamp) + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=UTC) except (ValueError, TypeError): timestamp = now diff --git a/sqlspec/extensions/adk/memory/service.py b/sqlspec/extensions/adk/memory/service.py index 058a96bb0..e4e8a4297 100644 --- a/sqlspec/extensions/adk/memory/service.py +++ b/sqlspec/extensions/adk/memory/service.py @@ -1,6 +1,7 @@ """SQLSpec-backed memory service for Google ADK.""" import inspect +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Literal, cast from google.adk.memory.base_memory_service import BaseMemoryService, SearchMemoryResponse @@ -16,7 +17,7 @@ from sqlspec.utils.sync_tools import async_ if TYPE_CHECKING: - from collections.abc import Callable, Mapping, Sequence + from collections.abc import Callable, Sequence from google.adk.events.event import Event from google.adk.memory.memory_entry import MemoryEntry @@ -56,7 +57,9 @@ def store(self) -> "BaseAsyncADKMemoryStore | BaseSyncADKMemoryStore": """Return the database store.""" return self._store - async def add_session_to_memory(self, session: "Session", scope: str = "user") -> None: + async def add_session_to_memory( + self, session: "Session", scope: str = "user", embeddings: "Mapping[str, Sequence[float]] | None" = None + ) -> None: """Add a completed session to the memory store. Extracts all events with content from the session and stores them @@ -69,8 +72,9 @@ async def add_session_to_memory(self, session: "Session", scope: str = "user") - Args: session: Completed ADK Session with events. scope: Visibility scope ('user' or 'app'). + embeddings: Optional mapping of ``event_id`` to embedding vector. """ - records = session_to_memory_records(session, scope=scope) + records = session_to_memory_records(session, scope=scope, embeddings=embeddings) if not records: logger.debug( @@ -92,6 +96,7 @@ async def add_events_to_memory( session_id: "str | None" = None, custom_metadata: "Mapping[str, object] | None" = None, scope: str = "user", + embeddings: "Mapping[str, Sequence[float]] | None" = None, ) -> None: """Add an explicit list of events to the memory service. @@ -108,13 +113,20 @@ async def add_events_to_memory( custom_metadata: Optional portable metadata stored in ``StoredMemory.metadata_json``. scope: Visibility scope ('user' or 'app'). + embeddings: Optional mapping of ``event_id`` to embedding vector. """ metadata_dict = dict(custom_metadata) if custom_metadata else None resolved_session_id = session_id or _UNKNOWN_SESSION_ID records = [] for event in events: + event_embedding = embeddings.get(event.id) if embeddings and event.id else None record = event_to_memory_record( - event=event, session_id=resolved_session_id, app_name=app_name, user_id=user_id, scope=scope + event=event, + session_id=resolved_session_id, + app_name=app_name, + user_id=user_id, + scope=scope, + embedding=event_embedding, ) if record is not None: if metadata_dict: @@ -140,6 +152,7 @@ async def add_memory( memories: "Sequence[MemoryEntry]", custom_metadata: "Mapping[str, object] | None" = None, scope: str = "user", + embeddings: "Mapping[str, Sequence[float]] | Sequence[Sequence[float] | None] | None" = None, ) -> None: """Add explicit memory items directly to the memory service. @@ -155,12 +168,25 @@ async def add_memory( custom_metadata: Optional portable metadata for memory writes. Merged with each entry's ``custom_metadata``. scope: Visibility scope ('user' or 'app'). + embeddings: Optional mapping of ``entry.id`` to embedding vector or + positional sequence of embedding vectors matching ``memories``. """ call_metadata = dict(custom_metadata) if custom_metadata else {} records = [] - for entry in memories: + for idx, entry in enumerate(memories): + entry_embedding: Sequence[float] | None = None + if isinstance(embeddings, Mapping): + if entry.id: + entry_embedding = embeddings.get(entry.id) + elif embeddings is not None and idx < len(embeddings): + entry_embedding = embeddings[idx] record = memory_entry_to_record( - entry=entry, app_name=app_name, user_id=user_id, extra_metadata=call_metadata, scope=scope + entry=entry, + app_name=app_name, + user_id=user_id, + extra_metadata=call_metadata, + scope=scope, + embedding=entry_embedding, ) if record is not None: records.append(record) @@ -255,7 +281,9 @@ def store(self) -> "BaseSyncADKMemoryStore": """Return the database store.""" return self._store - def add_session_to_memory(self, session: "Session", scope: str = "user") -> None: + def add_session_to_memory( + self, session: "Session", scope: str = "user", embeddings: "Mapping[str, Sequence[float]] | None" = None + ) -> None: """Add a completed session to the memory store. Extracts all events with content from the session and stores them @@ -264,8 +292,9 @@ def add_session_to_memory(self, session: "Session", scope: str = "user") -> None Args: session: Completed ADK Session with events. scope: Visibility scope ('user' or 'app'). + embeddings: Optional mapping of ``event_id`` to embedding vector. """ - records = session_to_memory_records(session, scope=scope) + records = session_to_memory_records(session, scope=scope, embeddings=embeddings) if not records: logger.debug( @@ -287,6 +316,7 @@ def add_events_to_memory( session_id: "str | None" = None, custom_metadata: "Mapping[str, object] | None" = None, scope: str = "user", + embeddings: "Mapping[str, Sequence[float]] | None" = None, ) -> None: """Add an explicit list of events to the memory service. @@ -303,13 +333,20 @@ def add_events_to_memory( custom_metadata: Optional portable metadata stored in ``StoredMemory.metadata_json``. scope: Visibility scope ('user' or 'app'). + embeddings: Optional mapping of ``event_id`` to embedding vector. """ metadata_dict = dict(custom_metadata) if custom_metadata else None resolved_session_id = session_id or _UNKNOWN_SESSION_ID records = [] for event in events: + event_embedding = embeddings.get(event.id) if embeddings and event.id else None record = event_to_memory_record( - event=event, session_id=resolved_session_id, app_name=app_name, user_id=user_id, scope=scope + event=event, + session_id=resolved_session_id, + app_name=app_name, + user_id=user_id, + scope=scope, + embedding=event_embedding, ) if record is not None: if metadata_dict: @@ -335,6 +372,7 @@ def add_memory( memories: "Sequence[MemoryEntry]", custom_metadata: "Mapping[str, object] | None" = None, scope: str = "user", + embeddings: "Mapping[str, Sequence[float]] | Sequence[Sequence[float] | None] | None" = None, ) -> None: """Add explicit memory items directly to the memory service. @@ -350,12 +388,25 @@ def add_memory( custom_metadata: Optional portable metadata for memory writes. Merged with each entry's ``custom_metadata``. scope: Visibility scope ('user' or 'app'). + embeddings: Optional mapping of ``entry.id`` to embedding vector or + positional sequence of embedding vectors matching ``memories``. """ call_metadata = dict(custom_metadata) if custom_metadata else {} records = [] - for entry in memories: + for idx, entry in enumerate(memories): + entry_embedding: Sequence[float] | None = None + if isinstance(embeddings, Mapping): + if entry.id: + entry_embedding = embeddings.get(entry.id) + elif embeddings is not None and idx < len(embeddings): + entry_embedding = embeddings[idx] record = memory_entry_to_record( - entry=entry, app_name=app_name, user_id=user_id, extra_metadata=call_metadata, scope=scope + entry=entry, + app_name=app_name, + user_id=user_id, + extra_metadata=call_metadata, + scope=scope, + embedding=entry_embedding, ) if record is not None: records.append(record) diff --git a/sqlspec/extensions/adk/memory/store.py b/sqlspec/extensions/adk/memory/store.py index 620f74e8e..ce0731618 100644 --- a/sqlspec/extensions/adk/memory/store.py +++ b/sqlspec/extensions/adk/memory/store.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Final, Generic, Literal, TypeVar, cast from sqlspec.extensions.adk._config_utils import _adk_memory_store_config, _ADKMemoryStoreConfig -from sqlspec.extensions.adk._table_utils import owner_id_column_name, unique_statements +from sqlspec.extensions.adk._table_utils import ensure_table_name, owner_id_column_name, unique_statements from sqlspec.extensions.adk.store import _reconcile_adk_schema_sync from sqlspec.migrations.schema import SchemaTarget, ensure_schema_async from sqlspec.observability import resolve_db_system @@ -59,6 +59,7 @@ def __init__(self, config: ConfigT) -> None: store_config = self._store_config_from_extension() self._enabled: bool = store_config.get("enable_memory", True) self._memory_table: str = str(store_config["memory_table"]) + ensure_table_name(self._memory_table) self._use_fts: bool = bool(store_config.get("use_fts", False)) self._max_results: int = store_config.get("max_results", 20) self._vector_index_type: str = store_config.get("vector_index_type", "hnsw") @@ -71,9 +72,6 @@ def __init__(self, config: ConfigT) -> None: owner_id_column_name(self._owner_id_column_ddl) if self._owner_id_column_ddl else None ) - def _store_config_from_extension(self) -> _ADKMemoryStoreConfig: - return _adk_memory_store_config(self._config) - @property def config(self) -> ConfigT: """Return the database configuration.""" @@ -134,6 +132,15 @@ def owner_id_column_name(self) -> str | None: """Return the extracted owner column name, if configured.""" return self._owner_id_column_name + @property + def create_schema_enabled(self) -> bool: + """Return whether adapter-level table creation should run.""" + manage_schema, create_schema = self._schema_management_flags() + return manage_schema and create_schema + + def _store_config_from_extension(self) -> _ADKMemoryStoreConfig: + return _adk_memory_store_config(self._config) + def _schema_management_flags(self) -> tuple[bool, bool]: extension_config = getattr(self._config, "extension_config", {}) adk_config = extension_config.get("adk", {}) if isinstance(extension_config, dict) else {} @@ -141,12 +148,6 @@ def _schema_management_flags(self) -> tuple[bool, bool]: create_schema = adk_config.get("create_schema", True) if isinstance(adk_config, dict) else True return bool(manage_schema), bool(create_schema) - @property - def create_schema_enabled(self) -> bool: - """Return whether adapter-level table creation should run.""" - manage_schema, create_schema = self._schema_management_flags() - return manage_schema and create_schema - def _drop_sql_for_table(self, table_name: str) -> list[str]: current_table = self._memory_table self._memory_table = table_name @@ -189,9 +190,26 @@ async def drop_tables(self) -> None: """ statements = self._drop_memory_table_sql() session_context = self._config.provide_session() - async with cast("Any", session_context) as driver: - for statement in statements: - await driver.execute(statement) + if hasattr(session_context, "__aenter__"): + async with cast("Any", session_context) as driver: + for statement in statements: + await driver.execute(statement) + commit = getattr(driver, "commit", None) + if callable(commit): + maybe_awaitable = commit() + if hasattr(maybe_awaitable, "__await__"): + await cast("Any", maybe_awaitable) + return + + def _execute_drop_sync() -> None: + with cast("Any", self._config.provide_session()) as driver: + for statement in statements: + driver.execute(statement) + commit = getattr(driver, "commit", None) + if callable(commit): + commit() + + await async_(_execute_drop_sync)() async def ensure_tables(self) -> None: """Create tables and emit a standardized log entry.""" @@ -337,6 +355,9 @@ def drop_tables(self) -> None: with cast("Any", self._config.provide_session()) as driver: for statement in statements: driver.execute(statement) + commit = getattr(driver, "commit", None) + if callable(commit): + commit() def ensure_tables(self) -> None: """Create tables and emit a standardized log entry.""" diff --git a/sqlspec/extensions/adk/migrations/__init__.py b/sqlspec/extensions/adk/migrations/__init__.py index e69de29bb..640f1bc8a 100644 --- a/sqlspec/extensions/adk/migrations/__init__.py +++ b/sqlspec/extensions/adk/migrations/__init__.py @@ -0,0 +1,3 @@ +"""SQLSpec ADK migration definitions.""" + +__all__ = () diff --git a/sqlspec/extensions/adk/service.py b/sqlspec/extensions/adk/service.py index 07ef1b708..c546f53d6 100644 --- a/sqlspec/extensions/adk/service.py +++ b/sqlspec/extensions/adk/service.py @@ -231,7 +231,16 @@ async def list_sessions( offset=page_offset, ) - sessions = [record_to_session(record, events=[]) for record in records] + sessions: list[Session] = [] + if records: + app_state = await self._call_store("get_app_state", app_name) + user_states: dict[str, dict[str, Any]] = {} + for record in records: + uid = record["user_id"] + if uid not in user_states: + user_states[uid] = await self._call_store("get_user_state", app_name, uid) + record["state"] = merge_scoped_state(record["state"], app_state, user_states[uid]) + sessions.append(record_to_session(record, events=[])) log_with_context( logger, logging.DEBUG, @@ -299,13 +308,6 @@ async def append_event(self, session: "Session", event: "Event") -> "Event": if event.partial: return event - self._apply_temp_state(session, event) - event = self._trim_temp_delta_state(event) - - event_record = event_to_record( - event=event, app_name=session.app_name, user_id=session.user_id, session_id=session.id - ) - current_record = await self._call_store("get_session", session.app_name, session.user_id, session.id) if current_record is None: msg = f"Session {session.id} not found." @@ -326,7 +328,14 @@ async def append_event(self, session: "Session", event: "Event") -> "Event": ) raise StaleSessionError(msg) - state_delta = (event.actions.state_delta if event.actions else None) or {} + original_event = event + trimmed_event = self._trim_temp_delta_state(event) + + event_record = event_to_record( + event=trimmed_event, app_name=session.app_name, user_id=session.user_id, session_id=session.id + ) + + state_delta = (trimmed_event.actions.state_delta if trimmed_event.actions else None) or {} app_state_delta, user_state_delta, session_state_delta = split_scoped_state(filter_temp_state(state_delta)) _, _, session_state = split_scoped_state(filter_temp_state(session.state)) @@ -354,17 +363,18 @@ async def append_event(self, session: "Session", event: "Event") -> "Event": ) updated_record["state"] = merge_scoped_state(updated_record["state"], app_state, user_state) + self._apply_temp_state(session, original_event) session.last_update_time = updated_record["update_time"].timestamp() session._storage_update_marker = compute_update_marker(updated_record["update_time"]) # pyright: ignore[reportPrivateUsage] - self._update_session_state(session, event) - session.events.append(event) + self._update_session_state(session, trimmed_event) + session.events.append(trimmed_event) log_with_context( logger, logging.DEBUG, "adk.session.event.append", app_name=session.app_name, session_id=session.id ) - return event + return trimmed_event async def _call_store(self, method_name: str, *args: Any, **kwargs: Any) -> Any: """Call an async store method or offload a sync store method.""" diff --git a/sqlspec/extensions/adk/store.py b/sqlspec/extensions/adk/store.py index 3aa1ecabc..e4caa6fcb 100644 --- a/sqlspec/extensions/adk/store.py +++ b/sqlspec/extensions/adk/store.py @@ -252,6 +252,7 @@ class BaseAsyncADKStore(_ADKStoreCommon[ConfigT], ABC): __slots__ = () + @abstractmethod async def create_tables(self) -> None: """Create the sessions and events tables if they don't exist.""" raise NotImplementedError @@ -259,6 +260,7 @@ async def create_tables(self) -> None: async def prepare_schema_async(self, driver: Any) -> None: """Prepare adapter-specific schema decisions with an asynchronous driver.""" + @abstractmethod async def create_session( self, session_id: str, app_name: str, user_id: str, state: "dict[str, Any]", owner_id: "Any | None" = None ) -> "StoredSession": diff --git a/sqlspec/extensions/events/_channel.py b/sqlspec/extensions/events/_channel.py index 1a5ae6009..f03809fc8 100644 --- a/sqlspec/extensions/events/_channel.py +++ b/sqlspec/extensions/events/_channel.py @@ -437,7 +437,22 @@ def _run_listener( event = self._backend.dequeue(channel, poll_interval) except Exception as error: _end_event_span(self._runtime, span, error=error) - raise + self._runtime.increment_metric("events.dequeue.error") + log_with_context( + logger, + logging.WARNING, + "event.listen", + adapter_name=self._adapter_name, + backend_name=self._backend_name, + channel=channel, + listener_id=listener_id, + mode="sync", + error_type=type(error).__name__, + status="dequeue_error", + ) + if not stop_event.is_set() and poll_interval > 0: + stop_event.wait(poll_interval) + continue if event is None: _end_event_span(self._runtime, span, result="empty") continue @@ -447,6 +462,10 @@ def _run_listener( if auto_ack: self._backend.ack(event.event_id) except Exception as error: + self._runtime.increment_metric("events.handler.error") + if auto_ack and hasattr(self._backend, "nack"): + with suppress(Exception): + self._backend.nack(event.event_id) log_with_context( logger, logging.WARNING, @@ -714,7 +733,22 @@ async def _run_listener( event = await self._backend.dequeue(channel, poll_interval) except Exception as error: _end_event_span(self._runtime, span, error=error) - raise + self._runtime.increment_metric("events.dequeue.error") + log_with_context( + logger, + logging.WARNING, + "event.listen", + adapter_name=self._adapter_name, + backend_name=self._backend_name, + channel=channel, + listener_id=listener_id, + mode="async", + error_type=type(error).__name__, + status="dequeue_error", + ) + if not stop_event.is_set() and poll_interval > 0: + await asyncio.sleep(poll_interval) + continue if event is None: _end_event_span(self._runtime, span, result="empty") continue @@ -726,6 +760,10 @@ async def _run_listener( if auto_ack: await self._backend.ack(event.event_id) except Exception as error: + self._runtime.increment_metric("events.handler.error") + if auto_ack and hasattr(self._backend, "nack"): + with suppress(Exception): + await self._backend.nack(event.event_id) log_with_context( logger, logging.WARNING, diff --git a/sqlspec/extensions/events/_queue.py b/sqlspec/extensions/events/_queue.py index 846201396..18dd901c6 100644 --- a/sqlspec/extensions/events/_queue.py +++ b/sqlspec/extensions/events/_queue.py @@ -1,6 +1,7 @@ """Table-backed queue implementation for EventChannel.""" import asyncio +import threading import time from contextlib import suppress from datetime import UTC, datetime, timedelta @@ -46,8 +47,10 @@ class _BaseTableEventQueue: "_claim_statement", "_cleanup_on_ack", "_config", + "_delete_by_id_statement", "_dialect", "_empty_poll_delays", + "_empty_poll_lock", "_insert_statement", "_lease_seconds", "_max_claim_attempts", @@ -79,9 +82,10 @@ def __init__( self._runtime = config.get_observability_runtime() self._dialect = str(self._statement_config.dialect or "").lower() if self._statement_config else "" self._empty_poll_delays: dict[str, float] = {} + self._empty_poll_lock = threading.Lock() self._table_name = normalize_queue_table_name(queue_table or _DEFAULT_TABLE) - self._lease_seconds = lease_seconds or 30 - self._retention_seconds = retention_seconds or 86_400 + self._lease_seconds = 30 if lease_seconds is None else lease_seconds + self._retention_seconds = 86_400 if retention_seconds is None else retention_seconds self._max_claim_attempts = 5 self._select_for_update = bool(select_for_update) self._cleanup_on_ack = bool(cleanup_on_ack) @@ -93,6 +97,7 @@ def __init__( self._ack_statement = self._ack_sql() self._nack_statement = self._nack_sql() self._acked_cleanup_statement = self._cleanup_sql() + self._delete_by_id_statement = self._delete_by_id_sql() @property def statement_config(self) -> "StatementConfig": @@ -126,17 +131,20 @@ def _uses_oracle_locking_select(self, select_for_update: bool | None = None) -> return bool(locking_enabled) and "oracle" in self._dialect def _next_empty_poll_delay(self, channel: str, poll_interval: "float | None") -> float: + """Record and return the bounded empty-poll delay for a channel.""" if poll_interval is None or poll_interval <= 0: return 0.0 - if channel not in self._empty_poll_delays and len(self._empty_poll_delays) >= _MAX_EMPTY_POLL_CHANNELS: - self._empty_poll_delays.pop(next(iter(self._empty_poll_delays))) - delay = poll_interval - self._empty_poll_delays[channel] = delay + with self._empty_poll_lock: + if channel not in self._empty_poll_delays and len(self._empty_poll_delays) >= _MAX_EMPTY_POLL_CHANNELS: + self._empty_poll_delays.pop(next(iter(self._empty_poll_delays))) + delay = poll_interval + self._empty_poll_delays[channel] = delay self._runtime.record_metric("events.poll.backoff", delay) return delay def _reset_empty_poll_delay(self, channel: str) -> None: - self._empty_poll_delays.pop(channel, None) + with self._empty_poll_lock: + self._empty_poll_delays.pop(channel, None) def _claim_sql(self) -> str: return f"UPDATE {self._table_name} SET status = :claimed_status, lease_expires_at = :lease_expires_at, attempts = attempts + 1 WHERE event_id = :event_id AND (status = :pending_status OR (status = :leased_status AND (lease_expires_at IS NULL OR lease_expires_at <= :lease_reentry_cutoff)))" @@ -145,11 +153,14 @@ def _ack_sql(self) -> str: return f"UPDATE {self._table_name} SET status = :acked, acknowledged_at = :acked_at WHERE event_id = :event_id" def _nack_sql(self) -> str: - return f"UPDATE {self._table_name} SET status = :pending, lease_expires_at = NULL, attempts = attempts + 1 WHERE event_id = :event_id" + return f"UPDATE {self._table_name} SET status = :pending, lease_expires_at = NULL WHERE event_id = :event_id" def _cleanup_sql(self) -> str: return f"DELETE FROM {self._table_name} WHERE status = :acked AND acknowledged_at IS NOT NULL AND acknowledged_at <= :cutoff" + def _delete_by_id_sql(self) -> str: + return f"DELETE FROM {self._table_name} WHERE event_id = :event_id" + @staticmethod def _candidate_parameters(channel: str, current_time: "datetime") -> "dict[str, Any]": return { @@ -210,7 +221,9 @@ def _claim_verified(row: "dict[str, Any] | None", leased_until: "datetime") -> b return claim_verified(row, leased_until) @staticmethod - def _hydrate_event(row: "dict[str, Any]", lease_expires_at: "datetime | None") -> EventMessage: + def _hydrate_event( + row: "dict[str, Any]", lease_expires_at: "datetime | None", *, increment_attempts: bool = False + ) -> EventMessage: payload_raw = row.get("payload_json") metadata_raw = row.get("metadata_json") payload_obj: object @@ -233,12 +246,13 @@ def _hydrate_event(row: "dict[str, Any]", lease_expires_at: "datetime | None") - created_at = parse_event_timestamp(row.get("created_at")) lease_value = lease_expires_at or row.get("lease_expires_at") lease_at = parse_event_timestamp(lease_value) if lease_value is not None else None + attempts = int(row.get("attempts", 0)) + (1 if increment_attempts else 0) return EventMessage( event_id=row["event_id"], channel=row["channel"], payload=payload_value, metadata=metadata_value, - attempts=int(row.get("attempts", 0)), + attempts=attempts, available_at=available_at, lease_expires_at=lease_at, created_at=created_at, @@ -337,7 +351,7 @@ def dequeue( claimed = self._claim_verified(self._fetch_by_event_id(row["event_id"]), leased_until) if claimed: self._reset_empty_poll_delay(channel) - return self._hydrate_event(row, leased_until) + return self._hydrate_event(row, leased_until, increment_attempts=True) return None def dequeue_by_event_id(self, event_id: str) -> "EventMessage | None": @@ -350,12 +364,16 @@ def dequeue_by_event_id(self, event_id: str) -> "EventMessage | None": if not claimed: claimed = self._claim_verified(self._fetch_by_event_id(row["event_id"]), leased_until) if claimed: - event = self._hydrate_event(row, leased_until) + event = self._hydrate_event(row, leased_until, increment_attempts=True) self._reset_empty_poll_delay(event.channel) return event return None def ack(self, event_id: str) -> None: + if self._cleanup_on_ack and self._retention_seconds <= 0: + self._execute(self._delete_by_id_statement, {"event_id": event_id}) + self._runtime.increment_metric("events.ack") + return now = self._utcnow() self._execute(self._ack_statement, {"acked": _ACKED_STATUS, "acked_at": now, "event_id": event_id}) if self._cleanup_on_ack: @@ -440,7 +458,7 @@ def _claim_locked_candidate(self, channel: str) -> "EventMessage | None": driver.rollback() return None driver.commit() - return self._hydrate_event(row, leased_until) + return self._hydrate_event(row, leased_until, increment_attempts=True) except Exception: with suppress(Exception): driver.rollback() @@ -574,7 +592,7 @@ async def dequeue(self, channel: str, poll_interval: float | None = None) -> "Ev claimed = self._claim_verified(await self._fetch_by_event_id(row["event_id"]), leased_until) if claimed: self._reset_empty_poll_delay(channel) - return self._hydrate_event(row, leased_until) + return self._hydrate_event(row, leased_until, increment_attempts=True) return None async def dequeue_by_event_id(self, event_id: str) -> "EventMessage | None": @@ -587,12 +605,16 @@ async def dequeue_by_event_id(self, event_id: str) -> "EventMessage | None": if not claimed: claimed = self._claim_verified(await self._fetch_by_event_id(row["event_id"]), leased_until) if claimed: - event = self._hydrate_event(row, leased_until) + event = self._hydrate_event(row, leased_until, increment_attempts=True) self._reset_empty_poll_delay(event.channel) return event return None async def ack(self, event_id: str) -> None: + if self._cleanup_on_ack and self._retention_seconds <= 0: + await self._execute(self._delete_by_id_statement, {"event_id": event_id}) + self._runtime.increment_metric("events.ack") + return now = self._utcnow() await self._execute(self._ack_statement, {"acked": _ACKED_STATUS, "acked_at": now, "event_id": event_id}) if self._cleanup_on_ack: @@ -682,7 +704,7 @@ async def _claim_locked_candidate(self, channel: str) -> "EventMessage | None": await driver.rollback() return None await driver.commit() - return self._hydrate_event(row, leased_until) + return self._hydrate_event(row, leased_until, increment_attempts=True) except Exception: with suppress(Exception): await driver.rollback() diff --git a/sqlspec/extensions/events/_store.py b/sqlspec/extensions/events/_store.py index 5fc4f440b..7d2c65023 100644 --- a/sqlspec/extensions/events/_store.py +++ b/sqlspec/extensions/events/_store.py @@ -37,6 +37,7 @@ class BaseEventQueueStore(ABC, Generic[ConfigT]): extension_config_options: ClassVar[frozenset[str]] = frozenset({ "backend", + "cleanup_on_ack", "create_schema", "event_poll_interval", "lease_seconds", diff --git a/sqlspec/extensions/fastapi/extension.py b/sqlspec/extensions/fastapi/extension.py index 8cabd7975..c6a62fa6c 100644 --- a/sqlspec/extensions/fastapi/extension.py +++ b/sqlspec/extensions/fastapi/extension.py @@ -14,12 +14,10 @@ from sqlspec.driver import AsyncDriverAdapterBase, SyncDriverAdapterBase from sqlspec.extensions.fastapi.providers import DependencyDefaults, FilterConfig - # Type aliases for static analysis - IDEs see the real types _AsyncSession = AsyncDriverAdapterBase _SyncSession = SyncDriverAdapterBase _Session = AsyncDriverAdapterBase | SyncDriverAdapterBase else: - # Runtime fallback - FastAPI sees Any (avoids NameError) _AsyncSession = Any _SyncSession = Any _Session = Any @@ -34,56 +32,20 @@ class SQLSpecPlugin(_StarlettePlugin): Depends() system. """ - def _extract_extension_settings(self, config: Any) -> "dict[str, Any]": - """Extract FastAPI settings from config.extension_config. - - Args: - config: Database configuration instance. - - Returns: - Dictionary of FastAPI-specific settings. - """ - framework_config = config.extension_config.get("fastapi", {}) - pool_key = framework_config.get("pool_key", "db_pool") - if not config.supports_connection_pooling and pool_key == "db_pool": - pool_key = f"_db_pool_{id(config)}" - correlation_headers = framework_config.get("correlation_headers") - return { - "connection_key": framework_config.get("connection_key", "db_connection"), - "pool_key": pool_key, - "session_key": framework_config.get("session_key", "db_session"), - "commit_mode": framework_config.get("commit_mode", "manual"), - "extra_commit_statuses": framework_config.get("extra_commit_statuses"), - "extra_rollback_statuses": framework_config.get("extra_rollback_statuses"), - "disable_di": framework_config.get("disable_di", False), - "enable_correlation_middleware": framework_config.get("enable_correlation_middleware", False), - "correlation_header": framework_config.get("correlation_header", "x-request-id"), - "correlation_headers": tuple(correlation_headers) if correlation_headers is not None else None, - "auto_trace_headers": framework_config.get("auto_trace_headers", True), - "enable_sqlcommenter_middleware": framework_config.get("enable_sqlcommenter_middleware", True), - "sqlcommenter_framework": framework_config.get("sqlcommenter_framework", "fastapi"), - } - @overload def provide_session( self, key: None = None ) -> "Callable[[Request], AsyncDriverAdapterBase | SyncDriverAdapterBase]": ... - @overload def provide_session(self, key: str) -> "Callable[[Request], AsyncDriverAdapterBase | SyncDriverAdapterBase]": ... - @overload def provide_session(self, key: "type[AsyncDatabaseConfig]") -> "Callable[[Request], AsyncDriverAdapterBase]": ... - @overload def provide_session(self, key: "type[SyncDatabaseConfig]") -> "Callable[[Request], SyncDriverAdapterBase]": ... - @overload def provide_session(self, key: "AsyncDatabaseConfig") -> "Callable[[Request], AsyncDriverAdapterBase]": ... - @overload def provide_session(self, key: "SyncDatabaseConfig") -> "Callable[[Request], SyncDriverAdapterBase]": ... - def provide_session( self, key: "str | type[AsyncDatabaseConfig | SyncDatabaseConfig] | AsyncDatabaseConfig | SyncDatabaseConfig | None" = None, @@ -99,7 +61,6 @@ def provide_session( Returns: Dependency callable for FastAPI Depends(). """ - # Extract string key if provided, ignore config types/instances (used only for type narrowing) session_key = key if isinstance(key, str) or key is None else None def dependency(request: Request) -> _Session: @@ -145,22 +106,16 @@ def dependency(request: Request) -> _SyncSession: @overload def provide_connection(self, key: None = None) -> "Callable[[Request], Any]": ... - @overload def provide_connection(self, key: str) -> "Callable[[Request], Any]": ... - @overload def provide_connection(self, key: "type[AsyncDatabaseConfig]") -> "Callable[[Request], Any]": ... - @overload def provide_connection(self, key: "type[SyncDatabaseConfig]") -> "Callable[[Request], Any]": ... - @overload def provide_connection(self, key: "AsyncDatabaseConfig") -> "Callable[[Request], Any]": ... - @overload def provide_connection(self, key: "SyncDatabaseConfig") -> "Callable[[Request], Any]": ... - def provide_connection( self, key: "str | type[AsyncDatabaseConfig | SyncDatabaseConfig] | AsyncDatabaseConfig | SyncDatabaseConfig | None" = None, @@ -176,7 +131,6 @@ def provide_connection( Returns: Dependency callable for FastAPI Depends(). """ - # Extract string key if provided, ignore config types/instances (used only for type narrowing) connection_key = key if isinstance(key, str) or key is None else None def dependency(request: Request) -> Any: @@ -242,3 +196,33 @@ def provide_filters( dep_defaults = DEPENDENCY_DEFAULTS return _provide_filters(config, dep_defaults=dep_defaults) + + def _extract_extension_settings(self, config: Any) -> "dict[str, Any]": + """Extract FastAPI settings from config.extension_config. + + Args: + config: Database configuration instance. + + Returns: + Dictionary of FastAPI-specific settings. + """ + framework_config = config.extension_config.get("fastapi", {}) + pool_key = framework_config.get("pool_key", "db_pool") + if not config.supports_connection_pooling and pool_key == "db_pool": + pool_key = f"_db_pool_{id(config)}" + correlation_headers = framework_config.get("correlation_headers") + return { + "connection_key": framework_config.get("connection_key", "db_connection"), + "pool_key": pool_key, + "session_key": framework_config.get("session_key", "db_session"), + "commit_mode": framework_config.get("commit_mode", "manual"), + "extra_commit_statuses": framework_config.get("extra_commit_statuses"), + "extra_rollback_statuses": framework_config.get("extra_rollback_statuses"), + "disable_di": framework_config.get("disable_di", False), + "enable_correlation_middleware": framework_config.get("enable_correlation_middleware", False), + "correlation_header": framework_config.get("correlation_header", "x-request-id"), + "correlation_headers": tuple(correlation_headers) if correlation_headers is not None else None, + "auto_trace_headers": framework_config.get("auto_trace_headers", True), + "enable_sqlcommenter_middleware": framework_config.get("enable_sqlcommenter_middleware", True), + "sqlcommenter_framework": framework_config.get("sqlcommenter_framework", "fastapi"), + } diff --git a/sqlspec/extensions/fastapi/providers.py b/sqlspec/extensions/fastapi/providers.py index 95a54646e..7f0661177 100644 --- a/sqlspec/extensions/fastapi/providers.py +++ b/sqlspec/extensions/fastapi/providers.py @@ -5,7 +5,7 @@ """ import copy -import datetime +import datetime as dt import inspect import typing from collections.abc import Callable, Mapping @@ -61,7 +61,7 @@ "provide_filters", ) -DTorNone = datetime.datetime | None +DTorNone = dt.datetime | None StringOrNone = str | None UuidOrNone = UUID | None IntOrNone = int | None @@ -131,7 +131,6 @@ def normalize_choice_field_types(choices: list[Any] | tuple[Any, ...] | type[Enu return cast("Any", typing.Literal).__getitem__(tuple(choices)) -# Keep FilterConfig field unions and provider signatures in sync with sqlspec.extensions.litestar.providers. class FilterConfig(TypedDict): """Configuration for generated FastAPI filter dependencies. @@ -553,13 +552,13 @@ def __deepcopy__(self, memo: dict[int, Any]) -> "_BeforeAfterFilterProvider": ) @staticmethod - def _parse_datetime(value: Any, alias: str) -> datetime.datetime | None: + def _parse_datetime(value: Any, alias: str) -> dt.datetime | None: if value is None: return None - if isinstance(value, datetime.datetime): + if isinstance(value, dt.datetime): return value try: - return datetime.datetime.fromisoformat(value) + return dt.datetime.fromisoformat(value) except (ValueError, TypeError, AttributeError): msg = f"Invalid date format for {alias}" raise RequestValidationError(errors=[{"loc": ("query", alias), "msg": msg, "type": "value_error.datetime"}]) diff --git a/sqlspec/extensions/flask/extension.py b/sqlspec/extensions/flask/extension.py index f8d7a803f..b01acfd0c 100644 --- a/sqlspec/extensions/flask/extension.py +++ b/sqlspec/extensions/flask/extension.py @@ -80,50 +80,6 @@ def __init__(self, sqlspec: SQLSpec, app: "Flask | None" = None) -> None: if app is not None: self.init_app(app) - def _config_state(self, config: Any) -> FlaskConfigState: - """Create configuration state from database config. - - Args: - config: Database configuration instance. - - Returns: - FlaskConfigState instance. - """ - flask_config = config.extension_config.get("flask", {}) - - session_key = flask_config.get("session_key", DEFAULT_SESSION_KEY) - connection_key = flask_config.get("connection_key", f"sqlspec_connection_{session_key}") - commit_mode = flask_config.get("commit_mode", DEFAULT_COMMIT_MODE) - extra_commit_statuses = flask_config.get("extra_commit_statuses") - extra_rollback_statuses = flask_config.get("extra_rollback_statuses") - disable_di = flask_config.get("disable_di", False) - - enable_correlation = flask_config.get("enable_correlation_middleware", False) - correlation_header = flask_config.get("correlation_header", "x-request-id") - correlation_headers = flask_config.get("correlation_headers") - if correlation_headers is not None: - correlation_headers = tuple(correlation_headers) - auto_trace_headers = flask_config.get("auto_trace_headers", True) - enable_sqlcommenter = flask_config.get("enable_sqlcommenter_middleware", True) - - is_async = isinstance(config, (AsyncDatabaseConfig, NoPoolAsyncConfig)) - - return FlaskConfigState( - config=config, - connection_key=connection_key, - session_key=session_key, - commit_mode=commit_mode, - extra_commit_statuses=extra_commit_statuses, - extra_rollback_statuses=extra_rollback_statuses, - is_async=is_async, - disable_di=disable_di, - enable_correlation_middleware=enable_correlation, - correlation_header=correlation_header, - correlation_headers=correlation_headers, - auto_trace_headers=auto_trace_headers, - enable_sqlcommenter_middleware=enable_sqlcommenter, - ) - def init_app(self, app: "Flask") -> None: """Initialize Flask application with SQLSpec. @@ -161,7 +117,12 @@ def init_app(self, app: "Flask") -> None: app.extensions["sqlspec"] = {"plugin": self, "pools": pools} - if any(not state.disable_di for state in self._config_states): + if any( + not state.disable_di + or state.enable_correlation_middleware + or (state.enable_sqlcommenter_middleware and state.config.statement_config.enable_sqlcommenter) + for state in self._config_states + ): app.before_request(self._before_request_handler) app.after_request(self._after_request_handler) app.teardown_appcontext(self._teardown_appcontext_handler) @@ -178,6 +139,111 @@ def init_app(self, app: "Flask") -> None: async_enabled=self._has_async_configs, ) + def get_session(self, key: "str | None" = None) -> Any: + """Get or create database session for current request. + + Sessions are cached per request for consistency. + + Args: + key: Session key for multi-database configs. Defaults to first config if None. + + Returns: + Database session (driver instance). + """ + config_state = self._config_states[0] if key is None else self._config_state_by_key(key) + + return get_or_create_session(config_state, self._portal.portal if self._portal else None) + + def get_connection(self, key: "str | None" = None) -> Any: + """Get database connection for current request. + + Note: + Imports flask components dynamically at runtime to avoid hard dependency. + + Args: + key: Session key for multi-database configs. Defaults to first config if None. + + Returns: + Raw database connection. + """ + from flask import g + + config_state = self._config_states[0] if key is None else self._config_state_by_key(key) + + return get_context_value(g, config_state.connection_key) + + def shutdown(self) -> None: + """Dispose connection pools and stop async portal.""" + + if self._shutdown_complete: + return + + self._shutdown_complete = True + + for config_state in self._config_states: + if config_state.config.supports_connection_pooling: + self._close_pool_state(config_state) + + if self._portal is not None: + try: + self._portal.stop() + except Exception as exc: + log_with_context( + logger, + logging.ERROR, + "extension.init", + framework="flask", + stage="shutdown", + status="failed", + error_type=type(exc).__name__, + ) + finally: + self._portal = None + + def _config_state(self, config: Any) -> FlaskConfigState: + """Create configuration state from database config. + + Args: + config: Database configuration instance. + + Returns: + FlaskConfigState instance. + """ + flask_config = config.extension_config.get("flask", {}) + + session_key = flask_config.get("session_key", DEFAULT_SESSION_KEY) + connection_key = flask_config.get("connection_key", f"sqlspec_connection_{session_key}") + commit_mode = flask_config.get("commit_mode", DEFAULT_COMMIT_MODE) + extra_commit_statuses = flask_config.get("extra_commit_statuses") + extra_rollback_statuses = flask_config.get("extra_rollback_statuses") + disable_di = flask_config.get("disable_di", False) + + enable_correlation = flask_config.get("enable_correlation_middleware", False) + correlation_header = flask_config.get("correlation_header", "x-request-id") + correlation_headers = flask_config.get("correlation_headers") + if correlation_headers is not None: + correlation_headers = tuple(correlation_headers) + auto_trace_headers = flask_config.get("auto_trace_headers", True) + enable_sqlcommenter = flask_config.get("enable_sqlcommenter_middleware", True) + + is_async = isinstance(config, (AsyncDatabaseConfig, NoPoolAsyncConfig)) + + return FlaskConfigState( + config=config, + connection_key=connection_key, + session_key=session_key, + commit_mode=commit_mode, + extra_commit_statuses=extra_commit_statuses, + extra_rollback_statuses=extra_rollback_statuses, + is_async=is_async, + disable_di=disable_di, + enable_correlation_middleware=enable_correlation, + correlation_header=correlation_header, + correlation_headers=correlation_headers, + auto_trace_headers=auto_trace_headers, + enable_sqlcommenter_middleware=enable_sqlcommenter, + ) + def _ensure_unique_keys(self) -> None: """Validate that all state keys are unique across configs. @@ -289,7 +355,8 @@ def _after_request_handler(self, response: "Response") -> "Response": def _teardown_appcontext_handler(self, _exc: "BaseException | None" = None) -> None: """Clean up connections when request context ends. - Closes all connections, cleans up g object, and clears correlation context. + Rolls back uncommitted transactions when an unhandled exception occurred, + closes all connections, cleans up g object, and clears correlation context. Note: Imports flask components dynamically at runtime to avoid hard dependency. @@ -307,21 +374,32 @@ def _teardown_appcontext_handler(self, _exc: "BaseException | None" = None) -> N if self._enable_sqlcommenter: SQLCommenterContext.set(None) + exc_info = (type(_exc), _exc, _exc.__traceback__) if _exc is not None else (None, None, None) + for config_state in self._config_states: if config_state.disable_di: continue + cache_key = f"sqlspec_session_cache_{config_state.session_key}" + session = get_context_value(g, cache_key, None) connection = get_context_value(g, config_state.connection_key, None) ctx_key = f"{config_state.connection_key}_ctx" conn_ctx = get_context_value(g, ctx_key, None) + if ( + _exc is not None + and config_state.commit_mode != "manual" + and (session is not None or connection is not None) + ): + self._execute_rollback(session, config_state) + if connection is not None: try: if conn_ctx is not None: if config_state.is_async: - self._portal.portal.call(conn_ctx.__aexit__, None, None, None) # type: ignore[union-attr] + self._portal.portal.call(conn_ctx.__aexit__, *exc_info) # type: ignore[union-attr] else: - conn_ctx.__exit__(None, None, None) + conn_ctx.__exit__(*exc_info) elif config_state.is_async: self._portal.portal.call(connection.close) # type: ignore[union-attr] else: @@ -343,43 +421,9 @@ def _teardown_appcontext_handler(self, _exc: "BaseException | None" = None) -> N if has_context_value(g, ctx_key): pop_context_value(g, ctx_key) - cache_key = f"sqlspec_session_cache_{config_state.session_key}" if has_context_value(g, cache_key): pop_context_value(g, cache_key) - def get_session(self, key: "str | None" = None) -> Any: - """Get or create database session for current request. - - Sessions are cached per request for consistency. - - Args: - key: Session key for multi-database configs. Defaults to first config if None. - - Returns: - Database session (driver instance). - """ - config_state = self._config_states[0] if key is None else self._config_state_by_key(key) - - return get_or_create_session(config_state, self._portal.portal if self._portal else None) - - def get_connection(self, key: "str | None" = None) -> Any: - """Get database connection for current request. - - Note: - Imports flask components dynamically at runtime to avoid hard dependency. - - Args: - key: Session key for multi-database configs. Defaults to first config if None. - - Returns: - Raw database connection. - """ - from flask import g - - config_state = self._config_states[0] if key is None else self._config_state_by_key(key) - - return get_context_value(g, config_state.connection_key) - def _config_state_by_key(self, key: str) -> FlaskConfigState: """Get config state by session key. @@ -398,34 +442,6 @@ def _config_state_by_key(self, key: str) -> FlaskConfigState: msg = f"No configuration found for key: {key}" raise ImproperConfigurationError(msg) - def shutdown(self) -> None: - """Dispose connection pools and stop async portal.""" - - if self._shutdown_complete: - return - - self._shutdown_complete = True - - for config_state in self._config_states: - if config_state.config.supports_connection_pooling: - self._close_pool_state(config_state) - - if self._portal is not None: - try: - self._portal.stop() - except Exception as exc: - log_with_context( - logger, - logging.ERROR, - "extension.init", - framework="flask", - stage="shutdown", - status="failed", - error_type=type(exc).__name__, - ) - finally: - self._portal = None - def _close_pool_state(self, config_state: FlaskConfigState) -> None: """Close pool associated with configuration state.""" @@ -468,19 +484,22 @@ def _close_pool_state(self, config_state: FlaskConfigState) -> None: ) def _execute_commit(self, session: Any, config_state: FlaskConfigState) -> None: - """Execute commit on session. + """Execute commit on session or connection. Args: session: Database session. config_state: Configuration state. """ try: + target = ( + session + if session is not None and hasattr(session, "commit") + else self.get_connection(config_state.session_key) + ) if config_state.is_async: - connection = self.get_connection(config_state.session_key) - self._portal.portal.call(connection.commit) # type: ignore[union-attr] + self._portal.portal.call(target.commit) # type: ignore[union-attr] else: - connection = self.get_connection(config_state.session_key) - connection.commit() + target.commit() except Exception as exc: log_with_context( logger, @@ -492,21 +511,26 @@ def _execute_commit(self, session: Any, config_state: FlaskConfigState) -> None: status="failed", error_type=type(exc).__name__, ) + self._execute_rollback(session, config_state) + raise def _execute_rollback(self, session: Any, config_state: FlaskConfigState) -> None: - """Execute rollback on session. + """Execute rollback on session or connection. Args: session: Database session. config_state: Configuration state. """ try: + target = ( + session + if session is not None and hasattr(session, "rollback") + else self.get_connection(config_state.session_key) + ) if config_state.is_async: - connection = self.get_connection(config_state.session_key) - self._portal.portal.call(connection.rollback) # type: ignore[union-attr] + self._portal.portal.call(target.rollback) # type: ignore[union-attr] else: - connection = self.get_connection(config_state.session_key) - connection.rollback() + target.rollback() except Exception as exc: log_with_context( logger, diff --git a/sqlspec/extensions/litestar/__init__.py b/sqlspec/extensions/litestar/__init__.py index 15fad927c..ef1ed335a 100644 --- a/sqlspec/extensions/litestar/__init__.py +++ b/sqlspec/extensions/litestar/__init__.py @@ -1,3 +1,5 @@ +"""Litestar framework integration for SQLSpec.""" + from sqlspec.extensions.litestar._utils import get_sqlspec_scope_state, set_sqlspec_scope_state from sqlspec.extensions.litestar.channels import SQLSpecChannelsBackend from sqlspec.extensions.litestar.cli import database_group diff --git a/sqlspec/extensions/litestar/_utils.py b/sqlspec/extensions/litestar/_utils.py index dfb98313b..57a319854 100644 --- a/sqlspec/extensions/litestar/_utils.py +++ b/sqlspec/extensions/litestar/_utils.py @@ -40,6 +40,6 @@ def delete_sqlspec_scope_state(scope: "Scope", key: str) -> None: Args: scope: The connection scope. - key: Key to set under internal namespace in scope state. + key: Key to remove under internal namespace in scope state. """ - del scope.setdefault(_SCOPE_NAMESPACE, {})[key] # type: ignore[misc] + scope.setdefault(_SCOPE_NAMESPACE, {}).pop(key, None) # type: ignore[misc] diff --git a/sqlspec/extensions/litestar/channels.py b/sqlspec/extensions/litestar/channels.py index 2124ea298..19a2aab84 100644 --- a/sqlspec/extensions/litestar/channels.py +++ b/sqlspec/extensions/litestar/channels.py @@ -17,6 +17,8 @@ from sqlspec.extensions.events import AsyncEventChannel +__all__ = ("SQLSpecChannelsBackend",) + logger = get_logger("sqlspec.extensions.litestar.channels") _IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -57,24 +59,6 @@ def __init__( self._to_db_channel: dict[str, str] = {} self._to_litestar_channel: dict[str, str] = {} - async def on_startup(self) -> None: - self._shutdown.clear() - if self._output_queue is None: - self._output_queue = asyncio.Queue() - - async def on_shutdown(self) -> None: - self._shutdown.set() - tasks = list(self._tasks.values()) - self._tasks.clear() - for task in tasks: - task.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - self._to_db_channel.clear() - self._to_litestar_channel.clear() - self._output_queue = None - await self._event_channel.shutdown() - @property def output_queue_depth(self) -> int: """Return the number of buffered channel messages.""" @@ -94,6 +78,24 @@ def notify_budget(self) -> "int | None": """ return MAX_NOTIFY_BYTES if self._event_channel.backend_name == "notify" else None + async def on_startup(self) -> None: + self._shutdown.clear() + if self._output_queue is None: + self._output_queue = asyncio.Queue() + + async def on_shutdown(self) -> None: + self._shutdown.set() + tasks = list(self._tasks.values()) + self._tasks.clear() + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self._to_db_channel.clear() + self._to_litestar_channel.clear() + self._output_queue = None + await self._event_channel.shutdown() + def measure(self, data: bytes) -> int: """Return the encoded size of the PostgreSQL ``notify`` envelope that wraps ``data``. @@ -224,6 +226,10 @@ async def _stream_channel(self, channel: str, db_channel: str) -> None: raise except Exception as error: # pragma: no cover logger.warning("litestar channel %s stream worker error: %s", channel, error) + finally: + current_task = asyncio.current_task() + if self._tasks.get(channel) is current_task: + self._tasks.pop(channel, None) @staticmethod def _wrap_payload(data: bytes) -> "dict[str, Any]": diff --git a/sqlspec/extensions/litestar/cli.py b/sqlspec/extensions/litestar/cli.py index 8fbdf5aac..5b2b35a22 100644 --- a/sqlspec/extensions/litestar/cli.py +++ b/sqlspec/extensions/litestar/cli.py @@ -17,6 +17,8 @@ from sqlspec.extensions.litestar.plugin import SQLSpecPlugin +__all__ = ("add_sessions_delete_expired_command", "database_group", "get_database_migration_plugin") + def _safe_group(*, aliases: "list[str] | None" = None, **kwargs: Any) -> Callable[[Callable[..., Any]], Any]: if aliases is None: diff --git a/sqlspec/extensions/litestar/handlers.py b/sqlspec/extensions/litestar/handlers.py index 08f7c4354..a8f359968 100644 --- a/sqlspec/extensions/litestar/handlers.py +++ b/sqlspec/extensions/litestar/handlers.py @@ -48,6 +48,8 @@ def manual_handler_maker(connection_scope_key: str) -> "Callable[[Message, Scope Returns: The handler callable. """ + ctx_scope_key = f"{connection_scope_key}_ctx" + provider_owned_key = f"{connection_scope_key}_provider_owned" async def handler(message: "Message", scope: "Scope") -> None: """Handle closing and cleaning up connections before sending the response. @@ -58,7 +60,15 @@ async def handler(message: "Message", scope: "Scope") -> None: """ connection = get_sqlspec_scope_state(scope, connection_scope_key) if connection and message["type"] in SESSION_TERMINUS_ASGI_EVENTS: - await ensure_async_(connection.close)() + if get_sqlspec_scope_state(scope, provider_owned_key): + return + connection_cm = get_sqlspec_scope_state(scope, ctx_scope_key, pop=True) + if isinstance(connection_cm, AbstractAsyncContextManager): + await connection_cm.__aexit__(None, None, None) + elif isinstance(connection_cm, AbstractContextManager): + await with_ensure_async_(connection_cm).__aexit__(None, None, None) + else: + await ensure_async_(connection.close)() delete_sqlspec_scope_state(scope, connection_scope_key) return handler @@ -95,6 +105,8 @@ def autocommit_handler_maker( raise ImproperConfigurationError(msg) commit_range = range(200, 400 if commit_on_redirect else 300) + ctx_scope_key = f"{connection_scope_key}_ctx" + provider_owned_key = f"{connection_scope_key}_provider_owned" async def handler(message: "Message", scope: "Scope") -> None: """Handle commit/rollback, closing and cleaning up connections before sending. @@ -113,8 +125,18 @@ async def handler(message: "Message", scope: "Scope") -> None: else: await ensure_async_(connection.rollback)() finally: - if connection and message["type"] in SESSION_TERMINUS_ASGI_EVENTS: - await ensure_async_(connection.close)() + if ( + connection + and message["type"] in SESSION_TERMINUS_ASGI_EVENTS + and not get_sqlspec_scope_state(scope, provider_owned_key) + ): + connection_cm = get_sqlspec_scope_state(scope, ctx_scope_key, pop=True) + if isinstance(connection_cm, AbstractAsyncContextManager): + await connection_cm.__aexit__(None, None, None) + elif isinstance(connection_cm, AbstractContextManager): + await with_ensure_async_(connection_cm).__aexit__(None, None, None) + else: + await ensure_async_(connection.close)() delete_sqlspec_scope_state(scope, connection_scope_key) return handler @@ -208,6 +230,7 @@ def connection_provider_maker( Returns: The connection provider function. """ + provider_owned_key = f"{connection_key}_provider_owned" async def provide_connection(state: "State", scope: "Scope") -> "AsyncGenerator[ConnectionT, None]": if (db_pool := state.get(pool_key)) is None: @@ -234,10 +257,12 @@ async def provide_connection(state: "State", scope: "Scope") -> "AsyncGenerator[ entered_connection = await context_manager.__aenter__() try: + set_sqlspec_scope_state(scope, provider_owned_key, True) set_sqlspec_scope_state(scope, connection_key, entered_connection) yield entered_connection finally: await context_manager.__aexit__(None, None, None) + delete_sqlspec_scope_state(scope, provider_owned_key) delete_sqlspec_scope_state(scope, connection_key) return provide_connection @@ -248,6 +273,11 @@ def session_provider_maker( ) -> "Callable[[Any], AsyncGenerator[DriverT, None]]": """Create provider for database driver sessions. + Note: + This provider intentionally stays out of mypyc: it combines *args/**kwargs + with yield in async def, participates in Litestar's @contextlib.asynccontextmanager + dependency wrapping, and mutates __signature__/__annotations__ at runtime. + Args: config: The database configuration object. connection_dependency_key: The key used for connection dependency injection. @@ -256,9 +286,6 @@ def session_provider_maker( The session provider function. """ - # This provider intentionally stays out of mypyc: it combines *args/**kwargs - # with yield in async def, participates in Litestar's @contextlib.asynccontextmanager - # dependency wrapping, and mutates __signature__/__annotations__ at runtime. async def provide_session(*args: Any, **kwargs: Any) -> "AsyncGenerator[DriverT, None]": connection_obj = args[0] if args else kwargs.get(connection_dependency_key) yield cast( diff --git a/sqlspec/extensions/litestar/plugin.py b/sqlspec/extensions/litestar/plugin.py index 85fb5e975..99c9295b0 100644 --- a/sqlspec/extensions/litestar/plugin.py +++ b/sqlspec/extensions/litestar/plugin.py @@ -278,84 +278,6 @@ def __init__(self, sqlspec: SQLSpec, *, loader: "SQLFileLoader | None" = None) - correlation_headers=len(self._correlation_headers), ) - def _extract_extension_settings(self, config: "AnyDatabaseConfig") -> "dict[str, Any]": - """Extract Litestar settings from config.extension_config.""" - litestar_config = config.extension_config.get("litestar", {}) - - connection_key = litestar_config.get("connection_key", DEFAULT_CONNECTION_KEY) - pool_key = litestar_config.get("pool_key", DEFAULT_POOL_KEY) - session_key = litestar_config.get("session_key", DEFAULT_SESSION_KEY) - commit_mode = litestar_config.get("commit_mode", DEFAULT_COMMIT_MODE) - - if not config.supports_connection_pooling and pool_key == DEFAULT_POOL_KEY: - pool_key = f"_{DEFAULT_POOL_KEY}_{id(config)}" - - correlation_header = str(litestar_config.get("correlation_header", DEFAULT_CORRELATION_HEADER)).lower() - configured_headers = _normalize_header_list(litestar_config.get("correlation_headers")) - auto_trace_headers = bool(litestar_config.get("auto_trace_headers", True)) - - disable_di = litestar_config.get("disable_di", False) - - return { - "connection_key": connection_key, - "pool_key": pool_key, - "session_key": session_key, - "commit_mode": commit_mode, - "extra_commit_statuses": litestar_config.get("extra_commit_statuses"), - "extra_rollback_statuses": litestar_config.get("extra_rollback_statuses"), - "enable_correlation_middleware": litestar_config.get("enable_correlation_middleware", True), - "correlation_header": correlation_header, - "correlation_headers": _build_correlation_headers( - primary=correlation_header, configured=configured_headers, auto_trace_headers=auto_trace_headers - ), - "disable_di": disable_di, - "manage_lifespan": litestar_config.get("manage_lifespan", not disable_di), - "enable_sqlcommenter_middleware": litestar_config.get("enable_sqlcommenter_middleware", True), - } - - def _config_state(self, config: "AnyDatabaseConfig", settings: "dict[str, Any]") -> PluginConfigState: - """Create plugin state with handlers for the given configuration.""" - state = PluginConfigState( - config=config, - connection_key=settings["connection_key"], - pool_key=settings["pool_key"], - session_key=settings["session_key"], - commit_mode=settings["commit_mode"], - extra_commit_statuses=settings.get("extra_commit_statuses"), - extra_rollback_statuses=settings.get("extra_rollback_statuses"), - enable_correlation_middleware=settings["enable_correlation_middleware"], - correlation_header=settings["correlation_header"], - enable_sqlcommenter_middleware=settings["enable_sqlcommenter_middleware"], - disable_di=settings["disable_di"], - manage_lifespan=settings["manage_lifespan"], - ) - state.correlation_headers = tuple(settings["correlation_headers"]) - - if state.manage_lifespan: - state.lifespan_handler = lifespan_handler_maker(config, state.pool_key) - if not state.disable_di: - self._setup_di_handlers(state) - return state - - def _setup_di_handlers(self, state: PluginConfigState) -> None: - """Build the dependency providers and before-send handler for the plugin state.""" - connection_key = state.connection_key - pool_key = state.pool_key - commit_mode = state.commit_mode - config = state.config - - state.connection_provider = connection_provider_maker(config, pool_key, connection_key) - state.pool_provider = pool_provider_maker(config, pool_key) - state.session_provider = session_provider_maker(config, connection_key) - - if commit_mode == "manual": - state.before_send_handler = manual_handler_maker(connection_key) - else: - commit_on_redirect = commit_mode == "autocommit_include_redirect" - state.before_send_handler = autocommit_handler_maker( - connection_key, commit_on_redirect, state.extra_commit_statuses, state.extra_rollback_statuses - ) - @property def config(self) -> "list[AnyDatabaseConfig]": """Return the plugin configurations. @@ -443,10 +365,6 @@ def store_sqlspec_in_state() -> None: app_config.exception_handlers.setdefault(NotFoundError, not_found_error_handler) app_config.exception_handlers.setdefault(IntegrityError, integrity_error_handler) - # Inject sqlspec's DEFAULT_TYPE_ENCODERS into Litestar's response serializer - # (user-supplied encoders win on conflict). Litestar's per-handler - # resolve_type_encoders() merges these with route/controller/router-level - # overrides automatically — no bidirectional thread needed. app_config.type_encoders = {**DEFAULT_TYPE_ENCODERS, **(app_config.type_encoders or {})} sqlspec_decoders = _build_litestar_type_decoders() if sqlspec_decoders: @@ -522,21 +440,16 @@ def get_annotation(self, key: "str | AnyDatabaseConfig | type[AnyDatabaseConfig] def get_config( self, name: "type[SyncDatabaseConfig[Any, Any, Any] | NoPoolSyncConfig[Any, Any]]" ) -> "SyncDatabaseConfig[Any, Any, Any] | NoPoolSyncConfig[Any, Any]": ... - @overload def get_config( self, name: "type[AsyncDatabaseConfig[Any, Any, Any] | NoPoolAsyncConfig[Any, Any]]" ) -> "AsyncDatabaseConfig[Any, Any, Any] | NoPoolAsyncConfig[Any, Any]": ... - @overload def get_config(self, name: "SyncConfigT") -> "SyncConfigT": ... - @overload def get_config(self, name: "AsyncConfigT") -> "AsyncConfigT": ... - @overload def get_config(self, name: str) -> "AnyDatabaseConfig": ... - def get_config( self, name: "type[DatabaseConfigProtocol[Any, Any, Any]] | str | Any" ) -> "DatabaseConfigProtocol[Any, Any, Any]": @@ -562,54 +475,6 @@ def get_config( state = self._registry_state(name) or self._dependency_state(name) return cast("DatabaseConfigProtocol[Any, Any, Any]", state.config) # type: ignore[redundant-cast] - def _ensure_connection_sync(self, plugin_state: PluginConfigState, state: "State", scope: "Scope") -> Any: - """Ensure a connection exists in scope, creating one from the pool if needed (sync).""" - connection = get_sqlspec_scope_state(scope, plugin_state.connection_key) - if connection is not None: - return connection - - pool = state.get(plugin_state.pool_key) - if pool is None: - self._raise_missing_connection(plugin_state.connection_key) - - cm = plugin_state.config.provide_connection(pool) - connection = cm.__enter__() # type: ignore[union-attr] - set_sqlspec_scope_state(scope, plugin_state.connection_key, connection) - return connection - - async def _ensure_connection_async(self, plugin_state: PluginConfigState, state: "State", scope: "Scope") -> Any: - """Ensure a connection exists in scope, creating one from the pool if needed (async).""" - connection = get_sqlspec_scope_state(scope, plugin_state.connection_key) - if connection is not None: - return connection - - pool = state.get(plugin_state.pool_key) - if pool is None: - self._raise_missing_connection(plugin_state.connection_key) - - cm = plugin_state.config.provide_connection(pool) - connection = await cm.__aenter__() # type: ignore[union-attr] - set_sqlspec_scope_state(scope, plugin_state.connection_key, connection) - return connection - - def _create_session( - self, plugin_state: PluginConfigState, connection: Any, scope: "Scope" - ) -> "SyncDriverAdapterBase | AsyncDriverAdapterBase": - """Create a session from a connection and store it in scope.""" - session_scope_key = f"{plugin_state.session_key}_instance" - - session = get_sqlspec_scope_state(scope, session_scope_key) - if session is not None: - return cast("SyncDriverAdapterBase | AsyncDriverAdapterBase", session) - - session = plugin_state.config.driver_type( - connection=connection, - statement_config=plugin_state.config.statement_config, - driver_features=plugin_state.config.driver_features, - ) - set_sqlspec_scope_state(scope, session_scope_key, session) - return cast("SyncDriverAdapterBase | AsyncDriverAdapterBase", session) - @overload def provide_request_session( self, @@ -617,7 +482,6 @@ def provide_request_session( state: "State", scope: "Scope", ) -> "DriverT": ... - @overload def provide_request_session( self, @@ -625,12 +489,10 @@ def provide_request_session( state: "State", scope: "Scope", ) -> "DriverT": ... - @overload def provide_request_session( self, key: str, state: "State", scope: "Scope" ) -> "SyncDriverAdapterBase | AsyncDriverAdapterBase": ... - def provide_request_session( self, key: "str | AnyDatabaseConfig | type[AnyDatabaseConfig]", state: "State", scope: "Scope" ) -> "SyncDriverAdapterBase | AsyncDriverAdapterBase": @@ -661,7 +523,6 @@ def provide_request_session_sync( state: "State", scope: "Scope", ) -> "DriverT": ... - @overload def provide_request_session_sync( self, @@ -669,10 +530,8 @@ def provide_request_session_sync( state: "State", scope: "Scope", ) -> "DriverT": ... - @overload def provide_request_session_sync(self, key: str, state: "State", scope: "Scope") -> "SyncDriverAdapterBase": ... - def provide_request_session_sync( self, key: "str | SyncDatabaseConfig[Any, Any, Any] | NoPoolSyncConfig[Any, Any] | type[SyncDatabaseConfig[Any, Any, Any] | NoPoolSyncConfig[Any, Any]]", @@ -705,7 +564,6 @@ async def provide_request_session_async( state: "State", scope: "Scope", ) -> "DriverT": ... - @overload async def provide_request_session_async( self, @@ -713,12 +571,10 @@ async def provide_request_session_async( state: "State", scope: "Scope", ) -> "DriverT": ... - @overload async def provide_request_session_async( self, key: str, state: "State", scope: "Scope" ) -> "AsyncDriverAdapterBase": ... - async def provide_request_session_async( self, key: "str | AsyncDatabaseConfig[Any, Any, Any] | NoPoolAsyncConfig[Any, Any] | type[AsyncDatabaseConfig[Any, Any, Any] | NoPoolAsyncConfig[Any, Any]]", @@ -751,7 +607,6 @@ def provide_request_connection( state: "State", scope: "Scope", ) -> "ConnectionT": ... - @overload def provide_request_connection( self, @@ -759,10 +614,8 @@ def provide_request_connection( state: "State", scope: "Scope", ) -> "ConnectionT": ... - @overload def provide_request_connection(self, key: str, state: "State", scope: "Scope") -> Any: ... - def provide_request_connection( self, key: "str | AnyDatabaseConfig | type[AnyDatabaseConfig]", state: "State", scope: "Scope" ) -> Any: @@ -793,7 +646,6 @@ def provide_request_connection_sync( state: "State", scope: "Scope", ) -> "ConnectionT": ... - @overload def provide_request_connection_sync( self, @@ -801,10 +653,8 @@ def provide_request_connection_sync( state: "State", scope: "Scope", ) -> "ConnectionT": ... - @overload def provide_request_connection_sync(self, key: str, state: "State", scope: "Scope") -> Any: ... - def provide_request_connection_sync( self, key: "str | SyncDatabaseConfig[Any, Any, Any] | NoPoolSyncConfig[Any, Any] | type[SyncDatabaseConfig[Any, Any, Any] | NoPoolSyncConfig[Any, Any]]", @@ -836,7 +686,6 @@ async def provide_request_connection_async( state: "State", scope: "Scope", ) -> "ConnectionT": ... - @overload async def provide_request_connection_async( self, @@ -844,10 +693,8 @@ async def provide_request_connection_async( state: "State", scope: "Scope", ) -> "ConnectionT": ... - @overload async def provide_request_connection_async(self, key: str, state: "State", scope: "Scope") -> Any: ... - async def provide_request_connection_async( self, key: "str | AsyncDatabaseConfig[Any, Any, Any] | NoPoolAsyncConfig[Any, Any] | type[AsyncDatabaseConfig[Any, Any, Any] | NoPoolAsyncConfig[Any, Any]]", @@ -872,6 +719,134 @@ async def provide_request_connection_async( plugin_state = self._get_plugin_state(key) return await self._ensure_connection_async(plugin_state, state, scope) + def _extract_extension_settings(self, config: "AnyDatabaseConfig") -> "dict[str, Any]": + """Extract Litestar settings from config.extension_config.""" + litestar_config = config.extension_config.get("litestar", {}) + + connection_key = litestar_config.get("connection_key", DEFAULT_CONNECTION_KEY) + pool_key = litestar_config.get("pool_key", DEFAULT_POOL_KEY) + session_key = litestar_config.get("session_key", DEFAULT_SESSION_KEY) + commit_mode = litestar_config.get("commit_mode", DEFAULT_COMMIT_MODE) + + if not config.supports_connection_pooling and pool_key == DEFAULT_POOL_KEY: + pool_key = f"_{DEFAULT_POOL_KEY}_{id(config)}" + + correlation_header = str(litestar_config.get("correlation_header", DEFAULT_CORRELATION_HEADER)).lower() + configured_headers = _normalize_header_list(litestar_config.get("correlation_headers")) + auto_trace_headers = bool(litestar_config.get("auto_trace_headers", True)) + + disable_di = litestar_config.get("disable_di", False) + + return { + "connection_key": connection_key, + "pool_key": pool_key, + "session_key": session_key, + "commit_mode": commit_mode, + "extra_commit_statuses": litestar_config.get("extra_commit_statuses"), + "extra_rollback_statuses": litestar_config.get("extra_rollback_statuses"), + "enable_correlation_middleware": litestar_config.get("enable_correlation_middleware", True), + "correlation_header": correlation_header, + "correlation_headers": _build_correlation_headers( + primary=correlation_header, configured=configured_headers, auto_trace_headers=auto_trace_headers + ), + "disable_di": disable_di, + "manage_lifespan": litestar_config.get("manage_lifespan", not disable_di), + "enable_sqlcommenter_middleware": litestar_config.get("enable_sqlcommenter_middleware", True), + } + + def _config_state(self, config: "AnyDatabaseConfig", settings: "dict[str, Any]") -> PluginConfigState: + """Create plugin state with handlers for the given configuration.""" + state = PluginConfigState( + config=config, + connection_key=settings["connection_key"], + pool_key=settings["pool_key"], + session_key=settings["session_key"], + commit_mode=settings["commit_mode"], + extra_commit_statuses=settings.get("extra_commit_statuses"), + extra_rollback_statuses=settings.get("extra_rollback_statuses"), + enable_correlation_middleware=settings["enable_correlation_middleware"], + correlation_header=settings["correlation_header"], + enable_sqlcommenter_middleware=settings["enable_sqlcommenter_middleware"], + disable_di=settings["disable_di"], + manage_lifespan=settings["manage_lifespan"], + ) + state.correlation_headers = tuple(settings["correlation_headers"]) + + if state.manage_lifespan: + state.lifespan_handler = lifespan_handler_maker(config, state.pool_key) + if not state.disable_di: + self._setup_di_handlers(state) + return state + + def _setup_di_handlers(self, state: PluginConfigState) -> None: + """Build the dependency providers and before-send handler for the plugin state.""" + connection_key = state.connection_key + pool_key = state.pool_key + commit_mode = state.commit_mode + config = state.config + + state.connection_provider = connection_provider_maker(config, pool_key, connection_key) + state.pool_provider = pool_provider_maker(config, pool_key) + state.session_provider = session_provider_maker(config, connection_key) + + if commit_mode == "manual": + state.before_send_handler = manual_handler_maker(connection_key) + else: + commit_on_redirect = commit_mode == "autocommit_include_redirect" + state.before_send_handler = autocommit_handler_maker( + connection_key, commit_on_redirect, state.extra_commit_statuses, state.extra_rollback_statuses + ) + + def _ensure_connection_sync(self, plugin_state: PluginConfigState, state: "State", scope: "Scope") -> Any: + """Ensure a connection exists in scope, creating one from the pool if needed (sync).""" + connection = get_sqlspec_scope_state(scope, plugin_state.connection_key) + if connection is not None: + return connection + + pool = state.get(plugin_state.pool_key) + if pool is None: + self._raise_missing_connection(plugin_state.connection_key) + + cm = plugin_state.config.provide_connection(pool) + connection = cm.__enter__() # type: ignore[union-attr] + set_sqlspec_scope_state(scope, f"{plugin_state.connection_key}_ctx", cm) + set_sqlspec_scope_state(scope, plugin_state.connection_key, connection) + return connection + + async def _ensure_connection_async(self, plugin_state: PluginConfigState, state: "State", scope: "Scope") -> Any: + """Ensure a connection exists in scope, creating one from the pool if needed (async).""" + connection = get_sqlspec_scope_state(scope, plugin_state.connection_key) + if connection is not None: + return connection + + pool = state.get(plugin_state.pool_key) + if pool is None: + self._raise_missing_connection(plugin_state.connection_key) + + cm = plugin_state.config.provide_connection(pool) + connection = await cm.__aenter__() # type: ignore[union-attr] + set_sqlspec_scope_state(scope, f"{plugin_state.connection_key}_ctx", cm) + set_sqlspec_scope_state(scope, plugin_state.connection_key, connection) + return connection + + def _create_session( + self, plugin_state: PluginConfigState, connection: Any, scope: "Scope" + ) -> "SyncDriverAdapterBase | AsyncDriverAdapterBase": + """Create a session from a connection and store it in scope.""" + session_scope_key = f"{plugin_state.session_key}_instance" + + session = get_sqlspec_scope_state(scope, session_scope_key) + if session is not None: + return cast("SyncDriverAdapterBase | AsyncDriverAdapterBase", session) + + session = plugin_state.config.driver_type( + connection=connection, + statement_config=plugin_state.config.statement_config, + driver_features=plugin_state.config.driver_features, + ) + set_sqlspec_scope_state(scope, session_scope_key, session) + return cast("SyncDriverAdapterBase | AsyncDriverAdapterBase", session) + def _registry_state(self, name: Any) -> "PluginConfigState | None": """Resolve plugin state from registry identity, independent of Litestar registration. diff --git a/sqlspec/extensions/litestar/providers.py b/sqlspec/extensions/litestar/providers.py index 0265d5d16..e6501407e 100644 --- a/sqlspec/extensions/litestar/providers.py +++ b/sqlspec/extensions/litestar/providers.py @@ -4,7 +4,7 @@ """ import copy -import datetime +import datetime as dt import inspect import typing from collections.abc import Callable, Mapping @@ -61,7 +61,7 @@ "normalize_choice_field_types", ) -DTorNone = datetime.datetime | None +DTorNone = dt.datetime | None StringOrNone = str | None UuidOrNone = UUID | None IntOrNone = int | None @@ -114,7 +114,6 @@ def normalize_choice_field_types(choices: list[Any] | tuple[Any, ...] | type[Enu return cast("Any", typing.Literal).__getitem__(tuple(choices)) -# Keep FilterConfig field unions and provider signatures in sync with sqlspec.extensions.fastapi.providers. class FilterConfig(TypedDict): """Configuration for generated Litestar filter dependencies. @@ -398,6 +397,7 @@ def _configured_filter_aggregator(config: FilterConfig) -> Callable[..., list[Fi annotations["order_by_filter"] = annotation if not_in_fields := config.get("not_in_fields"): + not_in_fields = {not_in_fields} if isinstance(not_in_fields, (str, FieldNameType)) else not_in_fields for field_def in not_in_fields: field_def = FieldNameType(name=field_def, type_hint=str) if isinstance(field_def, str) else field_def annotation = NamedDependency[SkipValidation[NotInCollectionFilter[Any]]] @@ -409,6 +409,7 @@ def _configured_filter_aggregator(config: FilterConfig) -> Callable[..., list[Fi annotations[f"{field_def.name}_not_in_filter"] = annotation if in_fields := config.get("in_fields"): + in_fields = {in_fields} if isinstance(in_fields, (str, FieldNameType)) else in_fields for field_def in in_fields: field_def = FieldNameType(name=field_def, type_hint=str) if isinstance(field_def, str) else field_def annotation = NamedDependency[SkipValidation[InCollectionFilter[Any]]] @@ -521,8 +522,15 @@ def _aggregate_filter_provider(**kwargs: Any) -> list[FilterTypes]: continue if isinstance(filter_value, list): filters.extend(filter_value) - elif (isinstance(filter_value, SearchFilter) and filter_value.value is None) or ( - isinstance(filter_value, OrderByFilter) and filter_value.field_name is None + elif ( + (isinstance(filter_value, SearchFilter) and filter_value.value is None) + or (isinstance(filter_value, OrderByFilter) and filter_value.field_name is None) + or ( + isinstance(filter_value, BeforeAfterFilter) + and filter_value.before is None + and filter_value.after is None + ) + or (isinstance(filter_value, (InCollectionFilter, NotInCollectionFilter)) and filter_value.values is None) ): continue else: diff --git a/sqlspec/extensions/prometheus/_observer.py b/sqlspec/extensions/prometheus/_observer.py index d13bf4e0c..d2ef67794 100644 --- a/sqlspec/extensions/prometheus/_observer.py +++ b/sqlspec/extensions/prometheus/_observer.py @@ -10,6 +10,9 @@ __all__ = ("PrometheusStatementObserver", "enable_metrics") +_FORBIDDEN_HIGH_CARDINALITY_LABELS = frozenset({"sql", "parameters", "correlation_id", "duration_s"}) + + class PrometheusStatementObserver: """Statement observer that records Prometheus metrics.""" @@ -24,7 +27,13 @@ def __init__( label_names: Iterable[str] = ("db_system", "operation"), duration_buckets: tuple[float, ...] | None = None, ) -> None: - self._label_names = tuple(label_names) + resolved_labels = tuple(label_names) + forbidden = _FORBIDDEN_HIGH_CARDINALITY_LABELS.intersection(resolved_labels) + if forbidden: + forbidden_names = ", ".join(sorted(forbidden)) + msg = f"High-cardinality Prometheus label names are not allowed: {forbidden_names}" + raise ValueError(msg) + self._label_names = resolved_labels self._counters = Counter( "query_total", "Total SQL statements executed", @@ -64,7 +73,7 @@ def __call__(self, event: StatementEvent) -> None: def _label_values(self, event: StatementEvent) -> tuple[str, ...]: values: list[str] = [] - payload = event.as_dict() + payload: dict[str, Any] | None = None for name in self._label_names: match name: case "db_system": @@ -81,6 +90,8 @@ def _label_values(self, event: StatementEvent) -> tuple[str, ...]: case "bind_key": values.append(event.bind_key or "default") case _: + if payload is None: + payload = event.as_dict() value = payload.get(name) values.append("" if value is None else str(value)) return tuple(values) diff --git a/sqlspec/extensions/sanic/extension.py b/sqlspec/extensions/sanic/extension.py index 1f1a3921e..e1d04063c 100644 --- a/sqlspec/extensions/sanic/extension.py +++ b/sqlspec/extensions/sanic/extension.py @@ -80,6 +80,43 @@ def __init__(self, sqlspec: SQLSpec, app: "Sanic[Any, Any] | None" = None) -> No config_count=len(self._config_states), ) + def init_app(self, app: "Sanic[Any, Any]") -> None: + """Initialize Sanic application with SQLSpec. + + Args: + app: Sanic application instance. + """ + self._ensure_unique_keys() + setattr(app.ctx, "sqlspec_plugin", self) + self._add_lifecycle_listeners(app) + self._add_request_middleware(app) + + def get_session(self, request: Any, key: "str | None" = None) -> Any: + """Get or create database session for request. + + Args: + request: Sanic request instance. + key: Optional session key to retrieve a specific database session. + + Returns: + Database session driver instance. + """ + config_state = self._config_states[0] if key is None else self._config_state_by_key(key) + return get_or_create_session(request, config_state) + + def get_connection(self, request: Any, key: "str | None" = None) -> Any: + """Get database connection from request context. + + Args: + request: Sanic request instance. + key: Optional session key to retrieve a specific database connection. + + Returns: + Database connection object. + """ + config_state = self._config_states[0] if key is None else self._config_state_by_key(key) + return get_context_value(request.ctx, config_state.connection_key) + def _extract_extension_settings(self, config: Any) -> "dict[str, Any]": """Extract Sanic settings from config.extension_config. @@ -137,17 +174,6 @@ def _config_state(self, config: Any, settings: "dict[str, Any]") -> SanicConfigS sqlcommenter_framework=settings["sqlcommenter_framework"], ) - def init_app(self, app: "Sanic[Any, Any]") -> None: - """Initialize Sanic application with SQLSpec. - - Args: - app: Sanic application instance. - """ - self._ensure_unique_keys() - setattr(app.ctx, "sqlspec_plugin", self) - self._add_lifecycle_listeners(app) - self._add_request_middleware(app) - def _add_lifecycle_listeners(self, app: "Sanic[Any, Any]") -> None: """Register Sanic server lifecycle listeners. @@ -172,6 +198,9 @@ def _add_request_middleware(self, app: "Sanic[Any, Any]") -> None: app.on_request(self._on_request) app.on_response(self._on_response) # type: ignore[no-untyped-call] + on_exception = getattr(app, "on_exception", None) + if callable(on_exception): + on_exception(self._on_exception) self._request_middleware_added = True async def _on_request(self, request: Any) -> None: @@ -209,6 +238,31 @@ async def _on_response(self, request: Any, response: Any) -> None: finally: self._restore_observability_contexts(request, response) + async def _on_exception(self, request: Any, exception: BaseException) -> None: + """Roll back and release request-scoped connections when an unhandled exception occurs. + + Args: + request: Sanic request instance. + exception: Unhandled exception raised during request processing. + """ + _ = exception + if request is None or not hasattr(request, "ctx"): + return + try: + for config_state in self._config_states[::-1]: + if config_state.disable_di: + continue + connection = get_context_value(request.ctx, config_state.connection_key, None) + if connection is None: + continue + try: + if config_state.commit_mode != "manual": + await self._rollback_target(request, connection, config_state) + finally: + await self._release_request_connection(request, config_state) + finally: + self._restore_observability_contexts(request, None) + def _needs_request_middleware(self) -> bool: """Return whether this plugin should register request middleware. @@ -408,12 +462,26 @@ async def _finalize_request_connection(self, request: Any, response: Any, config if config_state.commit_mode != "manual": status_code = self._response_status_code(response) if self._should_commit(config_state, status_code): - await ensure_async_(connection.commit)() + await self._commit_target(request, connection, config_state) else: - await ensure_async_(connection.rollback)() + await self._rollback_target(request, connection, config_state) finally: await self._release_request_connection(request, config_state) + async def _commit_target(self, request: Any, connection: Any, config_state: SanicConfigState) -> None: + """Commit transaction using active session or connection.""" + session = get_context_value(request.ctx, f"{config_state.session_key}_instance", None) + target = session if session is not None and hasattr(session, "commit") else connection + if hasattr(target, "commit"): + await ensure_async_(target.commit)() + + async def _rollback_target(self, request: Any, connection: Any, config_state: SanicConfigState) -> None: + """Roll back transaction using active session or connection.""" + session = get_context_value(request.ctx, f"{config_state.session_key}_instance", None) + target = session if session is not None and hasattr(session, "rollback") else connection + if hasattr(target, "rollback"): + await ensure_async_(target.rollback)() + async def _release_request_connection(self, request: Any, config_state: SanicConfigState) -> None: """Release and clear one request connection. @@ -520,7 +588,7 @@ async def _after_server_stop(self, app: Any, *_: Any) -> None: try: await ensure_async_(config_state.config.close_pool)() - except Exception: + except Exception as exc: log_with_context( logger, logging.ERROR, @@ -528,17 +596,19 @@ async def _after_server_stop(self, app: Any, *_: Any) -> None: framework="sanic", pool_key=config_state.pool_key, session_key=config_state.session_key, + error_type=type(exc).__name__, ) - raise - pop_context_value(app.ctx, config_state.pool_key) - log_with_context( - logger, - logging.DEBUG, - "pool.close", - framework="sanic", - pool_key=config_state.pool_key, - session_key=config_state.session_key, - ) + else: + log_with_context( + logger, + logging.DEBUG, + "pool.close", + framework="sanic", + pool_key=config_state.pool_key, + session_key=config_state.session_key, + ) + finally: + pop_context_value(app.ctx, config_state.pool_key) def _ensure_unique_keys(self) -> None: """Validate that all context keys are unique across configs. @@ -555,32 +625,6 @@ def _ensure_unique_keys(self) -> None: raise ImproperConfigurationError(msg) all_keys.update(keys) - def get_session(self, request: Any, key: "str | None" = None) -> Any: - """Get or create database session for request. - - Args: - request: Sanic request instance. - key: Optional session key to retrieve a specific database session. - - Returns: - Database session driver instance. - """ - config_state = self._config_states[0] if key is None else self._config_state_by_key(key) - return get_or_create_session(request, config_state) - - def get_connection(self, request: Any, key: "str | None" = None) -> Any: - """Get database connection from request context. - - Args: - request: Sanic request instance. - key: Optional session key to retrieve a specific database connection. - - Returns: - Database connection object. - """ - config_state = self._config_states[0] if key is None else self._config_state_by_key(key) - return get_context_value(request.ctx, config_state.connection_key) - def _config_state_by_key(self, key: str) -> SanicConfigState: """Get configuration state by session key. diff --git a/sqlspec/extensions/starlette/extension.py b/sqlspec/extensions/starlette/extension.py index c95f35eda..a3afdc7f5 100644 --- a/sqlspec/extensions/starlette/extension.py +++ b/sqlspec/extensions/starlette/extension.py @@ -68,6 +68,126 @@ def __init__(self, sqlspec: SQLSpec, app: "Starlette | None" = None) -> None: config_count=len(self._config_states), ) + def init_app(self, app: "Starlette") -> None: + """Initialize Starlette application with SQLSpec. + + Validates configuration, wraps lifespan, and adds middleware. + + Args: + app: Starlette application instance. + """ + if getattr(app.state, "sqlspec_plugin", None) is self: + return + + self._ensure_unique_keys() + app.state.sqlspec_plugin = self + + original_lifespan = app.router.lifespan_context + + @asynccontextmanager + async def combined_lifespan(app: "Starlette") -> "AsyncGenerator[None, None]": + async with self.lifespan(app), original_lifespan(app): + yield + + app.router.lifespan_context = combined_lifespan + + for config_state in self._config_states: + if not config_state.disable_di: + self._add_middleware(app, config_state) + + self._add_correlation_middleware(app) + self._add_sqlcommenter_middleware(app) + + log_with_context( + logger, + logging.DEBUG, + "extension.init", + framework="starlette", + stage="configured", + config_count=len(self._config_states), + ) + + @asynccontextmanager + async def lifespan(self, app: "Starlette") -> "AsyncGenerator[None, None]": + """Manage connection pool lifecycle. + + Args: + app: Starlette application instance. + + Yields: + None + """ + for config_state in self._config_states: + if config_state.config.supports_connection_pooling: + pool = await ensure_async_(config_state.config.create_pool)() + setattr(app.state, config_state.pool_key, pool) + log_with_context( + logger, + logging.DEBUG, + "session.create", + framework="starlette", + session_key=config_state.session_key, + pool_key=config_state.pool_key, + ) + + try: + yield + finally: + for config_state in self._config_states: + if config_state.config.supports_connection_pooling: + try: + await ensure_async_(config_state.config.close_pool)() + except Exception as exc: + log_with_context( + logger, + logging.ERROR, + "session.close", + framework="starlette", + session_key=config_state.session_key, + pool_key=config_state.pool_key, + status="failed", + error_type=type(exc).__name__, + ) + else: + log_with_context( + logger, + logging.DEBUG, + "session.close", + framework="starlette", + session_key=config_state.session_key, + pool_key=config_state.pool_key, + ) + + def get_session(self, request: "Request", key: "str | None" = None) -> Any: + """Get or create database session for request. + + Sessions are cached per request to ensure consistency. + + Args: + request: Starlette request instance. + key: Optional session key to retrieve specific database session. + + Returns: + Database session (driver instance). + """ + config_state = self._config_states[0] if key is None else self._config_state_by_key(key) + + return get_or_create_session(request, config_state) + + def get_connection(self, request: "Request", key: "str | None" = None) -> Any: + """Get database connection from request state. + + Args: + request: Starlette request instance. + key: Optional session key to retrieve specific database connection. + + Returns: + Database connection object. + """ + config_state = self._config_states[0] if key is None else self._config_state_by_key(key) + + return get_state_value(request.state, config_state.connection_key) + def _extract_extension_settings(self, config: Any) -> "dict[str, Any]": """Extract Starlette settings from config.extension_config. @@ -125,41 +245,6 @@ def _config_state(self, config: Any, settings: "dict[str, Any]") -> SQLSpecConfi sqlcommenter_framework=settings["sqlcommenter_framework"], ) - def init_app(self, app: "Starlette") -> None: - """Initialize Starlette application with SQLSpec. - - Validates configuration, wraps lifespan, and adds middleware. - - Args: - app: Starlette application instance. - """ - self._ensure_unique_keys() - - original_lifespan = app.router.lifespan_context - - @asynccontextmanager - async def combined_lifespan(app: "Starlette") -> "AsyncGenerator[None, None]": - async with self.lifespan(app), original_lifespan(app): - yield - - app.router.lifespan_context = combined_lifespan - - for config_state in self._config_states: - if not config_state.disable_di: - self._add_middleware(app, config_state) - - self._add_correlation_middleware(app) - self._add_sqlcommenter_middleware(app) - - log_with_context( - logger, - logging.DEBUG, - "extension.init", - framework="starlette", - stage="configured", - config_count=len(self._config_states), - ) - def _ensure_unique_keys(self) -> None: """Validate that all state keys are unique across configs. @@ -239,74 +324,6 @@ def _add_sqlcommenter_middleware(self, app: "Starlette") -> None: ) break - @asynccontextmanager - async def lifespan(self, app: "Starlette") -> "AsyncGenerator[None, None]": - """Manage connection pool lifecycle. - - Args: - app: Starlette application instance. - - Yields: - None - """ - for config_state in self._config_states: - if config_state.config.supports_connection_pooling: - pool = await ensure_async_(config_state.config.create_pool)() - setattr(app.state, config_state.pool_key, pool) - log_with_context( - logger, - logging.DEBUG, - "session.create", - framework="starlette", - session_key=config_state.session_key, - pool_key=config_state.pool_key, - ) - - try: - yield - finally: - for config_state in self._config_states: - if config_state.config.supports_connection_pooling: - await ensure_async_(config_state.config.close_pool)() - log_with_context( - logger, - logging.DEBUG, - "session.close", - framework="starlette", - session_key=config_state.session_key, - pool_key=config_state.pool_key, - ) - - def get_session(self, request: "Request", key: "str | None" = None) -> Any: - """Get or create database session for request. - - Sessions are cached per request to ensure consistency. - - Args: - request: Starlette request instance. - key: Optional session key to retrieve specific database session. - - Returns: - Database session (driver instance). - """ - config_state = self._config_states[0] if key is None else self._config_state_by_key(key) - - return get_or_create_session(request, config_state) - - def get_connection(self, request: "Request", key: "str | None" = None) -> Any: - """Get database connection from request state. - - Args: - request: Starlette request instance. - key: Optional session key to retrieve specific database connection. - - Returns: - Database connection object. - """ - config_state = self._config_states[0] if key is None else self._config_state_by_key(key) - - return get_state_value(request.state, config_state.connection_key) - def _config_state_by_key(self, key: str) -> SQLSpecConfigState: """Get configuration state by session key. diff --git a/sqlspec/extensions/starlette/middleware.py b/sqlspec/extensions/starlette/middleware.py index c41db6b10..f451486fe 100644 --- a/sqlspec/extensions/starlette/middleware.py +++ b/sqlspec/extensions/starlette/middleware.py @@ -56,6 +56,7 @@ async def _connection_cm(self, request: "Request") -> "AsyncIterator[Any]": """Yield a request-scoped connection for manual transaction mode.""" config = self.config_state.config connection_key = self.config_state.connection_key + cache_key = f"sqlspec_session_cache_{self.config_state.session_key}" if config.supports_connection_pooling: pool = get_state_value(request.app.state, self.config_state.pool_key) @@ -64,6 +65,7 @@ async def _connection_cm(self, request: "Request") -> "AsyncIterator[Any]": try: yield connection finally: + pop_state_value(request.state, cache_key) pop_state_value(request.state, connection_key) else: connection = await ensure_async_(config.create_connection)() @@ -71,6 +73,7 @@ async def _connection_cm(self, request: "Request") -> "AsyncIterator[Any]": try: yield connection finally: + pop_state_value(request.state, cache_key) pop_state_value(request.state, connection_key) await ensure_async_(connection.close)() @@ -101,16 +104,25 @@ async def dispatch(self, request: "Request", call_next: Any) -> Any: Returns: HTTP response. """ + cache_key = f"sqlspec_session_cache_{self.config_state.session_key}" async with self._connection_cm(request) as connection: try: response = await call_next(request) + session = get_state_value(request.state, cache_key, None) if self._should_commit(response.status_code): - await ensure_async_(connection.commit)() + target = session if session is not None and hasattr(session, "commit") else connection + if hasattr(target, "commit"): + await ensure_async_(target.commit)() else: - await ensure_async_(connection.rollback)() + target = session if session is not None and hasattr(session, "rollback") else connection + if hasattr(target, "rollback"): + await ensure_async_(target.rollback)() except Exception: - await ensure_async_(connection.rollback)() + session = get_state_value(request.state, cache_key, None) + target = session if session is not None and hasattr(session, "rollback") else connection + if hasattr(target, "rollback"): + await ensure_async_(target.rollback)() raise else: return response @@ -120,6 +132,7 @@ async def _connection_cm(self, request: "Request") -> "AsyncIterator[Any]": """Yield a request-scoped connection for autocommit transaction mode.""" config = self.config_state.config connection_key = self.config_state.connection_key + cache_key = f"sqlspec_session_cache_{self.config_state.session_key}" if config.supports_connection_pooling: pool = get_state_value(request.app.state, self.config_state.pool_key) @@ -128,6 +141,7 @@ async def _connection_cm(self, request: "Request") -> "AsyncIterator[Any]": try: yield connection finally: + pop_state_value(request.state, cache_key) pop_state_value(request.state, connection_key) else: connection = await ensure_async_(config.create_connection)() @@ -135,6 +149,7 @@ async def _connection_cm(self, request: "Request") -> "AsyncIterator[Any]": try: yield connection finally: + pop_state_value(request.state, cache_key) pop_state_value(request.state, connection_key) await ensure_async_(connection.close)() diff --git a/sqlspec/loader.py b/sqlspec/loader.py index 827d7ae31..f588c05f8 100644 --- a/sqlspec/loader.py +++ b/sqlspec/loader.py @@ -218,6 +218,7 @@ class SQLFileLoader: __slots__ = ( "_compiled_statements", + "_file_namespaces", "_files", "_fragment_to_file", "_fragments", @@ -254,6 +255,7 @@ def __init__( self._compiled_statements: dict[str, SQL] = {} self._queries: dict[str, NamedStatement] = {} self._files: dict[str, SQLFile] = {} + self._file_namespaces: dict[str, set[str | None]] = {} self._query_to_file: dict[str, str] = {} self._fragments: dict[str, SQLFragment] = {} self._fragment_to_file: dict[str, str] = {} @@ -520,25 +522,23 @@ def has_query(self, name: str) -> bool: def clear_cache(self) -> None: """Clear all cached files and queries.""" + self.clear_file_cache() self._compiled_statements.clear() - self._files.clear() self._queries.clear() self._query_to_file.clear() self._fragments.clear() self._fragment_to_file.clear() self._resolved_text.clear() - cache_config = get_cache_config() - if cache_config.compiled_cache_enabled: - cache = get_cache() - cache.clear() - def clear_file_cache(self) -> None: """Clear the file cache only, keeping loaded queries.""" cache_config = get_cache_config() if cache_config.compiled_cache_enabled: cache = get_cache() - cache.clear() + for path_str in self._files: + cache.delete_file(self._file_cache_key(path_str)) + self._files.clear() + self._file_namespaces.clear() def get_query_text(self, name: str) -> str: """Get raw SQL text for a query. @@ -620,12 +620,7 @@ def get_sql(self, name: str, **slots: Any) -> "SQL": except Exception as exc: raise SQLFileParseError(name=name, path="", original_error=exc) from exc if slot_parameters: - return SQL( - statement_text, - slot_parameters, - dialect=sqlglot_dialect, - declared_parameters=parsed_statement.parameters, - ) + return SQL(sql, slot_parameters, dialect=sqlglot_dialect, declared_parameters=parsed_statement.parameters) if not slots: self._compiled_statements[safe_name] = sql return sql @@ -714,7 +709,7 @@ def _reload_changed_files(self) -> "list[str]": Returns: Paths of files that were reloaded. """ - pending_reloads: list[tuple[str, str | None]] = [] + pending_reloads: list[tuple[str, list[str | None]]] = [] for path, sql_file in list(self._files.items()): if self._is_file_unchanged(path, sql_file): if self._runtime is not None: @@ -723,17 +718,24 @@ def _reload_changed_files(self) -> "list[str]": query_names = [name for name, source in self._query_to_file.items() if source == path] fragment_names = [name for name, source in self._fragment_to_file.items() if source == path] - namespaces = { - name[: -(len(statement.name) + 1)] - for name in query_names - if (statement := self._queries.get(name)) is not None and name.endswith(f".{statement.name}") - } - namespaces.update( - name[: -(len(fragment.name) + 1)] - for name in fragment_names - if (fragment := self._fragments.get(name)) is not None and name.endswith(f".{fragment.name}") - ) - namespace = next(iter(namespaces)) if len(namespaces) == 1 else None + recorded_namespaces = self._file_namespaces.pop(path, None) + reload_namespaces: list[str | None] + if recorded_namespaces: + reload_namespaces = sorted(recorded_namespaces, key=lambda ns: (ns is not None, ns or "")) + else: + namespaces: set[str | None] = { + name[: -(len(statement.name) + 1)] + for name in query_names + if (statement := self._queries.get(name)) is not None and name.endswith(f".{statement.name}") + } + namespaces.update( + name[: -(len(fragment.name) + 1)] + for name in fragment_names + if (fragment := self._fragments.get(name)) is not None and name.endswith(f".{fragment.name}") + ) + reload_namespaces = ( + sorted(namespaces, key=lambda ns: (ns is not None, ns or "")) if namespaces else [None] + ) for name in query_names: self._queries.pop(name, None) self._query_to_file.pop(name, None) @@ -742,13 +744,14 @@ def _reload_changed_files(self) -> "list[str]": self._fragments.pop(name, None) self._fragment_to_file.pop(name, None) self._files.pop(path, None) - pending_reloads.append((path, namespace)) + pending_reloads.append((path, reload_namespaces)) if pending_reloads: self._invalidate_resolved() changed_paths: list[str] = [] - for path, namespace in pending_reloads: - self._load_single_file(path, namespace) + for path, reload_namespaces in pending_reloads: + for namespace in reload_namespaces: + self._load_single_file(path, namespace) changed_paths.append(path) if self._runtime is not None: self._runtime.increment_metric("loader.reload.changed") @@ -1106,7 +1109,7 @@ def _load_single_file(self, file_path: str | Path, namespace: str | None) -> boo if runtime is not None: runtime.increment_metric("loader.files.considered") - if path_str in self._files: + if path_str in self._files and namespace in self._file_namespaces.get(path_str, set()): if runtime is not None: runtime.increment_metric("loader.cache.hit") return False @@ -1130,6 +1133,7 @@ def _load_single_file(self, file_path: str | Path, namespace: str | None) -> boo if file_content is not None and self._content_matches_cache(file_content, cached_file): self._files[path_str] = cached_file.sql_file + self._file_namespaces.setdefault(path_str, set()).add(namespace) for name, statement in cached_file.parsed_statements.items(): namespaced_name = f"{namespace}.{name}" if namespace else name if namespaced_name in self._queries: @@ -1193,6 +1197,7 @@ def _load_uncached_file( sql_file = SQLFile(content=content, path=path_str) self._files[path_str] = sql_file + self._file_namespaces.setdefault(path_str, set()).add(namespace) for name, statement in statements.items(): namespaced_name = f"{namespace}.{name}" if namespace else name diff --git a/sqlspec/migrations/_backup.py b/sqlspec/migrations/_backup.py index f4803b3ba..43a9d00ef 100644 --- a/sqlspec/migrations/_backup.py +++ b/sqlspec/migrations/_backup.py @@ -8,7 +8,7 @@ def create_backup(migrations_path: Path) -> Path: - timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S") + timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S_%f") backup_dir = migrations_path / f".backup_{timestamp}" backup_dir.mkdir(parents=True, exist_ok=False) diff --git a/sqlspec/migrations/base.py b/sqlspec/migrations/base.py index cee1d4cdc..3fdb77bc0 100644 --- a/sqlspec/migrations/base.py +++ b/sqlspec/migrations/base.py @@ -1,5 +1,6 @@ """Base classes for SQLSpec migrations.""" +import importlib import os from abc import abstractmethod from pathlib import Path @@ -78,8 +79,6 @@ class AppliedMigrationRecord(TypedDict): class BaseMigrationTracker(Generic[DriverT]): """Base class for migration version tracking.""" - __slots__ = ("_output_policy", "version_table", "version_table_name", "version_table_schema") - def __init__(self, version_table_name: str = "ddl_migrations", version_table_schema: str | None = None) -> None: """Initialize the migration tracker. @@ -150,13 +149,20 @@ def get_applied_migrations( @abstractmethod def record_migration( - self, driver: DriverT, version: str, description: str, execution_time_ms: int, checksum: str + self, + driver: DriverT, + version: str, + description: str, + execution_time_ms: int, + checksum: str, + *, + commit: bool = True, ) -> "Awaitable[None] | None": """Record a successfully applied migration.""" ... @abstractmethod - def remove_migration(self, driver: DriverT, version: str) -> "Awaitable[None] | None": + def remove_migration(self, driver: DriverT, version: str, *, commit: bool = True) -> "Awaitable[None] | None": """Remove a migration record.""" ... @@ -461,9 +467,7 @@ def init_directory(self, directory: str, package: bool = True) -> None: directory: Directory to initialize migrations in. package: Whether to create __init__.py file. """ - from rich.console import Console - - console = Console() + console = importlib.import_module("rich.console").Console() migrations_dir = Path(directory) migrations_dir.mkdir(parents=True, exist_ok=True) @@ -706,16 +710,24 @@ def _collect_pending_migrations( self, all_migrations: "list[tuple[str, Path]]", applied_set: set[str], revision: str ) -> "list[tuple[str, Path]]": """Collect pending migrations that need to be applied.""" + applied_parsed = set() + for applied_version in applied_set: + try: + applied_parsed.add(parse_version(applied_version)) + except ValueError: + continue pending = [] + parsed_revision = parse_version(revision) if revision != "head" else None for version, file_path in all_migrations: - if version not in applied_set: - if revision == "head": - pending.append((version, file_path)) - else: - parsed_version = parse_version(version) - parsed_revision = parse_version(revision) - if parsed_version <= parsed_revision: - pending.append((version, file_path)) + if version in applied_set: + continue + parsed_version = parse_version(version) + if parsed_version in applied_parsed: + continue + if parsed_revision is None or ( + parsed_version.extension == parsed_revision.extension and parsed_version <= parsed_revision + ): + pending.append((version, file_path)) return pending def _collect_revert_migrations( @@ -730,7 +742,10 @@ def _collect_revert_migrations( to_revert = [] for migration in applied[::-1]: parsed_migration_version = parse_version(migration["version_num"]) - if parsed_migration_version > parsed_revision: + if ( + parsed_migration_version.extension == parsed_revision.extension + and parsed_migration_version > parsed_revision + ): to_revert.append(migration) return to_revert diff --git a/sqlspec/migrations/commands.py b/sqlspec/migrations/commands.py index f395e2fdf..e77287744 100644 --- a/sqlspec/migrations/commands.py +++ b/sqlspec/migrations/commands.py @@ -3,6 +3,7 @@ This module provides the main command interface for database migrations. """ +import asyncio import functools import inspect import logging @@ -11,6 +12,7 @@ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast from rich.console import Console +from rich.prompt import Prompt from rich.table import Table from sqlspec.builder import sql @@ -20,9 +22,10 @@ from sqlspec.migrations.fix import MigrationFixer from sqlspec.migrations.runner import AsyncMigrationRunner, SyncMigrationRunner from sqlspec.migrations.squash import MigrationSquasher +from sqlspec.migrations.tracker import AsyncMigrationTracker, SyncMigrationTracker from sqlspec.migrations.utils import create_migration_file from sqlspec.migrations.validation import validate_migration_order -from sqlspec.migrations.version import generate_conversion_map, generate_timestamp_version +from sqlspec.migrations.version import generate_conversion_map, generate_timestamp_version, parse_version from sqlspec.observability import resolve_db_system from sqlspec.utils.logging import get_logger, log_with_context @@ -170,16 +173,6 @@ def __init__(self, config: "SyncConfigT") -> None: description_hints=self._template_settings.description_hints, ) - def _validate_migration_schema(self, driver: Any) -> None: - """Validate the configured migration schema exists before issuing DDL.""" - default_schema = self._resolve_default_schema() - if default_schema is None: - return - self._require_schema_support(default_schema) - if not driver.has_schema(default_schema): - msg = f"Configured schema '{default_schema}' does not exist" - raise MigrationError(msg) - def init(self, directory: str, package: bool = True) -> None: """Initialize migration directory structure. @@ -255,250 +248,6 @@ def current(self, verbose: bool = False) -> "str | None": ) return cast("str | None", current) - def _checksum_for_migration(self, version: str, file_path: "Path") -> "tuple[str, tuple[str, Path]] | None": - """Load checksum for a single migration. - - Args: - version: Migration version. - file_path: Path to migration file. - - Returns: - Tuple of (version, (checksum, file_path)) or None if load fails. - """ - try: - migration = self.runner.load_migration(file_path, version) - return (version, (migration["checksum"], file_path)) - except Exception as exc: - log_with_context( - logger, - logging.DEBUG, - "migration.list", - db_system=resolve_db_system(type(self.config).__name__), - version=version, - file_path=str(file_path), - error_type=type(exc).__name__, - status="failed", - operation="load_checksum", - ) - return None - - def _load_migration_checksums(self, all_migrations: "list[tuple[str, Path]]") -> "dict[str, tuple[str, Path]]": - """Load checksums for all migrations. - - Args: - all_migrations: List of (version, file_path) tuples. - - Returns: - Dictionary mapping version to (checksum, file_path) tuples. - """ - file_checksums = {} - for version, file_path in all_migrations: - result = self._checksum_for_migration(version, file_path) - if result: - file_checksums[result[0]] = result[1] - return file_checksums - - def _apply_single_migration( - self, - driver: Any, - migration: "LoadedMigrationMetadata", - version: str, - use_logger: bool, - echo: bool, - summary_only: bool, - ) -> int | None: - """Apply a single migration and record it. - - Args: - driver: Database driver instance. - migration: Migration dictionary with version, description, checksum. - version: Version string. - use_logger: Whether to output to logger instead of console. - echo: Whether to echo output to the console. - summary_only: Whether summary-only logging is enabled. - - Returns: - Execution time in ms on success, None on failure. - """ - try: - - def record_version(exec_time: int, migration: "LoadedMigrationMetadata" = migration) -> None: - self.tracker.record_migration( - driver, migration["version"], migration["description"], exec_time, migration["checksum"] - ) - - _, execution_time = self.runner.execute_upgrade(driver, migration, on_success=record_version) - except Exception as exc: - use_txn = self.runner.should_use_transaction(migration, self.config) - rollback_msg = " (transaction rolled back)" if use_txn else "" - _output_error( - use_logger, - echo, - summary_only, - "Migration %s failed%s", - version, - rollback_msg, - rich_message=f"[red]✗ Failed{rollback_msg}: {exc}[/]", - ) - self._last_command_error = exc - return None - else: - _output_info( - use_logger, - echo, - summary_only, - "Applied migration %s in %dms", - version, - execution_time, - rich_message=f"[green]✓ Applied in {execution_time}ms[/]", - ) - return execution_time - - def _revert_single_migration( - self, - driver: Any, - migration: "LoadedMigrationMetadata", - version: str, - use_logger: bool, - echo: bool, - summary_only: bool, - ) -> int | None: - """Revert a single migration. - - Args: - driver: Database driver instance. - migration: Migration dictionary. - version: Version string. - use_logger: Whether to output to logger instead of console. - echo: Whether to echo output to the console. - summary_only: Whether summary-only logging is enabled. - - Returns: - Execution time in ms on success, None on failure. - """ - try: - - def remove_version(exec_time: int, version: str = version) -> None: - self.tracker.remove_migration(driver, version) - - _, execution_time = self.runner.execute_downgrade(driver, migration, on_success=remove_version) - except Exception as exc: - use_txn = self.runner.should_use_transaction(migration, self.config) - rollback_msg = " (transaction rolled back)" if use_txn else "" - _output_error( - use_logger, - echo, - summary_only, - "Migration %s failed%s", - version, - rollback_msg, - rich_message=f"[red]✗ Failed{rollback_msg}: {exc}[/]", - ) - self._last_command_error = exc - return None - else: - _output_info( - use_logger, - echo, - summary_only, - "Reverted migration %s in %dms", - version, - execution_time, - rich_message=f"[green]✓ Reverted in {execution_time}ms[/]", - ) - return execution_time - - def _synchronize_version_records( - self, driver: Any, *, use_logger: bool = False, echo: bool = True, summary_only: bool = False - ) -> int: - """Synchronize database version records with migration files. - - Auto-updates DB tracking when migrations have been renamed by fix command. - This allows developers to just run upgrade after pulling changes without - manually running fix. - - Validates checksums match before updating to prevent incorrect matches. - - Args: - driver: Database driver instance. - use_logger: If True, output to logger instead of Rich console. - echo: Whether to echo output to the console. - summary_only: Whether summary-only logging is enabled. - - Returns: - Number of version records updated. - """ - all_migrations = self.runner.get_migration_files() - - try: - applied_migrations: list[AppliedMigrationRecord] = self.tracker.get_applied_migrations(driver) - except Exception as exc: - log_with_context( - logger, - logging.DEBUG, - "migration.list", - db_system=resolve_db_system(type(driver).__name__), - error_type=type(exc).__name__, - status="failed", - operation="applied_fetch", - ) - return 0 - - applied_map = {m["version_num"]: m for m in applied_migrations} - - conversion_map = generate_conversion_map(all_migrations) - - updated_count = 0 - if conversion_map: - for old_version, new_version in conversion_map.items(): - if old_version in applied_map and new_version not in applied_map: - applied_checksum = applied_map[old_version]["checksum"] - - file_path = next((path for v, path in all_migrations if v == new_version), None) - if file_path: - migration = self.runner.load_migration(file_path, new_version) - if migration["checksum"] == applied_checksum: - self.tracker.update_version_record( - driver, old_version, new_version, applied_versions=set(applied_map) - ) - if use_logger: - if not summary_only: - logger.info("Reconciled version: %s -> %s", old_version, new_version) - elif echo: - console.print(f" [dim]Reconciled version:[/] {old_version} → {new_version}") - updated_count += 1 - elif use_logger: - logger.warning( - "Checksum mismatch for %s -> %s, skipping auto-sync", old_version, new_version - ) - elif echo: - console.print( - f" [yellow]Warning: Checksum mismatch for {old_version} → {new_version}, skipping auto-sync[/]" - ) - else: - file_checksums = self._load_migration_checksums(all_migrations) - - for applied_version, applied_record in applied_map.items(): - for file_version, (file_checksum, _) in file_checksums.items(): - if file_version not in applied_map and applied_record["checksum"] == file_checksum: - self.tracker.update_version_record(driver, applied_version, file_version) - if use_logger: - if not summary_only: - logger.info("Reconciled version: %s -> %s", applied_version, file_version) - elif echo: - console.print(f" [dim]Reconciled version:[/] {applied_version} → {file_version}") - updated_count += 1 - break - - if updated_count > 0: - if use_logger: - if not summary_only: - logger.info("Reconciled %d version record(s)", updated_count) - elif echo: - console.print(f"[cyan]Reconciled {updated_count} version record(s)[/]") - - return updated_count - @_with_command_span("upgrade", metadata_fn=_command_metadata) def upgrade( self, @@ -620,12 +369,15 @@ def upgrade( error = exc raise finally: + effective_error = error or self._last_command_error + if not dry_run and applied_count: + self._record_command_metric("applied", float(applied_count)) duration_ms = int((time.perf_counter() - start_time) * 1000) _log_command_summary( use_logger=ul, summary_only=summary_value, command="upgrade", - status="failed" if error else "complete", + status="failed" if effective_error else "complete", revision=revision, dry_run=dry_run, pending_count=pending_count, @@ -635,7 +387,7 @@ def upgrade( db_system=db_system, bind_key=getattr(self.config, "bind_key", None), config_name=type(self.config).__name__, - error=error, + error=effective_error, allow_missing=allow_missing, auto_sync=auto_sync, ) @@ -645,8 +397,6 @@ def upgrade( "Dry run complete. No changes were made to the database.", rich_message="\n[bold yellow]Dry run complete.[/] No changes were made to the database.", ) - elif applied_count: - self._record_command_metric("applied", float(applied_count)) @_with_command_span("downgrade", metadata_fn=_command_metadata) def downgrade( @@ -727,7 +477,8 @@ def downgrade( ) if runtime is not None: runtime.increment_metric("migrations.command.downgrade.missing_files") - continue + self._last_command_error = MigrationError(f"Migration file not found for {version}") + return migration = self.runner.load_migration(all_files[version], version) action_verb = "Would revert" if dry_run else "Reverting" @@ -756,12 +507,15 @@ def downgrade( error = exc raise finally: + effective_error = error or self._last_command_error + if not dry_run and reverted_count: + self._record_command_metric("reverted", float(reverted_count)) duration_ms = int((time.perf_counter() - start_time) * 1000) _log_command_summary( use_logger=ul, summary_only=summary_value, command="downgrade", - status="failed" if error else "complete", + status="failed" if effective_error else "complete", revision=revision, dry_run=dry_run, pending_count=pending_count, @@ -771,7 +525,7 @@ def downgrade( db_system=db_system, bind_key=getattr(self.config, "bind_key", None), config_name=type(self.config).__name__, - error=error, + error=effective_error, ) if dry_run: @@ -779,8 +533,6 @@ def downgrade( "Dry run complete. No changes were made to the database.", rich_message="\n[bold yellow]Dry run complete.[/] No changes were made to the database.", ) - elif reverted_count: - self._record_command_metric("applied", float(reverted_count)) def stamp(self, revision: str) -> None: """Mark database as being at a specific revision without running migrations. @@ -790,14 +542,61 @@ def stamp(self, revision: str) -> None: """ with self.config.provide_session() as driver: self.tracker.ensure_tracking_table(driver) - all_migrations = dict(self.runner.get_migration_files()) - if revision not in all_migrations: + if revision == "base": + clear_sql = sql.delete().from_(self.tracker.version_table) + driver.execute(clear_sql) + if isinstance(self.tracker, SyncMigrationTracker): + self.tracker._safe_commit(driver) + console.print("[green]Database stamped at revision base[/]") + return + + all_migrations_list = self.runner.get_migration_files() + target_version: str | None = None + try: + parsed_target = parse_version(revision) + except ValueError: + parsed_target = None + + for v, _ in all_migrations_list: + if v == revision: + target_version = v + break + if parsed_target is not None: + try: + if parse_version(v) == parsed_target: + target_version = v + break + except ValueError: + continue + + if target_version is None: console.print(f"[red]Unknown revision: {revision}[/]") return + + target_parsed = parse_version(target_version) if parsed_target is not None else None clear_sql = sql.delete().from_(self.tracker.version_table) driver.execute(clear_sql) - self.tracker.record_migration(driver, revision, f"Stamped to {revision}", 0, "manual-stamp") - console.print(f"[green]Database stamped at revision {revision}[/]") + for v, path in all_migrations_list: + should_record = False + if target_parsed is not None: + try: + v_parsed = parse_version(v) + if v_parsed.extension == target_parsed.extension and v_parsed <= target_parsed: + should_record = True + except ValueError: + should_record = v == target_version + elif v == target_version: + should_record = True + if should_record: + if path.exists(): + migration = self.runner.load_migration(path, v) + description = migration["description"] or f"Stamped to {v}" + checksum = migration["checksum"] + else: + description = f"Stamped to {v}" + checksum = "manual-stamp" + self.tracker.record_migration(driver, v, description, 0, checksum) + console.print(f"[green]Database stamped at revision {target_version}[/]") def revision(self, message: str, file_type: str | None = None) -> None: """Create a new migration file with timestamp-based versioning. @@ -856,8 +655,6 @@ def squash( console.print(f"[cyan]Squashing range: {start_version} to {end_version}[/]") if description is None: - from rich.prompt import Prompt - description = Prompt.ask("Migration description", default="squashed_migrations") plans = squasher.plan_squash( @@ -891,27 +688,36 @@ def squash( console.print("[yellow]Squash cancelled[/]") return - squasher.apply_squash(plans) - for plan in plans: - console.print(f"[green]✓ Created squashed migration: {plan.target_path.name}[/]") + try: + squasher.apply_squash(plans, cleanup_backup=False) + for plan in plans: + console.print(f"[green]✓ Created squashed migration: {plan.target_path.name}[/]") - if update_database: - with self.config.provide_session() as driver: - self.tracker.ensure_tracking_table(driver) + if update_database: + with self.config.provide_session() as driver: + self.tracker.ensure_tracking_table(driver) - for plan in plans: - if self.tracker.is_squash_already_applied(driver, plan.target_version, plan.source_versions): - up_sql, down_sql = squasher.extract_sql(plan.source_migrations) - if plan.target_path.suffix == ".py": - content = squasher.generate_python_squash(plan, up_sql, down_sql) - else: - content = squasher.generate_squashed_content(plan, up_sql, down_sql) - checksum = self.runner.calculate_checksum(content) + for plan in plans: + if self.tracker.is_squash_already_applied(driver, plan.target_version, plan.source_versions): + if plan.target_path.exists(): + content = plan.target_path.read_text(encoding="utf-8") + else: + up_sql, down_sql = squasher.extract_sql(plan.source_migrations) + if plan.target_path.suffix == ".py": + content = squasher.generate_python_squash(plan, up_sql, down_sql) + else: + content = squasher.generate_squashed_content(plan, up_sql, down_sql) + checksum = self.runner.calculate_checksum(content) + + self.tracker.replace_with_squash( + driver, plan.target_version, plan.source_versions, description, checksum + ) + console.print("[green]✓ Updated migration tracking table[/]") - self.tracker.replace_with_squash( - driver, plan.target_version, plan.source_versions, description, checksum - ) - console.print("[green]✓ Updated migration tracking table[/]") + squasher._cleanup_backup() + except Exception: + squasher._rollback_backup() + raise console.print("[green]✓ Squash complete![/]") @@ -997,113 +803,17 @@ def fix(self, dry_run: bool = False, update_database: bool = True, yes: bool = F console.print("[yellow]Restored files from backup[/]") raise - -class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]): - """Asynchronous migration commands.""" - - def __init__(self, config: "AsyncConfigT") -> None: - """Initialize migration commands. - - Args: - config: The SQLSpec configuration. - """ - super().__init__(config) - self.tracker = self._create_tracker() - context = MigrationContext.from_config(config) - context.extension_config = self.extension_configs - - self.runner = AsyncMigrationRunner( - self.migrations_path, - self._discover_extension_migrations(), - context, - self.extension_configs, - runtime=self._runtime, - description_hints=self._template_settings.description_hints, - ) - - async def _validate_migration_schema(self, driver: Any) -> None: + def _validate_migration_schema(self, driver: Any) -> None: """Validate the configured migration schema exists before issuing DDL.""" default_schema = self._resolve_default_schema() if default_schema is None: return self._require_schema_support(default_schema) - if not await driver.has_schema(default_schema): + if not driver.has_schema(default_schema): msg = f"Configured schema '{default_schema}' does not exist" raise MigrationError(msg) - async def init(self, directory: str, package: bool = True) -> None: - """Initialize migration directory structure. - - Args: - directory: Directory path for migrations. - package: Whether to create __init__.py in the directory. - """ - self.init_directory(directory, package) - - async def current(self, verbose: bool = False) -> "str | None": - """Show current migration version. - - Args: - verbose: Whether to show detailed migration history. - - Returns: - The current migration version or None if no migrations applied. - """ - async with self.config.provide_session() as driver: - await self.tracker.ensure_tracking_table(driver) - - applied: list[AppliedMigrationRecord] = [] - if verbose: - applied = await self.tracker.get_applied_migrations(driver) - current = applied[-1]["version_num"] if applied else None - else: - current = await self.tracker.get_current_version(driver) - if not current: - log_with_context( - logger, - logging.DEBUG, - "migration.list", - db_system=resolve_db_system(type(driver).__name__), - current_version=None, - applied_count=0, - verbose=verbose, - status="empty", - ) - console.print("[yellow]No migrations applied yet[/]") - return None - - console.print(f"[green]Current version:[/] {current}") - if verbose: - table = Table(title="Applied Migrations") - table.add_column("Version", style="cyan") - table.add_column("Description") - table.add_column("Applied At") - table.add_column("Time (ms)", justify="right") - table.add_column("Applied By") - for migration in applied: - table.add_row( - migration["version_num"], - migration.get("description", ""), - str(migration.get("applied_at", "")), - str(migration.get("execution_time_ms", "")), - migration.get("applied_by", ""), - ) - console.print(table) - - applied_count = len(applied) if verbose else None - log_with_context( - logger, - logging.DEBUG, - "migration.list", - db_system=resolve_db_system(type(driver).__name__), - current_version=current, - applied_count=applied_count, - verbose=verbose, - status="complete", - ) - return cast("str | None", current) - - async def _checksum_for_migration(self, version: str, file_path: "Path") -> "tuple[str, tuple[str, Path]] | None": + def _checksum_for_migration(self, version: str, file_path: "Path") -> "tuple[str, tuple[str, Path]] | None": """Load checksum for a single migration. Args: @@ -1114,7 +824,7 @@ async def _checksum_for_migration(self, version: str, file_path: "Path") -> "tup Tuple of (version, (checksum, file_path)) or None if load fails. """ try: - migration = await self.runner.load_migration(file_path, version) + migration = self.runner.load_migration(file_path, version) return (version, (migration["checksum"], file_path)) except Exception as exc: log_with_context( @@ -1130,9 +840,7 @@ async def _checksum_for_migration(self, version: str, file_path: "Path") -> "tup ) return None - async def _load_migration_checksums( - self, all_migrations: "list[tuple[str, Path]]" - ) -> "dict[str, tuple[str, Path]]": + def _load_migration_checksums(self, all_migrations: "list[tuple[str, Path]]") -> "dict[str, tuple[str, Path]]": """Load checksums for all migrations. Args: @@ -1143,12 +851,12 @@ async def _load_migration_checksums( """ file_checksums = {} for version, file_path in all_migrations: - result = await self._checksum_for_migration(version, file_path) + result = self._checksum_for_migration(version, file_path) if result: file_checksums[result[0]] = result[1] return file_checksums - async def _apply_single_migration( + def _apply_single_migration( self, driver: Any, migration: "LoadedMigrationMetadata", @@ -1170,16 +878,34 @@ async def _apply_single_migration( Returns: Execution time in ms on success, None on failure. """ + use_txn = self.runner.should_use_transaction(migration, self.config) try: - async def record_version(exec_time: int, migration: "LoadedMigrationMetadata" = migration) -> None: - await self.tracker.record_migration( - driver, migration["version"], migration["description"], exec_time, migration["checksum"] - ) + def record_version( + exec_time: int, + migration: "LoadedMigrationMetadata" = migration, + version: str = version, + use_txn: bool = use_txn, + ) -> None: + recorded_version = migration["version"] or version + if isinstance(self.tracker, SyncMigrationTracker): + self.tracker.record_migration( + driver, + recorded_version, + migration["description"], + exec_time, + migration["checksum"], + commit=not use_txn, + ) + else: + self.tracker.record_migration( + driver, recorded_version, migration["description"], exec_time, migration["checksum"] + ) - _, execution_time = await self.runner.execute_upgrade(driver, migration, on_success=record_version) + _, execution_time = self.runner.execute_upgrade( + driver, migration, use_transaction=use_txn, on_success=record_version + ) except Exception as exc: - use_txn = self.runner.should_use_transaction(migration, self.config) rollback_msg = " (transaction rolled back)" if use_txn else "" _output_error( use_logger, @@ -1204,7 +930,7 @@ async def record_version(exec_time: int, migration: "LoadedMigrationMetadata" = ) return execution_time - async def _revert_single_migration( + def _revert_single_migration( self, driver: Any, migration: "LoadedMigrationMetadata", @@ -1226,14 +952,22 @@ async def _revert_single_migration( Returns: Execution time in ms on success, None on failure. """ + use_txn = self.runner.should_use_transaction(migration, self.config) + reverted = False try: - async def remove_version(exec_time: int, version: str = version) -> None: - await self.tracker.remove_migration(driver, version) + def remove_version(exec_time: int, version: str = version, use_txn: bool = use_txn) -> None: + nonlocal reverted + reverted = True + if isinstance(self.tracker, SyncMigrationTracker): + self.tracker.remove_migration(driver, version, commit=not use_txn) + else: + self.tracker.remove_migration(driver, version) - _, execution_time = await self.runner.execute_downgrade(driver, migration, on_success=remove_version) + _, execution_time = self.runner.execute_downgrade( + driver, migration, use_transaction=use_txn, on_success=remove_version + ) except Exception as exc: - use_txn = self.runner.should_use_transaction(migration, self.config) rollback_msg = " (transaction rolled back)" if use_txn else "" _output_error( use_logger, @@ -1246,19 +980,30 @@ async def remove_version(exec_time: int, version: str = version) -> None: ) self._last_command_error = exc return None - else: - _output_info( + if not reverted: + missing_exc = MigrationError(f"Migration {version} does not support downgrade") + _output_error( use_logger, echo, summary_only, - "Reverted migration %s in %dms", + "Migration %s failed", version, - execution_time, - rich_message=f"[green]✓ Reverted in {execution_time}ms[/]", + rich_message=f"[red]✗ Failed: {missing_exc}[/]", ) - return execution_time + self._last_command_error = missing_exc + return None + _output_info( + use_logger, + echo, + summary_only, + "Reverted migration %s in %dms", + version, + execution_time, + rich_message=f"[green]✓ Reverted in {execution_time}ms[/]", + ) + return execution_time - async def _synchronize_version_records( + def _synchronize_version_records( self, driver: Any, *, use_logger: bool = False, echo: bool = True, summary_only: bool = False ) -> int: """Synchronize database version records with migration files. @@ -1278,10 +1023,10 @@ async def _synchronize_version_records( Returns: Number of version records updated. """ - all_migrations = await self.runner.get_migration_files() + all_migrations = self.runner.get_migration_files() try: - applied_migrations: list[AppliedMigrationRecord] = await self.tracker.get_applied_migrations(driver) + applied_migrations: list[AppliedMigrationRecord] = self.tracker.get_applied_migrations(driver) except Exception as exc: log_with_context( logger, @@ -1295,43 +1040,59 @@ async def _synchronize_version_records( return 0 applied_map = {m["version_num"]: m for m in applied_migrations} + all_files_map = dict(all_migrations) + claimed_versions = set(applied_map) + reconciled_old_versions: set[str] = set() conversion_map = generate_conversion_map(all_migrations) updated_count = 0 if conversion_map: for old_version, new_version in conversion_map.items(): - if old_version in applied_map and new_version not in applied_map: + if old_version in applied_map and new_version not in claimed_versions: applied_checksum = applied_map[old_version]["checksum"] - file_path = next((path for v, path in all_migrations if v == new_version), None) + file_path = all_files_map.get(new_version) if file_path: - migration = await self.runner.load_migration(file_path, new_version) + migration = self.runner.load_migration(file_path, new_version) if migration["checksum"] == applied_checksum: - await self.tracker.update_version_record( - driver, old_version, new_version, applied_versions=set(applied_map) + self.tracker.update_version_record( + driver, old_version, new_version, applied_versions=claimed_versions ) + claimed_versions.discard(old_version) + claimed_versions.add(new_version) + reconciled_old_versions.add(old_version) if use_logger: if not summary_only: logger.info("Reconciled version: %s -> %s", old_version, new_version) elif echo: console.print(f" [dim]Reconciled version:[/] {old_version} → {new_version}") updated_count += 1 - elif use_logger: - logger.warning( - "Checksum mismatch for %s -> %s, skipping auto-sync", old_version, new_version - ) - elif echo: - console.print( - f" [yellow]Warning: Checksum mismatch for {old_version} → {new_version}, skipping auto-sync[/]" - ) - else: - file_checksums = await self._load_migration_checksums(all_migrations) + else: + reconciled_old_versions.add(old_version) + if use_logger: + logger.warning( + "Checksum mismatch for %s -> %s, skipping auto-sync", old_version, new_version + ) + elif echo: + console.print( + f" [yellow]Warning: Checksum mismatch for {old_version} → {new_version}, skipping auto-sync[/]" + ) + + unreconciled = [ + (v, rec) for v, rec in applied_map.items() if v not in all_files_map and v not in reconciled_old_versions + ] + if unreconciled: + file_checksums = self._load_migration_checksums(all_migrations) - for applied_version, applied_record in applied_map.items(): + for applied_version, applied_record in unreconciled: for file_version, (file_checksum, _) in file_checksums.items(): - if file_version not in applied_map and applied_record["checksum"] == file_checksum: - await self.tracker.update_version_record(driver, applied_version, file_version) + if file_version not in claimed_versions and applied_record["checksum"] == file_checksum: + self.tracker.update_version_record( + driver, applied_version, file_version, applied_versions=claimed_versions + ) + claimed_versions.discard(applied_version) + claimed_versions.add(file_version) if use_logger: if not summary_only: logger.info("Reconciled version: %s -> %s", applied_version, file_version) @@ -1349,6 +1110,102 @@ async def _synchronize_version_records( return updated_count + +class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]): + """Asynchronous migration commands.""" + + def __init__(self, config: "AsyncConfigT") -> None: + """Initialize migration commands. + + Args: + config: The SQLSpec configuration. + """ + super().__init__(config) + self.tracker = self._create_tracker() + context = MigrationContext.from_config(config) + context.extension_config = self.extension_configs + + self.runner = AsyncMigrationRunner( + self.migrations_path, + self._discover_extension_migrations(), + context, + self.extension_configs, + runtime=self._runtime, + description_hints=self._template_settings.description_hints, + ) + + async def init(self, directory: str, package: bool = True) -> None: + """Initialize migration directory structure. + + Args: + directory: Directory path for migrations. + package: Whether to create __init__.py in the directory. + """ + self.init_directory(directory, package) + + async def current(self, verbose: bool = False) -> "str | None": + """Show current migration version. + + Args: + verbose: Whether to show detailed migration history. + + Returns: + The current migration version or None if no migrations applied. + """ + async with self.config.provide_session() as driver: + await self.tracker.ensure_tracking_table(driver) + + applied: list[AppliedMigrationRecord] = [] + if verbose: + applied = await self.tracker.get_applied_migrations(driver) + current = applied[-1]["version_num"] if applied else None + else: + current = await self.tracker.get_current_version(driver) + if not current: + log_with_context( + logger, + logging.DEBUG, + "migration.list", + db_system=resolve_db_system(type(driver).__name__), + current_version=None, + applied_count=0, + verbose=verbose, + status="empty", + ) + console.print("[yellow]No migrations applied yet[/]") + return None + + console.print(f"[green]Current version:[/] {current}") + if verbose: + table = Table(title="Applied Migrations") + table.add_column("Version", style="cyan") + table.add_column("Description") + table.add_column("Applied At") + table.add_column("Time (ms)", justify="right") + table.add_column("Applied By") + for migration in applied: + table.add_row( + migration["version_num"], + migration.get("description", ""), + str(migration.get("applied_at", "")), + str(migration.get("execution_time_ms", "")), + migration.get("applied_by", ""), + ) + console.print(table) + + applied_count = len(applied) if verbose else None + log_with_context( + logger, + logging.DEBUG, + "migration.list", + db_system=resolve_db_system(type(driver).__name__), + current_version=current, + applied_count=applied_count, + verbose=verbose, + status="complete", + ) + return cast("str | None", current) + @_with_command_span("upgrade", metadata_fn=_command_metadata) async def upgrade( self, @@ -1472,12 +1329,15 @@ async def upgrade( error = exc raise finally: + effective_error = error or self._last_command_error + if not dry_run and applied_count: + self._record_command_metric("applied", float(applied_count)) duration_ms = int((time.perf_counter() - start_time) * 1000) _log_command_summary( use_logger=ul, summary_only=summary_value, command="upgrade", - status="failed" if error else "complete", + status="failed" if effective_error else "complete", revision=revision, dry_run=dry_run, pending_count=pending_count, @@ -1487,7 +1347,7 @@ async def upgrade( db_system=db_system, bind_key=getattr(self.config, "bind_key", None), config_name=type(self.config).__name__, - error=error, + error=effective_error, allow_missing=allow_missing, auto_sync=auto_sync, ) @@ -1497,8 +1357,6 @@ async def upgrade( "Dry run complete. No changes were made to the database.", rich_message="\n[bold yellow]Dry run complete.[/] No changes were made to the database.", ) - elif applied_count: - self._record_command_metric("applied", float(applied_count)) @_with_command_span("downgrade", metadata_fn=_command_metadata) async def downgrade( @@ -1580,7 +1438,8 @@ async def downgrade( ) if runtime is not None: runtime.increment_metric("migrations.command.downgrade.missing_files") - continue + self._last_command_error = MigrationError(f"Migration file not found for {version}") + return migration = await self.runner.load_migration(all_files[version], version) action_verb = "Would revert" if dry_run else "Reverting" @@ -1611,12 +1470,15 @@ async def downgrade( error = exc raise finally: + effective_error = error or self._last_command_error + if not dry_run and reverted_count: + self._record_command_metric("reverted", float(reverted_count)) duration_ms = int((time.perf_counter() - start_time) * 1000) _log_command_summary( use_logger=ul, summary_only=summary_value, command="downgrade", - status="failed" if error else "complete", + status="failed" if effective_error else "complete", revision=revision, dry_run=dry_run, pending_count=pending_count, @@ -1626,7 +1488,7 @@ async def downgrade( db_system=db_system, bind_key=getattr(self.config, "bind_key", None), config_name=type(self.config).__name__, - error=error, + error=effective_error, ) if dry_run: @@ -1634,8 +1496,6 @@ async def downgrade( "Dry run complete. No changes were made to the database.", rich_message="\n[bold yellow]Dry run complete.[/] No changes were made to the database.", ) - elif reverted_count: - self._record_command_metric("applied", float(reverted_count)) async def stamp(self, revision: str) -> None: """Mark database as being at a specific revision without running migrations. @@ -1646,15 +1506,61 @@ async def stamp(self, revision: str) -> None: async with self.config.provide_session() as driver: await self.tracker.ensure_tracking_table(driver) - all_migrations = dict(await self.runner.get_migration_files()) - if revision not in all_migrations: + if revision == "base": + clear_sql = sql.delete().from_(self.tracker.version_table) + await driver.execute(clear_sql) + if isinstance(self.tracker, AsyncMigrationTracker): + await self.tracker._safe_commit(driver) + console.print("[green]Database stamped at revision base[/]") + return + + all_migrations_list = await self.runner.get_migration_files() + target_version: str | None = None + try: + parsed_target = parse_version(revision) + except ValueError: + parsed_target = None + + for v, _ in all_migrations_list: + if v == revision: + target_version = v + break + if parsed_target is not None: + try: + if parse_version(v) == parsed_target: + target_version = v + break + except ValueError: + continue + + if target_version is None: console.print(f"[red]Unknown revision: {revision}[/]") return + target_parsed = parse_version(target_version) if parsed_target is not None else None clear_sql = sql.delete().from_(self.tracker.version_table) await driver.execute(clear_sql) - await self.tracker.record_migration(driver, revision, f"Stamped to {revision}", 0, "manual-stamp") - console.print(f"[green]Database stamped at revision {revision}[/]") + for v, path in all_migrations_list: + should_record = False + if target_parsed is not None: + try: + v_parsed = parse_version(v) + if v_parsed.extension == target_parsed.extension and v_parsed <= target_parsed: + should_record = True + except ValueError: + should_record = v == target_version + elif v == target_version: + should_record = True + if should_record: + if path.exists(): + migration = await self.runner.load_migration(path, v) + description = migration["description"] or f"Stamped to {v}" + checksum = migration["checksum"] + else: + description = f"Stamped to {v}" + checksum = "manual-stamp" + await self.tracker.record_migration(driver, v, description, 0, checksum) + console.print(f"[green]Database stamped at revision {target_version}[/]") async def revision(self, message: str, file_type: str | None = None) -> None: """Create a new migration file with timestamp-based versioning. @@ -1725,19 +1631,12 @@ async def squash( console.print(f"[cyan]Squashing range: {start_version} to {end_version}[/]") if description is None: - import anyio - from rich.prompt import Prompt - - description = await anyio.to_thread.run_sync( # pyright: ignore[reportAttributeAccessIssue] + description = await asyncio.to_thread( lambda: Prompt.ask("Migration description", default="squashed_migrations") ) plans = squasher.plan_squash( - start_version, - end_version, - description, # pyright: ignore[reportArgumentType] - allow_gaps=allow_gaps, - output_format=output_format, + start_version, end_version, description, allow_gaps=allow_gaps, output_format=output_format ) table = Table(title="Squash Plan") @@ -1762,34 +1661,43 @@ async def squash( return if not yes: - import anyio - - response = await anyio.to_thread.run_sync(input, "\nProceed with squash? [y/N]: ") # pyright: ignore[reportAttributeAccessIssue] + response = await asyncio.to_thread(input, "\nProceed with squash? [y/N]: ") if response.lower() != "y": console.print("[yellow]Squash cancelled[/]") return - squasher.apply_squash(plans) - for plan in plans: - console.print(f"[green]✓ Created squashed migration: {plan.target_path.name}[/]") + try: + squasher.apply_squash(plans, cleanup_backup=False) + for plan in plans: + console.print(f"[green]✓ Created squashed migration: {plan.target_path.name}[/]") - if update_database: - async with self.config.provide_session() as driver: - await self.tracker.ensure_tracking_table(driver) + if update_database: + async with self.config.provide_session() as driver: + await self.tracker.ensure_tracking_table(driver) - for plan in plans: - if await self.tracker.is_squash_already_applied(driver, plan.target_version, plan.source_versions): - up_sql, down_sql = squasher.extract_sql(plan.source_migrations) - if plan.target_path.suffix == ".py": - content = squasher.generate_python_squash(plan, up_sql, down_sql) - else: - content = squasher.generate_squashed_content(plan, up_sql, down_sql) - checksum = sync_runner.calculate_checksum(content) + for plan in plans: + if await self.tracker.is_squash_already_applied( + driver, plan.target_version, plan.source_versions + ): + if plan.target_path.exists(): + content = plan.target_path.read_text(encoding="utf-8") + else: + up_sql, down_sql = squasher.extract_sql(plan.source_migrations) + if plan.target_path.suffix == ".py": + content = squasher.generate_python_squash(plan, up_sql, down_sql) + else: + content = squasher.generate_squashed_content(plan, up_sql, down_sql) + checksum = sync_runner.calculate_checksum(content) + + await self.tracker.replace_with_squash( + driver, plan.target_version, plan.source_versions, description, checksum + ) + console.print("[green]✓ Updated migration tracking table[/]") - await self.tracker.replace_with_squash( - driver, plan.target_version, plan.source_versions, description, checksum - ) - console.print("[green]✓ Updated migration tracking table[/]") + squasher._cleanup_backup() + except Exception: + squasher._rollback_backup() + raise console.print("[green]✓ Squash complete![/]") @@ -1832,9 +1740,7 @@ async def fix(self, dry_run: bool = False, update_database: bool = True, yes: bo return if not yes: - import anyio - - response = await anyio.to_thread.run_sync(input, "\nProceed with conversion? [y/N]: ") # pyright: ignore[reportAttributeAccessIssue] + response = await asyncio.to_thread(input, "\nProceed with conversion? [y/N]: ") if response.lower() != "y": console.print("[yellow]Conversion cancelled[/]") return @@ -1877,6 +1783,315 @@ async def fix(self, dry_run: bool = False, update_database: bool = True, yes: bo console.print("[yellow]Restored files from backup[/]") raise + async def _validate_migration_schema(self, driver: Any) -> None: + """Validate the configured migration schema exists before issuing DDL.""" + default_schema = self._resolve_default_schema() + if default_schema is None: + return + self._require_schema_support(default_schema) + if not await driver.has_schema(default_schema): + msg = f"Configured schema '{default_schema}' does not exist" + raise MigrationError(msg) + + async def _checksum_for_migration(self, version: str, file_path: "Path") -> "tuple[str, tuple[str, Path]] | None": + """Load checksum for a single migration. + + Args: + version: Migration version. + file_path: Path to migration file. + + Returns: + Tuple of (version, (checksum, file_path)) or None if load fails. + """ + try: + migration = await self.runner.load_migration(file_path, version) + return (version, (migration["checksum"], file_path)) + except Exception as exc: + log_with_context( + logger, + logging.DEBUG, + "migration.list", + db_system=resolve_db_system(type(self.config).__name__), + version=version, + file_path=str(file_path), + error_type=type(exc).__name__, + status="failed", + operation="load_checksum", + ) + return None + + async def _load_migration_checksums( + self, all_migrations: "list[tuple[str, Path]]" + ) -> "dict[str, tuple[str, Path]]": + """Load checksums for all migrations. + + Args: + all_migrations: List of (version, file_path) tuples. + + Returns: + Dictionary mapping version to (checksum, file_path) tuples. + """ + file_checksums = {} + for version, file_path in all_migrations: + result = await self._checksum_for_migration(version, file_path) + if result: + file_checksums[result[0]] = result[1] + return file_checksums + + async def _apply_single_migration( + self, + driver: Any, + migration: "LoadedMigrationMetadata", + version: str, + use_logger: bool, + echo: bool, + summary_only: bool, + ) -> int | None: + """Apply a single migration and record it. + + Args: + driver: Database driver instance. + migration: Migration dictionary with version, description, checksum. + version: Version string. + use_logger: Whether to output to logger instead of console. + echo: Whether to echo output to the console. + summary_only: Whether summary-only logging is enabled. + + Returns: + Execution time in ms on success, None on failure. + """ + use_txn = self.runner.should_use_transaction(migration, self.config) + try: + + async def record_version( + exec_time: int, + migration: "LoadedMigrationMetadata" = migration, + version: str = version, + use_txn: bool = use_txn, + ) -> None: + recorded_version = migration["version"] or version + if isinstance(self.tracker, AsyncMigrationTracker): + await self.tracker.record_migration( + driver, + recorded_version, + migration["description"], + exec_time, + migration["checksum"], + commit=not use_txn, + ) + else: + await self.tracker.record_migration( + driver, recorded_version, migration["description"], exec_time, migration["checksum"] + ) + + _, execution_time = await self.runner.execute_upgrade( + driver, migration, use_transaction=use_txn, on_success=record_version + ) + except Exception as exc: + rollback_msg = " (transaction rolled back)" if use_txn else "" + _output_error( + use_logger, + echo, + summary_only, + "Migration %s failed%s", + version, + rollback_msg, + rich_message=f"[red]✗ Failed{rollback_msg}: {exc}[/]", + ) + self._last_command_error = exc + return None + else: + _output_info( + use_logger, + echo, + summary_only, + "Applied migration %s in %dms", + version, + execution_time, + rich_message=f"[green]✓ Applied in {execution_time}ms[/]", + ) + return execution_time + + async def _revert_single_migration( + self, + driver: Any, + migration: "LoadedMigrationMetadata", + version: str, + use_logger: bool, + echo: bool, + summary_only: bool, + ) -> int | None: + """Revert a single migration. + + Args: + driver: Database driver instance. + migration: Migration dictionary. + version: Version string. + use_logger: Whether to output to logger instead of console. + echo: Whether to echo output to the console. + summary_only: Whether summary-only logging is enabled. + + Returns: + Execution time in ms on success, None on failure. + """ + use_txn = self.runner.should_use_transaction(migration, self.config) + reverted = False + try: + + async def remove_version(exec_time: int, version: str = version, use_txn: bool = use_txn) -> None: + nonlocal reverted + reverted = True + if isinstance(self.tracker, AsyncMigrationTracker): + await self.tracker.remove_migration(driver, version, commit=not use_txn) + else: + await self.tracker.remove_migration(driver, version) + + _, execution_time = await self.runner.execute_downgrade( + driver, migration, use_transaction=use_txn, on_success=remove_version + ) + except Exception as exc: + rollback_msg = " (transaction rolled back)" if use_txn else "" + _output_error( + use_logger, + echo, + summary_only, + "Migration %s failed%s", + version, + rollback_msg, + rich_message=f"[red]✗ Failed{rollback_msg}: {exc}[/]", + ) + self._last_command_error = exc + return None + if not reverted: + missing_exc = MigrationError(f"Migration {version} does not support downgrade") + _output_error( + use_logger, + echo, + summary_only, + "Migration %s failed", + version, + rich_message=f"[red]✗ Failed: {missing_exc}[/]", + ) + self._last_command_error = missing_exc + return None + _output_info( + use_logger, + echo, + summary_only, + "Reverted migration %s in %dms", + version, + execution_time, + rich_message=f"[green]✓ Reverted in {execution_time}ms[/]", + ) + return execution_time + + async def _synchronize_version_records( + self, driver: Any, *, use_logger: bool = False, echo: bool = True, summary_only: bool = False + ) -> int: + """Synchronize database version records with migration files. + + Auto-updates DB tracking when migrations have been renamed by fix command. + This allows developers to just run upgrade after pulling changes without + manually running fix. + + Validates checksums match before updating to prevent incorrect matches. + + Args: + driver: Database driver instance. + use_logger: If True, output to logger instead of Rich console. + echo: Whether to echo output to the console. + summary_only: Whether summary-only logging is enabled. + + Returns: + Number of version records updated. + """ + all_migrations = await self.runner.get_migration_files() + + try: + applied_migrations: list[AppliedMigrationRecord] = await self.tracker.get_applied_migrations(driver) + except Exception as exc: + log_with_context( + logger, + logging.DEBUG, + "migration.list", + db_system=resolve_db_system(type(driver).__name__), + error_type=type(exc).__name__, + status="failed", + operation="applied_fetch", + ) + return 0 + + applied_map = {m["version_num"]: m for m in applied_migrations} + all_files_map = dict(all_migrations) + claimed_versions = set(applied_map) + reconciled_old_versions: set[str] = set() + + conversion_map = generate_conversion_map(all_migrations) + + updated_count = 0 + if conversion_map: + for old_version, new_version in conversion_map.items(): + if old_version in applied_map and new_version not in claimed_versions: + applied_checksum = applied_map[old_version]["checksum"] + + file_path = all_files_map.get(new_version) + if file_path: + migration = await self.runner.load_migration(file_path, new_version) + if migration["checksum"] == applied_checksum: + await self.tracker.update_version_record( + driver, old_version, new_version, applied_versions=claimed_versions + ) + claimed_versions.discard(old_version) + claimed_versions.add(new_version) + reconciled_old_versions.add(old_version) + if use_logger: + if not summary_only: + logger.info("Reconciled version: %s -> %s", old_version, new_version) + elif echo: + console.print(f" [dim]Reconciled version:[/] {old_version} → {new_version}") + updated_count += 1 + else: + reconciled_old_versions.add(old_version) + if use_logger: + logger.warning( + "Checksum mismatch for %s -> %s, skipping auto-sync", old_version, new_version + ) + elif echo: + console.print( + f" [yellow]Warning: Checksum mismatch for {old_version} → {new_version}, skipping auto-sync[/]" + ) + + unreconciled = [ + (v, rec) for v, rec in applied_map.items() if v not in all_files_map and v not in reconciled_old_versions + ] + if unreconciled: + file_checksums = await self._load_migration_checksums(all_migrations) + + for applied_version, applied_record in unreconciled: + for file_version, (file_checksum, _) in file_checksums.items(): + if file_version not in claimed_versions and applied_record["checksum"] == file_checksum: + await self.tracker.update_version_record( + driver, applied_version, file_version, applied_versions=claimed_versions + ) + claimed_versions.discard(applied_version) + claimed_versions.add(file_version) + if use_logger: + if not summary_only: + logger.info("Reconciled version: %s -> %s", applied_version, file_version) + elif echo: + console.print(f" [dim]Reconciled version:[/] {applied_version} → {file_version}") + updated_count += 1 + break + + if updated_count > 0: + if use_logger: + if not summary_only: + logger.info("Reconciled %d version record(s)", updated_count) + elif echo: + console.print(f"[cyan]Reconciled {updated_count} version record(s)[/]") + + return updated_count + def create_migration_commands( config: "SyncConfigT | AsyncConfigT", diff --git a/sqlspec/migrations/context.py b/sqlspec/migrations/context.py index e3d8c2c42..597c4e8a3 100644 --- a/sqlspec/migrations/context.py +++ b/sqlspec/migrations/context.py @@ -49,28 +49,6 @@ def __post_init__(self) -> None: if not self.extension_config: self.extension_config = {} - @classmethod - def from_config(cls, config: Any) -> "MigrationContext": - """Create context from database configuration. - - Args: - config: Database configuration object. - - Returns: - Migration context with dialect information. - """ - dialect: Any | None = None - try: - if isinstance(config, HasStatementConfigProtocol) and config.statement_config: - dialect = config.statement_config.dialect - elif has_statement_config_factory(config): - stmt_config = config._create_statement_config() # pyright: ignore[reportPrivateUsage] - dialect = stmt_config.dialect - except Exception: - logger.debug("Unable to extract dialect from config") - - return cls(dialect=_normalize_dialect_name(dialect), config=config) - @property def is_async_execution(self) -> bool: """Check if migrations are running in an async execution context. @@ -106,6 +84,28 @@ def execution_mode(self) -> str: """ return "async" if self.is_async_execution else "sync" + @classmethod + def from_config(cls, config: Any) -> "MigrationContext": + """Create context from database configuration. + + Args: + config: Database configuration object. + + Returns: + Migration context with dialect information. + """ + dialect: Any | None = None + try: + if isinstance(config, HasStatementConfigProtocol) and config.statement_config: + dialect = config.statement_config.dialect + elif has_statement_config_factory(config): + stmt_config = config._create_statement_config() # pyright: ignore[reportPrivateUsage] + dialect = stmt_config.dialect + except Exception: + logger.debug("Unable to extract dialect from config") + + return cls(dialect=_normalize_dialect_name(dialect), config=config) + def set_execution_metadata(self, key: str, value: Any) -> None: """Set execution metadata for tracking migration state. diff --git a/sqlspec/migrations/fix.py b/sqlspec/migrations/fix.py index f69b7d44e..32b2c289d 100644 --- a/sqlspec/migrations/fix.py +++ b/sqlspec/migrations/fix.py @@ -17,7 +17,7 @@ logger = get_logger(__name__) -@dataclass +@dataclass(slots=True) class MigrationRename: """Represents a planned migration file rename operation. @@ -27,7 +27,7 @@ class MigrationRename: old_version: Current version string. new_version: Target version string. needs_content_update: Whether file content needs updating. - True for SQL files that contain query names. + True for SQL and Python files that contain version headers. """ old_path: Path @@ -87,7 +87,13 @@ def plan_renames(self, conversion_map: dict[str, str]) -> list[MigrationRename]: msg = f"Target file already exists: {new_path}" raise ValueError(msg) - needs_content_update = suffix == ".sql" + if suffix == ".sql": + needs_content_update = True + elif suffix == ".py" and old_path.is_file(): + version_pattern = re.compile(rf"((?:--\s*)?Version:\s+){re.escape(old_version)}\b") + needs_content_update = bool(version_pattern.search(old_path.read_text(encoding="utf-8"))) + else: + needs_content_update = False renames.append( MigrationRename( @@ -136,6 +142,7 @@ def update_file_content(self, file_path: Path, old_version: "str | None", new_ve -- name: migrate-{old_version}-up → -- name: migrate-{new_version}-up -- name: migrate-{old_version}-down → -- name: migrate-{new_version}-down -- Version: {old_version} → -- Version: {new_version} + Version: {old_version} → Version: {new_version} Creates version-specific regex patterns to avoid unintended replacements of other migrate-* patterns in the file. @@ -151,9 +158,9 @@ def update_file_content(self, file_path: Path, old_version: "str | None", new_ve content = file_path.read_text(encoding="utf-8") - up_pattern = re.compile(rf"(-- name:\s+migrate-){re.escape(old_version)}(-up)") - down_pattern = re.compile(rf"(-- name:\s+migrate-){re.escape(old_version)}(-down)") - version_pattern = re.compile(rf"(-- Version:\s+){re.escape(old_version)}") + up_pattern = re.compile(rf"(-- name:\s+migrate-){re.escape(old_version)}(-up)\b") + down_pattern = re.compile(rf"(-- name:\s+migrate-){re.escape(old_version)}(-down)\b") + version_pattern = re.compile(rf"((?:--\s*)?Version:\s+){re.escape(old_version)}\b") content = up_pattern.sub(rf"\g<1>{new_version}\g<2>", content) content = down_pattern.sub(rf"\g<1>{new_version}\g<2>", content) @@ -171,7 +178,8 @@ def rollback(self) -> None: if not self.backup_path or not self.backup_path.exists(): return - restore_backup(self.migrations_path, self.backup_path) + restore_backup(self.migrations_path, self.backup_path, delete_backup=True) + self.backup_path = None def cleanup(self) -> None: """Remove backup directory after successful conversion. diff --git a/sqlspec/migrations/loaders.py b/sqlspec/migrations/loaders.py index aa3b84fcc..d8ae0f16e 100644 --- a/sqlspec/migrations/loaders.py +++ b/sqlspec/migrations/loaders.py @@ -4,17 +4,18 @@ """ import abc +import ast import inspect import sys import types -from collections.abc import Callable, Iterator -from contextlib import contextmanager +from collections.abc import Callable, Iterable, Iterator +from contextlib import contextmanager, suppress from pathlib import Path from typing import Any, Final, cast from sqlspec.loader import SQLFileLoader as CoreSQLFileLoader from sqlspec.migrations.context import MigrationContext -from sqlspec.migrations.version import _format_sequential_version, parse_extension_stem +from sqlspec.migrations.version import extract_migration_version from sqlspec.utils.sync_tools import await_ __all__ = ("BaseMigrationLoader", "MigrationLoadError", "PythonFileLoader", "SQLFileLoader", "get_migration_loader") @@ -74,6 +75,17 @@ def validate_migration_file(self, path: Path) -> None: """ ... + def has_downgrade(self, path: Path) -> bool: + """Return whether the migration file defines a downgrade direction. + + Args: + path: Path to the migration file. + + Returns: + True when the migration defines a downgrade direction. + """ + return False + class SQLFileLoader(BaseMigrationLoader): """Loader for SQL migration files.""" @@ -158,6 +170,11 @@ def validate_migration_file(self, path: Path) -> None: msg = f"Migration {path} missing required 'up' query: {up_query}" raise MigrationLoadError(msg) + def has_downgrade(self, path: Path) -> bool: + """Return whether the SQL migration file defines a downgrade query.""" + version = self._extract_version(path.name) + return bool(version) and self.sql_loader.has_query(f"migrate-{version}-down") + def _extract_version(self, filename: str) -> str: """Extract version from filename. @@ -170,28 +187,13 @@ def _extract_version(self, filename: str) -> str: Returns: Version string or empty string if invalid. """ - timestamp_min_length = 4 - - name_without_ext = filename.rsplit(".", 1)[0] - - if name_without_ext.startswith("ext_"): - extension = parse_extension_stem(name_without_ext) - if extension is None: - return "" - ext_name, ext_version = extension - return f"ext_{ext_name}_{ext_version}" - - parts = name_without_ext.split("_", 1) - if parts and parts[0].isdigit(): - return parts[0] if len(parts[0]) > timestamp_min_length else _format_sequential_version(parts[0]) - - return "" + return extract_migration_version(filename) or "" class PythonFileLoader(BaseMigrationLoader): """Loader for Python migration files.""" - __slots__ = ("context", "migrations_dir", "project_root") + __slots__ = ("_module_cache", "context", "migrations_dir", "project_root") def __init__(self, migrations_dir: Path, project_root: "Path | None" = None, context: "Any | None" = None) -> None: """Initialize Python file loader. @@ -204,6 +206,7 @@ def __init__(self, migrations_dir: Path, project_root: "Path | None" = None, con self.migrations_dir = migrations_dir self.project_root = project_root if project_root is not None else self._find_project_root(migrations_dir) self.context = context + self._module_cache: dict[str, tuple[int, int, types.ModuleType]] = {} async def get_up_sql(self, path: Path) -> list[str]: """Load Python migration and execute upgrade function. @@ -230,15 +233,12 @@ async def get_up_sql(self, path: Path) -> list[str]: if isinstance(self.context, MigrationContext): self.context.validate_async_usage(upgrade_func) - sig = inspect.signature(upgrade_func) - accepts_context = "context" in sig.parameters or len(sig.parameters) > 0 + pass_context = _callable_accepts_context(upgrade_func, self.context) if inspect.iscoroutinefunction(upgrade_func): - sql_result = ( - await upgrade_func(self.context) if accepts_context and self.context else await upgrade_func() - ) + sql_result = await upgrade_func(self.context) if pass_context else await upgrade_func() else: - sql_result = upgrade_func(self.context) if accepts_context and self.context else upgrade_func() + sql_result = upgrade_func(self.context) if pass_context else upgrade_func() return self._normalize_and_validate_sql(sql_result, path) @@ -250,7 +250,7 @@ async def get_down_sql(self, path: Path) -> "list[str] | None": Returns: List of SQL statements for downgrade, which may be empty. None when the - module defines no downgrade function. + module defines no downgrade function or returns None. """ with self._temporary_project_path(): module = self._load_module_from_path(path) @@ -264,15 +264,15 @@ async def get_down_sql(self, path: Path) -> "list[str] | None": if isinstance(self.context, MigrationContext): self.context.validate_async_usage(downgrade_func) - sig = inspect.signature(downgrade_func) - accepts_context = "context" in sig.parameters or len(sig.parameters) > 0 + pass_context = _callable_accepts_context(downgrade_func, self.context) if inspect.iscoroutinefunction(downgrade_func): - sql_result = ( - await downgrade_func(self.context) if accepts_context and self.context else await downgrade_func() - ) + sql_result = await downgrade_func(self.context) if pass_context else await downgrade_func() else: - sql_result = downgrade_func(self.context) if accepts_context and self.context else downgrade_func() + sql_result = downgrade_func(self.context) if pass_context else downgrade_func() + + if sql_result is None: + return None return self._normalize_and_validate_sql(sql_result, path) @@ -296,6 +296,17 @@ def validate_migration_file(self, path: Path) -> None: msg = f"Migration {path} missing required upgrade function. Expected 'up()' or 'migrate_up()'" raise MigrationLoadError(msg) + def has_downgrade(self, path: Path) -> bool: + """Return whether the Python migration defines a downgrade function without executing it.""" + with self._temporary_project_path(): + module = self._load_module_from_path(path) + func_name = "down" if _get_callable_attr(module, "down") is not None else None + if func_name is None and _get_callable_attr(module, "migrate_down") is not None: + func_name = "migrate_down" + if func_name is None: + return False + return not _is_literal_none_return_function(path, func_name) + def _find_project_root(self, start_path: Path) -> Path: """Find project root by searching upwards for marker files. @@ -327,7 +338,8 @@ def _temporary_project_path(self) -> Iterator[None]: try: yield finally: - sys.path.remove(path_to_add) + with suppress(ValueError): + sys.path.remove(path_to_add) def _load_module_from_path(self, path: Path) -> Any: """Load a Python module from file path. @@ -341,7 +353,18 @@ def _load_module_from_path(self, path: Path) -> Any: Raises: MigrationLoadError: If module loading fails. """ + cache_key = str(path) module_name = f"sqlspec_migration_{path.stem}" + try: + stat_result = path.stat() + except OSError as e: + msg = f"Failed to execute migration module {path}: {e}" + raise MigrationLoadError(msg) from e + + cached = self._module_cache.get(cache_key) + if cached is not None and cached[0] == stat_result.st_mtime_ns and cached[1] == stat_result.st_size: + sys.modules[module_name] = cached[2] + return cached[2] if module_name in sys.modules: sys.modules.pop(module_name, None) @@ -362,6 +385,7 @@ def _load_module_from_path(self, path: Path) -> Any: msg = f"Failed to execute migration module {path}: {e}" raise MigrationLoadError(msg) from e + self._module_cache[cache_key] = (stat_result.st_mtime_ns, stat_result.st_size, module) return module def _normalize_and_validate_sql(self, sql: Any, migration_path: Path) -> list[str]: @@ -380,7 +404,7 @@ def _normalize_and_validate_sql(self, sql: Any, migration_path: Path) -> list[st if isinstance(sql, str): stripped = sql.strip() return [stripped] if stripped else [] - if isinstance(sql, list): + if isinstance(sql, Iterable) and not isinstance(sql, (str, bytes)): result = [] for i, item in enumerate(sql): if not isinstance(item, str): @@ -452,6 +476,46 @@ def _get_callable_attr(module: types.ModuleType, name: str) -> "Callable[..., An return None +def _callable_accepts_context(func: "Callable[..., Any]", context: Any) -> bool: + """Return whether the migration function should be invoked with ``context``.""" + sig = inspect.signature(func) + positional_params = [ + p + for p in sig.parameters.values() + if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values()) + if not positional_params: + return bool(has_var_positional and context is not None) + first_param = positional_params[0] + if first_param.default is inspect.Parameter.empty: + return True + return context is not None + + +def _is_literal_none_return_function(path: Path, func_name: str) -> bool: + """Return True when a top-level function body only returns None.""" + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (OSError, SyntaxError): + return False + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name: + body = list(node.body) + if ( + body + and isinstance(body[0], ast.Expr) + and isinstance(body[0].value, ast.Constant) + and isinstance(body[0].value.value, str) + ): + body = body[1:] + if len(body) == 1 and isinstance(body[0], ast.Return): + ret_val = body[0].value + return ret_val is None or (isinstance(ret_val, ast.Constant) and ret_val.value is None) + return False + return False + + def _load_migration_sql(loader: BaseMigrationLoader, path: Path, direction: str) -> "list[str] | None": """Bridge the async loader contract for synchronous migration consumers.""" method = loader.get_up_sql if direction == "up" else loader.get_down_sql diff --git a/sqlspec/migrations/runner.py b/sqlspec/migrations/runner.py index d691ce61e..8d67d5a10 100644 --- a/sqlspec/migrations/runner.py +++ b/sqlspec/migrations/runner.py @@ -13,10 +13,10 @@ from sqlspec.exceptions import MigrationError from sqlspec.loader import SQLFileLoader from sqlspec.migrations.context import MigrationContext -from sqlspec.migrations.loaders import _load_migration_sql, get_migration_loader +from sqlspec.migrations.loaders import PythonFileLoader, _load_migration_sql, get_migration_loader from sqlspec.migrations.templates import TemplateDescriptionHints from sqlspec.migrations.utils import resolve_default_schema as _resolve_default_schema -from sqlspec.migrations.version import _format_sequential_version, parse_extension_stem, parse_version +from sqlspec.migrations.version import extract_migration_version, parse_extension_stem, parse_version from sqlspec.observability import resolve_db_system from sqlspec.utils.logging import get_logger, log_with_context from sqlspec.utils.sync_tools import async_ @@ -37,30 +37,6 @@ _SCHEMA_IDENTIFIER_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*") -class _CachedMigrationMetadata: - """Cached migration metadata keyed by file path.""" - - __slots__ = ("metadata", "mtime_ns", "size") - - def __init__(self, metadata: "dict[str, Any]", mtime_ns: int, size: int) -> None: - self.metadata = metadata - self.mtime_ns = mtime_ns - self.size = size - - def clone(self) -> "dict[str, Any]": - return dict(self.metadata) - - -class _MigrationFileEntry: - """Represents a migration file discovered during directory scanning.""" - - __slots__ = ("extension_name", "path") - - def __init__(self, path: Path, extension_name: "str | None") -> None: - self.path = path - self.extension_name = extension_name - - class BaseMigrationRunner: """Base migration runner with common functionality shared between sync and async implementations.""" @@ -120,6 +96,69 @@ def set_driver(self, driver: "SyncDriverAdapterBase | AsyncDriverAdapterBase") - if self.context is not None: self.context.driver = driver + def calculate_checksum(self, content: str) -> str: + """Calculate MD5 checksum of migration content. + + Canonicalizes content by excluding query name headers and version comment + lines that change during the fix command. This ensures checksums remain + stable when converting timestamp versions to sequential format. + + Args: + content: The migration file content. + + Returns: + MD5 checksum hex string. + """ + canonical_content = re.sub( + r"^(?:--\s*name:\s*migrate-[^-]+-(?:up|down)|--\s*Version:\s*.*|Version:\s*.*)\s*$", + "", + content, + flags=re.MULTILINE | re.IGNORECASE, + ) + + return hashlib.md5(canonical_content.encode()).hexdigest() # noqa: S324 + + @abstractmethod + def load_migration(self, file_path: Path) -> "LoadedMigrationMetadata | Awaitable[LoadedMigrationMetadata]": + """Load a migration file and extract its components. + + Args: + file_path: Path to the migration file. + + Returns: + Dictionary containing migration metadata and queries. + For async implementations, returns a coroutine. + """ + + @abstractmethod + def get_migration_files(self) -> "list[tuple[str, Path]] | Awaitable[list[tuple[str, Path]]]": + """Get all migration files sorted by version.""" + + def should_use_transaction( + self, migration: "LoadedMigrationMetadata", config: "DatabaseConfigProtocol[Any, Any, Any]" + ) -> bool: + """Determine if migration should run in a transaction. + + An explicit per-migration directive takes precedence over the config's + transactional-DDL capability, so a data migration can still run + atomically on a backend that restricts DDL in transactions. + + Args: + migration: Migration metadata dictionary. + config: The database configuration instance. + + Returns: + True if migration should be wrapped in a transaction. + """ + if migration.get("transactional") is not None: + return bool(migration["transactional"]) + + if not config.supports_transactional_ddl: + return False + + migration_config = cast("dict[str, Any]", config.migration_config) or {} + return bool(migration_config.get("transactional", True)) + def _log_migration_event(self, level: int, event: str, **extra_fields: Any) -> None: """Log migration events, respecting use_logger and summary_only settings.""" if not self.use_logger: @@ -165,7 +204,7 @@ def _collect_listing_entries(self) -> "tuple[list[_MigrationFileEntry], dict[str def _record_entry( self, - entry: _MigrationFileEntry, + entry: "_MigrationFileEntry", entries: "list[_MigrationFileEntry]", signatures: "dict[str, tuple[int, int]]", digest_source: Any, @@ -193,7 +232,7 @@ def _build_sorted_listing(self, entries: "list[_MigrationFileEntry]") -> "list[t version = self._extract_version(entry.path.name) if not version: continue - if entry.extension_name: + if entry.extension_name and not version.startswith(f"ext_{entry.extension_name}_"): version = f"ext_{entry.extension_name}_{version}" migrations.append((version, entry.path)) @@ -243,51 +282,7 @@ def _extract_version(self, filename: str) -> "str | None": Returns: The extracted version string or None. """ - timestamp_min_length = 4 - - name_without_ext = filename.rsplit(".", 1)[0] - - if name_without_ext.startswith("ext_"): - extension = parse_extension_stem(name_without_ext) - if extension is None: - return None - ext_name, ext_version = extension - return f"ext_{ext_name}_{ext_version}" - - parts = name_without_ext.split("_", 1) - if parts and parts[0].isdigit(): - return parts[0] if len(parts[0]) > timestamp_min_length else _format_sequential_version(parts[0]) - - return None - - def calculate_checksum(self, content: str) -> str: - """Calculate MD5 checksum of migration content. - - Canonicalizes content by excluding query name headers that change during - fix command (migrate-{version}-up/down). This ensures checksums remain - stable when converting timestamp versions to sequential format. - - Args: - content: The migration file content. - - Returns: - MD5 checksum hex string. - """ - canonical_content = re.sub(r"^--\s*name:\s*migrate-[^-]+-(?:up|down)\s*$", "", content, flags=re.MULTILINE) - - return hashlib.md5(canonical_content.encode()).hexdigest() # noqa: S324 - - @abstractmethod - def load_migration(self, file_path: Path) -> "LoadedMigrationMetadata | Awaitable[LoadedMigrationMetadata]": - """Load a migration file and extract its components. - - Args: - file_path: Path to the migration file. - - Returns: - Dictionary containing migration metadata and queries. - For async implementations, returns a coroutine. - """ + return extract_migration_version(filename) def _load_migration_listing(self) -> "list[tuple[str, Path]]": """Build the cached migration listing shared by sync/async runners.""" @@ -318,10 +313,6 @@ def _load_migration_listing(self) -> "list[tuple[str, Path]]": return files - @abstractmethod - def get_migration_files(self) -> "list[tuple[str, Path]] | Awaitable[list[tuple[str, Path]]]": - """Get all migration files sorted by version.""" - def _load_metadata(self, file_path: Path, version: "str | None" = None) -> "LoadedMigrationMetadata": """Load common migration metadata that doesn't require async operations. @@ -344,6 +335,8 @@ def _load_metadata(self, file_path: Path, version: "str | None" = None) -> "Load self._log_migration_event(logging.DEBUG, "migration.metadata.cache_hit", file_path=cache_key) metadata = cast("LoadedMigrationMetadata", cached_metadata.clone()) metadata["file_path"] = file_path + if version is not None: + metadata["version"] = version return metadata self._metric("migrations.metadata.cache_miss") @@ -476,31 +469,6 @@ def _migration_context(self, file_path: Path) -> "MigrationContext | None": return context_to_use - def should_use_transaction( - self, migration: "LoadedMigrationMetadata", config: "DatabaseConfigProtocol[Any, Any, Any]" - ) -> bool: - """Determine if migration should run in a transaction. - - An explicit per-migration directive takes precedence over the config's - transactional-DDL capability, so a data migration can still run - atomically on a backend that restricts DDL in transactions. - - Args: - migration: Migration metadata dictionary. - config: The database configuration instance. - - Returns: - True if migration should be wrapped in a transaction. - """ - if migration.get("transactional") is not None: - return bool(migration["transactional"]) - - if not config.supports_transactional_ddl: - return False - - migration_config = cast("dict[str, Any]", config.migration_config) or {} - return bool(migration_config.get("transactional", True)) - def _resolve_migration_schema(self, migration: "LoadedMigrationMetadata") -> str | None: """Return the migration's schema directive when present, otherwise the configured default.""" config = self.context.config if self.context else None @@ -698,6 +666,8 @@ def load_migration(self, file_path: Path, version: "str | None" = None) -> "Load partial = cast("LoadedMigrationMetadata", {"loader": loader, "file_path": file_path}) has_upgrade = self._migration_sql(partial, "up") is not None has_downgrade = self._migration_sql(partial, "down") is not None + elif isinstance(loader, PythonFileLoader): + has_downgrade = loader.has_downgrade(file_path) else: try: partial = cast("LoadedMigrationMetadata", {"loader": loader, "file_path": file_path}) @@ -708,50 +678,6 @@ def load_migration(self, file_path: Path, version: "str | None" = None) -> "Load metadata.update({"has_upgrade": has_upgrade, "has_downgrade": has_downgrade, "loader": loader}) return metadata - def _execute_migration_sql( - self, - driver: "SyncDriverAdapterBase", - sql_list: list[str], - *, - migration: "LoadedMigrationMetadata", - use_transaction: bool, - on_success: "Callable[[int], None] | None", - start_time: float, - ) -> int: - default_schema = self._resolve_migration_schema(migration) - directive_schema = migration.get("schema") - if directive_schema is not None and not driver.has_schema(directive_schema): - msg = f"Migration schema '{directive_schema}' does not exist" - raise MigrationError(msg) - if use_transaction: - driver.begin() - if default_schema: - driver.set_migration_session_schema(default_schema) - for sql_statement in sql_list: - if sql_statement.strip(): - driver.execute_script(sql_statement) - execution_time = int((time.perf_counter() - start_time) * 1000) - if on_success: - on_success(execution_time) - driver.commit() - if default_schema: - driver.reset_migration_session_schema() - return execution_time - - try: - if default_schema: - driver.set_migration_non_transactional_schema(default_schema) - for sql_statement in sql_list: - if sql_statement.strip(): - driver.execute_script(sql_statement) - execution_time = int((time.perf_counter() - start_time) * 1000) - if on_success: - on_success(execution_time) - return execution_time - finally: - if default_schema: - driver.reset_migration_session_schema() - def execute_upgrade( self, driver: "SyncDriverAdapterBase", @@ -852,37 +778,15 @@ def execute_downgrade( self._finish_migration_span_success(span, driver, migration, "downgrade", "migration.rollback", execution_time) return None, execution_time - def _migration_sql(self, migration: "LoadedMigrationMetadata", direction: str) -> "list[str] | None": - """Get migration SQL for given direction (sync version). - - Args: - migration: Migration metadata. - direction: Either 'up' or 'down'. - - Returns: - SQL statements for the migration. - """ - if self._check_missing_direction(migration, direction): - return None - - file_path, loader = migration["file_path"], migration["loader"] - - try: - sql_statements = _load_migration_sql(loader, file_path, direction) - except Exception as e: - self._handle_migration_sql_error(migration, direction, e) - return None - else: - return self._finalize_migration_sql(sql_statements) - def load_all_migrations(self) -> "dict[str, SQL]": """Load all migrations into a single namespace for bulk operations. Returns: Dictionary mapping query names to SQL objects. """ - all_queries = {} + all_queries: dict[str, SQL] = {} migrations = self.get_migration_files() + loaded_core_sql = False for version, file_path in migrations: if file_path.suffix == ".sql": @@ -891,17 +795,17 @@ def load_all_migrations(self) -> "dict[str, SQL]": up_sql = self._migration_sql(migration, "up") down_sql = self._migration_sql(migration, "down") if up_sql: - all_queries[f"migrate-{version}-up"] = SQL(up_sql[0]) + all_queries[f"migrate-{version}-up"] = SQL(";\n".join(up_sql)) if down_sql: - all_queries[f"migrate-{version}-down"] = SQL(down_sql[0]) + all_queries[f"migrate-{version}-down"] = SQL(";\n".join(down_sql)) continue if not self.loader.has_query(f"migrate-{version}-up"): self.loader.load_sql(file_path) - for query_name in self.loader.list_queries(): - all_queries[query_name] = self.loader.get_sql(query_name) + loaded_core_sql = True else: + context_to_use = self._migration_context(file_path) loader = get_migration_loader( - file_path, self.migrations_path, self.project_root, self.context, self.loader + file_path, self.migrations_path, self.project_root, context_to_use, self.loader ) try: @@ -909,17 +813,98 @@ def load_all_migrations(self) -> "dict[str, SQL]": down_sql = _load_migration_sql(loader, file_path, "down") if up_sql: - all_queries[f"migrate-{version}-up"] = SQL(up_sql[0]) + all_queries[f"migrate-{version}-up"] = SQL(";\n".join(up_sql)) if down_sql: - all_queries[f"migrate-{version}-down"] = SQL(down_sql[0]) + all_queries[f"migrate-{version}-down"] = SQL(";\n".join(down_sql)) except Exception as e: self._log_migration_event( logging.DEBUG, "migration.python.load_failed", file_path=str(file_path), error=str(e) ) + if loaded_core_sql: + for query_name in self.loader.list_queries(): + all_queries[query_name] = self.loader.get_sql(query_name) + return all_queries + def _execute_migration_sql( + self, + driver: "SyncDriverAdapterBase", + sql_list: list[str], + *, + migration: "LoadedMigrationMetadata", + use_transaction: bool, + on_success: "Callable[[int], None] | None", + start_time: float, + ) -> int: + default_schema = self._resolve_migration_schema(migration) + directive_schema = migration.get("schema") + if directive_schema is not None and not driver.has_schema(directive_schema): + msg = f"Migration schema '{directive_schema}' does not exist" + raise MigrationError(msg) + if use_transaction: + driver.begin() + schema_reset = False + try: + if default_schema: + driver.set_migration_session_schema(default_schema) + for sql_statement in sql_list: + if sql_statement.strip(): + driver.execute_script(sql_statement) + execution_time = int((time.perf_counter() - start_time) * 1000) + if on_success: + if default_schema: + driver.reset_migration_session_schema() + schema_reset = True + on_success(execution_time) + driver.commit() + return execution_time + finally: + if default_schema and not schema_reset: + driver.reset_migration_session_schema() + + schema_reset = False + try: + if default_schema: + driver.set_migration_non_transactional_schema(default_schema) + for sql_statement in sql_list: + if sql_statement.strip(): + driver.execute_script(sql_statement) + execution_time = int((time.perf_counter() - start_time) * 1000) + if on_success: + if default_schema: + driver.reset_migration_session_schema() + schema_reset = True + on_success(execution_time) + return execution_time + finally: + if default_schema and not schema_reset: + driver.reset_migration_session_schema() + + def _migration_sql(self, migration: "LoadedMigrationMetadata", direction: str) -> "list[str] | None": + """Get migration SQL for given direction (sync version). + + Args: + migration: Migration metadata. + direction: Either 'up' or 'down'. + + Returns: + SQL statements for the migration. + """ + if self._check_missing_direction(migration, direction): + return None + + file_path, loader = migration["file_path"], migration["loader"] + + try: + sql_statements = _load_migration_sql(loader, file_path, direction) + except Exception as e: + self._handle_migration_sql_error(migration, direction, e) + return None + else: + return self._finalize_migration_sql(sql_statements) + class AsyncMigrationRunner(BaseMigrationRunner): """Asynchronous migration runner with pure async methods.""" @@ -955,6 +940,8 @@ async def load_migration(self, file_path: Path, version: "str | None" = None) -> partial = cast("LoadedMigrationMetadata", {"loader": loader, "file_path": file_path}) has_upgrade = (await self._migration_sql(partial, "up")) is not None has_downgrade = (await self._migration_sql(partial, "down")) is not None + elif isinstance(loader, PythonFileLoader): + has_downgrade = loader.has_downgrade(file_path) else: try: partial = cast("LoadedMigrationMetadata", {"loader": loader, "file_path": file_path}) @@ -965,50 +952,6 @@ async def load_migration(self, file_path: Path, version: "str | None" = None) -> metadata.update({"has_upgrade": has_upgrade, "has_downgrade": has_downgrade, "loader": loader}) return metadata - async def _execute_migration_sql( - self, - driver: "AsyncDriverAdapterBase", - sql_list: list[str], - *, - migration: "LoadedMigrationMetadata", - use_transaction: bool, - on_success: "Callable[[int], Awaitable[None]] | None", - start_time: float, - ) -> int: - default_schema = self._resolve_migration_schema(migration) - directive_schema = migration.get("schema") - if directive_schema is not None and not await driver.has_schema(directive_schema): - msg = f"Migration schema '{directive_schema}' does not exist" - raise MigrationError(msg) - if use_transaction: - await driver.begin() - if default_schema: - await driver.set_migration_session_schema(default_schema) - for sql_statement in sql_list: - if sql_statement.strip(): - await driver.execute_script(sql_statement) - execution_time = int((time.perf_counter() - start_time) * 1000) - if on_success: - await on_success(execution_time) - await driver.commit() - if default_schema: - await driver.reset_migration_session_schema() - return execution_time - - try: - if default_schema: - await driver.set_migration_non_transactional_schema(default_schema) - for sql_statement in sql_list: - if sql_statement.strip(): - await driver.execute_script(sql_statement) - execution_time = int((time.perf_counter() - start_time) * 1000) - if on_success: - await on_success(execution_time) - return execution_time - finally: - if default_schema: - await driver.reset_migration_session_schema() - async def execute_upgrade( self, driver: "AsyncDriverAdapterBase", @@ -1109,38 +1052,15 @@ async def execute_downgrade( self._finish_migration_span_success(span, driver, migration, "downgrade", "migration.rollback", execution_time) return None, execution_time - async def _migration_sql(self, migration: "LoadedMigrationMetadata", direction: str) -> "list[str] | None": - """Get migration SQL for given direction (async version). - - Args: - migration: Migration metadata. - direction: Either 'up' or 'down'. - - Returns: - SQL statements for the migration. - """ - if self._check_missing_direction(migration, direction): - return None - - file_path, loader = migration["file_path"], migration["loader"] - - try: - method = loader.get_up_sql if direction == "up" else loader.get_down_sql - sql_statements = await method(file_path) - except Exception as e: - self._handle_migration_sql_error(migration, direction, e) - return None - else: - return self._finalize_migration_sql(sql_statements) - async def load_all_migrations(self) -> "dict[str, SQL]": """Load all migrations into a single namespace for bulk operations. Returns: Dictionary mapping query names to SQL objects. """ - all_queries = {} + all_queries: dict[str, SQL] = {} migrations = await self.get_migration_files() + loaded_core_sql = False for version, file_path in migrations: if file_path.suffix == ".sql": @@ -1149,17 +1069,17 @@ async def load_all_migrations(self) -> "dict[str, SQL]": up_sql = await self._migration_sql(migration, "up") down_sql = await self._migration_sql(migration, "down") if up_sql: - all_queries[f"migrate-{version}-up"] = SQL(up_sql[0]) + all_queries[f"migrate-{version}-up"] = SQL(";\n".join(up_sql)) if down_sql: - all_queries[f"migrate-{version}-down"] = SQL(down_sql[0]) + all_queries[f"migrate-{version}-down"] = SQL(";\n".join(down_sql)) continue if not self.loader.has_query(f"migrate-{version}-up"): await async_(self.loader.load_sql)(file_path) - for query_name in self.loader.list_queries(): - all_queries[query_name] = self.loader.get_sql(query_name) + loaded_core_sql = True else: + context_to_use = self._migration_context(file_path) loader = get_migration_loader( - file_path, self.migrations_path, self.project_root, self.context, self.loader + file_path, self.migrations_path, self.project_root, context_to_use, self.loader ) try: @@ -1167,17 +1087,99 @@ async def load_all_migrations(self) -> "dict[str, SQL]": down_sql = await loader.get_down_sql(file_path) if up_sql: - all_queries[f"migrate-{version}-up"] = SQL(up_sql[0]) + all_queries[f"migrate-{version}-up"] = SQL(";\n".join(up_sql)) if down_sql: - all_queries[f"migrate-{version}-down"] = SQL(down_sql[0]) + all_queries[f"migrate-{version}-down"] = SQL(";\n".join(down_sql)) except Exception as e: self._log_migration_event( logging.DEBUG, "migration.python.load_failed", file_path=str(file_path), error=str(e) ) + if loaded_core_sql: + for query_name in self.loader.list_queries(): + all_queries[query_name] = self.loader.get_sql(query_name) + return all_queries + async def _execute_migration_sql( + self, + driver: "AsyncDriverAdapterBase", + sql_list: list[str], + *, + migration: "LoadedMigrationMetadata", + use_transaction: bool, + on_success: "Callable[[int], Awaitable[None]] | None", + start_time: float, + ) -> int: + default_schema = self._resolve_migration_schema(migration) + directive_schema = migration.get("schema") + if directive_schema is not None and not await driver.has_schema(directive_schema): + msg = f"Migration schema '{directive_schema}' does not exist" + raise MigrationError(msg) + if use_transaction: + await driver.begin() + schema_reset = False + try: + if default_schema: + await driver.set_migration_session_schema(default_schema) + for sql_statement in sql_list: + if sql_statement.strip(): + await driver.execute_script(sql_statement) + execution_time = int((time.perf_counter() - start_time) * 1000) + if on_success: + if default_schema: + await driver.reset_migration_session_schema() + schema_reset = True + await on_success(execution_time) + await driver.commit() + return execution_time + finally: + if default_schema and not schema_reset: + await driver.reset_migration_session_schema() + + schema_reset = False + try: + if default_schema: + await driver.set_migration_non_transactional_schema(default_schema) + for sql_statement in sql_list: + if sql_statement.strip(): + await driver.execute_script(sql_statement) + execution_time = int((time.perf_counter() - start_time) * 1000) + if on_success: + if default_schema: + await driver.reset_migration_session_schema() + schema_reset = True + await on_success(execution_time) + return execution_time + finally: + if default_schema and not schema_reset: + await driver.reset_migration_session_schema() + + async def _migration_sql(self, migration: "LoadedMigrationMetadata", direction: str) -> "list[str] | None": + """Get migration SQL for given direction (async version). + + Args: + migration: Migration metadata. + direction: Either 'up' or 'down'. + + Returns: + SQL statements for the migration. + """ + if self._check_missing_direction(migration, direction): + return None + + file_path, loader = migration["file_path"], migration["loader"] + + try: + method = loader.get_up_sql if direction == "up" else loader.get_down_sql + sql_statements = await method(file_path) + except Exception as e: + self._handle_migration_sql_error(migration, direction, e) + return None + else: + return self._finalize_migration_sql(sql_statements) + @overload def create_migration_runner( @@ -1243,3 +1245,27 @@ def create_migration_runner( runtime=runtime, description_hints=description_hints, ) + + +class _CachedMigrationMetadata: + """Cached migration metadata keyed by file path.""" + + __slots__ = ("metadata", "mtime_ns", "size") + + def __init__(self, metadata: "dict[str, Any]", mtime_ns: int, size: int) -> None: + self.metadata = metadata + self.mtime_ns = mtime_ns + self.size = size + + def clone(self) -> "dict[str, Any]": + return dict(self.metadata) + + +class _MigrationFileEntry: + """Represents a migration file discovered during directory scanning.""" + + __slots__ = ("extension_name", "path") + + def __init__(self, path: Path, extension_name: "str | None") -> None: + self.path = path + self.extension_name = extension_name diff --git a/sqlspec/migrations/schema.py b/sqlspec/migrations/schema.py index 11274416f..3eb7889b7 100644 --- a/sqlspec/migrations/schema.py +++ b/sqlspec/migrations/schema.py @@ -204,7 +204,7 @@ def ensure_schema_sync( changed = True if changed: - driver.commit() + _commit_schema_sync(driver) return result @@ -291,10 +291,36 @@ async def ensure_schema_async( changed = True if changed: - await driver.commit() + await _commit_schema_async(driver) return result +def _commit_schema_sync(driver: Any) -> None: + """Commit schema changes on a sync driver unless autocommit is enabled.""" + driver_features = getattr(driver, "driver_features", None) + if isinstance(driver_features, Mapping) and driver_features.get("autocommit", False): + return + try: + driver.commit() + except Exception as exc: + exc_str = str(exc).lower() + if "autocommit" not in exc_str and "cannot commit" not in exc_str: + raise + + +async def _commit_schema_async(driver: Any) -> None: + """Commit schema changes on an async driver unless autocommit is enabled.""" + driver_features = getattr(driver, "driver_features", None) + if isinstance(driver_features, Mapping) and driver_features.get("autocommit", False): + return + try: + await driver.commit() + except Exception as exc: + exc_str = str(exc).lower() + if "autocommit" not in exc_str and "cannot commit" not in exc_str: + raise + + def _execute_create_sync(driver: Any, target: SchemaTarget) -> None: """Execute canonical table DDL through the appropriate synchronous path.""" if isinstance(target.create_statement, str): diff --git a/sqlspec/migrations/squash.py b/sqlspec/migrations/squash.py index 14944ade2..43b92f48f 100644 --- a/sqlspec/migrations/squash.py +++ b/sqlspec/migrations/squash.py @@ -4,6 +4,7 @@ into a single "release" migration file, following the Django-style squash workflow. """ +import re from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -16,7 +17,7 @@ validate_squash_idempotency, validate_squash_range, ) -from sqlspec.migrations.version import _format_sequential_version +from sqlspec.migrations.version import _format_sequential_version, parse_extension_stem, parse_version from sqlspec.utils.logging import get_logger from sqlspec.utils.text import slugify @@ -28,6 +29,9 @@ logger = get_logger("sqlspec.migrations.squash") +_TRANSACTIONAL_DIRECTIVE_RE = re.compile(r"^--\s*transactional:\s*(true|false)\s*$", re.MULTILINE | re.IGNORECASE) +_SCHEMA_DIRECTIVE_RE = re.compile(r"^--\s*schema:\s*(\S+)\s*$", re.MULTILINE | re.IGNORECASE) + def parse_version_range(range_str: str) -> tuple[str, str]: """Parse a version range string into (start, end) tuple. @@ -46,8 +50,8 @@ def parse_version_range(range_str: str) -> tuple[str, str]: for sep in (":", "..", "-"): if sep in range_str: parts = range_str.split(sep, 1) - start = _format_sequential_version(parts[0].strip()) - end = _format_sequential_version(parts[1].strip()) + start = _normalize_range_endpoint(parts[0].strip()) + end = _normalize_range_endpoint(parts[1].strip()) return start, end msg = f"Invalid VERSION_RANGE format: '{range_str}'. Use START:END, START..END, or START-END" raise ValueError(msg) @@ -101,6 +105,8 @@ class SquashPlan: target_path: Output file path for the squashed migration. description: Combined description for the squashed migration. source_versions: List of version strings being replaced (for tracking table updates). + transactional: Optional transactional directive preserved from source SQL migrations. + schema: Optional schema directive preserved from source SQL migrations. """ source_migrations: list[tuple[str, Path]] @@ -108,6 +114,8 @@ class SquashPlan: target_path: Path description: str source_versions: list[str] + transactional: bool | None = None + schema: str | None = None class MigrationSquasher: @@ -149,8 +157,8 @@ def plan_squash( """Plan a squash operation for a range of migrations. For homogeneous migrations (all SQL or all Python), returns a single plan. - For mixed SQL/Python migrations, returns multiple plans - one per - consecutive group of same-type migrations. + For mixed SQL/Python migrations or differing execution directives, returns + multiple plans - one per consecutive compatible group of migrations. Args: start_version: First version in the range to squash (inclusive). @@ -165,17 +173,24 @@ def plan_squash( all_migrations = self.runner.get_migration_files() source_migrations = validate_squash_range(all_migrations, start_version, end_version, allow_gaps=allow_gaps) - # Slugify description for safe filenames safe_description = slugify(description, separator="_")[:50] or "migration" - groups = [("py", source_migrations)] if output_format == "py" else group_migrations_by_type(source_migrations) + if output_format == "py": + directive_groups: list[tuple[str, bool | None, str | None, list[tuple[str, Path]]]] = [ + ("py", None, None, source_migrations) + ] + else: + directive_groups = _group_migrations_with_directives(source_migrations) + + parsed_start = parse_version(start_version) + version_counter = parsed_start.sequence or 1 + ext_prefix = f"ext_{parsed_start.extension}_" if parsed_start.extension else "" plans: list[SquashPlan] = [] - version_counter = int(start_version) - for file_type, group_migrations in groups: + for file_type, transactional, schema, group_migrations in directive_groups: group_versions = [v for v, _ in group_migrations] - target_version = _format_sequential_version(version_counter) + target_version = f"{ext_prefix}{_format_sequential_version(version_counter)}" extension = ".py" if file_type == "py" else ".sql" target_path = self.migrations_path / f"{target_version}_{safe_description}{extension}" @@ -186,6 +201,8 @@ def plan_squash( target_path=target_path, description=description, source_versions=group_versions, + transactional=transactional, + schema=schema, ) ) version_counter += 1 @@ -207,14 +224,12 @@ def extract_sql(self, migrations: list[tuple[str, Path]]) -> tuple[list[str], li up_statements: list[str] = [] down_statements: list[str] = [] - # Load and collect SQL from each migration (UP in order) migration_down_sql: list[list[str]] = [] for version, path in migrations: migration_data = self.runner.load_migration(path, version) loader = migration_data["loader"] - # Get UP SQL (handle async loaders via await_ bridge) up_sql = _load_migration_sql(loader, path, "up") if up_sql: if isinstance(up_sql, list): @@ -222,7 +237,6 @@ def extract_sql(self, migrations: list[tuple[str, Path]]) -> tuple[list[str], li else: up_statements.append(up_sql) - # Get DOWN SQL (handle async loaders via await_ bridge) down_sql = _load_migration_sql(loader, path, "down") if down_sql: if isinstance(down_sql, list): @@ -232,7 +246,6 @@ def extract_sql(self, migrations: list[tuple[str, Path]]) -> tuple[list[str], li else: migration_down_sql.append([]) - # DOWN statements in REVERSE order for down_sql in migration_down_sql[::-1]: down_statements.extend(down_sql) @@ -260,9 +273,12 @@ def generate_squashed_content(self, plan: SquashPlan, up_sql: list[str], down_sq f"-- Version: {plan.target_version}", f"-- Description: {plan.description}", f"-- Squashed from: {', '.join(plan.source_versions)}", - "", - f"-- name: migrate-{plan.target_version}-up", )) + if plan.transactional is not None: + lines.append(f"-- transactional: {'true' if plan.transactional else 'false'}") + if plan.schema is not None: + lines.append(f"-- schema: {plan.schema}") + lines.extend(("", f"-- name: migrate-{plan.target_version}-up")) lines.extend(statement.rstrip() for statement in up_sql) lines.append("") @@ -317,7 +333,7 @@ def generate_python_squash(self, plan: SquashPlan, up_sql: list[str], down_sql: return "\n".join(lines) - def apply_squash(self, plans: list[SquashPlan], *, dry_run: bool = False) -> None: + def apply_squash(self, plans: list[SquashPlan], *, dry_run: bool = False, cleanup_backup: bool = True) -> None: """Apply the squash operation for one or more plans. Creates backup, writes squashed files, deletes source migrations, @@ -326,6 +342,8 @@ def apply_squash(self, plans: list[SquashPlan], *, dry_run: bool = False) -> Non Args: plans: List of SquashPlan objects to execute. dry_run: If True, no files are modified (preview only). + cleanup_backup: If True (default), remove the backup directory before returning. + Set False when callers need to keep the backup until database tracking is updated. """ if dry_run: logger.debug("Dry run mode - no changes will be made") @@ -347,10 +365,10 @@ def apply_squash(self, plans: list[SquashPlan], *, dry_run: bool = False) -> Non if not ready_plans: return - # Create backup before making changes self._create_backup() try: + planned_contents: list[tuple[SquashPlan, str]] = [] for plan in ready_plans: up_sql, down_sql = self.extract_sql(plan.source_migrations) @@ -358,23 +376,29 @@ def apply_squash(self, plans: list[SquashPlan], *, dry_run: bool = False) -> Non content = self.generate_python_squash(plan, up_sql, down_sql) else: content = self.generate_squashed_content(plan, up_sql, down_sql) + planned_contents.append((plan, content)) + for plan, content in planned_contents: plan.target_path.write_text(content, encoding="utf-8") logger.debug("Wrote squashed migration to %s", plan.target_path) - # Collect all source paths to delete (avoid duplicates across plans) - all_source_paths = {source_path for plan in ready_plans for _, source_path in plan.source_migrations} + target_paths = {plan.target_path for plan in ready_plans} + all_source_paths = { + source_path + for plan in ready_plans + for _, source_path in plan.source_migrations + if source_path not in target_paths + } for source_path in all_source_paths: if source_path.exists(): source_path.unlink() logger.debug("Deleted source migration %s", source_path) - # Clean up backup on success - self._cleanup_backup() + if cleanup_backup: + self._cleanup_backup() except Exception: - # Rollback on error self._rollback_backup() raise @@ -407,3 +431,57 @@ def _rollback_backup(self) -> None: self.backup_path = None logger.debug("Rolled back from backup at %s", backup_dir) + + +def _normalize_range_endpoint(endpoint: str) -> str: + """Normalize a squash range endpoint, preserving any extension prefix.""" + if endpoint.startswith("ext_"): + parsed = parse_extension_stem(endpoint) + if parsed is not None: + ext_name, ext_version = parsed + return f"ext_{ext_name}_{_format_sequential_version(ext_version)}" + return _format_sequential_version(endpoint) + + +def _extract_sql_directives(path: Path) -> tuple[bool | None, str | None]: + """Extract transactional and schema directives from a SQL migration file if it exists.""" + if path.suffix != ".sql" or not path.exists(): + return None, None + try: + content = path.read_text(encoding="utf-8") + except OSError: + return None, None + tx_match = _TRANSACTIONAL_DIRECTIVE_RE.search(content) + transactional = tx_match.group(1).lower() == "true" if tx_match else None + schema_match = _SCHEMA_DIRECTIVE_RE.search(content) + schema = schema_match.group(1) if schema_match else None + return transactional, schema + + +def _group_migrations_with_directives( + migrations: list[tuple[str, Path]], +) -> list[tuple[str, bool | None, str | None, list[tuple[str, Path]]]]: + """Group consecutive migrations by file type and SQL execution directives.""" + if not migrations: + return [] + + groups: list[tuple[str, bool | None, str | None, list[tuple[str, Path]]]] = [] + current_key: tuple[str, bool | None, str | None] | None = None + current_group: list[tuple[str, Path]] = [] + + for version, path in migrations: + file_type = "py" if path.suffix == ".py" else "sql" + transactional, schema = _extract_sql_directives(path) if file_type == "sql" else (None, None) + key = (file_type, transactional, schema) + if key != current_key: + if current_group and current_key is not None: + groups.append((current_key[0], current_key[1], current_key[2], current_group)) + current_key = key + current_group = [(version, path)] + else: + current_group.append((version, path)) + + if current_group and current_key is not None: + groups.append((current_key[0], current_key[1], current_key[2], current_group)) + + return groups diff --git a/sqlspec/migrations/templates.py b/sqlspec/migrations/templates.py index 13a103d79..7db454e6a 100644 --- a/sqlspec/migrations/templates.py +++ b/sqlspec/migrations/templates.py @@ -87,6 +87,14 @@ class MigrationTemplateSettings: default_format: str profile: "MigrationTemplateProfile" + @property + def description_hints(self) -> "TemplateDescriptionHints": + """Expose description extraction hints derived from the active profile.""" + + return TemplateDescriptionHints( + sql_keys=self.profile.sql.description_keys, python_keys=self.profile.python.description_keys + ) + def resolve_format(self, requested: str | None) -> str: """Resolve the effective file format to render.""" @@ -96,14 +104,6 @@ def resolve_format(self, requested: str | None) -> str: raise TemplateValidationError(msg) return format_choice - @property - def description_hints(self) -> "TemplateDescriptionHints": - """Expose description extraction hints derived from the active profile.""" - - return TemplateDescriptionHints( - sql_keys=self.profile.sql.description_keys, python_keys=self.profile.python.description_keys - ) - def build_template_settings(migration_config: dict[str, Any] | None) -> "MigrationTemplateSettings": """Build template settings from migration configuration.""" @@ -206,7 +206,7 @@ def _format_template(template: str, context: "Mapping[str, str]", template_kind: "Replace 'def' with 'async def' if you need awaitables. The optional" " context argument receives the SQLSpec migration context when provided." ), - imports=["from typing import Iterable"], + imports=["from collections.abc import Iterable"], body=( "def up(context: object | None = None) -> str | Iterable[str]:\n" ' """Apply the migration (upgrade)."""\n' diff --git a/sqlspec/migrations/tracker.py b/sqlspec/migrations/tracker.py index 48c72a482..c5071d278 100644 --- a/sqlspec/migrations/tracker.py +++ b/sqlspec/migrations/tracker.py @@ -3,12 +3,14 @@ This module provides functionality to track applied migrations in the database. """ +import importlib import logging from contextlib import suppress from typing import TYPE_CHECKING, Any, cast from mypy_extensions import mypyc_attr +from sqlspec.exceptions import MigrationError from sqlspec.migrations.base import BaseMigrationTracker from sqlspec.migrations.schema import SchemaTarget, ensure_schema_async, ensure_schema_sync from sqlspec.observability import resolve_db_system @@ -26,15 +28,6 @@ _console: "Console | None" = None -def _get_console() -> "Console": - global _console - if _console is None: - from rich.console import Console - - _console = Console() - return _console - - @mypyc_attr(allow_interpreted_subclasses=True) class SyncMigrationTracker(BaseMigrationTracker["SyncDriverAdapterBase"]): """Synchronous migration version tracker.""" @@ -77,7 +70,14 @@ def get_applied_migrations(self, driver: "SyncDriverAdapterBase") -> "list[Appli return _finalize_applied_migrations(driver, result) def record_migration( - self, driver: "SyncDriverAdapterBase", version: str, description: str, execution_time_ms: int, checksum: str + self, + driver: "SyncDriverAdapterBase", + version: str, + description: str, + execution_time_ms: int, + checksum: str, + *, + commit: bool = True, ) -> None: """Record a successfully applied migration. @@ -90,6 +90,7 @@ def record_migration( description: Description of the migration. execution_time_ms: Execution time in milliseconds. checksum: MD5 checksum of the migration content. + commit: Whether to commit immediately after recording. """ version_type = self._derive_version_type(version) @@ -101,18 +102,21 @@ def record_migration( version, version_type, next_sequence, description, execution_time_ms, checksum, self._applied_by() ) ) - self._safe_commit(driver) + if commit: + self._safe_commit(driver) _log_migration_recorded(driver, version) - def remove_migration(self, driver: "SyncDriverAdapterBase", version: str) -> None: + def remove_migration(self, driver: "SyncDriverAdapterBase", version: str, *, commit: bool = True) -> None: """Remove a migration record (used during downgrade). Args: driver: The database driver to use. version: Version number to remove. + commit: Whether to commit immediately after removing. """ driver.execute(self._remove_migration_statement(version)) - self._safe_commit(driver) + if commit: + self._safe_commit(driver) _log_migration_removed(driver, version) def update_version_record( @@ -201,19 +205,22 @@ def is_squash_already_applied( ) -> bool: """Check if a squash operation has already been applied. - Determines if any of the replaced versions exist in the database, + Determines if all of the replaced versions exist in the database, indicating that the original migrations were applied before the squash. Args: driver: The database driver to use. - squashed_version: Version number of the squashed migration (unused but kept for API consistency). + squashed_version: Version number of the squashed migration. replaced_versions: List of version strings that would be replaced. Returns: - True if any replaced version exists (squash already applied), False otherwise. + True if all replaced versions exist (squash already applied), False if none exist. + + Raises: + MigrationError: If only a strict subset of replaced_versions exists in the database. """ result = driver.execute(self._check_versions_query(replaced_versions)) - return bool(result.data) + return _validate_squash_applied_state(result, squashed_version, replaced_versions) def _migrate_schema_if_needed(self, driver: "SyncDriverAdapterBase") -> None: """Add missing tracking-table columns through the shared schema engine. @@ -301,7 +308,14 @@ async def get_applied_migrations(self, driver: "AsyncDriverAdapterBase") -> "lis return _finalize_applied_migrations(driver, result) async def record_migration( - self, driver: "AsyncDriverAdapterBase", version: str, description: str, execution_time_ms: int, checksum: str + self, + driver: "AsyncDriverAdapterBase", + version: str, + description: str, + execution_time_ms: int, + checksum: str, + *, + commit: bool = True, ) -> None: """Record a successfully applied migration. @@ -314,6 +328,7 @@ async def record_migration( description: Description of the migration. execution_time_ms: Execution time in milliseconds. checksum: MD5 checksum of the migration content. + commit: Whether to commit immediately after recording. """ version_type = self._derive_version_type(version) @@ -325,18 +340,21 @@ async def record_migration( version, version_type, next_sequence, description, execution_time_ms, checksum, self._applied_by() ) ) - await self._safe_commit(driver) + if commit: + await self._safe_commit(driver) _log_migration_recorded(driver, version) - async def remove_migration(self, driver: "AsyncDriverAdapterBase", version: str) -> None: + async def remove_migration(self, driver: "AsyncDriverAdapterBase", version: str, *, commit: bool = True) -> None: """Remove a migration record (used during downgrade). Args: driver: The database driver to use. version: Version number to remove. + commit: Whether to commit immediately after removing. """ await driver.execute(self._remove_migration_statement(version)) - await self._safe_commit(driver) + if commit: + await self._safe_commit(driver) _log_migration_removed(driver, version) async def update_version_record( @@ -425,19 +443,22 @@ async def is_squash_already_applied( ) -> bool: """Check if a squash operation has already been applied. - Determines if any of the replaced versions exist in the database, + Determines if all of the replaced versions exist in the database, indicating that the original migrations were applied before the squash. Args: driver: The database driver to use. - squashed_version: Version number of the squashed migration (unused but kept for API consistency). + squashed_version: Version number of the squashed migration. replaced_versions: List of version strings that would be replaced. Returns: - True if any replaced version exists (squash already applied), False otherwise. + True if all replaced versions exist (squash already applied), False if none exist. + + Raises: + MigrationError: If only a strict subset of replaced_versions exists in the database. """ result = await driver.execute(self._check_versions_query(replaced_versions)) - return bool(result.data) + return _validate_squash_applied_state(result, squashed_version, replaced_versions) async def _migrate_schema_if_needed(self, driver: "AsyncDriverAdapterBase") -> None: """Add missing tracking-table columns through the shared schema engine. @@ -485,6 +506,31 @@ async def _safe_commit(self, driver: "AsyncDriverAdapterBase") -> None: raise +def _validate_squash_applied_state(result: Any, squashed_version: str, replaced_versions: "list[str]") -> bool: + """Validate that either all or none of the replaced versions are applied.""" + if not result.data: + return False + data = result.get_data() if callable(getattr(result, "get_data", None)) else result.data + if isinstance(data, list) and data and isinstance(data[0], dict) and "version_num" in data[0]: + found_versions = {row["version_num"] for row in data if "version_num" in row} + expected_versions = set(replaced_versions) + if 0 < len(found_versions) < len(expected_versions): + missing = sorted(expected_versions - found_versions) + msg = ( + f"Partial squash state detected for {squashed_version}: " + f"found {sorted(found_versions)}, missing {missing}" + ) + raise MigrationError(msg) + return True + + +def _get_console() -> "Console": + global _console + if _console is None: + _console = cast("Console", importlib.import_module("rich.console").Console()) + return _console + + def _log_schema_current(driver: Any, version_table: str) -> None: """Log that the tracking table schema already has all columns.""" log_with_context( diff --git a/sqlspec/migrations/utils.py b/sqlspec/migrations/utils.py index 8efc1079f..a59ff5403 100644 --- a/sqlspec/migrations/utils.py +++ b/sqlspec/migrations/utils.py @@ -84,6 +84,10 @@ def create_migration_file( extension = "py" if file_format == "py" else "sql" filename = f"{version}_{safe_message or 'migration'}.{extension}" file_path = migrations_dir / filename + migrations_dir.mkdir(parents=True, exist_ok=True) + if file_path.exists() or any(migrations_dir.glob(f"{version}_*")): + msg = f"Migration file already exists for version {version}: {file_path}" + raise MigrationError(msg) context = _build_template_context( settings=settings, version=version, diff --git a/sqlspec/migrations/validation.py b/sqlspec/migrations/validation.py index 5c8ed18fc..ff0b097ae 100644 --- a/sqlspec/migrations/validation.py +++ b/sqlspec/migrations/validation.py @@ -12,7 +12,7 @@ from rich.console import Console from sqlspec.exceptions import OutOfOrderMigrationError, SquashValidationError -from sqlspec.migrations.version import parse_version +from sqlspec.migrations.version import VersionType, parse_version from sqlspec.utils.logging import get_logger, log_with_context if TYPE_CHECKING: @@ -81,7 +81,6 @@ def detect_out_of_order_migrations( gaps: list[MigrationGap] = [] - # Filter out None values, empty strings, and whitespace-only strings valid_applied = [v for v in applied_versions if v is not None and v.strip()] valid_pending = [v for v in pending_versions if v is not None and v.strip()] @@ -210,22 +209,43 @@ def validate_squash_range( SquashValidationError: If validation fails (invalid range, missing versions, gaps). """ try: - start_int = int(start_version) - end_int = int(end_version) + parsed_start = parse_version(start_version) + parsed_end = parse_version(end_version) except ValueError as exc: msg = f"Squash range versions must be numeric: {start_version} to {end_version}" raise SquashValidationError(msg) from exc + if ( + parsed_start.type != VersionType.SEQUENTIAL + or parsed_end.type != VersionType.SEQUENTIAL + or parsed_start.sequence is None + or parsed_end.sequence is None + ): + msg = f"Squash range versions must be numeric: {start_version} to {end_version}" + raise SquashValidationError(msg) + + if parsed_start.extension != parsed_end.extension: + msg = f"Cannot squash migrations from different extensions: {start_version} to {end_version}" + raise SquashValidationError(msg) + + start_int = parsed_start.sequence + end_int = parsed_end.sequence + if start_int > end_int: msg = f"Invalid range: start version {start_version} is greater than end version {end_version}" raise SquashValidationError(msg) - version_map: dict[str, Path] = dict(migrations) + parsed_map: dict[MigrationVersion, str] = {} + for version, _ in migrations: + try: + parsed_map[parse_version(version)] = version + except ValueError: + continue - if start_version not in version_map: + if parsed_start not in parsed_map: msg = f"Start version {start_version} not found in migrations" raise SquashValidationError(msg) - if end_version not in version_map: + if parsed_end not in parsed_map: msg = f"End version {end_version} not found in migrations" raise SquashValidationError(msg) @@ -233,20 +253,25 @@ def validate_squash_range( for version, path in migrations: try: - version_int = int(version) + parsed = parse_version(version) except ValueError: continue - if start_int <= version_int <= end_int: + if ( + parsed.type == VersionType.SEQUENTIAL + and parsed.extension == parsed_start.extension + and parsed.sequence is not None + and start_int <= parsed.sequence <= end_int + ): result.append((version, path)) - result.sort(key=lambda x: int(x[0])) + result.sort(key=lambda x: parse_version(x[0])) if not result: msg = f"No migrations found in range {start_version} to {end_version}" raise SquashValidationError(msg) if not allow_gaps and len(result) > 1: - sorted_versions = [int(v) for v, _ in result] + sorted_versions = [parse_version(v).sequence or 0 for v, _ in result] for i in range(1, len(sorted_versions)): if sorted_versions[i] - sorted_versions[i - 1] != 1: msg = f"Gap detected in version sequence between {sorted_versions[i - 1]:04d} and {sorted_versions[i]:04d}" @@ -308,6 +333,20 @@ def validate_squash_idempotency(source_files: "list[Path]", target_file: "Path") if not target_exists and any_source_exists: return "ready" + if target_file in source_files: + remaining_sources = [f for f in source_files if f != target_file] + if not remaining_sources: + if target_exists and "Squashed from:" in target_file.read_text(encoding="utf-8"): + return "already_squashed" + return "ready" + remaining_exist = [f.exists() for f in remaining_sources] + if target_exists and all(remaining_exist): + return "ready" + if target_exists and not any(remaining_exist): + return "already_squashed" + if target_exists and any(remaining_exist): + return "partial" + if target_exists and not any_source_exists: return "already_squashed" diff --git a/sqlspec/migrations/version.py b/sqlspec/migrations/version.py index 0a1fd14f2..c27c6078a 100644 --- a/sqlspec/migrations/version.py +++ b/sqlspec/migrations/version.py @@ -19,6 +19,7 @@ "MigrationVersion", "VersionType", "convert_to_sequential_version", + "extract_migration_version", "generate_conversion_map", "generate_timestamp_version", "get_next_sequential_number", @@ -44,7 +45,7 @@ class VersionType(Enum): @final -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class MigrationVersion: """Parsed migration version with structured comparison support. @@ -144,15 +145,20 @@ def __eq__(self, other: object) -> bool: """ if not isinstance(other, MigrationVersion): return NotImplemented - return self.raw == other.raw + return (self.extension, self.type, self.sequence, self.timestamp) == ( + other.extension, + other.type, + other.sequence, + other.timestamp, + ) def __hash__(self) -> int: """Hash version for use in sets and dicts. Returns: - Hash value based on raw version string. + Hash value based on parsed version components. """ - return hash(self.raw) + return hash((self.extension, self.type, self.sequence, self.timestamp)) def __repr__(self) -> str: """Get string representation for debugging. @@ -275,6 +281,38 @@ def parse_extension_stem(stem: str) -> "tuple[str, str] | None": return match.group("name"), match.group("version") +def extract_migration_version(filename: str) -> "str | None": + """Extract canonical version string from a migration filename. + + Supports sequential (0001), timestamp (20251011120000), and extension-prefixed + (ext_litestar_0001) version formats. + + Args: + filename: Migration filename to parse. + + Returns: + Canonical version string or None when the filename is not a valid migration. + """ + timestamp_min_length = 4 + name_without_ext = filename.rsplit(".", 1)[0] + + if name_without_ext.startswith("ext_"): + extension = parse_extension_stem(name_without_ext) + if extension is None: + return None + ext_name, ext_version = extension + padded_ext_version = ( + ext_version if len(ext_version) > timestamp_min_length else _format_sequential_version(ext_version) + ) + return f"ext_{ext_name}_{padded_ext_version}" + + parts = name_without_ext.split("_", 1) + if parts and parts[0].isdigit(): + return parts[0] if len(parts[0]) > timestamp_min_length else _format_sequential_version(parts[0]) + + return None + + def generate_timestamp_version() -> str: """Generate new timestamp version in UTC. diff --git a/sqlspec/observability/_common.py b/sqlspec/observability/_common.py index ae0fc5d8b..6e82622c8 100644 --- a/sqlspec/observability/_common.py +++ b/sqlspec/observability/_common.py @@ -2,18 +2,25 @@ import functools import hashlib +import re from typing import Any -__all__ = ("compute_sql_hash", "get_trace_context", "resolve_db_system") +__all__ = ("compute_sql_hash", "get_trace_context", "redact_sql_literals", "resolve_db_system") + +_LITERAL_PATTERN = re.compile(r"\$([A-Za-z0-9_]*)\$.*?\$\1\$|'(?:''|\\.|[^'\\])*'", re.DOTALL) _DB_SYSTEM_MAP: "tuple[tuple[str, str], ...]" = ( + ("cockroach", "cockroachdb"), ("asyncpg", "postgresql"), ("psycopg", "postgresql"), ("psqlpy", "postgresql"), ("postgres", "postgresql"), ("asyncmy", "mysql"), + ("mariadb", "mariadb"), ("mysql", "mysql"), - ("mariadb", "mysql"), + ("mssql", "mssql"), + ("sqlserver", "mssql"), + ("pyodbc", "mssql"), ("aiosqlite", "sqlite"), ("sqlite", "sqlite"), ("duckdb", "duckdb"), @@ -78,3 +85,8 @@ def compute_sql_hash(sql: str) -> str: SHA256 hash prefix. """ return hashlib.sha256(sql.encode("utf-8"), usedforsecurity=False).hexdigest()[:16] + + +def redact_sql_literals(sql: str) -> str: + """Mask single-quoted (including backslash escapes) and dollar-quoted SQL literals.""" + return _LITERAL_PATTERN.sub("'***'", sql) diff --git a/sqlspec/observability/_config.py b/sqlspec/observability/_config.py index 372c3c04c..e7ad9d02e 100644 --- a/sqlspec/observability/_config.py +++ b/sqlspec/observability/_config.py @@ -49,14 +49,6 @@ def __hash__(self) -> int: # pragma: no cover msg = "RedactionConfig objects are mutable and unhashable" raise TypeError(msg) - def copy(self) -> "RedactionConfig": - """Return a copy to avoid sharing mutable state.""" - - allow_list = tuple(self.parameter_allow_list) if self.parameter_allow_list else None - return RedactionConfig( - mask_parameters=self.mask_parameters, mask_literals=self.mask_literals, parameter_allow_list=allow_list - ) - def __repr__(self) -> str: return f"RedactionConfig(mask_parameters={self.mask_parameters!r}, mask_literals={self.mask_literals!r}, parameter_allow_list={self.parameter_allow_list!r})" @@ -69,6 +61,14 @@ def __eq__(self, other: object) -> bool: and self.parameter_allow_list == other.parameter_allow_list ) + def copy(self) -> "RedactionConfig": + """Return a copy to avoid sharing mutable state.""" + + allow_list = tuple(self.parameter_allow_list) if self.parameter_allow_list else None + return RedactionConfig( + mask_parameters=self.mask_parameters, mask_literals=self.mask_literals, parameter_allow_list=allow_list + ) + class TelemetryConfig: """Span emission and tracer provider settings.""" @@ -90,14 +90,6 @@ def __hash__(self) -> int: # pragma: no cover msg = "TelemetryConfig objects are mutable and unhashable" raise TypeError(msg) - def copy(self) -> "TelemetryConfig": - """Return a shallow copy preserving optional dictionaries.""" - - attributes = dict(self.resource_attributes) if self.resource_attributes else None - return TelemetryConfig( - enable_spans=self.enable_spans, provider_factory=self.provider_factory, resource_attributes=attributes - ) - def __repr__(self) -> str: return f"TelemetryConfig(enable_spans={self.enable_spans!r}, provider_factory={self.provider_factory!r}, resource_attributes={self.resource_attributes!r})" @@ -110,6 +102,14 @@ def __eq__(self, other: object) -> bool: and self.resource_attributes == other.resource_attributes ) + def copy(self) -> "TelemetryConfig": + """Return a shallow copy preserving optional dictionaries.""" + + attributes = dict(self.resource_attributes) if self.resource_attributes else None + return TelemetryConfig( + enable_spans=self.enable_spans, provider_factory=self.provider_factory, resource_attributes=attributes + ) + class LoggingConfig: """Controls log output format and verbosity.""" @@ -141,17 +141,6 @@ def __hash__(self) -> int: # pragma: no cover msg = "LoggingConfig objects are mutable and unhashable" raise TypeError(msg) - def copy(self) -> "LoggingConfig": - """Return a shallow copy of the logging configuration.""" - - return LoggingConfig( - include_sql_hash=self.include_sql_hash, - sql_truncation_length=self.sql_truncation_length, - parameter_truncation_count=self.parameter_truncation_count, - include_trace_context=self.include_trace_context, - include_driver_name=self.include_driver_name, - ) - def __repr__(self) -> str: return ( f"LoggingConfig(include_sql_hash={self.include_sql_hash!r}, sql_truncation_length={self.sql_truncation_length!r}, " @@ -170,6 +159,17 @@ def __eq__(self, other: object) -> bool: and self.include_driver_name == other.include_driver_name ) + def copy(self) -> "LoggingConfig": + """Return a shallow copy of the logging configuration.""" + + return LoggingConfig( + include_sql_hash=self.include_sql_hash, + sql_truncation_length=self.sql_truncation_length, + parameter_truncation_count=self.parameter_truncation_count, + include_trace_context=self.include_trace_context, + include_driver_name=self.include_driver_name, + ) + class ObservabilityConfig: """Aggregates lifecycle hooks, observers, and telemetry toggles.""" @@ -210,6 +210,26 @@ def __hash__(self) -> int: # pragma: no cover msg = "ObservabilityConfig objects are mutable and unhashable" raise TypeError(msg) + def __repr__(self) -> str: + return ( + f"ObservabilityConfig(lifecycle={self.lifecycle!r}, print_sql={self.print_sql!r}, statement_observers={self.statement_observers!r}, telemetry={self.telemetry!r}, " + f"redaction={self.redaction!r}, logging={self.logging!r}, sampling={self.sampling!r}, cloud_formatter={self.cloud_formatter!r})" + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ObservabilityConfig): + return NotImplemented + return ( + _normalize_lifecycle(self.lifecycle) == _normalize_lifecycle(other.lifecycle) + and self.print_sql == other.print_sql + and self.statement_observers == other.statement_observers + and self.telemetry == other.telemetry + and self.redaction == other.redaction + and self.logging == other.logging + and self.sampling == other.sampling + and self.cloud_formatter == other.cloud_formatter + ) + def copy(self) -> "ObservabilityConfig": """Return a deep copy of the configuration.""" @@ -274,26 +294,6 @@ def merge( cloud_formatter=cloud_formatter, ) - def __repr__(self) -> str: - return ( - f"ObservabilityConfig(lifecycle={self.lifecycle!r}, print_sql={self.print_sql!r}, statement_observers={self.statement_observers!r}, telemetry={self.telemetry!r}, " - f"redaction={self.redaction!r}, logging={self.logging!r}, sampling={self.sampling!r}, cloud_formatter={self.cloud_formatter!r})" - ) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, ObservabilityConfig): - return NotImplemented - return ( - _normalize_lifecycle(self.lifecycle) == _normalize_lifecycle(other.lifecycle) - and self.print_sql == other.print_sql - and self.statement_observers == other.statement_observers - and self.telemetry == other.telemetry - and self.redaction == other.redaction - and self.logging == other.logging - and self.sampling == other.sampling - and self.cloud_formatter == other.cloud_formatter - ) - def _merge_redaction(base: "RedactionConfig | None", override: "RedactionConfig | None") -> "RedactionConfig | None": if base is None and override is None: @@ -331,8 +331,9 @@ def _merge_sampling(base: "SamplingConfig | None", override: "SamplingConfig | N merged.sample_rate = override.sample_rate merged.deterministic = override.deterministic merged.force_sample_on_error = override.force_sample_on_error - if override.force_sample_slow_queries_ms is not None: + if override._slow_query_explicit: merged.force_sample_slow_queries_ms = override.force_sample_slow_queries_ms + merged._slow_query_explicit = True return merged diff --git a/sqlspec/observability/_diagnostics.py b/sqlspec/observability/_diagnostics.py index f4ebebd27..59bf5574e 100644 --- a/sqlspec/observability/_diagnostics.py +++ b/sqlspec/observability/_diagnostics.py @@ -48,9 +48,10 @@ def snapshot(self) -> "DiagnosticsPayload": for key, value in get_storage_bridge_diagnostics().items(): _increment_metric(numeric_payload, key, float(value)) - for _prefix, counters in self._lifecycle_sections: + for prefix, counters in self._lifecycle_sections: for metric, value in counters.items(): - _increment_metric(numeric_payload, metric, value) + prefixed = f"{prefix}.{metric}" if prefix and not metric.startswith(f"{prefix}.") else metric + _increment_metric(numeric_payload, prefixed, value) for metric, value in self._metrics.items(): _increment_metric(numeric_payload, metric, float(value)) diff --git a/sqlspec/observability/_dispatcher.py b/sqlspec/observability/_dispatcher.py index dc1b67056..f7a8cd62a 100644 --- a/sqlspec/observability/_dispatcher.py +++ b/sqlspec/observability/_dispatcher.py @@ -240,6 +240,9 @@ def emit_error(self, context: LifecycleContext) -> None: def register_hook(self, event: LifecycleEvent, callback: LifecycleHook) -> None: """Append a hook at runtime.""" + if event not in EVENT_ATTRS: + msg = f"Unsupported lifecycle event: {event!r}" + raise ValueError(msg) callbacks = self._hooks.setdefault(event, []) callbacks.append(callback) self._enable_event_guard(event) @@ -307,6 +310,11 @@ async def _emit_async(self, event: LifecycleEvent, context: LifecycleContext) -> @staticmethod def _invoke_callback(callback: LifecycleHook, context: LifecycleContext, event: LifecycleEvent) -> None: try: - callback(context) + result = callback(context) except Exception as exc: # pragma: no cover logger.warning("Lifecycle hook failed: event=%s error=%s", event, exc) + return + if inspect.isawaitable(result): + if inspect.iscoroutine(result): + result.close() + logger.warning("Async lifecycle hook invoked on sync path: event=%s", event) diff --git a/sqlspec/observability/_formatters/_aws.py b/sqlspec/observability/_formatters/_aws.py index 520b017e3..0c128957a 100644 --- a/sqlspec/observability/_formatters/_aws.py +++ b/sqlspec/observability/_formatters/_aws.py @@ -30,6 +30,17 @@ class AWSLogFormatter: "CRITICAL": "FATAL", } + def __hash__(self) -> int: + return hash(self.__class__.__name__) + + def __repr__(self) -> str: + return "AWSLogFormatter()" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, AWSLogFormatter): + return NotImplemented + return True + def format( self, level: str, @@ -77,14 +88,3 @@ def format( entry.update(extra) return entry - - def __hash__(self) -> int: - return hash(self.__class__.__name__) - - def __repr__(self) -> str: - return "AWSLogFormatter()" - - def __eq__(self, other: object) -> bool: - if not isinstance(other, AWSLogFormatter): - return NotImplemented - return True diff --git a/sqlspec/observability/_formatters/_azure.py b/sqlspec/observability/_formatters/_azure.py index b652a33f3..76d15bb5b 100644 --- a/sqlspec/observability/_formatters/_azure.py +++ b/sqlspec/observability/_formatters/_azure.py @@ -23,6 +23,17 @@ class AzureLogFormatter: SEVERITY_MAP: ClassVar[dict[str, int]] = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4} + def __hash__(self) -> int: + return hash(self.__class__.__name__) + + def __repr__(self) -> str: + return "AzureLogFormatter()" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, AzureLogFormatter): + return NotImplemented + return True + def format( self, level: str, @@ -71,14 +82,3 @@ def format( entry["properties"] = properties return entry - - def __hash__(self) -> int: - return hash(self.__class__.__name__) - - def __repr__(self) -> str: - return "AzureLogFormatter()" - - def __eq__(self, other: object) -> bool: - if not isinstance(other, AzureLogFormatter): - return NotImplemented - return True diff --git a/sqlspec/observability/_formatters/_gcp.py b/sqlspec/observability/_formatters/_gcp.py index 9fd0c88df..d32c80e48 100644 --- a/sqlspec/observability/_formatters/_gcp.py +++ b/sqlspec/observability/_formatters/_gcp.py @@ -41,6 +41,17 @@ def __init__(self, project_id: str | None = None) -> None: """ self._project_id = project_id or os.environ.get("GOOGLE_CLOUD_PROJECT") + def __hash__(self) -> int: + return hash((self.__class__.__name__, self._project_id)) + + def __repr__(self) -> str: + return f"GCPLogFormatter(project_id={self._project_id!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, GCPLogFormatter): + return NotImplemented + return self._project_id == other._project_id + @property def project_id(self) -> str | None: """Get the configured GCP project ID.""" @@ -106,14 +117,3 @@ def format( entry.update(extra) return entry - - def __hash__(self) -> int: - return hash((self.__class__.__name__, self._project_id)) - - def __repr__(self) -> str: - return f"GCPLogFormatter(project_id={self._project_id!r})" - - def __eq__(self, other: object) -> bool: - if not isinstance(other, GCPLogFormatter): - return NotImplemented - return self._project_id == other._project_id diff --git a/sqlspec/observability/_observer.py b/sqlspec/observability/_observer.py index d8fa7c448..931277a96 100644 --- a/sqlspec/observability/_observer.py +++ b/sqlspec/observability/_observer.py @@ -1,12 +1,16 @@ """Statement observer primitives for SQL execution events.""" import logging +from collections.abc import Mapping, Sequence from time import time -from typing import Any +from typing import TYPE_CHECKING, Any from sqlspec.observability._config import LoggingConfig, StatementObserver from sqlspec.utils.logging import SQL_LOGGER_NAME, get_logger +if TYPE_CHECKING: + from sqlspec.observability._formatters._base import CloudLogFormatter + __all__ = ( "SQL_LOGGER_NAME", "StatementEvent", @@ -108,35 +112,6 @@ def __hash__(self) -> int: # pragma: no cover msg = "StatementEvent objects are mutable and unhashable" raise TypeError(msg) - def as_dict(self) -> "dict[str, Any]": - """Return event payload as a dictionary.""" - - return { - "sql": self.sql, - "parameters": self.parameters, - "driver": self.driver, - "adapter": self.adapter, - "bind_key": self.bind_key, - "db_system": self.db_system, - "operation": self.operation, - "execution_mode": self.execution_mode, - "is_many": self.is_many, - "is_script": self.is_script, - "rows_affected": self.rows_affected, - "duration_s": self.duration_s, - "started_at": self.started_at, - "correlation_id": self.correlation_id, - "storage_backend": self.storage_backend, - "sql_hash": self.sql_hash, - "sql_truncated": self.sql_truncated, - "sql_original_length": self.sql_original_length, - "transaction_state": self.transaction_state, - "prepared_statement": self.prepared_statement, - "trace_id": self.trace_id, - "span_id": self.span_id, - "sampled": self.sampled, - } - def __repr__(self) -> str: return ( f"StatementEvent(sql={self.sql!r}, parameters={self.parameters!r}, driver={self.driver!r}, adapter={self.adapter!r}, bind_key={self.bind_key!r}, db_system={self.db_system!r}, " @@ -174,6 +149,35 @@ def __eq__(self, other: object) -> bool: and self.sampled == other.sampled ) + def as_dict(self) -> "dict[str, Any]": + """Return event payload as a dictionary.""" + + return { + "sql": self.sql, + "parameters": self.parameters, + "driver": self.driver, + "adapter": self.adapter, + "bind_key": self.bind_key, + "db_system": self.db_system, + "operation": self.operation, + "execution_mode": self.execution_mode, + "is_many": self.is_many, + "is_script": self.is_script, + "rows_affected": self.rows_affected, + "duration_s": self.duration_s, + "started_at": self.started_at, + "correlation_id": self.correlation_id, + "storage_backend": self.storage_backend, + "sql_hash": self.sql_hash, + "sql_truncated": self.sql_truncated, + "sql_original_length": self.sql_original_length, + "transaction_state": self.transaction_state, + "prepared_statement": self.prepared_statement, + "trace_id": self.trace_id, + "span_id": self.span_id, + "sampled": self.sampled, + } + def format_statement_event(event: StatementEvent) -> str: """Create a concise human-readable representation of a statement event.""" @@ -192,11 +196,13 @@ def format_statement_event(event: StatementEvent) -> str: ) -def create_statement_observer(logging_config: "LoggingConfig") -> StatementObserver: +def create_statement_observer( + logging_config: "LoggingConfig", cloud_formatter: "CloudLogFormatter | None" = None +) -> StatementObserver: """Create a statement observer bound to the provided logging config.""" def observer(event: StatementEvent) -> None: - _emit_otel_statement_log(event, logging_config) + _emit_otel_statement_log(event, logging_config, cloud_formatter=cloud_formatter) return observer @@ -262,7 +268,9 @@ def create_event( ) -def _emit_otel_statement_log(event: StatementEvent, logging_config: "LoggingConfig") -> None: +def _emit_otel_statement_log( + event: StatementEvent, logging_config: "LoggingConfig", *, cloud_formatter: "CloudLogFormatter | None" = None +) -> None: sql_preview, sql_truncated, sql_length = _truncate_text(event.sql, max_chars=logging_config.sql_truncation_length) if event.sql_original_length is not None: sql_length = event.sql_original_length @@ -315,6 +323,18 @@ def _emit_otel_statement_log(event: StatementEvent, logging_config: "LoggingConf extra["parameters_truncated"] = True extra["parameters"] = params + if cloud_formatter is not None: + formatted = cloud_formatter.format( + "INFO", + event.operation, + correlation_id=event.correlation_id, + trace_id=event.trace_id, + span_id=event.span_id, + duration_ms=event.duration_s * 1000, + extra={k: v for k, v in extra.items() if v is not None}, + ) + extra.update({key: val for key, val in formatted.items() if key not in {"message", "asctime"}}) + sql_logger.info(event.operation, extra=extra) @@ -330,25 +350,56 @@ def _summarize_parameters(parameters: Any) -> "dict[str, str | int | None]": return {"parameters_type": None, "parameters_size": None} if isinstance(parameters, dict): return {"parameters_type": "dict", "parameters_size": len(parameters)} + if isinstance(parameters, Mapping): + return {"parameters_type": type(parameters).__name__, "parameters_size": len(parameters)} if isinstance(parameters, list): return {"parameters_type": "list", "parameters_size": len(parameters)} if isinstance(parameters, tuple): return {"parameters_type": "tuple", "parameters_size": len(parameters)} + if isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes, bytearray)): + return {"parameters_type": type(parameters).__name__, "parameters_size": len(parameters)} return {"parameters_type": type(parameters).__name__, "parameters_size": None} def _maybe_truncate_parameters(parameters: Any, *, max_items: int) -> tuple[Any, bool]: - if isinstance(parameters, dict): - if len(parameters) <= max_items: - return parameters, False - truncated = dict(list(parameters.items())[:max_items]) - return truncated, True + if isinstance(parameters, Mapping): + truncated_flag = len(parameters) > max_items + items = list(parameters.items())[:max_items] if truncated_flag else list(parameters.items()) + result_dict: dict[Any, Any] = {} + for key, val in items: + sub_val, sub_trunc = _maybe_truncate_parameters(val, max_items=max_items) + result_dict[key] = sub_val + if sub_trunc: + truncated_flag = True + return result_dict, truncated_flag if isinstance(parameters, list): - if len(parameters) <= max_items: - return parameters, False - return parameters[:max_items], True + truncated_flag = len(parameters) > max_items + sliced = parameters[:max_items] if truncated_flag else parameters + result_list: list[Any] = [] + for item in sliced: + sub_val, sub_trunc = _maybe_truncate_parameters(item, max_items=max_items) + result_list.append(sub_val) + if sub_trunc: + truncated_flag = True + return result_list, truncated_flag if isinstance(parameters, tuple): - if len(parameters) <= max_items: - return parameters, False - return parameters[:max_items], True + truncated_flag = len(parameters) > max_items + sliced_tuple = parameters[:max_items] if truncated_flag else parameters + result_items: list[Any] = [] + for item in sliced_tuple: + sub_val, sub_trunc = _maybe_truncate_parameters(item, max_items=max_items) + result_items.append(sub_val) + if sub_trunc: + truncated_flag = True + return tuple(result_items), truncated_flag + if isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes, bytearray)): + truncated_flag = len(parameters) > max_items + sliced_seq = list(parameters[:max_items]) if truncated_flag else list(parameters) + result_seq: list[Any] = [] + for item in sliced_seq: + sub_val, sub_trunc = _maybe_truncate_parameters(item, max_items=max_items) + result_seq.append(sub_val) + if sub_trunc: + truncated_flag = True + return result_seq, truncated_flag return parameters, False diff --git a/sqlspec/observability/_runtime.py b/sqlspec/observability/_runtime.py index 785c9fb00..5b34da347 100644 --- a/sqlspec/observability/_runtime.py +++ b/sqlspec/observability/_runtime.py @@ -1,14 +1,17 @@ """Runtime helpers that bundle lifecycle, observer, and span orchestration.""" -import re +from collections.abc import Mapping +from time import time from typing import TYPE_CHECKING, Any, cast +from urllib.parse import urlsplit, urlunsplit -from sqlspec.observability._common import compute_sql_hash, get_trace_context, resolve_db_system +from sqlspec.observability._common import compute_sql_hash, get_trace_context, redact_sql_literals, resolve_db_system from sqlspec.observability._config import LoggingConfig, ObservabilityConfig, StatementObserver from sqlspec.observability._dispatcher import LifecycleDispatcher, LifecycleHook from sqlspec.observability._observer import create_event, create_statement_observer from sqlspec.observability._spans import SpanManager from sqlspec.utils.correlation import CorrelationContext +from sqlspec.utils.logging import get_logger from sqlspec.utils.type_guards import has_span_attribute if TYPE_CHECKING: @@ -19,7 +22,9 @@ __all__ = ("ObservabilityRuntime", "compute_sql_hash") -_LITERAL_PATTERN = re.compile(r"'(?:''|[^'])*'") +logger = get_logger("sqlspec.observability.runtime") + +_MIN_WALL_CLOCK_EPOCH_S = 1_000_000_000.0 class ObservabilityRuntime: @@ -37,7 +42,6 @@ class ObservabilityRuntime: "span_manager", ) - # Allow test injection with fake span managers (mypyc strict typing workaround) span_manager: "Any" def __init__( @@ -56,12 +60,10 @@ def __init__( if config.statement_observers: observers.extend(config.statement_observers) if config.print_sql: - observers.append(create_statement_observer(config.logging)) + observers.append(create_statement_observer(config.logging, cloud_formatter=config.cloud_formatter)) self._statement_observers = tuple(observers) self._redaction = config.redaction.copy() if config.redaction else None self._metrics: dict[str, float] = {} - # Pre-compute the non-span idle state (lifecycle and observers are immutable) - # span_manager can be replaced for testing so we check it separately self._is_idle_cached = not self.lifecycle.is_enabled and not self._statement_observers @property @@ -72,8 +74,6 @@ def is_idle(self) -> bool: and telemetry spans are disabled. Drivers can use this to skip expensive context construction. """ - # Fast path: lifecycle and observers state is cached (immutable after init) - # span_manager is checked each time as it can be replaced for testing return self._is_idle_cached and not self.span_manager.is_enabled @property @@ -101,11 +101,6 @@ def base_context(self) -> dict[str, Any]: context["correlation_id"] = correlation_id return context - def _build_context(self, **extras: Any) -> dict[str, Any]: - context = self.base_context() - context.update({key: value for key, value in extras.items() if value is not None}) - return context - def lifecycle_snapshot(self) -> dict[str, int]: """Return lifecycle counters keyed under the diagnostics prefix.""" @@ -182,15 +177,21 @@ def emit_pool_create(self, pool: Any) -> None: def emit_pool_destroying_sync(self, pool: Any) -> None: """Fire pre-destruction lifecycle hooks synchronously.""" - if not self.lifecycle.has_pool_destroying: - return - self.lifecycle.emit_pool_destroying_sync(self._build_context(pool=pool)) + span = self._start_lifecycle_span("pool.destroying", subject=pool) + try: + if self.lifecycle.has_pool_destroying: + self.lifecycle.emit_pool_destroying_sync(self._build_context(pool=pool)) + finally: + self.span_manager.end_span(span) async def emit_pool_destroying_async(self, pool: Any) -> None: """Fire pre-destruction lifecycle hooks, awaiting any awaitables.""" - if not self.lifecycle.has_pool_destroying: - return - await self.lifecycle.emit_pool_destroying_async(self._build_context(pool=pool)) + span = self._start_lifecycle_span("pool.destroying", subject=pool) + try: + if self.lifecycle.has_pool_destroying: + await self.lifecycle.emit_pool_destroying_async(self._build_context(pool=pool)) + finally: + self.span_manager.end_span(span) def emit_pool_destroying(self, pool: Any) -> None: self.emit_pool_destroying_sync(pool) @@ -232,6 +233,7 @@ def register_lifecycle_hook(self, event: str, callback: "LifecycleHook") -> None callback: Callable receiving the lifecycle context. """ self.lifecycle.register_hook(event, callback) # type: ignore[arg-type] + self._is_idle_cached = not self.lifecycle.is_enabled and not self._statement_observers def emit_connection_create_sync(self, connection: Any) -> None: span = self._start_lifecycle_span("connection.create", subject=connection) @@ -333,15 +335,13 @@ def emit_query_complete(self, **extras: Any) -> None: def emit_error_sync(self, exception: Exception, **extras: Any) -> None: if self.lifecycle.has_error: - payload = self._build_context(exception=exception) - payload.update({key: value for key, value in extras.items() if value is not None}) + payload = self._build_context(exception=exception, **extras) self.lifecycle.emit_error_sync(payload) self.increment_metric("errors", 1.0) async def emit_error_async(self, exception: Exception, **extras: Any) -> None: if self.lifecycle.has_error: - payload = self._build_context(exception=exception) - payload.update({key: value for key, value in extras.items() if value is not None}) + payload = self._build_context(exception=exception, **extras) await self.lifecycle.emit_error_async(payload) self.increment_metric("errors", 1.0) @@ -363,6 +363,9 @@ def emit_statement_event( storage_backend: str | None, started_at: float | None = None, precomputed_sql_hash: str | None = None, + transaction_state: str | None = None, + prepared_statement: bool | None = None, + is_error: bool = False, ) -> None: """Emit a statement event to all registered observers.""" @@ -371,16 +374,23 @@ def emit_statement_event( correlation_id = CorrelationContext.get() sampled = True if self.config.sampling is not None: - sampled = self.config.sampling.should_sample(correlation_id=correlation_id, duration_ms=duration_s * 1000) + sampled = self.config.sampling.should_sample( + correlation_id=correlation_id, duration_ms=duration_s * 1000, is_error=is_error + ) if not sampled: return sanitized_sql = self._redact_sql(sql) sanitized_params = self._redact_parameters(parameters) logging_config = self.config.logging db_system = resolve_db_system(self.config_name) + if db_system == "other_sql" and driver: + db_system = resolve_db_system(driver) sql_hash = None if logging_config and logging_config.include_sql_hash: - sql_hash = precomputed_sql_hash if precomputed_sql_hash is not None else compute_sql_hash(sanitized_sql) + if precomputed_sql_hash is not None and (self._redaction is None or not self._redaction.mask_literals): + sql_hash = precomputed_sql_hash + else: + sql_hash = compute_sql_hash(sanitized_sql) sql_truncation_length = logging_config.sql_truncation_length if logging_config else 2000 sql_original_length = len(sanitized_sql) sql_truncated = sql_original_length > sql_truncation_length @@ -388,6 +398,9 @@ def emit_statement_event( span_id = None if logging_config and logging_config.include_trace_context: trace_id, span_id = get_trace_context() + resolved_started_at = ( + started_at if (started_at is not None and started_at >= _MIN_WALL_CLOCK_EPOCH_S) else (time() - duration_s) + ) event = create_event( sql=sanitized_sql, parameters=sanitized_params, @@ -403,16 +416,21 @@ def emit_statement_event( duration_s=duration_s, correlation_id=correlation_id, storage_backend=storage_backend, - started_at=started_at, + started_at=resolved_started_at, sql_hash=sql_hash, sql_truncated=sql_truncated, sql_original_length=sql_original_length, + transaction_state=transaction_state, + prepared_statement=prepared_statement, trace_id=trace_id, span_id=span_id, sampled=sampled, ) for observer in self._statement_observers: - observer(event) + try: + observer(event) + except Exception as exc: + logger.warning("Statement observer failed: %s", exc) def start_query_span(self, sql: str, operation: str, driver: str, *, sql_hash: str | None = None) -> Any: """Start a query span with runtime metadata.""" @@ -420,9 +438,14 @@ def start_query_span(self, sql: str, operation: str, driver: str, *, sql_hash: s if not self.span_manager.is_enabled: return None - if sql_hash is None: - sql_hash = compute_sql_hash(sql) - connection_info = {"sqlspec.statement.hash": sql_hash, "sqlspec.statement.length": len(sql)} + correlation_id = CorrelationContext.get() + if self.config.sampling is not None and not self.config.sampling.should_sample(correlation_id=correlation_id): + return None + + redacted_sql = self._redact_sql(sql) if (self._redaction is not None and self._redaction.mask_literals) else sql + if sql_hash is None or (self._redaction is not None and self._redaction.mask_literals): + sql_hash = compute_sql_hash(redacted_sql) + connection_info: dict[str, Any] = {"sqlspec.statement.hash": sql_hash, "sqlspec.statement.length": len(sql)} sql_payload = "" if self.config.print_sql: sql_payload = self._redact_sql(sql) @@ -430,7 +453,6 @@ def start_query_span(self, sql: str, operation: str, driver: str, *, sql_hash: s if truncated: connection_info["sqlspec.statement.truncated"] = True - correlation_id = CorrelationContext.get() return self.span_manager.start_query_span( driver=driver, adapter=self.config_name, @@ -455,7 +477,7 @@ def start_storage_span( if correlation_id: attributes["sqlspec.correlation_id"] = correlation_id if destination: - attributes["sqlspec.storage.destination"] = destination + attributes["sqlspec.storage.destination"] = _sanitize_storage_destination(destination) if format_label: attributes["sqlspec.storage.format"] = format_label return self.span_manager.start_span(f"sqlspec.storage.{operation}", attributes) @@ -494,7 +516,7 @@ def end_storage_span( def annotate_storage_telemetry(self, telemetry: "StorageTelemetry") -> "StorageTelemetry": """Add bind key / config / correlation metadata to telemetry payloads.""" - annotated = telemetry + annotated = cast("StorageTelemetry", dict(telemetry)) base = self.base_context() correlation_id = base.get("correlation_id") if correlation_id and not annotated.get("correlation_id"): @@ -504,6 +526,19 @@ def annotate_storage_telemetry(self, telemetry: "StorageTelemetry") -> "StorageT annotated["bind_key"] = self.bind_key return annotated + def _build_context(self, **extras: Any) -> dict[str, Any]: + context = self.base_context() + for key, value in extras.items(): + if value is None: + continue + if key == "sql" and isinstance(value, str): + context[key] = self._redact_sql(value) + elif key == "parameters": + context[key] = self._redact_parameters(value) + else: + context[key] = value + return context + def _start_lifecycle_span(self, event: str, subject: Any | None = None) -> Any: if not self.span_manager.is_enabled: return None @@ -527,7 +562,7 @@ def _attach_storage_telemetry(self, span: Any, telemetry: "StorageTelemetry") -> if "rows_processed" in telemetry and telemetry["rows_processed"] is not None: span.set_attribute("sqlspec.storage.rows_processed", telemetry["rows_processed"]) if "destination" in telemetry and telemetry["destination"] is not None: - span.set_attribute("sqlspec.storage.destination", telemetry["destination"]) + span.set_attribute("sqlspec.storage.destination", _sanitize_storage_destination(telemetry["destination"])) if "format" in telemetry and telemetry["format"] is not None: span.set_attribute("sqlspec.storage.format", telemetry["format"]) if "duration_s" in telemetry and telemetry["duration_s"] is not None: @@ -539,7 +574,7 @@ def _redact_sql(self, sql: str) -> str: config = self._redaction if config is None or not config.mask_literals: return sql - return _LITERAL_PATTERN.sub("'***'", sql) + return redact_sql_literals(sql) def _redact_parameters(self, parameters: Any) -> Any: config = self._redaction @@ -549,23 +584,29 @@ def _redact_parameters(self, parameters: Any) -> Any: return _mask_parameters(parameters, allow_list) -def _mask_parameters(value: Any, allow_list: set[str]) -> Any: - if isinstance(value, dict): - masked: dict[str, Any] = {} - for key, item in value.items(): - if allow_list and key in allow_list: - masked[key] = _mask_parameters(item, allow_list) - else: - masked[key] = "***" - return masked +def _mask_parameters(value: Any, allow_list: set[str], *, is_allowed: bool = False) -> Any: + if isinstance(value, Mapping): + return { + key: _mask_parameters(item, allow_list, is_allowed=bool(allow_list and key in allow_list)) + for key, item in value.items() + } if isinstance(value, list): - return [_mask_parameters(item, allow_list) for item in value] + return [_mask_parameters(item, allow_list, is_allowed=is_allowed) for item in value] if isinstance(value, tuple): - return tuple(_mask_parameters(item, allow_list) for item in value) - return "***" + return tuple(_mask_parameters(item, allow_list, is_allowed=is_allowed) for item in value) + return value if is_allowed else "***" + + +def _sanitize_storage_destination(destination: str) -> str: + if "://" not in destination and "?" not in destination: + return destination + parsed = urlsplit(destination) + netloc = parsed.netloc + if "@" in netloc: + netloc = netloc.rsplit("@", 1)[-1] + return urlunsplit((parsed.scheme, netloc, parsed.path, "", "")) -# Keep in sync with sqlspec.observability._observer._truncate_text. def _truncate_text(value: str, *, max_chars: int) -> tuple[str, bool]: if len(value) <= max_chars: return value, False diff --git a/sqlspec/observability/_sampling.py b/sqlspec/observability/_sampling.py index 90390a251..73b2c7bdc 100644 --- a/sqlspec/observability/_sampling.py +++ b/sqlspec/observability/_sampling.py @@ -7,6 +7,7 @@ """ import random +import zlib from typing import ClassVar __all__ = ("SamplingConfig",) @@ -26,7 +27,13 @@ class SamplingConfig: deterministic: If True, use hash-based sampling for consistency across distributed systems. """ - __slots__ = ("deterministic", "force_sample_on_error", "force_sample_slow_queries_ms", "sample_rate") + __slots__ = ( + "_slow_query_explicit", + "deterministic", + "force_sample_on_error", + "force_sample_slow_queries_ms", + "sample_rate", + ) DEFAULT_SAMPLE_RATE: ClassVar[float] = 1.0 """Default sample rate (100% - sample everything).""" @@ -42,7 +49,7 @@ def __init__( *, sample_rate: float = 1.0, force_sample_on_error: bool = True, - force_sample_slow_queries_ms: float | None = 100.0, + force_sample_slow_queries_ms: float | None = -1.0, deterministic: bool = True, ) -> None: """Initialize sampling configuration. @@ -61,9 +68,36 @@ def __init__( """ self.sample_rate = max(0.0, min(1.0, sample_rate)) self.force_sample_on_error = force_sample_on_error - self.force_sample_slow_queries_ms = force_sample_slow_queries_ms + if force_sample_slow_queries_ms is not None and force_sample_slow_queries_ms < 0.0: + self.force_sample_slow_queries_ms: float | None = self.DEFAULT_SLOW_QUERY_THRESHOLD_MS + self._slow_query_explicit = False + else: + self.force_sample_slow_queries_ms = force_sample_slow_queries_ms + self._slow_query_explicit = True self.deterministic = deterministic + def __repr__(self) -> str: + return ( + f"SamplingConfig(sample_rate={self.sample_rate!r}, " + f"force_sample_on_error={self.force_sample_on_error!r}, " + f"force_sample_slow_queries_ms={self.force_sample_slow_queries_ms!r}, " + f"deterministic={self.deterministic!r})" + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SamplingConfig): + return NotImplemented + return ( + self.sample_rate == other.sample_rate + and self.force_sample_on_error == other.force_sample_on_error + and self.force_sample_slow_queries_ms == other.force_sample_slow_queries_ms + and self.deterministic == other.deterministic + ) + + def __hash__(self) -> int: + msg = "SamplingConfig objects are mutable and unhashable" + raise TypeError(msg) + def should_sample( self, correlation_id: str | None = None, @@ -87,7 +121,6 @@ def should_sample( Returns: True if the request should be sampled, False otherwise. """ - # Force sample conditions take precedence if force: return True @@ -108,8 +141,7 @@ def should_sample( return False if self.deterministic and correlation_id: - # Hash-based sampling for consistency across distributed systems - hash_value = hash(correlation_id) % self.HASH_MODULUS + hash_value = zlib.crc32(correlation_id.encode("utf-8")) % self.HASH_MODULUS threshold = int(self.sample_rate * self.HASH_MODULUS) return hash_value < threshold @@ -121,32 +153,11 @@ def copy(self) -> "SamplingConfig": Returns: A new SamplingConfig instance with the same values. """ - return SamplingConfig( + copied = SamplingConfig( sample_rate=self.sample_rate, force_sample_on_error=self.force_sample_on_error, force_sample_slow_queries_ms=self.force_sample_slow_queries_ms, deterministic=self.deterministic, ) - - def __repr__(self) -> str: - return ( - f"SamplingConfig(sample_rate={self.sample_rate!r}, " - f"force_sample_on_error={self.force_sample_on_error!r}, " - f"force_sample_slow_queries_ms={self.force_sample_slow_queries_ms!r}, " - f"deterministic={self.deterministic!r})" - ) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, SamplingConfig): - return NotImplemented - return ( - self.sample_rate == other.sample_rate - and self.force_sample_on_error == other.force_sample_on_error - and self.force_sample_slow_queries_ms == other.force_sample_slow_queries_ms - and self.deterministic == other.deterministic - ) - - def __hash__(self) -> int: - # SamplingConfig is mutable, so it should not be hashable - msg = "SamplingConfig objects are mutable and unhashable" - raise TypeError(msg) + copied._slow_query_explicit = self._slow_query_explicit + return copied diff --git a/sqlspec/observability/_spans.py b/sqlspec/observability/_spans.py index 05c4ab50f..78517268a 100644 --- a/sqlspec/observability/_spans.py +++ b/sqlspec/observability/_spans.py @@ -4,7 +4,7 @@ from typing import Any from sqlspec.exceptions import MissingDependencyError -from sqlspec.observability._common import resolve_db_system +from sqlspec.observability._common import redact_sql_literals, resolve_db_system from sqlspec.observability._config import TelemetryConfig from sqlspec.utils.logging import get_logger from sqlspec.utils.module_loader import ensure_opentelemetry @@ -20,10 +20,12 @@ class SpanManager: """Lazy OpenTelemetry span manager with graceful degradation.""" __slots__ = ( + "_context_api", "_enabled", "_provider_factory", "_resource_attributes", "_span_kind", + "_span_tokens", "_status_cls", "_status_code_cls", "_trace_api", @@ -35,11 +37,13 @@ def __init__(self, telemetry: TelemetryConfig | None = None) -> None: self._enabled = bool(telemetry.enable_spans) self._provider_factory = telemetry.provider_factory self._resource_attributes = dict(telemetry.resource_attributes or {}) + self._context_api: Any | None = None self._trace_api: Any | None = None self._status_cls: Any | None = None self._status_code_cls: Any | None = None self._span_kind: Any | None = None self._tracer: Any | None = None + self._span_tokens: dict[int, Any] = {} if self._enabled: self._resolve_api() @@ -65,11 +69,10 @@ def start_query_span( if not self._enabled: return None - attributes: dict[str, Any] = { - "db.system": resolve_db_system(adapter), - "db.operation": operation, - "sqlspec.driver": driver, - } + db_system = resolve_db_system(adapter) + if db_system == "other_sql" and driver: + db_system = resolve_db_system(driver) + attributes: dict[str, Any] = {"db.system": db_system, "db.operation": operation, "sqlspec.driver": driver} if sql: attributes["db.statement"] = sql if bind_key: @@ -98,10 +101,16 @@ def end_span(self, span: Any, error: Exception | None = None) -> None: if span is None: return + token = self._span_tokens.pop(id(span), None) + if token is not None and self._context_api is not None: + try: + self._context_api.detach(token) + except Exception as exc: # pragma: no cover + logger.debug("Failed to detach span context: %s", exc) try: if error and self._status_cls and self._status_code_cls: span.record_exception(error) - status = self._status_cls(self._status_code_cls.ERROR, str(error)) + status = self._status_cls(self._status_code_cls.ERROR, redact_sql_literals(str(error))) span.set_status(status) span.end() except Exception as exc: # pragma: no cover @@ -112,9 +121,20 @@ def _start_span(self, name: str, attributes: dict[str, Any]) -> Any: if tracer is None: return None span_kind = self._span_kind - if span_kind is None: - return tracer.start_span(name=name, attributes=attributes) - return tracer.start_span(name=name, attributes=attributes, kind=span_kind) + span = ( + tracer.start_span(name=name, attributes=attributes) + if span_kind is None + else tracer.start_span(name=name, attributes=attributes, kind=span_kind) + ) + if span is not None and self._context_api is not None and self._trace_api is not None: + set_span = getattr(self._trace_api, "set_span_in_context", None) + if callable(set_span): + try: + token = self._context_api.attach(set_span(span)) + self._span_tokens[id(span)] = token + except Exception as exc: # pragma: no cover + logger.debug("Failed to attach span context: %s", exc) + return span def _get_tracer(self) -> Any: if not self._enabled: @@ -141,6 +161,11 @@ def _resolve_api(self) -> None: self._tracer = None return + try: + self._context_api = import_module("opentelemetry.context") + except ImportError: + self._context_api = None + span_kind_cls = trace.SpanKind status_cls = status_module.Status status_code_cls = status_module.StatusCode diff --git a/sqlspec/protocols.py b/sqlspec/protocols.py index eec0357b9..2fcda3032 100644 --- a/sqlspec/protocols.py +++ b/sqlspec/protocols.py @@ -14,7 +14,7 @@ from sqlglot.dialects.dialect import DialectType from sqlspec.config import ExtensionConfigs - from sqlspec.core import StatementConfig + from sqlspec.core import ArrowResult, StatementConfig from sqlspec.data_dictionary import ( ColumnMetadata, DDLResult, @@ -392,14 +392,14 @@ class MappingLikeProtocol(Protocol): that support dictionary-like access with keys() method. """ - def keys(self) -> "Iterator[str]": - """Return an iterator over the keys.""" - ... - def __getitem__(self, key: str) -> Any: """Get item by key.""" ... + def keys(self) -> "Iterator[str]": + """Return an iterator over the keys.""" + ... + @runtime_checkable class HasAsDictProtocol(Protocol): @@ -482,6 +482,16 @@ class ObjectStoreProtocol(Protocol): def __init__(self, uri: str, **kwargs: Any) -> None: return + @property + def supports_signing(self) -> bool: + """Whether this backend supports URL signing. + + Returns: + True if the backend supports generating signed URLs, False otherwise. + Only S3, GCS, and Azure backends via obstore support signing. + """ + return False + def resolve_uri(self, path: "str | Path") -> str: """Resolve a backend-relative path to its unsigned address.""" return "" @@ -627,30 +637,20 @@ async def write_arrow_async(self, path: "str | Path", table: "ArrowTable", **kwa msg = "Async arrow writing not implemented" raise NotImplementedError(msg) - # NOTE: Returns AsyncIterator directly; this is intentionally not async def. def stream_arrow_async( self, pattern: str, *, file_format: Literal["parquet"] = "parquet", batch_size: int = 65_536, **kwargs: Any ) -> "AsyncIterator[ArrowRecordBatch]": - """Stream Arrow record batches from matching objects.""" - msg = "Async arrow streaming not implemented" - raise NotImplementedError(msg) + """Stream Arrow record batches from matching objects. - @property - def supports_signing(self) -> bool: - """Whether this backend supports URL signing. - - Returns: - True if the backend supports generating signed URLs, False otherwise. - Only S3, GCS, and Azure backends via obstore support signing. + Returns AsyncIterator directly (not a coroutine). """ - return False + msg = "Async arrow streaming not implemented" + raise NotImplementedError(msg) @overload def sign_sync(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... - @overload def sign_sync(self, paths: list[str], expires_in: int = 3600, for_upload: bool = False) -> list[str]: ... - def sign_sync( self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False ) -> "str | list[str]": @@ -673,10 +673,8 @@ def sign_sync( @overload async def sign_async(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... - @overload async def sign_async(self, paths: list[str], expires_in: int = 3600, for_upload: bool = False) -> list[str]: ... - async def sign_async( self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False ) -> "str | list[str]": @@ -753,10 +751,13 @@ class SQLBuilderProtocol(Protocol): _parameters: dict[str, Any] _parameter_counter: int _parameter_name_counters: dict[str, int] - _columns: Any # Optional attribute for some builders - _with_ctes: Any # Optional attribute for some builders + _with_ctes: Any dialect: Any - dialect_name: "str | None" + + @property + def dialect_name(self) -> "str | None": + """Return the name of the dialect.""" + ... @property def parameters(self) -> dict[str, Any]: @@ -771,34 +772,14 @@ def add_parameter(self, value: Any, name: "str | None" = None) -> tuple[Any, str """Add a parameter to the builder.""" ... - def _next_parameter_name(self, base_name: str) -> str: - """Generate a unique parameter name.""" - ... - def create_placeholder(self, value: Any, base_name: str) -> "tuple[exp.Placeholder, str]": """Create placeholder expression with bound parameter (public).""" ... - def _parameterize_expression(self, expression: "exp.Expr") -> "exp.Expr": - """Replace literal values in an expression with bound parameters.""" - ... - def build(self) -> "exp.Expr | Any": """Build and return the final expression.""" ... - def _merge_parameters(self, sql_obj: Any) -> None: - """Merge parameters from SQL objects into the builder.""" - ... - - def _build_final_expression(self, *, copy: bool = False) -> "exp.Expr": - """Return the expression with attached CTEs.""" - ... - - def _spawn_like_self(self) -> "Self": - """Create a new builder with matching configuration.""" - ... - def set_expression(self, expression: "exp.Expr") -> None: """Replace the underlying SQLGlot expression.""" ... @@ -821,6 +802,26 @@ def build_static_expression( """Compile a pre-built expression with optional caching and parameters.""" ... + def _next_parameter_name(self, base_name: str) -> str: + """Generate a unique parameter name.""" + ... + + def _parameterize_expression(self, expression: "exp.Expr") -> "exp.Expr": + """Replace literal values in an expression with bound parameters.""" + ... + + def _merge_parameters(self, sql_obj: Any) -> Any: + """Merge parameters from SQL objects into the builder.""" + ... + + def _build_final_expression(self, *, copy: bool = False) -> "exp.Expr": + """Return the expression with attached CTEs.""" + ... + + def _spawn_like_self(self) -> "Self": + """Create a new builder with matching configuration.""" + ... + @runtime_checkable class SupportsArrowResults(Protocol): @@ -842,7 +843,7 @@ def select_to_arrow( batch_size: int | None = None, arrow_schema: Any | None = None, **kwargs: Any, - ) -> "ArrowTable | ArrowRecordBatch": + ) -> "ArrowResult": """Execute query and return results as Apache Arrow Table or RecordBatch. Args: @@ -899,6 +900,7 @@ class HasMigrationConfigProtocol(Protocol): migration_config: "Mapping[str, Any] | None" +@runtime_checkable class SyncDataDictionaryProtocol(Protocol): """Protocol for sync data dictionary implementations.""" @@ -997,6 +999,7 @@ def get_foreign_keys( def list_available_features(self) -> "list[str]": ... +@runtime_checkable class AsyncDataDictionaryProtocol(Protocol): """Protocol for async data dictionary implementations.""" diff --git a/sqlspec/service/_base.py b/sqlspec/service/_base.py index 6d88c5bd1..dcec544da 100644 --- a/sqlspec/service/_base.py +++ b/sqlspec/service/_base.py @@ -118,7 +118,8 @@ def provide_session(self, session: AsyncDriverT | None = None) -> AbstractAsyncC return nullcontext(cast("AsyncDriverT", active)) if self._config is not None: return self._config.provide_session() - return nullcontext(self.session) + msg = "No session or config is available to provide a session." + raise ImproperConfigurationError(msg) @overload async def paginate( @@ -332,16 +333,13 @@ async def paginate_cursor( Raises: ImproperConfigurationError: Filters conflict with the requested pagination mode. """ + if "count_with_window" in kwargs: + msg = "count_with_window is incompatible with cursor pagination" + raise ImproperConfigurationError(msg) return cast( "CursorPagination[SchemaT] | CursorPagination[dict[str, Any]]", await _async_paginate( - self.provide_session(session), - statement, - parameters, - schema_type, - kwargs.pop("count_with_window", False), - kwargs, - mode="cursor", + self.provide_session(session), statement, parameters, schema_type, False, kwargs, mode="cursor" ), ) @@ -553,7 +551,8 @@ def provide_session(self, session: SyncDriverT | None = None) -> AbstractContext return nullcontext(cast("SyncDriverT", active)) if self._config is not None: return self._config.provide_session() - return nullcontext(self.session) + msg = "No session or config is available to provide a session." + raise ImproperConfigurationError(msg) @overload def paginate( @@ -767,16 +766,13 @@ def paginate_cursor( Raises: ImproperConfigurationError: Filters conflict with the requested pagination mode. """ + if "count_with_window" in kwargs: + msg = "count_with_window is incompatible with cursor pagination" + raise ImproperConfigurationError(msg) return cast( "CursorPagination[SchemaT] | CursorPagination[dict[str, Any]]", _sync_paginate( - self.provide_session(session), - statement, - parameters, - schema_type, - kwargs.pop("count_with_window", False), - kwargs, - mode="cursor", + self.provide_session(session), statement, parameters, schema_type, False, kwargs, mode="cursor" ), ) diff --git a/sqlspec/service/_core.py b/sqlspec/service/_core.py index fa4b1876a..5835bdd81 100644 --- a/sqlspec/service/_core.py +++ b/sqlspec/service/_core.py @@ -24,6 +24,22 @@ from sqlspec.service import SQLSpecAsyncService, SQLSpecSyncService from sqlspec.typing import SchemaT, StatementParameters +__all__ = ( + "_AsyncBeginTransactionContext", + "_SyncBeginTransactionContext", + "_TransactionState", + "_async_exists", + "_async_get_one", + "_async_paginate", + "_async_paginate_cursor", + "_cursor_pagination_parameters", + "_sync_exists", + "_sync_get_one", + "_sync_paginate", + "_sync_paginate_cursor", + "_transaction_session", +) + AsyncDriverT = TypeVar("AsyncDriverT", bound=AsyncDriverAdapterBase, default=AsyncDriverAdapterBase) SyncDriverT = TypeVar("SyncDriverT", bound=SyncDriverAdapterBase, default=SyncDriverAdapterBase) @@ -80,7 +96,8 @@ async def _async_paginate( statement, parameters = cursor_parameters return await _async_paginate_cursor(context, statement, parameters, schema_type, kwargs) async with context as driver: - limit_offset: LimitOffsetFilter | None = driver.find_filter(LimitOffsetFilter, parameters) + pending = statement.filters if isinstance(statement, SQL) else [] + limit_offset: LimitOffsetFilter | None = find_filter(LimitOffsetFilter, (*pending, *parameters)) items, total = await driver.select_with_total( statement, *parameters, schema_type=schema_type, count_with_window=count_with_window, **kwargs @@ -136,7 +153,7 @@ async def _async_paginate_cursor( if schema_type is None: return page return CursorPagination( - items=cast("list[SchemaT]", driver.to_schema(list(page.items), schema_type=schema_type)), + items=cast("list[SchemaT]", driver.to_schema(page.items, schema_type=schema_type)), limit=page.limit, next_cursor=page.next_cursor, previous_cursor=page.previous_cursor, @@ -168,8 +185,11 @@ async def _async_exists( kwargs: dict[str, Any], ) -> bool: """Execute the async service exists operation in the supplied session context.""" + pending = statement.filters if isinstance(statement, SQL) else [] + has_limit = find_filter(LimitOffsetFilter, (*pending, *parameters)) is not None + bound_params = parameters if has_limit else (*parameters, LimitOffsetFilter(limit=1, offset=0)) async with context as driver: - return await driver.select_one_or_none(statement, *parameters, **kwargs) is not None + return await driver.select_one_or_none(statement, *bound_params, **kwargs) is not None def _sync_paginate( @@ -196,7 +216,8 @@ def _sync_paginate( statement, parameters = cursor_parameters return _sync_paginate_cursor(context, statement, parameters, schema_type, kwargs) with context as driver: - limit_offset: LimitOffsetFilter | None = driver.find_filter(LimitOffsetFilter, parameters) + pending = statement.filters if isinstance(statement, SQL) else [] + limit_offset: LimitOffsetFilter | None = find_filter(LimitOffsetFilter, (*pending, *parameters)) items, total = driver.select_with_total( statement, *parameters, schema_type=schema_type, count_with_window=count_with_window, **kwargs @@ -233,7 +254,7 @@ def _sync_paginate_cursor( if schema_type is None: return page return CursorPagination( - items=cast("list[SchemaT]", driver.to_schema(list(page.items), schema_type=schema_type)), + items=cast("list[SchemaT]", driver.to_schema(page.items, schema_type=schema_type)), limit=page.limit, next_cursor=page.next_cursor, previous_cursor=page.previous_cursor, @@ -265,8 +286,11 @@ def _sync_exists( kwargs: dict[str, Any], ) -> bool: """Execute the sync service exists operation in the supplied session context.""" + pending = statement.filters if isinstance(statement, SQL) else [] + has_limit = find_filter(LimitOffsetFilter, (*pending, *parameters)) is not None + bound_params = parameters if has_limit else (*parameters, LimitOffsetFilter(limit=1, offset=0)) with context as driver: - return driver.select_one_or_none(statement, *parameters, **kwargs) is not None + return driver.select_one_or_none(statement, *bound_params, **kwargs) is not None def _active_transaction(key: object) -> _TransactionState | None: @@ -361,6 +385,10 @@ async def __aenter__(self) -> AsyncDriverT: nested = session.transaction() await nested.__aenter__() self._nested = nested + if active is None: + state = _TransactionState(session) + service._transaction_state = state + self._state = state return session if not _connection_in_transaction(session): await service.begin() @@ -401,8 +429,14 @@ async def __aexit__( self._state = None self._stack = None if nested is not None: - await nested.__aexit__(exc_type, exc, traceback) - return False + try: + await nested.__aexit__(exc_type, exc, traceback) + return False + finally: + if state is not None: + state.driver = None + if service._transaction_state is state: + service._transaction_state = None if stack is not None: await stack.__aexit__(exc_type, exc, traceback) return False @@ -448,6 +482,10 @@ def __enter__(self) -> SyncDriverT: nested = session.transaction() nested.__enter__() self._nested = nested + if active is None: + state = _TransactionState(session) + service._transaction_state = state + self._state = state return session if not _connection_in_transaction(session): service.begin() @@ -488,8 +526,14 @@ def __exit__( self._state = None self._stack = None if nested is not None: - nested.__exit__(exc_type, exc, traceback) - return False + try: + nested.__exit__(exc_type, exc, traceback) + return False + finally: + if state is not None: + state.driver = None + if service._transaction_state is state: + service._transaction_state = None if stack is not None: stack.__exit__(exc_type, exc, traceback) return False diff --git a/sqlspec/storage/__init__.py b/sqlspec/storage/__init__.py index 5285e7fdb..733df124b 100644 --- a/sqlspec/storage/__init__.py +++ b/sqlspec/storage/__init__.py @@ -8,23 +8,27 @@ - Capability-based backend selection """ -from sqlspec.storage._paths import resolve_storage_path -from sqlspec.storage.pipeline import ( - AsyncStoragePipeline, - PartitionStrategyConfig, - ResolvedStorageTarget, - StorageBridgeJob, - StorageCapabilities, - StorageDestination, - StorageFormat, - StorageTelemetry, - SyncStoragePipeline, - create_storage_bridge_job, - get_storage_bridge_diagnostics, - get_storage_bridge_metrics, - reset_storage_bridge_metrics, -) -from sqlspec.storage.registry import StorageRegistry, storage_registry +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from sqlspec.storage._paths import resolve_storage_path + from sqlspec.storage.pipeline import ( + AsyncStoragePipeline, + PartitionStrategyConfig, + ResolvedStorageTarget, + StorageBridgeJob, + StorageCapabilities, + StorageDestination, + StorageFormat, + StorageTelemetry, + SyncStoragePipeline, + create_storage_bridge_job, + get_storage_bridge_diagnostics, + get_storage_bridge_metrics, + reset_storage_bridge_metrics, + ) + from sqlspec.storage.registry import StorageRegistry, storage_registry __all__ = ( "AsyncStoragePipeline", @@ -44,3 +48,46 @@ "resolve_storage_path", "storage_registry", ) + +_EXPORTS: dict[str, tuple[str, str]] = { + "AsyncStoragePipeline": ("sqlspec.storage.pipeline", "AsyncStoragePipeline"), + "PartitionStrategyConfig": ("sqlspec.storage.pipeline", "PartitionStrategyConfig"), + "ResolvedStorageTarget": ("sqlspec.storage.pipeline", "ResolvedStorageTarget"), + "StorageBridgeJob": ("sqlspec.storage.pipeline", "StorageBridgeJob"), + "StorageCapabilities": ("sqlspec.storage.pipeline", "StorageCapabilities"), + "StorageDestination": ("sqlspec.storage.pipeline", "StorageDestination"), + "StorageFormat": ("sqlspec.storage.pipeline", "StorageFormat"), + "StorageRegistry": ("sqlspec.storage.registry", "StorageRegistry"), + "StorageTelemetry": ("sqlspec.storage.pipeline", "StorageTelemetry"), + "SyncStoragePipeline": ("sqlspec.storage.pipeline", "SyncStoragePipeline"), + "create_storage_bridge_job": ("sqlspec.storage.pipeline", "create_storage_bridge_job"), + "get_storage_bridge_diagnostics": ("sqlspec.storage.pipeline", "get_storage_bridge_diagnostics"), + "get_storage_bridge_metrics": ("sqlspec.storage.pipeline", "get_storage_bridge_metrics"), + "reset_storage_bridge_metrics": ("sqlspec.storage.pipeline", "reset_storage_bridge_metrics"), + "resolve_storage_path": ("sqlspec.storage._paths", "resolve_storage_path"), + "storage_registry": ("sqlspec.storage.registry", "storage_registry"), +} + +_COMPILED = getattr(__spec__, "origin", "").endswith((".so", ".pyd")) + + +def __getattr__(name: str) -> Any: + """Lazily resolve package exports via PEP 562.""" + target = _EXPORTS.get(name) + if target is None: + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + module_path, attr_name = target + value = getattr(import_module(module_path), attr_name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + """Return sorted module attributes including lazy exports.""" + return sorted({*globals(), *__all__}) + + +if _COMPILED: + for _export_name in __all__: + __getattr__(_export_name) diff --git a/sqlspec/storage/_arrow_payload.py b/sqlspec/storage/_arrow_payload.py index 60c1faa55..72c90627e 100644 --- a/sqlspec/storage/_arrow_payload.py +++ b/sqlspec/storage/_arrow_payload.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from sqlspec.typing import ArrowTable -__all__ = ("decode_arrow_payload", "encode_arrow_payload") +__all__ = ("StorageFormat", "decode_arrow_payload", "encode_arrow_payload") StorageFormat = Literal["jsonl", "json", "parquet", "arrow-ipc", "csv"] @@ -28,9 +28,9 @@ def encode_arrow_payload( pa = import_pyarrow() sink = pa.BufferOutputStream() if format_choice == "arrow-ipc": - writer = pa.ipc.new_file(sink, table.schema) - writer.write_table(table) - writer.close() + ipc_options = pa.ipc.IpcWriteOptions(compression=compression) if compression else None + with pa.ipc.new_file(sink, table.schema, options=ipc_options) as writer: + writer.write_table(table) elif format_choice == "csv": pa_csv = import_pyarrow_csv() csv_opts: Any = None @@ -53,13 +53,15 @@ def decode_arrow_payload(payload: bytes, format_choice: StorageFormat) -> "Arrow pq = import_pyarrow_parquet() return cast("ArrowTable", pq.read_table(pa.BufferReader(payload))) if format_choice == "arrow-ipc": - reader = pa.ipc.open_file(pa.BufferReader(payload)) - return cast("ArrowTable", reader.read_all()) + with pa.ipc.open_file(pa.BufferReader(payload)) as reader: + return cast("ArrowTable", reader.read_all()) if format_choice == "csv": pa_csv = import_pyarrow_csv() return cast("ArrowTable", pa_csv.read_csv(pa.BufferReader(payload))) if format_choice == "json": - data = from_json(payload.decode()) + if not payload.strip(): + return cast("ArrowTable", pa.table({})) + data = from_json(payload) rows = data if isinstance(data, list) else [data] return cast("ArrowTable", pa.Table.from_pylist(rows)) if format_choice == "jsonl": diff --git a/sqlspec/storage/_paths.py b/sqlspec/storage/_paths.py index 910e3cab3..799a18c7a 100644 --- a/sqlspec/storage/_paths.py +++ b/sqlspec/storage/_paths.py @@ -3,6 +3,7 @@ import re from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Final +from urllib.parse import unquote, urlparse from sqlspec.exceptions import StoragePathTraversalError @@ -13,6 +14,7 @@ "extract_glob_static_prefix", "glob_to_regex", "is_file_destination", + "parse_file_uri_path", "reject_parent_traversal", "resolve_storage_path", "strip_windows_drive_prefix", @@ -21,6 +23,8 @@ FILE_PROTOCOL: Final[str] = "file" FILE_SCHEME_PREFIX: Final[str] = "file://" +_WINDOWS_URI_DRIVE_MIN_LEN: Final[int] = 3 +_WINDOWS_DRIVE_PATH_MIN_LEN: Final[int] = 2 def reject_parent_traversal(path: "str | Path") -> None: @@ -136,11 +140,20 @@ def glob_to_regex(pattern: str) -> "re.Pattern[str]": def strip_windows_drive_prefix(path: str) -> str: """Drop a leading slash from a urlparse'd Windows drive path (``/C:/x`` -> ``C:/x``).""" - if path and len(path) > 2 and path[2] == ":": # noqa: PLR2004 + if len(path) >= _WINDOWS_URI_DRIVE_MIN_LEN and path[0] == "/" and path[1].isalpha() and path[2] == ":": return path[1:] return path +def parse_file_uri_path(uri: str) -> str: + """Extract, unquote, and normalize a filesystem path from a ``file://`` URI.""" + parsed = urlparse(uri) + raw_path = f"{parsed.netloc}{parsed.path}" if parsed.netloc else (parsed.path or "/") + if parsed.fragment: + raw_path = f"{raw_path}#{parsed.fragment}" + return strip_windows_drive_prefix(unquote(raw_path)) + + def is_file_destination(path: "str | Path") -> bool: """Classify a local path as a file (vs directory) destination. @@ -172,30 +185,45 @@ def resolve_storage_path( Resolved path string suitable for the storage backend. Raises: - StoragePathTraversalError: If the path contains a parent reference. + StoragePathTraversalError: If the path contains a parent reference or escapes ``base_path``. """ - reject_parent_traversal(path) path_str = str(path) - if strip_file_scheme and path_str.startswith(FILE_SCHEME_PREFIX): - path_str = path_str.removeprefix(FILE_SCHEME_PREFIX) + if path_str.startswith(FILE_SCHEME_PREFIX) and (strip_file_scheme or bool(base_path)): + path_str = parse_file_uri_path(path_str) if protocol == FILE_PROTOCOL: + if _is_windows_drive_path(path_str): + if base_path: + if not _is_windows_drive_path(base_path): + raise StoragePathTraversalError(path_str, base_path) + try: + win_rel = PureWindowsPath(path_str).relative_to(PureWindowsPath(base_path)) + except ValueError as exc: + raise StoragePathTraversalError(path_str, base_path) from exc + if str(win_rel) == ".": + return base_path + trimmed_base = base_path.rstrip("/\\") + return f"{trimmed_base}/{win_rel.as_posix()}" + return path_str + path_obj = Path(path_str) if path_obj.is_absolute(): if base_path: base_obj = Path(base_path) + if not base_obj.is_absolute(): + raise StoragePathTraversalError(path_str, base_path) try: relative = path_obj.relative_to(base_obj) - if str(relative) == ".": - return base_path - return f"{base_path.rstrip('/')}/{relative}" - except ValueError: - return path_str.lstrip("/") - return path_str.lstrip("/") + except ValueError as exc: + raise StoragePathTraversalError(path_str, base_path) from exc + if str(relative) == ".": + return base_path + return f"{base_path.rstrip('/')}/{relative.as_posix()}" + return path_str.lstrip("/") if strip_file_scheme else path_str if base_path: return f"{base_path.rstrip('/')}/{path_str}" @@ -210,6 +238,11 @@ def resolve_storage_path( return f"{clean_base}/{clean_path}" +def _is_windows_drive_path(path_str: str) -> bool: + r"""Return True when ``path_str`` starts with a Windows drive prefix such as ``C:/`` or ``C:\\``.""" + return len(path_str) >= _WINDOWS_DRIVE_PATH_MIN_LEN and path_str[0].isalpha() and path_str[1] == ":" + + def _glob_segment_regex(segment: str) -> str: """Translate one glob path segment to regex source that never crosses ``/``.""" out: list[str] = [] diff --git a/sqlspec/storage/_utils.py b/sqlspec/storage/_utils.py index 8daf73bc3..55e43cac6 100644 --- a/sqlspec/storage/_utils.py +++ b/sqlspec/storage/_utils.py @@ -4,19 +4,59 @@ from typing import Any from sqlspec.utils.logging import get_logger, log_with_context -from sqlspec.utils.module_loader import ensure_pyarrow +from sqlspec.utils.module_loader import ensure_fsspec, ensure_obstore, ensure_pyarrow __all__ = ( "_log_storage_event", + "import_fsspec", + "import_obstore", + "import_obstore_store", "import_pyarrow", "import_pyarrow_csv", "import_pyarrow_json", "import_pyarrow_parquet", + "log_storage_event", ) logger = get_logger(__name__) +def import_fsspec() -> "Any": + """Import fsspec with optional dependency guard. + + Returns: + fsspec module. + """ + ensure_fsspec() + import fsspec + + return fsspec + + +def import_obstore() -> "Any": + """Import obstore with optional dependency guard. + + Returns: + obstore module. + """ + ensure_obstore() + import obstore + + return obstore + + +def import_obstore_store() -> "Any": + """Import obstore.store with optional dependency guard. + + Returns: + obstore.store module. + """ + ensure_obstore() + import obstore.store as obstore_store + + return obstore_store + + def import_pyarrow() -> "Any": """Import PyArrow with optional dependency guard. @@ -69,7 +109,7 @@ def import_pyarrow_json() -> "Any": return pa_json -def _log_storage_event( +def log_storage_event( event: str, *, backend_type: str, @@ -82,6 +122,7 @@ def _log_storage_event( count: int | None = None, exists: bool | None = None, ) -> None: + """Emit a structured debug log event for a storage backend operation.""" fields: dict[str, Any] = {"backend_type": backend_type, "protocol": protocol} if operation is not None: fields["operation"] = operation @@ -98,3 +139,6 @@ def _log_storage_event( if exists is not None: fields["exists"] = exists log_with_context(logger, logging.DEBUG, event, **fields) + + +_log_storage_event = log_storage_event diff --git a/sqlspec/storage/backends/__init__.py b/sqlspec/storage/backends/__init__.py index 17d48d27e..d3bf088ec 100644 --- a/sqlspec/storage/backends/__init__.py +++ b/sqlspec/storage/backends/__init__.py @@ -1 +1,43 @@ """Storage backends.""" + +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from sqlspec.storage.backends.base import ObjectStoreBase + from sqlspec.storage.backends.fsspec import FSSpecBackend + from sqlspec.storage.backends.local import LocalStore + from sqlspec.storage.backends.obstore import ObStoreBackend + +__all__ = ("FSSpecBackend", "LocalStore", "ObStoreBackend", "ObjectStoreBase") + +_EXPORTS: dict[str, tuple[str, str]] = { + "FSSpecBackend": ("sqlspec.storage.backends.fsspec", "FSSpecBackend"), + "LocalStore": ("sqlspec.storage.backends.local", "LocalStore"), + "ObStoreBackend": ("sqlspec.storage.backends.obstore", "ObStoreBackend"), + "ObjectStoreBase": ("sqlspec.storage.backends.base", "ObjectStoreBase"), +} + +_COMPILED = getattr(__spec__, "origin", "").endswith((".so", ".pyd")) + + +def __getattr__(name: str) -> Any: + """Lazily resolve storage backend exports via PEP 562.""" + target = _EXPORTS.get(name) + if target is None: + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + module_path, attr_name = target + value = getattr(import_module(module_path), attr_name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + """Return sorted module attributes including lazy exports.""" + return sorted({*globals(), *__all__}) + + +if _COMPILED: + for _export_name in __all__: + __getattr__(_export_name) diff --git a/sqlspec/storage/backends/base.py b/sqlspec/storage/backends/base.py index 2355cf6d4..078217565 100644 --- a/sqlspec/storage/backends/base.py +++ b/sqlspec/storage/backends/base.py @@ -6,10 +6,12 @@ import contextlib from abc import abstractmethod from collections.abc import AsyncIterator, Iterator -from typing import TYPE_CHECKING, Any, Literal, Self, cast +from typing import TYPE_CHECKING, Any, Literal, Self, cast, overload from mypy_extensions import mypyc_attr +from sqlspec.utils.sync_tools import get_default_async_executor + if TYPE_CHECKING: from pathlib import Path from types import TracebackType @@ -56,6 +58,9 @@ async def __aexit__( ) -> None: await self.aclose() + def __anext__(self) -> Any: + return asyncio.get_running_loop().run_in_executor(get_default_async_executor(), self._sync_next) + async def aclose(self) -> None: """Close the underlying generator and its active storage reader.""" if self._closed: @@ -63,7 +68,7 @@ async def aclose(self) -> None: self._closed = True close = getattr(self._sync_iter, "close", None) if close is not None: - await asyncio.get_running_loop().run_in_executor(None, close) + await asyncio.get_running_loop().run_in_executor(get_default_async_executor(), close) def _sync_next(self) -> "ArrowRecordBatch": if self._closed: @@ -74,10 +79,6 @@ def _sync_next(self) -> "ArrowRecordBatch": raise _StopAsync() return cast("ArrowRecordBatch", result) - def __anext__(self) -> Any: - # Returning a Future avoids mypyc coroutine state machine bugs entirely. - return asyncio.get_running_loop().run_in_executor(None, self._sync_next) - class AsyncObStoreStreamIterator: """Async iterator wrapper for obstore streaming.""" @@ -91,8 +92,35 @@ def __init__(self, stream: Any, chunk_size: "int | None" = None) -> None: def __aiter__(self) -> "AsyncObStoreStreamIterator": return self - def __anext__(self) -> Any: - return self._stream.__anext__() + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" + ) -> None: + await self.aclose() + + async def __anext__(self) -> bytes: + if self._stream is None: + raise StopAsyncIteration + chunk = await self._stream.__anext__() + return chunk if type(chunk) is bytes else bytes(chunk) + + async def aclose(self) -> None: + """Close the underlying stream if it exposes a close hook.""" + stream = self._stream + if stream is None: + return + self._stream = None + aclose: Any = getattr(stream, "aclose", None) + if aclose is not None: + with contextlib.suppress(Exception): + await aclose() + return + close: Any = getattr(stream, "close", None) + if close is not None: + with contextlib.suppress(Exception): + close() class AsyncThreadedBytesIterator: @@ -116,12 +144,15 @@ async def __aexit__( ) -> None: await self.aclose() + def __anext__(self) -> Any: + return asyncio.get_running_loop().run_in_executor(get_default_async_executor(), self._sync_read) + async def aclose(self) -> None: if self._closed: return self._closed = True with contextlib.suppress(Exception): - self._file_obj.close() + await asyncio.get_running_loop().run_in_executor(get_default_async_executor(), self._file_obj.close) def _sync_read(self) -> bytes: if self._closed: @@ -134,9 +165,6 @@ def _sync_read(self) -> bytes: raise _StopAsync() return cast("bytes", result) - def __anext__(self) -> Any: - return asyncio.get_running_loop().run_in_executor(None, self._sync_read) - @mypyc_attr(allow_interpreted_subclasses=True) class ObjectStoreBase: @@ -148,33 +176,39 @@ class ObjectStoreBase: __slots__ = () + @property + @abstractmethod + def supports_signing(self) -> bool: + """Whether this backend supports URL signing.""" + raise NotImplementedError + @abstractmethod def resolve_uri(self, path: "str | Path") -> str: """Resolve a backend-relative path to its unsigned address.""" raise NotImplementedError @abstractmethod - def read_bytes_sync(self, path: str, **kwargs: Any) -> bytes: + def read_bytes_sync(self, path: "str | Path", **kwargs: Any) -> bytes: """Read bytes from storage synchronously.""" raise NotImplementedError @abstractmethod - def write_bytes_sync(self, path: str, data: bytes, **kwargs: Any) -> None: + def write_bytes_sync(self, path: "str | Path", data: bytes, **kwargs: Any) -> None: """Write bytes to storage synchronously.""" raise NotImplementedError @abstractmethod - def stream_read_sync(self, path: str, chunk_size: "int | None" = None, **kwargs: Any) -> Iterator[bytes]: + def stream_read_sync(self, path: "str | Path", chunk_size: "int | None" = None, **kwargs: Any) -> Iterator[bytes]: """Stream bytes from storage synchronously.""" raise NotImplementedError @abstractmethod - def read_text_sync(self, path: str, encoding: str = "utf-8", **kwargs: Any) -> str: + def read_text_sync(self, path: "str | Path", encoding: str = "utf-8", **kwargs: Any) -> str: """Read text from storage synchronously.""" raise NotImplementedError @abstractmethod - def write_text_sync(self, path: str, data: str, encoding: str = "utf-8", **kwargs: Any) -> None: + def write_text_sync(self, path: "str | Path", data: str, encoding: str = "utf-8", **kwargs: Any) -> None: """Write text to storage synchronously.""" raise NotImplementedError @@ -184,22 +218,22 @@ def list_objects_sync(self, prefix: str = "", recursive: bool = True, **kwargs: raise NotImplementedError @abstractmethod - def exists_sync(self, path: str, **kwargs: Any) -> bool: + def exists_sync(self, path: "str | Path", **kwargs: Any) -> bool: """Check if object exists in storage synchronously.""" raise NotImplementedError @abstractmethod - def delete_sync(self, path: str, **kwargs: Any) -> None: + def delete_sync(self, path: "str | Path", **kwargs: Any) -> None: """Delete object from storage synchronously.""" raise NotImplementedError @abstractmethod - def copy_sync(self, source: str, destination: str, **kwargs: Any) -> None: + def copy_sync(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Copy object within storage synchronously.""" raise NotImplementedError @abstractmethod - def move_sync(self, source: str, destination: str, **kwargs: Any) -> None: + def move_sync(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Move object within storage synchronously.""" raise NotImplementedError @@ -209,27 +243,27 @@ def glob_sync(self, pattern: str, **kwargs: Any) -> "list[str]": raise NotImplementedError @abstractmethod - def get_metadata_sync(self, path: str, **kwargs: Any) -> "dict[str, object]": + def get_metadata_sync(self, path: "str | Path", **kwargs: Any) -> "dict[str, object]": """Get object metadata from storage synchronously.""" raise NotImplementedError @abstractmethod - def is_object_sync(self, path: str) -> bool: + def is_object_sync(self, path: "str | Path") -> bool: """Check if path points to an object synchronously.""" raise NotImplementedError @abstractmethod - def is_path_sync(self, path: str) -> bool: + def is_path_sync(self, path: "str | Path") -> bool: """Check if path points to a directory synchronously.""" raise NotImplementedError @abstractmethod - def read_arrow_sync(self, path: str, **kwargs: Any) -> "ArrowTable": + def read_arrow_sync(self, path: "str | Path", **kwargs: Any) -> "ArrowTable": """Read Arrow table from storage synchronously.""" raise NotImplementedError @abstractmethod - def write_arrow_sync(self, path: str, table: "ArrowTable", **kwargs: Any) -> None: + def write_arrow_sync(self, path: "str | Path", table: "ArrowTable", **kwargs: Any) -> None: """Write Arrow table to storage synchronously.""" raise NotImplementedError @@ -240,29 +274,40 @@ def stream_arrow_sync( """Stream Arrow record batches from storage synchronously.""" raise NotImplementedError + @overload + def sign_sync(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... + @overload + def sign_sync(self, paths: "list[str]", expires_in: int = 3600, for_upload: bool = False) -> "list[str]": ... + @abstractmethod + def sign_sync( + self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False + ) -> "str | list[str]": + """Generate signed URL(s) synchronously.""" + raise NotImplementedError + @abstractmethod - async def read_bytes_async(self, path: str, **kwargs: Any) -> bytes: + async def read_bytes_async(self, path: "str | Path", **kwargs: Any) -> bytes: """Read bytes from storage asynchronously.""" raise NotImplementedError @abstractmethod - async def write_bytes_async(self, path: str, data: bytes, **kwargs: Any) -> None: + async def write_bytes_async(self, path: "str | Path", data: bytes, **kwargs: Any) -> None: """Write bytes to storage asynchronously.""" raise NotImplementedError @abstractmethod - async def read_text_async(self, path: str, encoding: str = "utf-8", **kwargs: Any) -> str: + async def read_text_async(self, path: "str | Path", encoding: str = "utf-8", **kwargs: Any) -> str: """Read text from storage asynchronously.""" raise NotImplementedError @abstractmethod - async def write_text_async(self, path: str, data: str, encoding: str = "utf-8", **kwargs: Any) -> None: + async def write_text_async(self, path: "str | Path", data: str, encoding: str = "utf-8", **kwargs: Any) -> None: """Write text to storage asynchronously.""" raise NotImplementedError @abstractmethod async def stream_read_async( - self, path: str, chunk_size: "int | None" = None, **kwargs: Any + self, path: "str | Path", chunk_size: "int | None" = None, **kwargs: Any ) -> AsyncIterator[bytes]: """Stream bytes from storage asynchronously.""" raise NotImplementedError @@ -273,46 +318,59 @@ async def list_objects_async(self, prefix: str = "", recursive: bool = True, **k raise NotImplementedError @abstractmethod - async def exists_async(self, path: str, **kwargs: Any) -> bool: + async def exists_async(self, path: "str | Path", **kwargs: Any) -> bool: """Check if object exists in storage asynchronously.""" raise NotImplementedError @abstractmethod - async def delete_async(self, path: str, **kwargs: Any) -> None: + async def delete_async(self, path: "str | Path", **kwargs: Any) -> None: """Delete object from storage asynchronously.""" raise NotImplementedError @abstractmethod - async def copy_async(self, source: str, destination: str, **kwargs: Any) -> None: + async def copy_async(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Copy object within storage asynchronously.""" raise NotImplementedError @abstractmethod - async def move_async(self, source: str, destination: str, **kwargs: Any) -> None: + async def move_async(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Move object within storage asynchronously.""" raise NotImplementedError @abstractmethod - async def get_metadata_async(self, path: str, **kwargs: Any) -> "dict[str, object]": + async def get_metadata_async(self, path: "str | Path", **kwargs: Any) -> "dict[str, object]": """Get object metadata from storage asynchronously.""" raise NotImplementedError @abstractmethod - async def read_arrow_async(self, path: str, **kwargs: Any) -> "ArrowTable": + async def read_arrow_async(self, path: "str | Path", **kwargs: Any) -> "ArrowTable": """Read Arrow table from storage asynchronously.""" raise NotImplementedError @abstractmethod - async def write_arrow_async(self, path: str, table: "ArrowTable", **kwargs: Any) -> None: + async def write_arrow_async(self, path: "str | Path", table: "ArrowTable", **kwargs: Any) -> None: """Write Arrow table to storage asynchronously.""" raise NotImplementedError - # NOTE: Returns AsyncIterator directly; keep in sync with ObjectStoreProtocol. @abstractmethod def stream_arrow_async( self, pattern: str, *, file_format: Literal["parquet"] = "parquet", batch_size: int = 65_536, **kwargs: Any ) -> "AsyncIterator[ArrowRecordBatch]": - """Stream Arrow record batches from storage asynchronously.""" + """Stream Arrow record batches from storage asynchronously. + + Returns AsyncIterator directly (not a coroutine). + """ + raise NotImplementedError + + @overload + async def sign_async(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... + @overload + async def sign_async(self, paths: "list[str]", expires_in: int = 3600, for_upload: bool = False) -> "list[str]": ... + @abstractmethod + async def sign_async( + self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False + ) -> "str | list[str]": + """Generate signed URL(s) asynchronously.""" raise NotImplementedError diff --git a/sqlspec/storage/backends/fsspec.py b/sqlspec/storage/backends/fsspec.py index 3b90d4798..997f5b704 100644 --- a/sqlspec/storage/backends/fsspec.py +++ b/sqlspec/storage/backends/fsspec.py @@ -1,5 +1,5 @@ -# pyright: reportPrivateUsage=false -import asyncio +"""Storage backend using fsspec.""" + from collections.abc import AsyncIterator, Iterator from functools import partial from pathlib import Path @@ -9,11 +9,10 @@ from mypy_extensions import mypyc_attr from sqlspec.storage._arrow_stream import iter_parquet_row_groups, validate_parquet_stream_options -from sqlspec.storage._paths import resolve_storage_path -from sqlspec.storage._utils import _log_storage_event, import_pyarrow_parquet +from sqlspec.storage._paths import is_file_destination, parse_file_uri_path, resolve_storage_path +from sqlspec.storage._utils import import_fsspec, import_pyarrow_parquet, log_storage_event from sqlspec.storage.backends.base import AsyncArrowBatchIterator, AsyncThreadedBytesIterator -from sqlspec.storage.errors import execute_sync_storage_operation -from sqlspec.utils.module_loader import ensure_fsspec +from sqlspec.storage.errors import execute_async_storage_operation, execute_sync_storage_operation from sqlspec.utils.sync_tools import async_ if TYPE_CHECKING: @@ -49,8 +48,7 @@ def __init__(self, uri: str, **kwargs: Any) -> None: URI path when no explicit base_path is provided. When both URI and base_path are provided, they are combined (base_path is appended to URI-derived path). """ - ensure_fsspec() - import fsspec + fsspec = import_fsspec() explicit_base_path = kwargs.pop("base_path", "") @@ -64,16 +62,16 @@ def __init__(self, uri: str, **kwargs: Any) -> None: uri_base_path = parsed.netloc if parsed.path and parsed.path != "/": uri_base_path = f"{uri_base_path}{parsed.path}" - # Combine URI path with explicit base_path if both provided if explicit_base_path: uri_base_path = f"{uri_base_path.rstrip('/')}/{explicit_base_path.lstrip('/')}" explicit_base_path = uri_base_path elif self.protocol == "file": - parsed = urlparse(uri) - if parsed.path and parsed.path != "/": - # For file protocol, keep the path as-is (preserve leading slash for absolute paths) - uri_base_path = parsed.path - # Combine URI path with explicit base_path if both provided + parsed_path = parse_file_uri_path(uri) + if parsed_path and parsed_path != "/": + path_obj = Path(parsed_path) + if not explicit_base_path and is_file_destination(path_obj): + parsed_path = str(path_obj.parent) + uri_base_path = parsed_path if explicit_base_path: uri_base_path = f"{uri_base_path.rstrip('/')}/{explicit_base_path.lstrip('/')}" explicit_base_path = uri_base_path @@ -84,7 +82,7 @@ def __init__(self, uri: str, **kwargs: Any) -> None: self.base_path = explicit_base_path.rstrip("/") if explicit_base_path else "" self.fs = fsspec.filesystem(self.protocol, **kwargs) - _log_storage_event( + log_storage_event( "storage.backend.ready", backend_type=self.backend_type, protocol=self.protocol, @@ -92,7 +90,21 @@ def __init__(self, uri: str, **kwargs: Any) -> None: path=self._fs_uri, ) - super().__init__() + @property + def base_uri(self) -> str: + return self._fs_uri + + @property + def supports_signing(self) -> bool: + """Whether this backend supports URL signing. + + FSSpec backends do not support URL signing. Use ObStoreBackend + for S3, GCS, or Azure if you need signed URLs. + + Returns: + Always False for fsspec backends. + """ + return False @classmethod def from_config(cls, config: "dict[str, Any]") -> "FSSpecBackend": @@ -107,10 +119,6 @@ def from_config(cls, config: "dict[str, Any]") -> "FSSpecBackend": return cls(uri=uri, **kwargs) - @property - def base_uri(self) -> str: - return self._fs_uri - def resolve_uri(self, path: str | Path) -> str: """Resolve a backend-relative path to an unsigned address. @@ -126,9 +134,6 @@ def resolve_uri(self, path: str | Path) -> str: return str(Path(resolved_path).resolve()) return str(self.fs.unstrip_protocol(resolved_path)) - def _resolve_path(self, path: str | Path) -> str: - return resolve_storage_path(path, self.base_path, self.protocol, strip_file_scheme=False) - def read_bytes_sync(self, path: str | Path, **kwargs: Any) -> bytes: """Read bytes from an object synchronously.""" resolved_path = self._resolve_path(path) @@ -141,7 +146,7 @@ def read_bytes_sync(self, path: str | Path, **kwargs: Any) -> bytes: path=resolved_path, ), ) - _log_storage_event( + log_storage_event( "storage.read", backend_type=self.backend_type, protocol=self.protocol, @@ -165,7 +170,7 @@ def write_bytes_sync(self, path: str | Path, data: bytes, **kwargs: Any) -> None operation="write_bytes", path=resolved_path, ) - _log_storage_event( + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -186,7 +191,7 @@ def exists_sync(self, path: str | Path, **kwargs: Any) -> bool: """Check if an object exists synchronously.""" resolved_path = self._resolve_path(path) exists = bool(self.fs.exists(resolved_path, **kwargs)) - _log_storage_event( + log_storage_event( "storage.read", backend_type=self.backend_type, protocol=self.protocol, @@ -205,7 +210,7 @@ def delete_sync(self, path: str | Path, **kwargs: Any) -> None: operation="delete", path=resolved_path, ) - _log_storage_event( + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -223,7 +228,7 @@ def copy_sync(self, source: str | Path, destination: str | Path, **kwargs: Any) operation="copy", path=f"{source_path}->{dest_path}", ) - _log_storage_event( + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -242,7 +247,7 @@ def move_sync(self, source: str | Path, destination: str | Path, **kwargs: Any) operation="move", path=f"{source_path}->{dest_path}", ) - _log_storage_event( + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -265,7 +270,7 @@ def read_arrow_sync(self, path: str | Path, **kwargs: Any) -> "ArrowTable": path=resolved_path, ), ) - _log_storage_event( + log_storage_event( "storage.read", backend_type=self.backend_type, protocol=self.protocol, @@ -286,7 +291,7 @@ def write_arrow_sync(self, path: str | Path, table: "ArrowTable", **kwargs: Any) operation="write_arrow", path=resolved_path, ) - _log_storage_event( + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -294,18 +299,26 @@ def write_arrow_sync(self, path: str | Path, table: "ArrowTable", **kwargs: Any) path=resolved_path, ) - def _read_parquet_table(self, resolved_path: str, pq: Any, options: "dict[str, Any]") -> Any: - with self.fs.open(resolved_path, mode="rb", **options) as file_obj: - return pq.read_table(file_obj) - def list_objects_sync(self, prefix: str = "", recursive: bool = True, **kwargs: Any) -> "list[str]": """List objects with optional prefix synchronously.""" resolved_prefix = resolve_storage_path(prefix, self.base_path, self.protocol, strip_file_scheme=False) if recursive: - results = sorted(self.fs.find(resolved_prefix, **kwargs)) + raw_results = execute_sync_storage_operation( + partial(self.fs.find, resolved_prefix, **kwargs), + backend=self.backend_type, + operation="list_objects", + path=resolved_prefix, + ) + results = sorted(raw_results) else: - results = sorted(self.fs.ls(resolved_prefix, detail=False, **kwargs)) - _log_storage_event( + raw_results = execute_sync_storage_operation( + partial(self.fs.ls, resolved_prefix, detail=False, **kwargs), + backend=self.backend_type, + operation="list_objects", + path=resolved_prefix, + ) + results = sorted(raw_results) + log_storage_event( "storage.list", backend_type=self.backend_type, protocol=self.protocol, @@ -318,8 +331,14 @@ def list_objects_sync(self, prefix: str = "", recursive: bool = True, **kwargs: def glob_sync(self, pattern: str, **kwargs: Any) -> "list[str]": """Find objects matching a glob pattern synchronously.""" resolved_pattern = resolve_storage_path(pattern, self.base_path, self.protocol, strip_file_scheme=False) - results = cast("list[str]", sorted(self.fs.glob(resolved_pattern, **kwargs))) - _log_storage_event( + raw_results = execute_sync_storage_operation( + partial(self.fs.glob, resolved_pattern, **kwargs), + backend=self.backend_type, + operation="glob", + path=resolved_pattern, + ) + results = cast("list[str]", sorted(raw_results)) + log_storage_event( "storage.list", backend_type=self.backend_type, protocol=self.protocol, @@ -363,24 +382,10 @@ def get_metadata_sync(self, path: str | Path, **kwargs: Any) -> "dict[str, objec "type": info.type, } - @property - def supports_signing(self) -> bool: - """Whether this backend supports URL signing. - - FSSpec backends do not support URL signing. Use ObStoreBackend - for S3, GCS, or Azure if you need signed URLs. - - Returns: - Always False for fsspec backends. - """ - return False - @overload def sign_sync(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... - @overload def sign_sync(self, paths: "list[str]", expires_in: int = 3600, for_upload: bool = False) -> "list[str]": ... - def sign_sync( self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False ) -> "str | list[str]": @@ -405,7 +410,13 @@ def stream_read_sync(self, path: "str | Path", chunk_size: "int | None" = None, resolved_path = self._resolve_path(path) chunk_size = chunk_size or 65536 - with self.fs.open(resolved_path, mode="rb", **kwargs) as f: + file_handle = execute_sync_storage_operation( + partial(self.fs.open, resolved_path, mode="rb", **kwargs), + backend=self.backend_type, + operation="stream_read", + path=resolved_path, + ) + with file_handle as f: while True: chunk = f.read(chunk_size) if not chunk: @@ -472,7 +483,12 @@ async def stream_read_async( resolved_path = self._resolve_path(path) chunk_size = chunk_size or 65536 - file_obj = await asyncio.to_thread(self.fs.open, resolved_path, mode="rb", **kwargs) + file_obj = await execute_async_storage_operation( + partial(async_(self.fs.open), resolved_path, mode="rb", **kwargs), + backend=self.backend_type, + operation="stream_read", + path=resolved_path, + ) return AsyncThreadedBytesIterator(file_obj, chunk_size) @@ -528,10 +544,8 @@ async def get_metadata_async(self, path: str | Path, **kwargs: Any) -> "dict[str @overload async def sign_async(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... - @overload async def sign_async(self, paths: "list[str]", expires_in: int = 3600, for_upload: bool = False) -> "list[str]": ... - async def sign_async( self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False ) -> "str | list[str]": @@ -552,6 +566,13 @@ async def write_arrow_async(self, path: str | Path, table: "ArrowTable", **kwarg """ await async_(self.write_arrow_sync)(path, table, **kwargs) + def _resolve_path(self, path: str | Path) -> str: + return resolve_storage_path(path, self.base_path, self.protocol, strip_file_scheme=False) + + def _read_parquet_table(self, resolved_path: str, pq: Any, options: "dict[str, Any]") -> Any: + with self.fs.open(resolved_path, mode="rb", **options) as file_obj: + return pq.read_table(file_obj) + def _write_fsspec_bytes(fs: Any, resolved_path: str, data: bytes, options: "dict[str, Any]") -> None: """Write raw bytes via an fsspec filesystem handle.""" diff --git a/sqlspec/storage/backends/local.py b/sqlspec/storage/backends/local.py index 2ebd94771..53eb5a521 100644 --- a/sqlspec/storage/backends/local.py +++ b/sqlspec/storage/backends/local.py @@ -9,16 +9,15 @@ from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload -from urllib.parse import unquote, urlparse from mypy_extensions import mypyc_attr -from sqlspec.exceptions import FileNotFoundInStorageError +from sqlspec.exceptions import FileNotFoundInStorageError, StoragePathTraversalError from sqlspec.storage._arrow_stream import iter_parquet_row_groups, validate_parquet_stream_options -from sqlspec.storage._paths import ensure_path_within_root, strip_windows_drive_prefix +from sqlspec.storage._paths import ensure_path_within_root, parse_file_uri_path, reject_parent_traversal from sqlspec.storage._utils import import_pyarrow_parquet from sqlspec.storage.backends.base import AsyncArrowBatchIterator, AsyncThreadedBytesIterator -from sqlspec.storage.errors import execute_sync_storage_operation +from sqlspec.storage.errors import execute_async_storage_operation, execute_sync_storage_operation from sqlspec.utils.sync_tools import async_ if TYPE_CHECKING: @@ -56,8 +55,7 @@ def __init__(self, uri: str = "", **kwargs: Any) -> None: - file:///home/user/storage + base_path="/other" -> /other (absolute takes precedence) """ if uri.startswith("file://"): - parsed = urlparse(uri) - path = strip_windows_drive_prefix(unquote(parsed.path)) + path = parse_file_uri_path(uri) self.base_path = Path(path).resolve() elif uri: self.base_path = Path(uri).resolve() @@ -65,8 +63,6 @@ def __init__(self, uri: str = "", **kwargs: Any) -> None: self.base_path = Path.cwd() if "base_path" in kwargs: - # Combine URI path with base_path (Path division handles absolute paths correctly) - # If base_path is absolute, it takes precedence (backward compatible) self.base_path = (self.base_path / kwargs["base_path"]).resolve() if not self.base_path.exists(): @@ -76,31 +72,28 @@ def __init__(self, uri: str = "", **kwargs: Any) -> None: self.protocol = "file" - def resolve_uri(self, path: "str | Path") -> str: - """Resolve a backend-relative path to an absolute filesystem path. + @property + def supports_signing(self) -> bool: + """Whether this backend supports URL signing. - Args: - path: The same backend-relative path accepted by read and write methods. + Local file storage does not support URL signing. + Local files are accessed directly via file:// URIs. Returns: - The absolute filesystem path. The target does not need to exist. + Always False for local storage. """ - return str(self._resolve_path(path).resolve()) + return False - def _resolve_path(self, path: "str | Path") -> Path: - """Resolve path relative to base_path, refusing anything outside it. + def resolve_uri(self, path: "str | Path") -> str: + """Resolve a backend-relative path to an absolute filesystem path. Args: - path: Path to resolve (absolute or relative). + path: The same backend-relative path accepted by read and write methods. Returns: - Resolved Path object inside ``base_path``. - - Raises: - StoragePathTraversalError: If the path resolves outside ``base_path``. + The absolute filesystem path. The target does not need to exist. """ - relative = ensure_path_within_root(path, self.base_path) - return self.base_path if not relative else self.base_path / relative + return str(self._resolve_path(path).resolve()) def read_bytes_sync(self, path: "str | Path", **kwargs: Any) -> bytes: """Read bytes from file synchronously.""" @@ -160,14 +153,17 @@ def list_objects_sync(self, prefix: str = "", recursive: bool = True, **kwargs: **kwargs: Additional backend-specific options (currently unused). When the prefix resembles a directory (contains a slash or ends with '/'), we treat it as a path; otherwise we filter filenames within the base path. - Paths outside base_path are returned with their absolute names. """ - if prefix and (prefix.endswith("/") or "/" in prefix): - search_path = self._resolve_path(prefix) - if not search_path.exists(): - return [] - if search_path.is_file(): - return [str(search_path.relative_to(self.base_path))] + if prefix: + reject_parent_traversal(prefix) + if prefix.endswith("/") or "/" in prefix: + search_path = self._resolve_path(prefix) + if not search_path.exists(): + return [] + if search_path.is_file(): + return [str(search_path.relative_to(self.base_path))] + else: + search_path = self.base_path else: search_path = self.base_path @@ -176,14 +172,14 @@ def list_objects_sync(self, prefix: str = "", recursive: bool = True, **kwargs: for path in search_path.glob(pattern): if path.is_file(): try: + resolved_file = path.resolve() + resolved_file.relative_to(self.base_path) relative = path.relative_to(self.base_path) - relative_str = str(relative) - if not prefix or relative_str.startswith(prefix): - files.append(relative_str) except ValueError: - path_str = str(path) - if not prefix or path_str.startswith(prefix): - files.append(path_str) + continue + relative_str = str(relative) + if not prefix or relative_str.startswith(prefix): + files.append(relative_str) return sorted(files) @@ -221,23 +217,37 @@ def move_sync(self, source: "str | Path", destination: "str | Path", **kwargs: A def glob_sync(self, pattern: str, **kwargs: Any) -> "list[str]": """Find files matching pattern synchronously. - Supports both relative and absolute patterns by adjusting where the glob search begins. + Supports both relative and absolute patterns inside ``base_path``. """ - if Path(pattern).is_absolute(): - base_path = Path(pattern).parent - pattern_name = Path(pattern).name - matches = base_path.rglob(pattern_name) if "**" in pattern else base_path.glob(pattern_name) + if not pattern: + return [] + reject_parent_traversal(pattern) + pattern_path = Path(pattern) + if pattern_path.is_absolute(): + try: + rel_pattern_path = pattern_path.relative_to(self.base_path) + except ValueError as exc: + msg = f"Glob pattern resolves outside base_path '{self.base_path}': {pattern!r}" + raise StoragePathTraversalError(msg) from exc + rel_pattern = str(rel_pattern_path) else: - matches = self.base_path.rglob(pattern) if "**" in pattern else self.base_path.glob(pattern) + rel_pattern = pattern + + matches = ( + self.base_path.rglob(rel_pattern) + if "**" in rel_pattern and not rel_pattern.startswith("**/") + else self.base_path.glob(rel_pattern) + ) results = [] for match in matches: if match.is_file(): try: + match.resolve().relative_to(self.base_path) relative = match.relative_to(self.base_path) - results.append(str(relative)) except ValueError: - results.append(str(match)) + continue + results.append(str(relative)) return sorted(results) @@ -251,20 +261,6 @@ def get_metadata_sync(self, path: "str | Path", **kwargs: Any) -> "dict[str, obj path=str(resolved), ) - def _collect_metadata(self, resolved: "Path") -> "dict[str, object]": - if not resolved.exists(): - return {} - - stat = resolved.stat() - return { - "size": stat.st_size, - "modified": stat.st_mtime, - "created": stat.st_ctime, - "is_file": resolved.is_file(), - "is_dir": resolved.is_dir(), - "path": str(resolved), - } - def is_object_sync(self, path: "str | Path") -> bool: """Check if path points to a file synchronously.""" return self._resolve_path(path).is_file() @@ -321,24 +317,10 @@ def stream_arrow_sync( ) yield from iter_parquet_row_groups(parquet_file, batch_size=batch_size, **kwargs) - @property - def supports_signing(self) -> bool: - """Whether this backend supports URL signing. - - Local file storage does not support URL signing. - Local files are accessed directly via file:// URIs. - - Returns: - Always False for local storage. - """ - return False - @overload def sign_sync(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... - @overload def sign_sync(self, paths: "list[str]", expires_in: int = 3600, for_upload: bool = False) -> "list[str]": ... - def sign_sync( self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False ) -> "str | list[str]": @@ -385,8 +367,12 @@ async def stream_read_async( """ resolved = self._resolve_path(path) chunk_size = chunk_size or 65536 - # Open file in thread pool to avoid blocking, then use threaded iterator - file_obj = await async_(_open_file_for_read)(resolved) + file_obj = await execute_async_storage_operation( + partial(async_(_open_file_for_read), resolved), + backend=self.backend_type, + operation="stream_read", + path=str(resolved), + ) return AsyncThreadedBytesIterator(file_obj, chunk_size) async def list_objects_async(self, prefix: str = "", recursive: bool = True, **kwargs: Any) -> "list[str]": @@ -447,16 +433,43 @@ def stream_arrow_async( @overload async def sign_async(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... - @overload async def sign_async(self, paths: "list[str]", expires_in: int = 3600, for_upload: bool = False) -> "list[str]": ... - async def sign_async( self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False ) -> "str | list[str]": """Generate signed URL(s) asynchronously.""" return await async_(self.sign_sync)(paths, expires_in, for_upload) # type: ignore[arg-type] + def _resolve_path(self, path: "str | Path") -> Path: + """Resolve path relative to base_path, refusing anything outside it. + + Args: + path: Path to resolve (absolute or relative). + + Returns: + Resolved Path object inside ``base_path``. + + Raises: + StoragePathTraversalError: If the path resolves outside ``base_path``. + """ + relative = ensure_path_within_root(path, self.base_path) + return self.base_path if not relative else self.base_path / relative + + def _collect_metadata(self, resolved: "Path") -> "dict[str, object]": + if not resolved.exists(): + return {} + + stat = resolved.stat() + return { + "size": stat.st_size, + "modified": stat.st_mtime, + "created": stat.st_ctime, + "is_file": resolved.is_file(), + "is_dir": resolved.is_dir(), + "path": str(resolved), + } + def _write_local_bytes(resolved: "Path", data: bytes) -> None: """Write bytes to a local file, ensuring parent directories exist.""" diff --git a/sqlspec/storage/backends/obstore.py b/sqlspec/storage/backends/obstore.py index fbeecbd5c..22cee5238 100644 --- a/sqlspec/storage/backends/obstore.py +++ b/sqlspec/storage/backends/obstore.py @@ -4,6 +4,7 @@ and local file storage. """ +import contextlib import io import re from collections.abc import AsyncIterator, Iterator @@ -22,10 +23,17 @@ extract_glob_static_prefix, glob_to_regex, is_file_destination, + parse_file_uri_path, reject_parent_traversal, resolve_storage_path, ) -from sqlspec.storage._utils import _log_storage_event, import_pyarrow, import_pyarrow_parquet +from sqlspec.storage._utils import ( + import_obstore, + import_obstore_store, + import_pyarrow, + import_pyarrow_parquet, + log_storage_event, +) from sqlspec.storage.backends.base import AsyncArrowBatchIterator, AsyncObStoreStreamIterator if TYPE_CHECKING: @@ -33,11 +41,10 @@ from sqlspec.typing import ArrowRecordBatch, ArrowTable -from sqlspec.storage.errors import execute_sync_storage_operation +from sqlspec.storage.errors import execute_async_storage_operation, execute_sync_storage_operation from sqlspec.utils.module_loader import ensure_obstore from sqlspec.utils.sync_tools import async_ -DEFAULT_OPTIONS: Final[dict[str, Any]] = {"connect_timeout": "30s", "request_timeout": "60s"} _MAX_SIGN_EXPIRES_SECONDS: Final[int] = 604800 _SIGNABLE_PROTOCOLS: Final[frozenset[str]] = frozenset({"s3", "gs", "gcs", "az", "azure"}) @@ -90,6 +97,7 @@ def __init__(self, uri: str, **kwargs: Any) -> None: - Other obstore configuration options (timeouts, credentials, etc.) """ ensure_obstore() + obs_store = import_obstore_store() base_path = kwargs.pop("base_path", "") self.store_uri = uri @@ -101,16 +109,9 @@ def __init__(self, uri: str, **kwargs: Any) -> None: self.protocol = uri.split("://", 1)[0] if "://" in uri else "file" try: if uri.startswith("memory://"): - from obstore.store import MemoryStore - - self.store = MemoryStore() + self.store = obs_store.MemoryStore() elif uri.startswith("file://"): - from obstore.store import LocalStore - - parsed = urlparse(uri) - path_str = parsed.path or "/" - if parsed.fragment: - path_str = f"{path_str}#{parsed.fragment}" + path_str = parse_file_uri_path(uri) path_obj = Path(path_str) if is_file_destination(path_obj): @@ -122,13 +123,11 @@ def __init__(self, uri: str, **kwargs: Any) -> None: self._is_local_store = True self._local_store_root = str(local_store_root_obj.resolve()) - self.store = LocalStore(self._local_store_root, mkdir=True) + self.store = obs_store.LocalStore(self._local_store_root, mkdir=True) else: - from obstore.store import from_url - - self.store = from_url(uri, **kwargs) # pyright: ignore[reportAttributeAccessIssue] + self.store = obs_store.from_url(uri, **kwargs) - _log_storage_event( + log_storage_event( "storage.backend.ready", backend_type=self.backend_type, protocol=self.protocol, @@ -141,6 +140,18 @@ def __init__(self, uri: str, **kwargs: Any) -> None: msg = f"Failed to initialize obstore backend for {uri}" raise StorageOperationFailedError(msg) from exc + @property + def supports_signing(self) -> bool: + """Whether this backend supports URL signing. + + Only S3, GCS, and Azure backends support pre-signed URLs. + Local file storage does not support URL signing. + + Returns: + True if the protocol supports signing, False otherwise. + """ + return self.protocol in _SIGNABLE_PROTOCOLS + @classmethod def from_config(cls, config: "dict[str, Any]") -> "ObStoreBackend": """Create backend from configuration dictionary.""" @@ -165,7 +176,7 @@ def resolve_uri(self, path: "str | Path") -> str: URI for remote stores. The target does not need to exist. """ resolved_path = self._resolve_path(path) - if self._is_local_store: + if getattr(self, "_is_local_store", False): return str((Path(self._local_store_root) / resolved_path).resolve()) parsed = urlparse(self.store_uri) @@ -178,88 +189,15 @@ def resolve_uri(self, path: "str | Path") -> str: address = f"{address}#{parsed.fragment}" return address - def _resolve_path(self, path: "str | Path") -> str: - if self._is_local_store: - return self._local_store_path(path) - return resolve_storage_path(path, self.base_path, self.protocol, strip_file_scheme=True) - - def _resolve_list_prefix(self, prefix: str) -> str: - """Resolve a caller-supplied listing prefix to a store-relative one. - - A local store's root already contains ``base_path``, so resolving the - prefix against ``base_path`` again would look for that segment twice and - match nothing. - - Args: - prefix: Caller-supplied listing prefix, possibly empty. - - Returns: - The prefix as the store expects it, or ``""`` to list everything. - """ - if not prefix: - return "" if self._is_local_store else (self.base_path or "") - base = "" if self._is_local_store else self.base_path - return resolve_storage_path(prefix, base, self.protocol, strip_file_scheme=True) - - def _local_store_path(self, path: "str | Path") -> str: - """Resolve path for LocalStore, which expects relative paths from its root. - - Args: - path: Caller-supplied storage path. - - Returns: - The path relative to the store root. - - Raises: - StoragePathTraversalError: If the path resolves outside the store root. - """ - - if not self._local_store_root: - reject_parent_traversal(path) - return str(path) - - return ensure_path_within_root(path, self._local_store_root) - - def _read_bytes_resolved_sync(self, resolved_path: str) -> bytes: - result = execute_sync_storage_operation( - partial(_read_obstore_bytes, self.store, resolved_path), - backend=self.backend_type, - operation="read_bytes", - path=resolved_path, - ) - _log_storage_event( - "storage.read", - backend_type=self.backend_type, - protocol=self.protocol, - operation="read_bytes", - mode="sync", - path=resolved_path, - ) - return result - - def read_bytes_sync(self, path: "str | Path", **kwargs: Any) -> bytes: # pyright: ignore[reportUnusedParameter] + def read_bytes_sync(self, path: "str | Path", **kwargs: Any) -> bytes: """Read bytes using obstore synchronously.""" + _ = kwargs resolved_path = self._resolve_path(path) return self._read_bytes_resolved_sync(resolved_path) - def _write_bytes_resolved_sync(self, resolved_path: str, data: bytes) -> None: - execute_sync_storage_operation( - partial(self.store.put, resolved_path, data), - backend=self.backend_type, - operation="write_bytes", - path=resolved_path, - ) - _log_storage_event( - "storage.write", - backend_type=self.backend_type, - protocol=self.protocol, - operation="write_bytes", - mode="sync", - path=resolved_path, - ) - - def write_bytes_sync(self, path: "str | Path", data: bytes, **kwargs: Any) -> None: # pyright: ignore[reportUnusedParameter] + def write_bytes_sync(self, path: "str | Path", data: bytes, **kwargs: Any) -> None: """Write bytes using obstore synchronously.""" + _ = kwargs resolved_path = self._resolve_path(path) self._write_bytes_resolved_sync(resolved_path, data) @@ -271,26 +209,12 @@ def write_text_sync(self, path: "str | Path", data: str, encoding: str = "utf-8" """Write text using obstore synchronously.""" self.write_bytes_sync(path, data.encode(encoding), **kwargs) - def _list_resolved_sync(self, resolved_prefix: str, recursive: bool) -> "list[str]": - """List object keys under a prefix that is already store-relative. - - Args: - resolved_prefix: Prefix as the store expects it; ``""`` lists everything. - recursive: Whether to descend into nested prefixes. - - Returns: - Sorted object keys. - """ - if not recursive: - result = self.store.list_with_delimiter(resolved_prefix) - return sorted(item["path"] for item in result["objects"]) - return sorted(item["path"] for batch in self.store.list(resolved_prefix) for item in batch) - - def list_objects_sync(self, prefix: str = "", recursive: bool = True, **kwargs: Any) -> "list[str]": # pyright: ignore[reportUnusedParameter] + def list_objects_sync(self, prefix: str = "", recursive: bool = True, **kwargs: Any) -> "list[str]": """List objects using obstore synchronously.""" + _ = kwargs resolved_prefix = self._resolve_list_prefix(prefix) paths = self._list_resolved_sync(resolved_prefix, recursive) - _log_storage_event( + log_storage_event( "storage.list", backend_type=self.backend_type, protocol=self.protocol, @@ -301,13 +225,14 @@ def list_objects_sync(self, prefix: str = "", recursive: bool = True, **kwargs: ) return paths - def exists_sync(self, path: "str | Path", **kwargs: Any) -> bool: # pyright: ignore[reportUnusedParameter] + def exists_sync(self, path: "str | Path", **kwargs: Any) -> bool: """Check if object exists using obstore synchronously.""" + _ = kwargs + resolved_path = self._resolve_path(path) try: - resolved_path = self._resolve_path(path) self.store.head(resolved_path) # pyright: ignore[reportUnknownMemberType] except Exception: - _log_storage_event( + log_storage_event( "storage.read", backend_type=self.backend_type, protocol=self.protocol, @@ -317,7 +242,7 @@ def exists_sync(self, path: "str | Path", **kwargs: Any) -> bool: # pyright: ig exists=False, ) return False - _log_storage_event( + log_storage_event( "storage.read", backend_type=self.backend_type, protocol=self.protocol, @@ -328,13 +253,14 @@ def exists_sync(self, path: "str | Path", **kwargs: Any) -> bool: # pyright: ig ) return True - def delete_sync(self, path: "str | Path", **kwargs: Any) -> None: # pyright: ignore[reportUnusedParameter] + def delete_sync(self, path: "str | Path", **kwargs: Any) -> None: """Delete object using obstore synchronously.""" + _ = kwargs resolved_path = self._resolve_path(path) execute_sync_storage_operation( partial(self.store.delete, resolved_path), backend=self.backend_type, operation="delete", path=resolved_path ) - _log_storage_event( + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -343,8 +269,9 @@ def delete_sync(self, path: "str | Path", **kwargs: Any) -> None: # pyright: ig path=resolved_path, ) - def copy_sync(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: # pyright: ignore[reportUnusedParameter] + def copy_sync(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Copy object using obstore synchronously.""" + _ = kwargs source_path = self._resolve_path(source) dest_path = self._resolve_path(destination) execute_sync_storage_operation( @@ -353,7 +280,7 @@ def copy_sync(self, source: "str | Path", destination: "str | Path", **kwargs: A operation="copy", path=f"{source_path}->{dest_path}", ) - _log_storage_event( + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -363,8 +290,9 @@ def copy_sync(self, source: "str | Path", destination: "str | Path", **kwargs: A destination_path=dest_path, ) - def move_sync(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: # pyright: ignore[reportUnusedParameter] + def move_sync(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Move object using obstore synchronously.""" + _ = kwargs source_path = self._resolve_path(source) dest_path = self._resolve_path(destination) execute_sync_storage_operation( @@ -373,7 +301,7 @@ def move_sync(self, source: "str | Path", destination: "str | Path", **kwargs: A operation="move", path=f"{source_path}->{dest_path}", ) - _log_storage_event( + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -391,19 +319,19 @@ def glob_sync(self, pattern: str, **kwargs: Any) -> "list[str]": within one path segment and ``**`` spans zero or more segments, matching the local and fsspec backends. """ - + _ = kwargs if not pattern: return [] reject_parent_traversal(pattern) resolved_pattern = ( pattern - if self._is_local_store + if getattr(self, "_is_local_store", False) else resolve_storage_path(pattern, self.base_path, self.protocol, strip_file_scheme=True) ) all_objects = self._list_resolved_sync(extract_glob_static_prefix(resolved_pattern), recursive=True) matcher = glob_to_regex(resolved_pattern) results = [obj for obj in all_objects if matcher.match(obj)] - _log_storage_event( + log_storage_event( "storage.list", backend_type=self.backend_type, protocol=self.protocol, @@ -414,8 +342,9 @@ def glob_sync(self, pattern: str, **kwargs: Any) -> "list[str]": ) return results - def get_metadata_sync(self, path: "str | Path", **kwargs: Any) -> "dict[str, object]": # pyright: ignore[reportUnusedParameter] + def get_metadata_sync(self, path: "str | Path", **kwargs: Any) -> "dict[str, object]": """Get object metadata using obstore synchronously.""" + _ = kwargs resolved_path = self._resolve_path(path) try: @@ -438,14 +367,14 @@ def get_metadata_sync(self, path: "str | Path", **kwargs: Any) -> "dict[str, obj def is_object_sync(self, path: "str | Path") -> bool: """Check if path is an object using obstore synchronously.""" - resolved_path = resolve_storage_path(path, self.base_path, self.protocol, strip_file_scheme=True) - return self.exists_sync(path) and not resolved_path.endswith("/") + resolved_path = self._resolve_path(path) + return self.exists_sync(path) and not str(path).endswith("/") and not resolved_path.endswith("/") def is_path_sync(self, path: "str | Path") -> bool: """Check if path is a prefix/directory using obstore synchronously.""" - resolved_path = resolve_storage_path(path, self.base_path, self.protocol, strip_file_scheme=True) + resolved_path = self._resolve_path(path) - if resolved_path.endswith("/"): + if str(path).endswith("/") or resolved_path.endswith("/"): return True try: @@ -468,7 +397,7 @@ def read_arrow_sync(self, path: "str | Path", **kwargs: Any) -> "ArrowTable": path=resolved_path, ), ) - _log_storage_event( + log_storage_event( "storage.read", backend_type=self.backend_type, protocol=self.protocol, @@ -480,32 +409,17 @@ def read_arrow_sync(self, path: "str | Path", **kwargs: Any) -> "ArrowTable": def write_arrow_sync(self, path: "str | Path", table: "ArrowTable", **kwargs: Any) -> None: """Write Arrow table using obstore synchronously.""" - pa = import_pyarrow() + import_pyarrow() import_pyarrow_parquet() resolved_path = self._resolve_path(path) - schema = table.schema - if any(str(f.type).startswith("decimal64") for f in schema): - new_fields = [] - for field in schema: - if str(field.type).startswith("decimal64"): - match = re.match(r"decimal64\((\d+),\s*(\d+)\)", str(field.type)) - if match: - precision, scale = int(match.group(1)), int(match.group(2)) - new_fields.append(pa.field(field.name, pa.decimal128(precision, scale))) - else: - new_fields.append(field) - else: - new_fields.append(field) - table = table.cast(pa.schema(new_fields)) - execute_sync_storage_operation( partial(self._stream_parquet_sync, resolved_path, table, **kwargs), backend=self.backend_type, operation="write_arrow", path=resolved_path, ) - _log_storage_event( + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -514,32 +428,6 @@ def write_arrow_sync(self, path: "str | Path", table: "ArrowTable", **kwargs: An path=resolved_path, ) - def _stream_parquet_sync(self, resolved_path: str, table: "ArrowTable", **kwargs: Any) -> None: - """Serialize a table row group by row group into an obstore multipart writer. - - Peak memory is bounded by one serialized row group plus the upload - buffer rather than the serialized size of the whole table. The writer - and the sink are closed only after every row group is written; a - failure leaves the multipart upload unfinished, and it is discarded - when the writer is released, so no partial object is published. - - Args: - resolved_path: Store-relative destination key. - table: Table to serialize. - **kwargs: Options forwarded to ``pyarrow.parquet.ParquetWriter``; - ``row_group_size`` is forwarded to ``write_table``. - """ - from obstore import open_writer - - pa = import_pyarrow() - pq = import_pyarrow_parquet() - row_group_size = kwargs.pop("row_group_size", None) - sink = open_writer(self.store, resolved_path) - writer = pq.ParquetWriter(pa.PythonFile(_ObstoreSink(sink), mode="w"), table.schema, **kwargs) - writer.write_table(table, row_group_size=row_group_size) - writer.close() - sink.close() - def stream_read_sync(self, path: "str | Path", chunk_size: "int | None" = None, **kwargs: Any) -> Iterator[bytes]: """Stream bytes using obstore's native streaming synchronously. @@ -549,6 +437,7 @@ def stream_read_sync(self, path: "str | Path", chunk_size: "int | None" = None, Yields: Chunks of bytes from the file, with size determined by chunk_size (default: 65536 bytes). """ + _ = kwargs resolved_path = self._resolve_path(path) chunk_size = chunk_size or 65536 @@ -572,13 +461,13 @@ def stream_arrow_sync( Yields: Arrow record batches in file and row-group order. """ - from obstore import open_reader + obs = import_obstore() validate_parquet_stream_options(pattern, file_format, batch_size) pq = import_pyarrow_parquet() for obj_path in self.glob_sync(pattern): reader = execute_sync_storage_operation( - partial(open_reader, self.store, obj_path), + partial(obs.open_reader, self.store, obj_path), backend=self.backend_type, operation="stream_open", path=obj_path, @@ -589,52 +478,10 @@ def stream_arrow_sync( ) yield from iter_parquet_row_groups(parquet_file, batch_size=batch_size, **kwargs) - @property - def supports_signing(self) -> bool: - """Whether this backend supports URL signing. - - Only S3, GCS, and Azure backends support pre-signed URLs. - Local file storage does not support URL signing. - - Returns: - True if the protocol supports signing, False otherwise. - """ - return self.protocol in _SIGNABLE_PROTOCOLS - - def _prepare_sign_request( - self, paths: "str | list[str]", expires_in: int, for_upload: bool - ) -> "tuple[str, timedelta, list[str], bool]": - if self.protocol not in _SIGNABLE_PROTOCOLS: - msg = ( - f"URL signing is not supported for protocol '{self.protocol}'. " - f"Only S3, GCS, and Azure backends support pre-signed URLs." - ) - raise NotImplementedError(msg) - - if expires_in > _MAX_SIGN_EXPIRES_SECONDS: - msg = f"expires_in cannot exceed {_MAX_SIGN_EXPIRES_SECONDS} seconds (7 days), got {expires_in}" - raise ValueError(msg) - - method = "PUT" if for_upload else "GET" - expires_delta = timedelta(seconds=expires_in) - if isinstance(paths, str): - path_list = [paths] - is_single = True - else: - path_list = list(paths) - is_single = False - - resolved_paths = [ - resolve_storage_path(path, self.base_path, self.protocol, strip_file_scheme=True) for path in path_list - ] - return method, expires_delta, resolved_paths, is_single - @overload def sign_sync(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... - @overload def sign_sync(self, paths: "list[str]", expires_in: int = 3600, for_upload: bool = False) -> "list[str]": ... - def sign_sync( self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False ) -> "str | list[str]": @@ -649,49 +496,26 @@ def sign_sync( Single signed URL string if paths is a string, or list of signed URLs if paths is a list. Preserves input type for convenience. """ - import obstore as obs + obs = import_obstore() method, expires_delta, resolved_paths, is_single = self._prepare_sign_request(paths, expires_in, for_upload) try: - signed_urls: list[str] = obs.sign(self.store, method, resolved_paths, expires_delta) # type: ignore[call-overload] + signed_urls: list[str] = obs.sign(self.store, method, resolved_paths, expires_delta) return signed_urls[0] if is_single else signed_urls except Exception as exc: msg = f"Failed to generate signed URL(s) for {resolved_paths}" raise StorageOperationFailedError(msg) from exc - async def _read_bytes_resolved_async(self, resolved_path: str) -> bytes: - result = await self.store.get_async(resolved_path) - bytes_obj = await result.bytes_async() # pyright: ignore[reportAttributeAccessIssue] - data = cast("bytes", bytes_obj.to_bytes()) - _log_storage_event( - "storage.read", - backend_type=self.backend_type, - protocol=self.protocol, - operation="read_bytes", - mode="async", - path=resolved_path, - ) - return data - - async def read_bytes_async(self, path: "str | Path", **kwargs: Any) -> bytes: # pyright: ignore[reportUnusedParameter] + async def read_bytes_async(self, path: "str | Path", **kwargs: Any) -> bytes: """Read bytes from storage asynchronously.""" + _ = kwargs resolved_path = self._resolve_path(path) return await self._read_bytes_resolved_async(resolved_path) - async def _write_bytes_resolved_async(self, resolved_path: str, data: bytes) -> None: - await self.store.put_async(resolved_path, data) - _log_storage_event( - "storage.write", - backend_type=self.backend_type, - protocol=self.protocol, - operation="write_bytes", - mode="async", - path=resolved_path, - ) - - async def write_bytes_async(self, path: "str | Path", data: bytes, **kwargs: Any) -> None: # pyright: ignore[reportUnusedParameter] + async def write_bytes_async(self, path: "str | Path", data: bytes, **kwargs: Any) -> None: """Write bytes to storage asynchronously.""" + _ = kwargs resolved_path = self._resolve_path(path) await self._write_bytes_resolved_async(resolved_path, data) @@ -703,28 +527,34 @@ async def stream_read_async( Uses obstore's native async streaming to yield chunks of bytes without buffering the entire file into memory. """ - if self._is_local_store: - resolved_path = self._local_store_path(path) - else: - resolved_path = resolve_storage_path(path, self.base_path, self.protocol, strip_file_scheme=True) + _ = kwargs + resolved_path = self._resolve_path(path) + effective_chunk_size = chunk_size or 65536 - result = await self.store.get_async(resolved_path) - return AsyncObStoreStreamIterator(result.stream(), chunk_size) + result = await execute_async_storage_operation( + partial(self.store.get_async, resolved_path), + backend=self.backend_type, + operation="stream_read", + path=resolved_path, + ) + try: + stream = result.stream(min_chunk_size=effective_chunk_size) + except TypeError: + stream = result.stream() + return AsyncObStoreStreamIterator(stream, effective_chunk_size) - async def list_objects_async(self, prefix: str = "", recursive: bool = True, **kwargs: Any) -> "list[str]": # pyright: ignore[reportUnusedParameter] + async def list_objects_async(self, prefix: str = "", recursive: bool = True, **kwargs: Any) -> "list[str]": """List objects in storage asynchronously.""" + _ = kwargs resolved_prefix = self._resolve_list_prefix(prefix) - objects: list[str] = [] - async for batch in self.store.list_async(resolved_prefix): # pyright: ignore[reportAttributeAccessIssue] - objects.extend(item["path"] for item in batch) - - if not recursive and resolved_prefix: - base_depth = resolved_prefix.count("/") - objects = [obj for obj in objects if obj.count("/") <= base_depth + 1] - - results = sorted(objects) - _log_storage_event( + results = await execute_async_storage_operation( + partial(self._list_resolved_async, resolved_prefix, recursive), + backend=self.backend_type, + operation="list_objects", + path=resolved_prefix, + ) + log_storage_event( "storage.list", backend_type=self.backend_type, protocol=self.protocol, @@ -740,22 +570,20 @@ async def read_text_async(self, path: "str | Path", encoding: str = "utf-8", **k data = await self.read_bytes_async(path, **kwargs) return data.decode(encoding) - async def write_text_async(self, path: "str | Path", data: str, encoding: str = "utf-8", **kwargs: Any) -> None: # pyright: ignore[reportUnusedParameter] + async def write_text_async(self, path: "str | Path", data: str, encoding: str = "utf-8", **kwargs: Any) -> None: """Write text to storage asynchronously.""" encoded_data = data.encode(encoding) await self.write_bytes_async(path, encoded_data, **kwargs) - async def exists_async(self, path: "str | Path", **kwargs: Any) -> bool: # pyright: ignore[reportUnusedParameter] + async def exists_async(self, path: "str | Path", **kwargs: Any) -> bool: """Check if object exists in storage asynchronously.""" - if self._is_local_store: - resolved_path = self._local_store_path(path) - else: - resolved_path = resolve_storage_path(path, self.base_path, self.protocol, strip_file_scheme=True) + _ = kwargs + resolved_path = self._resolve_path(path) try: await self.store.head_async(resolved_path) except Exception: - _log_storage_event( + log_storage_event( "storage.read", backend_type=self.backend_type, protocol=self.protocol, @@ -765,7 +593,7 @@ async def exists_async(self, path: "str | Path", **kwargs: Any) -> bool: # pyri exists=False, ) return False - _log_storage_event( + log_storage_event( "storage.read", backend_type=self.backend_type, protocol=self.protocol, @@ -776,15 +604,18 @@ async def exists_async(self, path: "str | Path", **kwargs: Any) -> bool: # pyri ) return True - async def delete_async(self, path: "str | Path", **kwargs: Any) -> None: # pyright: ignore[reportUnusedParameter] + async def delete_async(self, path: "str | Path", **kwargs: Any) -> None: """Delete object from storage asynchronously.""" - if self._is_local_store: - resolved_path = self._local_store_path(path) - else: - resolved_path = resolve_storage_path(path, self.base_path, self.protocol, strip_file_scheme=True) + _ = kwargs + resolved_path = self._resolve_path(path) - await self.store.delete_async(resolved_path) - _log_storage_event( + await execute_async_storage_operation( + partial(self.store.delete_async, resolved_path), + backend=self.backend_type, + operation="delete", + path=resolved_path, + ) + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -793,17 +624,19 @@ async def delete_async(self, path: "str | Path", **kwargs: Any) -> None: # pyri path=resolved_path, ) - async def copy_async(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: # pyright: ignore[reportUnusedParameter] + async def copy_async(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Copy object in storage asynchronously.""" - if self._is_local_store: - source_path = self._local_store_path(source) - dest_path = self._local_store_path(destination) - else: - source_path = resolve_storage_path(source, self.base_path, self.protocol, strip_file_scheme=True) - dest_path = resolve_storage_path(destination, self.base_path, self.protocol, strip_file_scheme=True) + _ = kwargs + source_path = self._resolve_path(source) + dest_path = self._resolve_path(destination) - await self.store.copy_async(source_path, dest_path) - _log_storage_event( + await execute_async_storage_operation( + partial(self.store.copy_async, source_path, dest_path), + backend=self.backend_type, + operation="copy", + path=f"{source_path}->{dest_path}", + ) + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -813,17 +646,19 @@ async def copy_async(self, source: "str | Path", destination: "str | Path", **kw destination_path=dest_path, ) - async def move_async(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: # pyright: ignore[reportUnusedParameter] + async def move_async(self, source: "str | Path", destination: "str | Path", **kwargs: Any) -> None: """Move object in storage asynchronously.""" - if self._is_local_store: - source_path = self._local_store_path(source) - dest_path = self._local_store_path(destination) - else: - source_path = resolve_storage_path(source, self.base_path, self.protocol, strip_file_scheme=True) - dest_path = resolve_storage_path(destination, self.base_path, self.protocol, strip_file_scheme=True) + _ = kwargs + source_path = self._resolve_path(source) + dest_path = self._resolve_path(destination) - await self.store.rename_async(source_path, dest_path) - _log_storage_event( + await execute_async_storage_operation( + partial(self.store.rename_async, source_path, dest_path), + backend=self.backend_type, + operation="move", + path=f"{source_path}->{dest_path}", + ) + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -833,12 +668,10 @@ async def move_async(self, source: "str | Path", destination: "str | Path", **kw destination_path=dest_path, ) - async def get_metadata_async(self, path: "str | Path", **kwargs: Any) -> "dict[str, object]": # pyright: ignore[reportUnusedParameter] + async def get_metadata_async(self, path: "str | Path", **kwargs: Any) -> "dict[str, object]": """Get object metadata from storage asynchronously.""" - if self._is_local_store: - resolved_path = self._local_store_path(path) - else: - resolved_path = resolve_storage_path(path, self.base_path, self.protocol, strip_file_scheme=True) + _ = kwargs + resolved_path = self._resolve_path(path) result: dict[str, object] = {} try: @@ -869,9 +702,14 @@ async def read_arrow_async(self, path: "str | Path", **kwargs: Any) -> "ArrowTab resolved_path = self._resolve_path(path) data = await self._read_bytes_resolved_async(resolved_path) - result = await async_(pq.read_table)(io.BytesIO(data), **kwargs) + result = await execute_async_storage_operation( + partial(async_(pq.read_table), io.BytesIO(data), **kwargs), + backend=self.backend_type, + operation="read_arrow", + path=resolved_path, + ) - _log_storage_event( + log_storage_event( "storage.read", backend_type=self.backend_type, protocol=self.protocol, @@ -887,11 +725,18 @@ async def write_arrow_async(self, path: "str | Path", table: "ArrowTable", **kwa Uses async_() with storage limiter to offload blocking PyArrow serialization to thread pool, preventing event loop blocking. """ + import_pyarrow() + import_pyarrow_parquet() resolved_path = self._resolve_path(path) - await async_(self._stream_parquet_sync)(resolved_path, table, **kwargs) + await execute_async_storage_operation( + partial(async_(self._stream_parquet_sync), resolved_path, table, **kwargs), + backend=self.backend_type, + operation="write_arrow", + path=resolved_path, + ) - _log_storage_event( + log_storage_event( "storage.write", backend_type=self.backend_type, protocol=self.protocol, @@ -920,10 +765,8 @@ def stream_arrow_async( @overload async def sign_async(self, paths: str, expires_in: int = 3600, for_upload: bool = False) -> str: ... - @overload async def sign_async(self, paths: "list[str]", expires_in: int = 3600, for_upload: bool = False) -> "list[str]": ... - async def sign_async( self, paths: "str | list[str]", expires_in: int = 3600, for_upload: bool = False ) -> "str | list[str]": @@ -938,17 +781,252 @@ async def sign_async( Single signed URL string if paths is a string, or list of signed URLs if paths is a list. Preserves input type for convenience. """ - import obstore as obs + obs = import_obstore() method, expires_delta, resolved_paths, is_single = self._prepare_sign_request(paths, expires_in, for_upload) try: - signed_urls: list[str] = await obs.sign_async(self.store, method, resolved_paths, expires_delta) # type: ignore[call-overload] + signed_urls: list[str] = await obs.sign_async(self.store, method, resolved_paths, expires_delta) return signed_urls[0] if is_single else signed_urls except Exception as exc: msg = f"Failed to generate signed URL(s) for {resolved_paths}" raise StorageOperationFailedError(msg) from exc + def _resolve_path(self, path: "str | Path") -> str: + if getattr(self, "_is_local_store", False): + return self._local_store_path(path) + return resolve_storage_path(path, self.base_path, self.protocol, strip_file_scheme=True) + + def _resolve_list_prefix(self, prefix: str) -> str: + """Resolve a caller-supplied listing prefix to a store-relative one. + + A local store's root already contains ``base_path``, so resolving the + prefix against ``base_path`` again would look for that segment twice and + match nothing. + + Args: + prefix: Caller-supplied listing prefix, possibly empty. + + Returns: + The prefix as the store expects it, or ``""`` to list everything. + """ + is_local = getattr(self, "_is_local_store", False) + if not prefix: + return "" if is_local else (self.base_path or "") + base = "" if is_local else self.base_path + return resolve_storage_path(prefix, base, self.protocol, strip_file_scheme=True) + + def _local_store_path(self, path: "str | Path") -> str: + """Resolve path for LocalStore, which expects relative paths from its root. + + Args: + path: Caller-supplied storage path. + + Returns: + The path relative to the store root. + + Raises: + StoragePathTraversalError: If the path resolves outside the store root. + """ + if not getattr(self, "_local_store_root", ""): + reject_parent_traversal(path) + return str(path) + + return ensure_path_within_root(path, self._local_store_root) + + def _read_bytes_resolved_sync(self, resolved_path: str) -> bytes: + result = execute_sync_storage_operation( + partial(_read_obstore_bytes, self.store, resolved_path), + backend=self.backend_type, + operation="read_bytes", + path=resolved_path, + ) + log_storage_event( + "storage.read", + backend_type=self.backend_type, + protocol=self.protocol, + operation="read_bytes", + mode="sync", + path=resolved_path, + ) + return result + + def _write_bytes_resolved_sync(self, resolved_path: str, data: bytes) -> None: + execute_sync_storage_operation( + partial(self.store.put, resolved_path, data), + backend=self.backend_type, + operation="write_bytes", + path=resolved_path, + ) + log_storage_event( + "storage.write", + backend_type=self.backend_type, + protocol=self.protocol, + operation="write_bytes", + mode="sync", + path=resolved_path, + ) + + def _list_resolved_sync(self, resolved_prefix: str, recursive: bool) -> "list[str]": + """List object keys under a prefix that is already store-relative. + + Args: + resolved_prefix: Prefix as the store expects it; ``""`` lists everything. + recursive: Whether to descend into nested prefixes. + + Returns: + Sorted object keys. + """ + if not recursive: + result = execute_sync_storage_operation( + partial(self.store.list_with_delimiter, resolved_prefix), + backend=self.backend_type, + operation="list_objects", + path=resolved_prefix, + ) + return sorted(item["path"] for item in result["objects"]) + batches = execute_sync_storage_operation( + partial(self.store.list, resolved_prefix), + backend=self.backend_type, + operation="list_objects", + path=resolved_prefix, + ) + return sorted(item["path"] for batch in batches for item in batch) + + async def _list_resolved_async(self, resolved_prefix: str, recursive: bool) -> "list[str]": + if not recursive and hasattr(self.store, "list_with_delimiter_async"): + result = await self.store.list_with_delimiter_async(resolved_prefix) + return sorted(item["path"] for item in result["objects"]) + objects: list[str] = [] + async for batch in self.store.list_async(resolved_prefix): # pyright: ignore[reportAttributeAccessIssue] + objects.extend(item["path"] for item in batch) + if not recursive: + base_depth = resolved_prefix.rstrip("/").count("/") + (1 if resolved_prefix.strip("/") else 0) + objects = [obj for obj in objects if obj.count("/") < base_depth] + return sorted(objects) + + def _stream_parquet_sync(self, resolved_path: str, table: "ArrowTable", **kwargs: Any) -> None: + """Serialize a table row group by row group into an obstore multipart writer. + + Peak memory is bounded by one serialized row group plus the upload + buffer rather than the serialized size of the whole table. The writer + and the sink are closed only after every row group is written; a + failure leaves the multipart upload unfinished, and it is discarded + when the writer is released, so no partial object is published. + + Args: + resolved_path: Store-relative destination key. + table: Table to serialize. + **kwargs: Options forwarded to ``pyarrow.parquet.ParquetWriter``; + ``row_group_size`` is forwarded to ``write_table``. + """ + obs = import_obstore() + + pa = import_pyarrow() + pq = import_pyarrow_parquet() + schema = table.schema + if any(str(f.type).startswith("decimal64") for f in schema): + new_fields = [] + for field in schema: + if str(field.type).startswith("decimal64"): + match = re.match(r"decimal64\((\d+),\s*(\d+)\)", str(field.type)) + if match: + precision, scale = int(match.group(1)), int(match.group(2)) + new_fields.append(pa.field(field.name, pa.decimal128(precision, scale))) + else: + new_fields.append(field) + else: + new_fields.append(field) + table = table.cast(pa.schema(new_fields)) + + row_group_size = kwargs.pop("row_group_size", None) + sink = obs.open_writer(self.store, resolved_path) + sink_adapter = _ObstoreSink(sink) + writer: Any = None + try: + writer = pq.ParquetWriter(pa.PythonFile(sink_adapter, mode="w"), table.schema, **kwargs) + writer.write_table(table, row_group_size=row_group_size) + writer.close() + sink_adapter.close() + except BaseException: + sink_adapter.abort() + if writer is not None: + with contextlib.suppress(Exception): + writer.close() + if getattr(self, "_is_local_store", False): + with contextlib.suppress(Exception): + sink.close() + if getattr(self, "_local_store_root", ""): + with contextlib.suppress(Exception): + (Path(self._local_store_root) / resolved_path).unlink(missing_ok=True) + raise + + def _prepare_sign_request( + self, paths: "str | list[str]", expires_in: int, for_upload: bool + ) -> "tuple[str, timedelta, list[str], bool]": + if self.protocol not in _SIGNABLE_PROTOCOLS: + msg = ( + f"URL signing is not supported for protocol '{self.protocol}'. " + f"Only S3, GCS, and Azure backends support pre-signed URLs." + ) + raise NotImplementedError(msg) + + if expires_in > _MAX_SIGN_EXPIRES_SECONDS: + msg = f"expires_in cannot exceed {_MAX_SIGN_EXPIRES_SECONDS} seconds (7 days), got {expires_in}" + raise ValueError(msg) + + method = "PUT" if for_upload else "GET" + expires_delta = timedelta(seconds=expires_in) + if isinstance(paths, str): + path_list = [paths] + is_single = True + else: + path_list = list(paths) + is_single = False + + resolved_paths = [self._resolve_path(path) for path in path_list] + return method, expires_delta, resolved_paths, is_single + + async def _read_bytes_resolved_async(self, resolved_path: str) -> bytes: + data = await execute_async_storage_operation( + partial(_read_obstore_bytes_async, self.store, resolved_path), + backend=self.backend_type, + operation="read_bytes", + path=resolved_path, + ) + log_storage_event( + "storage.read", + backend_type=self.backend_type, + protocol=self.protocol, + operation="read_bytes", + mode="async", + path=resolved_path, + ) + return data + + async def _write_bytes_resolved_async(self, resolved_path: str, data: bytes) -> None: + await execute_async_storage_operation( + partial(self.store.put_async, resolved_path, data), + backend=self.backend_type, + operation="write_bytes", + path=resolved_path, + ) + log_storage_event( + "storage.write", + backend_type=self.backend_type, + protocol=self.protocol, + operation="write_bytes", + mode="async", + path=resolved_path, + ) + + +async def _read_obstore_bytes_async(store: Any, resolved_path: str) -> bytes: + """Read bytes via obstore asynchronously.""" + result = await store.get_async(resolved_path) + bytes_obj = await result.bytes_async() + return cast("bytes", bytes_obj.to_bytes()) + def _read_obstore_bytes(store: Any, resolved_path: str) -> bytes: """Read bytes via obstore.""" @@ -965,6 +1043,12 @@ def __init__(self, reader: Any) -> None: self._reader = reader self._closed = False + def __enter__(self) -> Self: + return self + + def __exit__(self, *_: Any) -> None: + self.close() + @property def closed(self) -> bool: return self._closed @@ -999,12 +1083,6 @@ def close(self) -> None: self._closed = True self._reader.close() - def __enter__(self) -> Self: - return self - - def __exit__(self, *_: Any) -> None: - self.close() - class _ObstoreSink: """Adapt an obstore writer to the file-like surface ``pyarrow.PythonFile`` expects. @@ -1012,20 +1090,30 @@ class _ObstoreSink: obstore exposes ``closed`` as a method; pyarrow reads it as an attribute. """ - __slots__ = ("_writer",) + __slots__ = ("_aborted", "_writer") def __init__(self, writer: Any) -> None: self._writer = writer + self._aborted = False @property def closed(self) -> bool: - return bool(self._writer.closed()) + return self._aborted or bool(self._writer.closed()) + + def abort(self) -> None: + self._aborted = True + abort_fn = getattr(self._writer, "abort", None) + if callable(abort_fn): + with contextlib.suppress(Exception): + abort_fn() def write(self, data: Any) -> int: return int(self._writer.write(data)) def flush(self) -> None: - self._writer.flush() + if not self._aborted: + self._writer.flush() def close(self) -> None: - self._writer.close() + if not self._aborted: + self._writer.close() diff --git a/sqlspec/storage/errors.py b/sqlspec/storage/errors.py index 11f62dd10..3f74364d2 100644 --- a/sqlspec/storage/errors.py +++ b/sqlspec/storage/errors.py @@ -38,6 +38,9 @@ def __init__( def raise_storage_error(error: Exception, *, backend: str, operation: str, path: str | None) -> NoReturn: + if isinstance(error, (FileNotFoundInStorageError, StorageOperationFailedError)): + raise error + is_missing = _is_missing_error(error) normalized = _storage_error(error, backend=backend, operation=operation, path=path) @@ -74,7 +77,7 @@ async def execute_async_storage_operation( def _is_missing_error(error: Exception) -> bool: - if isinstance(error, FileNotFoundError): + if isinstance(error, (FileNotFoundError, FileNotFoundInStorageError)): return True if isinstance(error, OSError) and error.errno in {errno.ENOENT, errno.ENOTDIR}: diff --git a/sqlspec/storage/pipeline.py b/sqlspec/storage/pipeline.py index a848663e4..da9e4ef05 100644 --- a/sqlspec/storage/pipeline.py +++ b/sqlspec/storage/pipeline.py @@ -5,14 +5,14 @@ from pathlib import Path from time import perf_counter from typing import TYPE_CHECKING, Any, NamedTuple, NotRequired, TypeAlias, cast -from urllib.parse import unquote, urlparse +from urllib.parse import urlparse from mypy_extensions import mypyc_attr from typing_extensions import TypedDict from sqlspec.exceptions import ImproperConfigurationError, StorageCapabilityError from sqlspec.storage._arrow_payload import StorageFormat, decode_arrow_payload, encode_arrow_payload -from sqlspec.storage._paths import FILE_PROTOCOL, FILE_SCHEME_PREFIX, strip_windows_drive_prefix +from sqlspec.storage._paths import FILE_PROTOCOL, FILE_SCHEME_PREFIX, parse_file_uri_path from sqlspec.storage.errors import execute_async_storage_operation, execute_sync_storage_operation from sqlspec.storage.registry import StorageRegistry, storage_registry from sqlspec.utils.serializers import get_serializer_metrics, serialize_collection, to_json @@ -123,6 +123,7 @@ def reset(self) -> None: _EMPTY_STORAGE_OPTIONS: dict[str, Any] = {} _ARROW_WRITE_FORMATS = frozenset({"parquet", "arrow-ipc", "csv"}) _ROW_WRITE_FORMATS = frozenset({"json", "jsonl"}) +_CLOUD_OBJECT_STORE_SCHEMES = frozenset({"s3", "gs", "gcs", "az", "azure"}) def get_storage_bridge_metrics() -> "dict[str, int]": @@ -140,7 +141,7 @@ def reset_storage_bridge_metrics() -> None: def record_storage_diagnostic_event(telemetry: StorageTelemetry) -> None: """Record telemetry for inclusion in diagnostics snapshots.""" - _RECENT_STORAGE_EVENTS.append(cast("StorageTelemetry", dict(telemetry))) + _RECENT_STORAGE_EVENTS.appendleft(cast("StorageTelemetry", dict(telemetry))) def get_recent_storage_events() -> "list[StorageTelemetry]": @@ -182,7 +183,7 @@ class _StoragePipelineBase: def __init__( self, *, registry: StorageRegistry | None = None, storage_options: "dict[str, Any] | None" = None ) -> None: - self.registry = registry or storage_registry + self.registry = storage_registry if registry is None else registry self._resolved_backend_cache: dict[str, tuple[ObjectStoreProtocol, str, str]] = {} self._storage_options = _EMPTY_STORAGE_OPTIONS if storage_options is None else storage_options self._csv_write_options = _csv_write_options_from_kwargs(self._storage_options) @@ -191,20 +192,6 @@ def clear_cache(self) -> None: """Clear cached storage backend resolutions for this pipeline instance.""" self._resolved_backend_cache.clear() - def _backend( - self, destination: StorageDestination, backend_options: "dict[str, Any] | None" - ) -> "tuple[ObjectStoreProtocol, str, str]": - """Resolve storage backend and normalized path for a destination.""" - cache_key = _backend_cache_key(destination, backend_options) - if cache_key is None: - return _storage_backend(self.registry, destination, backend_options) - cached = self._resolved_backend_cache.get(cache_key) - if cached is not None: - return cached - resolved = _storage_backend(self.registry, destination, backend_options) - self._resolved_backend_cache[cache_key] = resolved - return resolved - def resolve_destination( self, destination: StorageDestination, storage_options: "dict[str, Any] | None" = None ) -> ResolvedStorageTarget: @@ -232,12 +219,26 @@ def resolve_destination( uri = backend.resolve_uri(path) elif backend.protocol == FILE_PROTOCOL: if destination_str.startswith(FILE_SCHEME_PREFIX): - path = strip_windows_drive_prefix(unquote(urlparse(destination_str).path)) + path = parse_file_uri_path(destination_str) uri = backend.resolve_uri(Path(path).expanduser().resolve()) else: uri = destination_str return ResolvedStorageTarget(uri, backend.protocol) + def _backend( + self, destination: StorageDestination, backend_options: "dict[str, Any] | None" + ) -> "tuple[ObjectStoreProtocol, str, str]": + """Resolve storage backend and normalized path for a destination.""" + cache_key = _backend_cache_key(destination, backend_options) + if cache_key is None: + return _storage_backend(self.registry, destination, backend_options) + cached = self._resolved_backend_cache.get(cache_key) + if cached is not None: + return cached + resolved = _storage_backend(self.registry, destination, backend_options) + self._resolved_backend_cache[cache_key] = resolved + return resolved + @mypyc_attr(allow_interpreted_subclasses=True) class SyncStoragePipeline(_StoragePipelineBase): @@ -359,11 +360,10 @@ async def write_rows( ) -> StorageTelemetry: format_choice = format_hint or "jsonl" _validate_row_write_format(format_choice) - serialized = serialize_collection(rows) - payload = await async_(_encode_row_payload)(serialized, format_choice) + payload, row_count = await async_(_serialize_and_encode_row_payload)(rows, format_choice) resolved_options = _storage_options(self._storage_options, storage_options) return await self._write_bytes_async( - payload, destination, rows=len(serialized), format_label=format_choice, storage_options=resolved_options + payload, destination, rows=row_count, format_label=format_choice, storage_options=resolved_options ) async def write_arrow( @@ -388,6 +388,39 @@ async def write_arrow( payload, destination, rows=int(table.num_rows), format_label=format_choice, storage_options=resolved_options ) + async def read_arrow_async( + self, source: StorageDestination, *, file_format: StorageFormat, storage_options: "dict[str, Any] | None" = None + ) -> "tuple[ArrowTable, StorageTelemetry]": + backend, path, backend_name = self._backend(source, storage_options) + if supports_async_read_bytes(backend): + payload = await execute_async_storage_operation( + partial(backend.read_bytes_async, path), backend=backend_name, operation="read_bytes", path=path + ) + else: + payload = await async_(_read_backend_sync)(backend=backend, path=path, backend_name=backend_name) + + table = await async_(_decode_arrow_payload)(payload, file_format) + rows_processed = int(table.num_rows) + telemetry: StorageTelemetry = { + "destination": path, + "bytes_processed": len(payload), + "rows_processed": rows_processed, + "format": file_format, + "backend": backend_name, + } + return table, telemetry + + async def stream_read_async( + self, + source: StorageDestination, + *, + chunk_size: int | None = None, + storage_options: "dict[str, Any] | None" = None, + ) -> "AsyncIterator[bytes]": + """Stream bytes from an artifact asynchronously.""" + backend, path, _backend_name = self._backend(source, storage_options) + return await backend.stream_read_async(path, chunk_size=chunk_size) + async def _write_bytes_async( self, payload: bytes, @@ -422,39 +455,6 @@ async def _write_bytes_async( } return telemetry - async def read_arrow_async( - self, source: StorageDestination, *, file_format: StorageFormat, storage_options: "dict[str, Any] | None" = None - ) -> "tuple[ArrowTable, StorageTelemetry]": - backend, path, backend_name = self._backend(source, storage_options) - if supports_async_read_bytes(backend): - payload = await execute_async_storage_operation( - partial(backend.read_bytes_async, path), backend=backend_name, operation="read_bytes", path=path - ) - else: - payload = await async_(_read_backend_sync)(backend=backend, path=path, backend_name=backend_name) - - table = await async_(_decode_arrow_payload)(payload, file_format) - rows_processed = int(table.num_rows) - telemetry: StorageTelemetry = { - "destination": path, - "bytes_processed": len(payload), - "rows_processed": rows_processed, - "format": file_format, - "backend": backend_name, - } - return table, telemetry - - async def stream_read_async( - self, - source: StorageDestination, - *, - chunk_size: int | None = None, - storage_options: "dict[str, Any] | None" = None, - ) -> "AsyncIterator[bytes]": - """Stream bytes from an artifact asynchronously.""" - backend, path, _backend_name = self._backend(source, storage_options) - return await backend.stream_read_async(path, chunk_size=chunk_size) - def _storage_options(default_options: "dict[str, Any]", storage_options: "dict[str, Any] | None") -> "dict[str, Any]": return default_options if storage_options is None else storage_options @@ -487,6 +487,11 @@ def _encode_row_payload(rows: "list[Any]", format_hint: StorageFormat) -> bytes: return bytes(buffer) +def _serialize_and_encode_row_payload(rows: "list[dict[str, Any]]", format_hint: StorageFormat) -> "tuple[bytes, int]": + serialized = serialize_collection(rows) + return _encode_row_payload(serialized, format_hint), len(serialized) + + def _validate_arrow_write_format(format_choice: StorageFormat) -> None: """Reject Arrow-table writes for formats that cannot carry an Arrow payload. @@ -566,9 +571,13 @@ def _resolve_alias_destination( def _backend_path(destination: str) -> str: if destination.startswith("file://"): - return destination.removeprefix("file://") + return parse_file_uri_path(destination) if "://" in destination: - _, remainder = destination.split("://", 1) + scheme, remainder = destination.split("://", 1) + if scheme.lower() in _CLOUD_OBJECT_STORE_SCHEMES: + parsed = urlparse(destination) + if parsed.netloc: + return parsed.path.lstrip("/") return remainder.lstrip("/") return destination @@ -583,6 +592,8 @@ def _storage_backend( return alias_resolution backend = registry.get(destination_str, **options) normalized_path = _backend_path(destination_str) + if getattr(backend, "protocol", None) == FILE_PROTOCOL: + normalized_path = Path(normalized_path).expanduser().resolve().as_posix() return backend, normalized_path, backend.backend_type diff --git a/sqlspec/storage/registry.py b/sqlspec/storage/registry.py index 365f7af6f..dc98d0318 100644 --- a/sqlspec/storage/registry.py +++ b/sqlspec/storage/registry.py @@ -9,13 +9,16 @@ import re from pathlib import Path from typing import Any, Final, cast -from urllib.parse import unquote, urlparse, urlunparse +from urllib.parse import urlparse, urlunparse from mypy_extensions import mypyc_attr from sqlspec.exceptions import ImproperConfigurationError, MissingDependencyError from sqlspec.protocols import ObjectStoreProtocol -from sqlspec.storage._paths import is_file_destination, strip_windows_drive_prefix +from sqlspec.storage._paths import is_file_destination, parse_file_uri_path +from sqlspec.storage.backends.fsspec import FSSpecBackend +from sqlspec.storage.backends.local import LocalStore +from sqlspec.storage.backends.obstore import ObStoreBackend from sqlspec.typing import FSSPEC_INSTALLED, OBSTORE_INSTALLED from sqlspec.utils.logging import get_logger, log_with_context from sqlspec.utils.type_guards import is_local_path @@ -28,6 +31,7 @@ FSSPEC_ONLY_SCHEMES: Final[frozenset[str]] = frozenset({"http", "https", "ftp", "sftp", "ssh"}) +_CLOUD_OBJECT_STORE_SCHEMES: Final[frozenset[str]] = frozenset({"s3", "gs", "gcs", "az", "azure"}) @mypyc_attr(allow_interpreted_subclasses=True) @@ -45,16 +49,6 @@ def __init__(self) -> None: self._alias_configs: dict[str, tuple[type[ObjectStoreProtocol], str, dict[str, Any]]] = {} self._instances: dict[str | tuple[str, tuple[tuple[str, Any], ...]], ObjectStoreProtocol] = {} - def _make_hashable(self, obj: Any) -> Any: - """Convert nested dict/list structures to hashable tuples.""" - if isinstance(obj, dict): - return tuple(sorted((k, self._make_hashable(v)) for k, v in obj.items())) - if isinstance(obj, list): - return tuple(self._make_hashable(item) for item in obj) - if isinstance(obj, set): - return tuple(sorted(self._make_hashable(item) for item in obj)) - return obj - def register_alias( self, alias: str, uri: str, *, backend: str | None = None, base_path: str = "", **kwargs: Any ) -> None: @@ -112,7 +106,6 @@ def get(self, uri_or_alias: str | Path, *, backend: str | None = None, **kwargs: base_uri = path_str is_alias = False - # Check if it's an alias first (either exact match or prefix match like "alias/path") parts = path_str.split("/", 1) potential_alias = parts[0] @@ -121,11 +114,13 @@ def get(self, uri_or_alias: str | Path, *, backend: str | None = None, **kwargs: is_alias = True elif scheme: if scheme == "file": - parsed = urlparse(path_str) - file_path = strip_windows_drive_prefix(unquote(parsed.path)) - + file_path = parse_file_uri_path(path_str) path_obj = Path(file_path).expanduser().resolve() base_uri = _local_backend_uri(path_obj) + elif scheme in _CLOUD_OBJECT_STORE_SCHEMES: + parsed = urlparse(path_str) + if parsed.netloc: + base_uri = f"{scheme}://{parsed.netloc}" elif is_local_path(path_str): scheme = "file" path_obj = Path(path_str).expanduser().resolve() @@ -142,7 +137,6 @@ def get(self, uri_or_alias: str | Path, *, backend: str | None = None, **kwargs: if not is_alias: instance = self._backend_from_uri(base_uri, backend_override=backend, **kwargs) else: - # It must be an alias (already validated above) backend_cls, stored_uri, config = self._alias_configs[base_uri] if backend: backend_cls = self._backend_class(backend) @@ -152,6 +146,59 @@ def get(self, uri_or_alias: str | Path, *, backend: str | None = None, **kwargs: log_with_context(logger, logging.DEBUG, "storage.resolve", uri_or_alias=path_str, cached=False) return instance + def is_alias_registered(self, alias: str) -> bool: + """Check if a named alias is registered.""" + return alias in self._alias_configs + + def list_aliases(self) -> "list[str]": + """List all registered aliases.""" + return list(self._alias_configs.keys()) + + def clear_cache(self, uri_or_alias: str | None = None) -> None: + """Clear resolved backend cache.""" + if uri_or_alias: + keys_to_remove: list[str | tuple[str, tuple[tuple[str, Any], ...]]] = [] + for key in list(self._instances): + if isinstance(key, str): + if key == uri_or_alias: + keys_to_remove.append(key) + continue + if key and key[0] == uri_or_alias: + keys_to_remove.append(key) + for key in keys_to_remove: + self._instances.pop(key, None) + return + self._instances.clear() + + def clear(self) -> None: + """Clear all aliases and instances.""" + self._alias_configs.clear() + self._instances.clear() + + def clear_instances(self) -> None: + """Clear only cached instances, keeping aliases.""" + self._instances.clear() + + def clear_aliases(self) -> None: + """Clear only aliases, keeping cached instances.""" + self._alias_configs.clear() + + def _make_hashable(self, obj: Any) -> Any: + """Convert nested dict/list structures to hashable tuples.""" + if isinstance(obj, dict): + return tuple(sorted(((str(k), self._make_hashable(v)) for k, v in obj.items()), key=repr)) + if isinstance(obj, (list, tuple)): + return tuple(self._make_hashable(item) for item in obj) + if isinstance(obj, (set, frozenset)): + return tuple(sorted((self._make_hashable(item) for item in obj), key=repr)) + if isinstance(obj, Path): + return obj.as_posix() + try: + hash(obj) + except TypeError: + return repr(obj) + return obj + def _backend_from_uri(self, uri: str, *, backend_override: str | None = None, **kwargs: Any) -> ObjectStoreProtocol: """Resolve backend from URI with optional backend override. @@ -247,16 +294,10 @@ def _determine_backend_class(self, uri: str) -> type[ObjectStoreProtocol]: def _backend_class(self, backend_type: str) -> type[ObjectStoreProtocol]: """Get backend class by type name.""" if backend_type == "local": - from sqlspec.storage.backends.local import LocalStore - return cast("type[ObjectStoreProtocol]", LocalStore) if backend_type == "obstore": - from sqlspec.storage.backends.obstore import ObStoreBackend - return cast("type[ObjectStoreProtocol]", ObStoreBackend) if backend_type == "fsspec": - from sqlspec.storage.backends.fsspec import FSSpecBackend - return cast("type[ObjectStoreProtocol]", FSSpecBackend) msg = f"Unknown backend type: {backend_type}. Supported types: 'local', 'obstore', 'fsspec'" raise ValueError(msg) @@ -272,43 +313,6 @@ def _scheme(self, uri: str) -> str | None: match = SCHEME_REGEX.match(uri) return match.group(1).lower() if match else None - def is_alias_registered(self, alias: str) -> bool: - """Check if a named alias is registered.""" - return alias in self._alias_configs - - def list_aliases(self) -> "list[str]": - """List all registered aliases.""" - return list(self._alias_configs.keys()) - - def clear_cache(self, uri_or_alias: str | None = None) -> None: - """Clear resolved backend cache.""" - if uri_or_alias: - keys_to_remove: list[str | tuple[str, tuple[tuple[str, Any], ...]]] = [] - for key in list(self._instances): - if isinstance(key, str): - if key == uri_or_alias: - keys_to_remove.append(key) - continue - if key and key[0] == uri_or_alias: - keys_to_remove.append(key) - for key in keys_to_remove: - self._instances.pop(key, None) - return - self._instances.clear() - - def clear(self) -> None: - """Clear all aliases and instances.""" - self._alias_configs.clear() - self._instances.clear() - - def clear_instances(self) -> None: - """Clear only cached instances, keeping aliases.""" - self._instances.clear() - - def clear_aliases(self) -> None: - """Clear only aliases, keeping cached instances.""" - self._alias_configs.clear() - storage_registry = StorageRegistry() diff --git a/sqlspec/utils/__init__.py b/sqlspec/utils/__init__.py index 088ab81f0..6e361bea7 100644 --- a/sqlspec/utils/__init__.py +++ b/sqlspec/utils/__init__.py @@ -5,3 +5,5 @@ portal pattern for async bridging, sync/async conversion, text processing, and type guards. """ + +__all__ = () diff --git a/sqlspec/utils/arrow_helpers.py b/sqlspec/utils/arrow_helpers.py index 217030da7..96e85da35 100644 --- a/sqlspec/utils/arrow_helpers.py +++ b/sqlspec/utils/arrow_helpers.py @@ -117,7 +117,10 @@ def convert_dict_to_arrow( if return_format == "reader": return pa.RecordBatchReader.from_batches(empty_table.schema, empty_table.to_batches()) - if return_format in {"batch", "batches"}: + if return_format == "batches": + return [] + + if return_format == "batch": batches = empty_table.to_batches(max_chunksize=batch_size) return batches[0] if batches else pa.RecordBatch.from_pydict({}) @@ -592,15 +595,12 @@ def __init__(self, reader: Any, close_callback: "Callable[[], None]") -> None: self._close_callback = close_callback self._closed = False + def __del__(self) -> None: + self._finalize() + def __iter__(self) -> "_DeferredCloseBatchIterator": return self - def _finalize(self) -> None: - if not self._closed: - self._closed = True - with contextlib.suppress(Exception): - self._close_callback() - def __next__(self) -> Any: try: return self._reader.read_next_batch() @@ -610,3 +610,12 @@ def __next__(self) -> Any: except Exception: self._finalize() raise + + def close(self) -> None: + self._finalize() + + def _finalize(self) -> None: + if not self._closed: + self._closed = True + with contextlib.suppress(Exception): + self._close_callback() diff --git a/sqlspec/utils/config_tools.py b/sqlspec/utils/config_tools.py index 26fdc69a6..7033ca851 100644 --- a/sqlspec/utils/config_tools.py +++ b/sqlspec/utils/config_tools.py @@ -73,11 +73,9 @@ def find_pyproject_toml() -> "Path | None": if pyproject.exists(): return pyproject - # Stop at .git boundary (repository root) if (current / ".git").exists(): return None - # Stop at filesystem root if current == current.parent: return None @@ -316,24 +314,28 @@ def parse_mysql_dsn(dsn: str) -> dict[str, Any]: query = parse_qs(parsed.query) for k, v in query.items(): if v: - val = v[-1] - if val.lower() == "true": - params[k] = True - elif val.lower() == "false": - params[k] = False - elif val.isdigit(): - params[k] = int(val) - else: - params[k] = val + params[k] = _coerce_dsn_value(v[-1]) return params key_value_params: dict[str, Any] = {} for item in dsn.split(";"): if "=" in item: param_key, param_val = item.split("=", 1) - key_value_params[param_key.strip()] = param_val.strip() + key_value_params[param_key.strip()] = _coerce_dsn_value(param_val.strip()) return key_value_params +def _coerce_dsn_value(val: str) -> Any: + """Coerce a raw DSN string value to bool, int, or str.""" + lower = val.lower() + if lower == "true": + return True + if lower == "false": + return False + if val.isdecimal() and val.isascii(): + return int(val) + return val + + def _normalize_config_path(config_path: str) -> str: """Normalize supported config resolver path syntax to a dotted path. @@ -480,7 +482,6 @@ def _is_valid_config(config: Any) -> bool: Returns: True if object is a valid config instance (not a class). """ - # Reject config classes - must be instances if isinstance(config, type): return False diff --git a/sqlspec/utils/correlation.py b/sqlspec/utils/correlation.py index 801c0a9da..9d042d12a 100644 --- a/sqlspec/utils/correlation.py +++ b/sqlspec/utils/correlation.py @@ -69,13 +69,12 @@ def context(cls, correlation_id: str | None = None) -> Generator[str, None, None if correlation_id is None: correlation_id = cls.generate() - previous_id = cls.get() + token = cls._correlation_id.set(correlation_id) try: - cls.set(correlation_id) yield correlation_id finally: - cls.set(previous_id) + cls._correlation_id.reset(token) @classmethod def clear(cls) -> None: @@ -128,8 +127,8 @@ class _CorrelationAdapter(LoggerAdapter): # pyright: ignore def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> "tuple[str, dict[str, Any]]": """Add correlation ID to the log record.""" - - extra = kwargs.get("extra", {}) + raw_extra = kwargs.get("extra") + extra: dict[str, Any] = dict(raw_extra) if isinstance(raw_extra, MutableMapping) else {} if correlation_id := CorrelationContext.get(): extra["correlation_id"] = correlation_id diff --git a/sqlspec/utils/deprecation.py b/sqlspec/utils/deprecation.py index 8dd41363b..7f0967f5c 100644 --- a/sqlspec/utils/deprecation.py +++ b/sqlspec/utils/deprecation.py @@ -179,6 +179,30 @@ def __init__( self._kind: Literal["function", "method", "classmethod", "property"] | None = kind def __call__(self, func: "Callable[P, T]") -> "Callable[P, T]": + if isinstance(func, property): + if func.fget is None: + return cast("Callable[P, T]", func) + wrapped_fget = _DeprecatedWrapper( + func.fget, + self._version, + self._removal_in, + self._alternative, + self._info, + self._pending, + self._kind or "property", + ) + return cast("Callable[P, T]", property(wrapped_fget, func.fset, func.fdel, func.__doc__)) + if isinstance(func, classmethod): + wrapped_cm = _DeprecatedWrapper( + func.__func__, + self._version, + self._removal_in, + self._alternative, + self._info, + self._pending, + self._kind or "classmethod", + ) + return cast("Callable[P, T]", classmethod(wrapped_cm)) kind: DeprecatedKind = self._kind or _infer_deprecated_kind(func) return _DeprecatedWrapper( func, self._version, self._removal_in, self._alternative, self._info, self._pending, kind diff --git a/sqlspec/utils/dispatch.py b/sqlspec/utils/dispatch.py index ab8940319..862a8c630 100644 --- a/sqlspec/utils/dispatch.py +++ b/sqlspec/utils/dispatch.py @@ -32,7 +32,7 @@ def register(self, type_: type, value: T) -> None: value: The value associated with the type. """ self._registry[type_] = value - self._cache.clear() # Invalidate cache on new registration + self._cache.clear() def register_all(self, registrations: "tuple[tuple[type, T], ...]") -> None: """Register multiple values while invalidating the cache once.""" @@ -63,6 +63,10 @@ def resolve_type(self, obj_type: type) -> T | None: return self._resolve(obj_type) + def clear_cache(self) -> None: + """Clear the resolution cache.""" + self._cache.clear() + def _resolve(self, obj_type: type) -> T | None: """Resolve the value by walking the MRO. @@ -72,20 +76,17 @@ def _resolve(self, obj_type: type) -> T | None: Returns: The resolved value or None. """ - # Fast path: check registry directly direct_value = self._registry.get(obj_type, _CACHE_MISS) if direct_value is not _CACHE_MISS: self._cache[obj_type] = direct_value return cast("T", direct_value) - # Slow path: walk MRO for base in obj_type.__mro__[1:]: value = self._registry.get(base, _CACHE_MISS) if value is not _CACHE_MISS: self._cache[obj_type] = value return cast("T", value) - # ABC/protocol fallback: issubclass() resolves virtual hierarchies not present in __mro__. for registered_type, value in self._registry.items(): if registered_type is obj_type: continue @@ -99,7 +100,3 @@ def _resolve(self, obj_type: type) -> T | None: self._cache[obj_type] = _NO_MATCH return None - - def clear_cache(self) -> None: - """Clear the resolution cache.""" - self._cache.clear() diff --git a/sqlspec/utils/fixtures.py b/sqlspec/utils/fixtures.py index acb8ef241..00348b296 100644 --- a/sqlspec/utils/fixtures.py +++ b/sqlspec/utils/fixtures.py @@ -6,6 +6,7 @@ """ import base64 +import contextlib import gzip import os import re @@ -50,7 +51,9 @@ _TABLE_FIXTURE_EXTENSIONS: Final["tuple[str, ...]"] = (".json", ".json.gz", ".jsonl", ".jsonl.gz") _COLUMN_NAME_PATTERN: Final["re.Pattern[str]"] = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") _TABLE_NAME_PATTERN: Final["re.Pattern[str]"] = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?") -_POSTGRES_DIALECTS: Final["frozenset[str]"] = frozenset({"postgres", "postgresql"}) +_INSERT_VALUES_CLAUSE_PATTERN: Final["re.Pattern[str]"] = re.compile(r"\)\s+VALUES\s+\(") +_POSTGRES_DIALECTS: Final["frozenset[str]"] = frozenset({"cockroach", "cockroachdb", "postgres", "postgresql"}) +_PG_SEQUENCE_DIALECTS: Final["frozenset[str]"] = frozenset({"postgres", "postgresql"}) _MYSQL_DIALECTS: Final["frozenset[str]"] = frozenset({"mariadb", "mysql"}) _ON_CONFLICT_FAMILIES: Final["frozenset[str]"] = frozenset({"duckdb", "postgres", "sqlite"}) _JSON_VALUE_FAMILIES: Final["frozenset[str]"] = frozenset({"duckdb", "mysql", "postgres", "sqlite"}) @@ -83,6 +86,7 @@ _DUCKDB_INTERVAL_PARTS: Final[int] = 3 _NANOSECONDS_PER_MICROSECOND: Final[int] = 1000 _TEMPORARY_FILE_MODE: Final[int] = 0o666 +_MAX_ZIP_FIXTURE_BYTES: Final[int] = 256 * 1024 * 1024 logger = get_logger("sqlspec.utils.fixtures") @@ -160,8 +164,9 @@ def write_fixture_sync( **storage_kwargs: Additional arguments for the storage backend Raises: - ValueError: If storage backend is not found + ValueError: If storage backend is not found or table_name is invalid """ + _validated_fixture_name(table_name) if storage_backend == "local": uri = "file://" storage_kwargs["base_path"] = _resolve_path_str(fixtures_path) @@ -204,8 +209,9 @@ async def write_fixture_async( **storage_kwargs: Additional arguments for the storage backend Raises: - ValueError: If storage backend is not found + ValueError: If storage backend is not found or table_name is invalid """ + _validated_fixture_name(table_name) if storage_backend == "local": uri = "file://" storage_kwargs["base_path"] = _resolve_path_str(fixtures_path) @@ -561,6 +567,20 @@ def _compress_text(content: str) -> bytes: return gzip.compress(content.encode("utf-8")) +def _read_zip_member(zf: zipfile.ZipFile, member_name: str) -> str: + """Read and decode a single JSON member from a ZIP archive with size bounds checking.""" + info = zf.getinfo(member_name) + if info.file_size > _MAX_ZIP_FIXTURE_BYTES: + msg = f"ZIP fixture member {member_name!r} exceeds maximum allowed size ({_MAX_ZIP_FIXTURE_BYTES} bytes)" + raise ValueError(msg) + with zf.open(member_name) as member_file: + data = member_file.read(_MAX_ZIP_FIXTURE_BYTES + 1) + if len(data) > _MAX_ZIP_FIXTURE_BYTES: + msg = f"ZIP fixture member {member_name!r} exceeds maximum allowed size ({_MAX_ZIP_FIXTURE_BYTES} bytes)" + raise ValueError(msg) + return data.decode("utf-8") + + def _read_compressed_file(file_path: Path) -> str: """Read and decompress a file based on its extension. @@ -580,12 +600,10 @@ def _read_compressed_file(file_path: Path) -> str: with zipfile.ZipFile(file_path, "r") as zf: json_name = file_path.stem + ".json" if json_name in zf.namelist(): - with zf.open(json_name) as f: - return f.read().decode("utf-8") + return _read_zip_member(zf, json_name) json_files = [name for name in zf.namelist() if name.endswith(".json")] if json_files: - with zf.open(json_files[0]) as f: - return f.read().decode("utf-8") + return _read_zip_member(zf, json_files[0]) msg = f"No JSON file found in ZIP archive: {file_path}" raise ValueError(msg) else: @@ -605,11 +623,16 @@ def _find_fixture_file(fixtures_path: Any, fixture_name: str) -> Path: Raises: FileNotFoundError: If no fixture file is found + ValueError: If fixture_name contains path traversal segments """ - base_path = Path(fixtures_path) + validated_name = _validated_fixture_name(fixture_name) + base_path = Path(fixtures_path).resolve() for extension in [".json", ".json.gz", ".json.zip"]: - fixture_path = base_path / f"{fixture_name}{extension}" + fixture_path = (base_path / f"{validated_name}{extension}").resolve() + if not fixture_path.is_relative_to(base_path): + msg = f"Invalid fixture name: {fixture_name!r}" + raise ValueError(msg) if fixture_path.exists(): return fixture_path @@ -642,6 +665,26 @@ def _serialize_data(data: Any) -> str: return encode_json(schema_dump(data)) +def _validated_fixture_name(name: str) -> str: + """Return the fixture name when it is a safe single-segment file stem. + + Raises: + ValueError: If the name is empty or contains path separators or traversal segments. + """ + candidate = Path(name) + if ( + not name + or candidate.is_absolute() + or len(candidate.parts) != 1 + or name in {".", ".."} + or "/" in name + or "\\" in name + ): + msg = f"Invalid fixture name: {name!r}" + raise ValueError(msg) + return name + + def _validated_table_name(name: str) -> str: """Return the table name when it is a plain or schema-qualified SQL identifier. @@ -931,6 +974,13 @@ def _table_columns_sync(driver: "SyncDriverAdapterBase", family: str, table: str return [] table_name, schema_name = _column_lookup_names(family, table) rows = driver.data_dictionary.get_columns(driver, table=table_name, schema=schema_name) + if not rows and family == "postgres" and schema_name is None: + with contextlib.suppress(Exception): + active_schema = driver.select_value_or_none("SELECT current_schema()") + if isinstance(active_schema, str) and active_schema: + rows = driver.data_dictionary.get_columns( + driver, table=table_name, schema=_quoted_identifier_sql(active_schema) + ) return [_table_column(row) for row in rows] @@ -940,6 +990,13 @@ async def _table_columns_async(driver: "AsyncDriverAdapterBase", family: str, ta return [] table_name, schema_name = _column_lookup_names(family, table) rows = await driver.data_dictionary.get_columns(driver, table=table_name, schema=schema_name) + if not rows and family == "postgres" and schema_name is None: + with contextlib.suppress(Exception): + active_schema = await driver.select_value_or_none("SELECT current_schema()") + if isinstance(active_schema, str) and active_schema: + rows = await driver.data_dictionary.get_columns( + driver, table=table_name, schema=_quoted_identifier_sql(active_schema) + ) return [_table_column(row) for row in rows] @@ -1182,9 +1239,8 @@ def _table_insert_statement( ) if family != "postgres" or always_identity.isdisjoint(columns): return statement - return insert_expression.sql(dialect=dialect, copy=False).replace( - ") VALUES (", ") OVERRIDING SYSTEM VALUE VALUES (", 1 - ) + rendered_sql = insert_expression.sql(dialect=dialect, copy=False) + return _INSERT_VALUES_CLAUSE_PATTERN.sub(") OVERRIDING SYSTEM VALUE VALUES (", rendered_sql, count=1) def _insert_table_rows_sync( @@ -1255,7 +1311,6 @@ def _table_export_query(dialect: "DialectType", table: str, table_columns: "list for column in table_columns: expression = exp.Column(this=_quoted_identifier(column.name)) if column.data_type in _JSON_TYPE_NAMES: - # Normalize native JSON decoding across drivers before reading scalar strings. projections.append( exp.Alias( this=exp.Cast(this=expression, to=exp.DataType(this=exp.DataType.Type.TEXT)), @@ -1276,7 +1331,7 @@ def _sequence_resync_enabled(resync_sequences: bool, dialect_name: str, family: """Return whether sequence resync applies, logging when it is skipped for the dialect.""" if not resync_sequences: return False - if family == "postgres": + if dialect_name in _PG_SEQUENCE_DIALECTS: return True logger.debug("Skipping table fixture sequence resync for non-PostgreSQL dialect %r", dialect_name or None) return False diff --git a/sqlspec/utils/logging.py b/sqlspec/utils/logging.py index eecc66283..9fa946c2a 100644 --- a/sqlspec/utils/logging.py +++ b/sqlspec/utils/logging.py @@ -59,6 +59,7 @@ """ _BASE_RECORD_KEYS: "set[str] | None" = None +_TO_JSON_FN: "Any | None" = None correlation_id_var: "ContextVar[str | None]" = _correlation_id_var @@ -95,8 +96,6 @@ def format(self, record: LogRecord) -> str: Returns: JSON formatted log entry """ - from sqlspec.utils.serializers import to_json - record_dict = record.__dict__ log_entry = { "timestamp": self.formatTime(record, self.datefmt), @@ -134,7 +133,7 @@ def format(self, record: LogRecord) -> str: if record.exc_info: log_entry["exception"] = self.formatException(record.exc_info) - return to_json(log_entry) + return _to_json_str(log_entry) @mypyc_attr(allow_interpreted_subclasses=True) @@ -245,6 +244,16 @@ def _get_base_record_keys() -> "set[str]": return _BASE_RECORD_KEYS +def _to_json_str(data: Any) -> str: + """Serialize log record dict to JSON string using cached serializer lookup.""" + global _TO_JSON_FN + if _TO_JSON_FN is None: + from sqlspec.utils.serializers import to_json + + _TO_JSON_FN = to_json + return cast("str", _TO_JSON_FN(data)) + + def _get_trace_context() -> "tuple[str | None, str | None]": """Resolve trace context lazily to avoid import cycles. diff --git a/sqlspec/utils/module_loader.py b/sqlspec/utils/module_loader.py index 035973a05..560e643bc 100644 --- a/sqlspec/utils/module_loader.py +++ b/sqlspec/utils/module_loader.py @@ -284,7 +284,7 @@ def import_string(dotted_path: str) -> "Any": _raise_import_error(f"Could not import '{dotted_path}': {e}", e) except SQLSpecError: raise - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: _raise_import_error(f"Could not import '{dotted_path}': {e}", e) return obj diff --git a/sqlspec/utils/portal.py b/sqlspec/utils/portal.py index 3a95b693f..dfd45cf5f 100644 --- a/sqlspec/utils/portal.py +++ b/sqlspec/utils/portal.py @@ -10,12 +10,13 @@ import os import queue import threading -from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeVar, cast +from typing import TYPE_CHECKING, Any, ClassVar, Final, Self, TypeVar, cast from sqlspec.exceptions import ImproperConfigurationError from sqlspec.utils.logging import get_logger if TYPE_CHECKING: + import concurrent.futures from collections.abc import Callable, Coroutine __all__ = ("Portal", "PortalManager", "PortalProvider", "get_global_portal") @@ -23,6 +24,7 @@ logger = get_logger("sqlspec.utils.portal") _R = TypeVar("_R") +_REQUEST_WITH_FUTURE_HOLDER_LEN: Final[int] = 5 class PortalProvider: @@ -34,14 +36,7 @@ class PortalProvider: def __init__(self) -> None: """Initialize the PortalProvider.""" - self._request_queue: queue.Queue[ - tuple[ - Callable[..., Coroutine[Any, Any, Any]], - tuple[Any, ...], - dict[str, Any], - queue.Queue[tuple[Any | None, Exception | None]], - ] - ] = queue.Queue() + self._request_queue: queue.Queue[tuple[Any, ...]] = queue.Queue() self._loop: asyncio.AbstractEventLoop | None = None self._loop_thread: threading.Thread | None = None self._ready_event: threading.Event = threading.Event() @@ -120,7 +115,7 @@ def stop(self) -> None: if self._loop_thread.is_alive(): logger.warning("Portal thread did not stop within 5 seconds, skipping loop.close()") - else: + elif not self._loop.is_closed(): self._loop.close() self._loop = None @@ -129,21 +124,6 @@ def stop(self) -> None: self._pid = None logger.debug("Portal provider stopped") - def _run_event_loop(self) -> None: - if self._loop is None: - self._loop = asyncio.new_event_loop() - - asyncio.set_event_loop(self._loop) - self._ready_event.set() - self._loop.run_forever() - - @staticmethod - async def _async_caller( - func: "Callable[..., Coroutine[Any, Any, _R]]", args: "tuple[Any, ...]", kwargs: "dict[str, Any]" - ) -> _R: - result: _R = await func(*args, **kwargs) - return result - def call(self, func: "Callable[..., Coroutine[Any, Any, _R]]", *args: Any, **kwargs: Any) -> _R: """Call an async function from synchronous context. @@ -159,40 +139,87 @@ def call(self, func: "Callable[..., Coroutine[Any, Any, _R]]", *args: Any, **kwa Result of the async function. Raises: - ImproperConfigurationError: If portal provider not started or timeout reached. + ImproperConfigurationError: If portal provider not started, called from + within the portal event loop thread, or timeout reached. """ timeout: float = float(kwargs.pop("timeout", 300.0)) if self._loop is None or not self.is_running: msg = "Portal provider not running. Call start() first." raise ImproperConfigurationError(msg) + if threading.current_thread() is self._loop_thread: + msg = "Cannot call PortalProvider.call() from within the portal event loop thread" + raise ImproperConfigurationError(msg) - local_result_queue: queue.Queue[tuple[_R | None, Exception | None]] = queue.Queue() + local_result_queue: queue.Queue[tuple[_R | None, BaseException | None]] = queue.Queue() + future_holder: list[concurrent.futures.Future[Any]] = [] - self._request_queue.put((func, args, kwargs, local_result_queue)) + self._request_queue.put((func, args, kwargs, local_result_queue, future_holder)) self._loop.call_soon_threadsafe(self._process_request) try: result, exception = local_result_queue.get(timeout=timeout) except queue.Empty: + if future_holder: + future_holder[0].cancel() msg = f"Portal call timed out after {timeout} seconds" raise ImproperConfigurationError(msg) from None - if exception: + if exception is not None: raise exception return result # type: ignore[return-value] + def _run_event_loop(self) -> None: + if self._loop is None: + self._loop = asyncio.new_event_loop() + + loop = self._loop + asyncio.set_event_loop(loop) + self._ready_event.set() + try: + loop.run_forever() + finally: + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + loop.run_until_complete(loop.shutdown_asyncgens()) + while True: + try: + request = self._request_queue.get_nowait() + except queue.Empty: + break + result_queue = request[3] + result_queue.put((None, ImproperConfigurationError("Portal provider stopped"))) + loop.close() + + @staticmethod + async def _async_caller( + func: "Callable[..., Coroutine[Any, Any, _R]]", args: "tuple[Any, ...]", kwargs: "dict[str, Any]" + ) -> _R: + result: _R = await func(*args, **kwargs) + return result + def _process_request(self) -> None: """Process a request from the request queue in the event loop.""" if self._loop is None: return try: - func, args, kwargs, local_result_queue = self._request_queue.get_nowait() + request = self._request_queue.get_nowait() except queue.Empty: return + if len(request) == _REQUEST_WITH_FUTURE_HOLDER_LEN: + func, args, kwargs, local_result_queue, future_holder = request + else: + func, args, kwargs, local_result_queue = request + future_holder = None + future = asyncio.run_coroutine_threadsafe(self._async_caller(func, args, kwargs), self._loop) + if future_holder is not None: + future_holder.append(future) future.add_done_callback( functools.partial(self._handle_future_result, local_result_queue=local_result_queue) # pyright: ignore[reportArgumentType] @@ -200,7 +227,7 @@ def _process_request(self) -> None: @staticmethod def _handle_future_result( - future: "asyncio.Future[Any]", local_result_queue: "queue.Queue[tuple[Any | None, Exception | None]]" + future: "asyncio.Future[Any]", local_result_queue: "queue.Queue[tuple[Any | None, BaseException | None]]" ) -> None: """Handle result or exception from completed future. @@ -208,10 +235,13 @@ def _handle_future_result( future: The completed future. local_result_queue: Queue to put result in. """ + if future.cancelled(): + local_result_queue.put((None, asyncio.CancelledError())) + return try: result = future.result() local_result_queue.put((result, None)) - except Exception as exc: + except BaseException as exc: local_result_queue.put((None, exc)) @@ -272,6 +302,15 @@ def __init__(self) -> None: self._atexit_registered: bool = False self._initialized = True + @property + def is_running(self) -> bool: + """Check if global portal is running. + + Returns: + True if portal provider exists and is running, False otherwise. + """ + return self._provider is not None and self._provider.is_running + def get_or_create_portal(self) -> Portal: """Get or create the global portal instance. @@ -296,6 +335,23 @@ def get_or_create_portal(self) -> Portal: return cast("Portal", self._portal) + def stop(self) -> None: + """Stop the global portal provider. + + Should typically only be called during application shutdown. + """ + if self._provider is not None: + self._provider.stop() + self._provider = None + self._portal = None + self._pid = None + logger.debug("Global portal provider stopped") + + @classmethod + def get_instance(cls) -> "PortalManager": + """Get the singleton instance.""" + return cls() + def _register_atexit(self) -> None: """Register atexit handler for graceful shutdown. @@ -316,38 +372,12 @@ def _atexit_cleanup(self) -> None: logger.debug("Portal atexit cleanup: stopping provider") self.stop() - @property - def is_running(self) -> bool: - """Check if global portal is running. - - Returns: - True if portal provider exists and is running, False otherwise. - """ - return self._provider is not None and self._provider.is_running - - def stop(self) -> None: - """Stop the global portal provider. - - Should typically only be called during application shutdown. - """ - if self._provider is not None: - self._provider.stop() - self._provider = None - self._portal = None - self._pid = None - logger.debug("Global portal provider stopped") - def _needs_restart(self, current_pid: int) -> bool: provider_missing = self._provider is None or not self._provider.is_running portal_missing = self._portal is None pid_changed = self._pid is not None and self._pid != current_pid return portal_missing or provider_missing or pid_changed - @classmethod - def get_instance(cls) -> "PortalManager": - """Get the singleton instance.""" - return cls() - def get_global_portal() -> Portal: """Get the global portal instance for async-to-sync bridging. diff --git a/sqlspec/utils/schema.py b/sqlspec/utils/schema.py index 11d4099de..fef6040a3 100644 --- a/sqlspec/utils/schema.py +++ b/sqlspec/utils/schema.py @@ -1,6 +1,6 @@ """Schema transformation utilities for converting data to various schema types.""" -import datetime +import datetime as dt from collections.abc import Callable, Mapping, Sequence from decimal import Decimal, InvalidOperation from enum import Enum @@ -36,6 +36,7 @@ "_convert_numpy_to_list", "_default_msgspec_deserializer", "_is_list_type_target", + "_reset_schema_caches", "to_schema", "to_value_type", "transform_dict_keys", @@ -44,9 +45,10 @@ DataT = TypeVar("DataT", default=dict[str, Any]) ValueT = TypeVar("ValueT") -_DATETIME_TYPES: Final[set[type]] = {datetime.datetime, datetime.date, datetime.time} -_DATETIME_TYPE_TUPLE: Final[tuple[type, ...]] = (datetime.datetime, datetime.date, datetime.time) +_DATETIME_TYPES: Final[set[type]] = {dt.datetime, dt.date, dt.time} +_DATETIME_TYPE_TUPLE: Final[tuple[type, ...]] = (dt.datetime, dt.date, dt.time) _MAPPING_TYPE_ARGUMENT_COUNT: Final = 2 +_SCHEMA_CACHE_MAX_SIZE: Final[int] = 1024 _MSGSPEC_FIELD_CACHE: "dict[type, tuple[tuple[tuple[str, str, Any], ...], frozenset[str], frozenset[str]]]" = {} _NUMPY_RECURSIVE_DISPATCHER: "TypeDispatcher[Callable[[Any], Any]] | None" = None _NULLABLE_UNION_ARGUMENT_COUNT: Final = 2 @@ -157,11 +159,11 @@ def to_value_type(value: Any, value_type: "type[ValueT]") -> "ValueT": return cast("ValueT", _convert_to_float(value)) if value_type is bool: return cast("ValueT", _convert_to_bool(value)) - if value_type is datetime.datetime: + if value_type is dt.datetime: return cast("ValueT", _convert_to_datetime(value)) - if value_type is datetime.date: + if value_type is dt.date: return cast("ValueT", _convert_to_date(value)) - if value_type is datetime.time: + if value_type is dt.time: return cast("ValueT", _convert_to_time(value)) if value_type is Decimal: return cast("ValueT", _convert_to_decimal(value)) @@ -238,6 +240,11 @@ def _transform_list(data: list, converter: Callable[[str], str]) -> list: return [transform_dict_keys(item, converter) for item in data] +def _is_sequence_input(data: Any) -> bool: + """Check if data is a non-string, non-bytes sequence.""" + return isinstance(data, Sequence) and not isinstance(data, (str, bytes, bytearray)) + + def _is_list_type_target(target_type: Any) -> "TypeGuard[list[object]]": """Check if target type is a list type.""" try: @@ -263,9 +270,9 @@ def _convert_numpy_to_list(target_type: Any, value: Any) -> Any: def _convert_foreign_key_metadata(data: Any, schema_type: Any) -> Any: """Convert data to ForeignKeyMetadata schema type. - Handles both single items and lists for consistency with other converters. + Handles both single items and sequences for consistency with other converters. """ - if isinstance(data, list): + if _is_sequence_input(data): return [_coerce_foreign_key_item(item, schema_type) for item in data] return _coerce_foreign_key_item(data, schema_type) @@ -288,12 +295,18 @@ def _coerce_foreign_key_item(data: Any, schema_type: Any) -> Any: def _convert_typed_dict(data: Any, schema_type: Any) -> Any: """Convert data to TypedDict.""" + if isinstance(data, list): + return data + if _is_sequence_input(data): + return [dict(item) if isinstance(item, Mapping) and not isinstance(item, dict) else item for item in data] + if isinstance(data, Mapping) and not isinstance(item := data, dict): + return dict(item) return data def _convert_dataclass(data: Any, schema_type: Any) -> Any: """Convert data to dataclass.""" - if isinstance(data, list): + if _is_sequence_input(data): return [schema_type(**item) if is_dict(item) else item for item in data] return schema_type(**data) if is_dict(data) else data @@ -310,13 +323,39 @@ def __call__(self, x: Any) -> bool: return x is self._type +class _IsSubclassPredicate: + """Callable predicate to check if a type is a subclass of a target type.""" + + __slots__ = ("_type",) + + def __init__(self, target_type: type) -> None: + self._type = target_type + + def __call__(self, x: Any) -> bool: + return isinstance(x, type) and issubclass(x, self._type) + + class _UUIDDecoder: """Decoder for UUID types.""" __slots__ = () def __call__(self, t: type, v: Any) -> Any: - return t(v.hex) + if isinstance(v, t): + return v + if isinstance(v, UUID): + return t(v.hex) + if isinstance(v, (bytes, bytearray, memoryview)): + raw_bytes = bytes(v) + return uuid_from_bytes(raw_bytes) if t is UUID else t(bytes=raw_bytes) + if isinstance(v, int) and not isinstance(v, bool): + return t(int=v) + if isinstance(v, str): + return uuid_from_string(v) if t is UUID else t(v) + hex_attr = getattr(v, "hex", None) + if isinstance(hex_attr, str): + return t(hex_attr) + return t(str(v)) class _ISOFormatDecoder: @@ -325,7 +364,20 @@ class _ISOFormatDecoder: __slots__ = () def __call__(self, t: type, v: Any) -> Any: - return t(v.isoformat()) + if type(v) is t: + return v + if t is dt.date: + return _convert_to_date(v) + if t is dt.time: + return _convert_to_time(v) + if t is dt.datetime: + return _convert_to_datetime(v) + if isinstance(v, str) and hasattr(t, "fromisoformat"): + return t.fromisoformat(v) + iso_method = getattr(v, "isoformat", None) + if callable(iso_method) and hasattr(t, "fromisoformat"): + return t.fromisoformat(iso_method()) + return t(v) class _EnumDecoder: @@ -334,15 +386,19 @@ class _EnumDecoder: __slots__ = () def __call__(self, t: type, v: Any) -> Any: - return t(v.value) + if isinstance(v, t): + return v + if isinstance(v, Enum): + return t(v.value) + return t(v) _DEFAULT_TYPE_DECODERS: Final[list[tuple[Callable[[Any], bool], Callable[[Any, Any], Any]]]] = [ (_IsTypePredicate(UUID), _UUIDDecoder()), - (_IsTypePredicate(datetime.datetime), _ISOFormatDecoder()), - (_IsTypePredicate(datetime.date), _ISOFormatDecoder()), - (_IsTypePredicate(datetime.time), _ISOFormatDecoder()), - (_IsTypePredicate(Enum), _EnumDecoder()), + (_IsTypePredicate(dt.datetime), _ISOFormatDecoder()), + (_IsTypePredicate(dt.date), _ISOFormatDecoder()), + (_IsTypePredicate(dt.time), _ISOFormatDecoder()), + (_IsSubclassPredicate(Enum), _EnumDecoder()), (_is_list_type_target, _convert_numpy_to_list), ] @@ -371,15 +427,14 @@ def _default_msgspec_deserializer( if predicate(target_type): return decoder(target_type, value) - if target_type is UUID and isinstance(value, UUID): - return value.hex + if target_type is UUID: + return _UUIDDecoder()(target_type, value) - if target_type in _DATETIME_TYPES and isinstance(value, _DATETIME_TYPE_TUPLE): - datetime_value = cast("datetime.datetime | datetime.date | datetime.time", value) - return datetime_value.isoformat() + if target_type in _DATETIME_TYPES: + return _ISOFormatDecoder()(target_type, value) - if isinstance(target_type, type) and issubclass(target_type, Enum) and isinstance(value, Enum): - return value.value + if isinstance(target_type, type) and issubclass(target_type, Enum): + return _EnumDecoder()(target_type, value) try: if isinstance(target_type, type) and isinstance(value, target_type): @@ -454,11 +509,11 @@ def _convert_msgspec(data: Any, schema_type: Any) -> Any: """Convert data to msgspec Struct.""" transformed_data = ( [_normalize_msgspec_input(item, schema_type) for item in data] - if isinstance(data, Sequence) and not isinstance(data, (str, bytes, bytearray)) + if _is_sequence_input(data) else _normalize_msgspec_input(data, schema_type) ) - target_type = list[schema_type] if isinstance(transformed_data, Sequence) else schema_type + target_type = list[schema_type] if _is_sequence_input(transformed_data) else schema_type try: return convert( @@ -494,7 +549,7 @@ def _normalize_msgspec_input(data: Any, target_type: Any) -> Any: return {key: _normalize_msgspec_input(value, value_type) for key, value in data.items()} if _is_sequence_origin(origin): - if not isinstance(data, Sequence) or isinstance(data, (str, bytes, bytearray)): + if not _is_sequence_input(data): return data if origin is tuple and len(args) > 1 and args[1] is not Ellipsis: return [ @@ -533,6 +588,8 @@ def _msgspec_field_plan(schema_type: type) -> "tuple[tuple[tuple[str, str, Any], (field.name, field.encode_name, field.type) for field in structs.fields(cast("Any", schema_type)) ) plan = fields, frozenset(field[0] for field in fields), frozenset(field[1] for field in fields) + if len(_MSGSPEC_FIELD_CACHE) >= _SCHEMA_CACHE_MAX_SIZE: + _MSGSPEC_FIELD_CACHE.pop(next(iter(_MSGSPEC_FIELD_CACHE)), None) _MSGSPEC_FIELD_CACHE[schema_type] = plan return plan @@ -567,30 +624,55 @@ def _is_sequence_origin(origin: Any) -> bool: def _convert_pydantic(data: Any, schema_type: Any) -> Any: """Convert data to Pydantic model.""" - if isinstance(data, Sequence): + if _is_sequence_input(data): return get_type_adapter(list[schema_type]).validate_python(data, from_attributes=True) return get_type_adapter(schema_type).validate_python(data, from_attributes=True) +def _coerce_attrs_item(item: Any, schema_type: Any, attrs_asdict: "Callable[[Any], dict[str, Any]]") -> Any: + if isinstance(item, schema_type): + return item + if is_dict(item): + return schema_type(**item) + if is_attrs_instance(item): + return schema_type(**attrs_asdict(item)) + msg = f"Cannot convert {type(item).__name__} to {schema_type.__name__}" + raise TypeError(msg) + + def _convert_attrs(data: Any, schema_type: Any) -> Any: """Convert data to attrs class.""" if CATTRS_INSTALLED: cattrs_structure = import_optional_attr("cattrs", "structure") cattrs_unstructure = import_optional_attr("cattrs", "unstructure") - if isinstance(data, Sequence): - return cattrs_structure(data, list[schema_type]) + if _is_sequence_input(data): + for item in data: + if not isinstance(item, (Mapping, schema_type)) and not is_attrs_instance(item): + msg = f"Cannot convert {type(item).__name__} to {schema_type.__name__}" + raise TypeError(msg) + items = [cattrs_unstructure(item) if is_attrs_instance(item) else item for item in data] + return cattrs_structure(items, list[schema_type]) + if not isinstance(data, (Mapping, schema_type)) and not is_attrs_instance(data): + msg = f"Cannot convert {type(data).__name__} to {schema_type.__name__}" + raise TypeError(msg) structured = cattrs_unstructure(data) if is_attrs_instance(data) else data return cattrs_structure(structured, schema_type) - if isinstance(data, list): - attrs_asdict = import_optional_attr("attrs", "asdict") - return [schema_type(**item) if is_dict(item) else schema_type(**attrs_asdict(item)) for item in data] - return schema_type(**data) if is_dict(data) else data + attrs_asdict = import_optional_attr("attrs", "asdict") + if _is_sequence_input(data): + return [_coerce_attrs_item(item, schema_type, attrs_asdict) for item in data] + return _coerce_attrs_item(data, schema_type, attrs_asdict) _SCHEMA_CONVERTER_CACHE: "dict[type, Callable[[Any, Any], Any] | None]" = {} +def _reset_schema_caches() -> None: + """Clear internal schema converter and msgspec field caches.""" + _MSGSPEC_FIELD_CACHE.clear() + _SCHEMA_CONVERTER_CACHE.clear() + + def _get_schema_converter(schema_type: type) -> "Callable[[Any, Any], Any] | None": """Get the converter function for a schema type with caching. @@ -620,6 +702,8 @@ def _get_schema_converter(schema_type: type) -> "Callable[[Any, Any], Any] | Non conv = _convert_foreign_key_metadata else: conv = None + if len(_SCHEMA_CONVERTER_CACHE) >= _SCHEMA_CACHE_MAX_SIZE: + _SCHEMA_CONVERTER_CACHE.pop(next(iter(_SCHEMA_CONVERTER_CACHE)), None) _SCHEMA_CONVERTER_CACHE[schema_type] = conv # pyright: ignore[reportArgumentType] return conv @@ -660,7 +744,8 @@ def _try_parse_json(value: str) -> Any: _BOOL_TRUE_VALUES: Final[frozenset[str]] = frozenset({"true", "1", "yes", "y", "t", "on"}) -_STRICT_IDENTITY_TYPES: Final[tuple[type, ...]] = (int, bool, datetime.date, datetime.time) +_BOOL_FALSE_VALUES: Final[frozenset[str]] = frozenset({"false", "0", "no", "n", "f", "off", ""}) +_STRICT_IDENTITY_TYPES: Final[tuple[type, ...]] = (int, bool, dt.date, dt.time) def _convert_to_int(value: Any) -> int: @@ -678,14 +763,20 @@ def _convert_to_int(value: Any) -> int: if isinstance(value, bool): return int(value) if isinstance(value, (int, float, Decimal)): - return int(value) + try: + return int(value) + except (ValueError, OverflowError) as e: + msg = f"Cannot convert {type(value).__name__} to int" + raise TypeError(msg) from e if isinstance(value, str): try: return int(value) except ValueError: try: - return int(float(value)) - except ValueError: + parsed_float = float(value) + if parsed_float.is_integer(): + return int(parsed_float) + except (ValueError, OverflowError): pass msg = f"Cannot convert {type(value).__name__} to int" raise TypeError(msg) @@ -733,12 +824,16 @@ def _convert_to_bool(value: Any) -> bool: if isinstance(value, (int, float)): return bool(value) if isinstance(value, str): - return value.lower() in _BOOL_TRUE_VALUES + normalized = value.strip().lower() + if normalized in _BOOL_TRUE_VALUES: + return True + if normalized in _BOOL_FALSE_VALUES: + return False msg = f"Cannot convert {type(value).__name__} to bool" raise TypeError(msg) -def _convert_to_datetime(value: Any) -> datetime.datetime: +def _convert_to_datetime(value: Any) -> dt.datetime: """Convert a value to datetime. Args: @@ -750,20 +845,20 @@ def _convert_to_datetime(value: Any) -> datetime.datetime: Raises: TypeError: If value cannot be converted to datetime. """ - if isinstance(value, datetime.datetime): + if isinstance(value, dt.datetime): return value if isinstance(value, str): try: - return datetime.datetime.fromisoformat(value) + return dt.datetime.fromisoformat(value) except ValueError: pass - if isinstance(value, datetime.date) and not isinstance(value, datetime.datetime): - return datetime.datetime.combine(value, datetime.time.min) + if isinstance(value, dt.date) and not isinstance(value, dt.datetime): + return dt.datetime.combine(value, dt.time.min) msg = f"Cannot convert {type(value).__name__} to datetime" raise TypeError(msg) -def _convert_to_date(value: Any) -> datetime.date: +def _convert_to_date(value: Any) -> dt.date: """Convert a value to date. Args: @@ -775,23 +870,23 @@ def _convert_to_date(value: Any) -> datetime.date: Raises: TypeError: If value cannot be converted to date. """ - if isinstance(value, datetime.datetime): + if isinstance(value, dt.datetime): return value.date() - if isinstance(value, datetime.date): + if isinstance(value, dt.date): return value if isinstance(value, str): try: - return datetime.date.fromisoformat(value) + return dt.date.fromisoformat(value) except ValueError: try: - return datetime.datetime.fromisoformat(value).date() + return dt.datetime.fromisoformat(value).date() except ValueError: pass msg = f"Cannot convert {type(value).__name__} to date" raise TypeError(msg) -def _convert_to_time(value: Any) -> datetime.time: +def _convert_to_time(value: Any) -> dt.time: """Convert a value to time. Args: @@ -803,15 +898,19 @@ def _convert_to_time(value: Any) -> datetime.time: Raises: TypeError: If value cannot be converted to time. """ - if isinstance(value, datetime.datetime): - return value.time() - if isinstance(value, datetime.time): + if isinstance(value, dt.datetime): + return value.timetz() if value.tzinfo is not None else value.time() + if isinstance(value, dt.time): return value if isinstance(value, str): try: - return datetime.time.fromisoformat(value) + return dt.time.fromisoformat(value) except ValueError: - pass + try: + parsed_dt = dt.datetime.fromisoformat(value) + return parsed_dt.timetz() if parsed_dt.tzinfo is not None else parsed_dt.time() + except ValueError: + pass msg = f"Cannot convert {type(value).__name__} to time" raise TypeError(msg) diff --git a/sqlspec/utils/serializers/_json.py b/sqlspec/utils/serializers/_json.py index 38ff6ba45..823659487 100644 --- a/sqlspec/utils/serializers/_json.py +++ b/sqlspec/utils/serializers/_json.py @@ -1,7 +1,7 @@ """Private JSON serialization engine for ``sqlspec.utils.serializers``.""" import contextlib -import datetime +import datetime as dt import enum import json import threading @@ -16,7 +16,7 @@ if TYPE_CHECKING: from typing import Self -from sqlspec.typing import MSGSPEC_INSTALLED, NUMPY_INSTALLED, ORJSON_INSTALLED, PYDANTIC_INSTALLED +from sqlspec.typing import MSGSPEC_INSTALLED, NUMPY_INSTALLED, ORJSON_INSTALLED, PYDANTIC_INSTALLED, UNSET from sqlspec.utils.logging import get_logger from sqlspec.utils.module_loader import import_optional_attr from sqlspec.utils.serializers._numpy import numpy_array_enc_hook @@ -42,72 +42,21 @@ StructuralEncoder = Callable[[Any], Any] -def _uuid_utils_type() -> "type[Any] | None": - return cast("type[Any] | None", import_optional_attr("uuid_utils", "UUID")) - - -_UUID_UTILS_TYPE: "type[Any] | None" = _uuid_utils_type() logger = get_logger(__name__) -def convert_datetime_to_gmt_iso(value: datetime.datetime) -> str: +def convert_datetime_to_gmt_iso(value: dt.datetime) -> str: """Normalize datetime values to ISO 8601 strings.""" if value.tzinfo is None: - value = value.replace(tzinfo=datetime.UTC) + value = value.replace(tzinfo=dt.UTC) return value.isoformat().replace("+00:00", "Z") -def convert_date_to_iso(value: datetime.date) -> str: +def convert_date_to_iso(value: dt.date) -> str: """Normalize date values to ISO 8601 strings.""" return value.isoformat() -def _dump_pydantic_model(value: Any) -> Any: - if hasattr(value, "model_dump"): - return value.model_dump(mode="json") - return value.dict() - - -def _dump_msgspec_struct_fields(value: Any) -> "dict[str, Any]": - return {field_name: value.__getattribute__(field_name) for field_name in value.__struct_fields__} - - -def _default_type_encoders() -> "dict[type, Callable[[Any], Any]]": - """Construct the default type encoder registry. - - Mirrors ``advanced_alchemy.utils.serialization.DEFAULT_TYPE_ENCODERS`` in - shape, with one deliberate divergence: ``Decimal`` encodes to ``float`` to - preserve SQLSpec's historical JSON normalization behavior. - """ - encoders: dict[type, Callable[[Any], Any]] = { - datetime.datetime: convert_datetime_to_gmt_iso, - datetime.date: convert_date_to_iso, - datetime.time: lambda v: v.isoformat(), - datetime.timedelta: lambda v: v.total_seconds(), - # NOTE: Diverges from advanced_alchemy (str): sqlspec keeps Decimal -> float - # to match the historical JSON normalization behavior. - Decimal: float, - uuid_mod.UUID: str, - Path: str, - PurePath: str, - IPv4Address: str, - IPv4Interface: str, - IPv4Network: str, - IPv6Address: str, - IPv6Interface: str, - IPv6Network: str, - frozenset: list, - set: list, - bytes: lambda v: v.decode("utf-8", errors="replace"), - enum.Enum: lambda v: v.value, - } - - if _UUID_UTILS_TYPE is not None: - encoders[_UUID_UTILS_TYPE] = str - - return encoders - - class _LazyTypeEncoders(dict[type, Callable[[Any], Any]]): """Load optional type keys when the public registry is first used.""" @@ -119,29 +68,6 @@ def __init__(self, encoders: "dict[type, Callable[[Any], Any]]") -> None: self._load_lock = threading.Lock() self._loaded = False - def _ensure_loaded(self) -> None: - if self._loaded: - return - with self._load_lock: - if self._loaded: - return - optional_encoders: dict[type, Callable[[Any], Any]] = {} - with contextlib.suppress(ImportError): - # Register driver UUIDs for direct consumers as well as frameworks. - from asyncpg.pgproto import pgproto # pyright: ignore[reportMissingImports] - - optional_encoders[pgproto.UUID] = str - if NUMPY_INSTALLED: - ndarray = cast("type[Any]", import_optional_attr("numpy", "ndarray")) - generic = cast("type[Any]", import_optional_attr("numpy", "generic")) - optional_encoders[ndarray] = numpy_array_enc_hook - optional_encoders[generic] = numpy_array_enc_hook - if PYDANTIC_INSTALLED: - base_model = cast("type[Any]", import_optional_attr("pydantic", "BaseModel")) - optional_encoders[base_model] = _dump_pydantic_model - dict.update(self, optional_encoders) - self._loaded = True - def __contains__(self, key: object) -> bool: self._ensure_loaded() return super().__contains__(key) @@ -232,68 +158,30 @@ def values(self) -> "Any": self._ensure_loaded() return super().values() + def _ensure_loaded(self) -> None: + if self._loaded: + return + with self._load_lock: + if self._loaded: + return + optional_encoders: dict[type, Callable[[Any], Any]] = {} + with contextlib.suppress(ImportError): + from asyncpg.pgproto import pgproto # pyright: ignore[reportMissingImports] -DEFAULT_TYPE_ENCODERS: Final["dict[type, Callable[[Any], Any]]"] = _LazyTypeEncoders(_default_type_encoders()) -_STRUCTURAL_ENCODER_CACHE: Final["dict[type[Any], StructuralEncoder | None]"] = {} - - -def _dump_attrs_instance(value: Any) -> Any: - attrs_asdict = import_optional_attr("attrs", "asdict") - return attrs_asdict(value, recurse=True) - - -def _structural_encoder(value: Any) -> Any: - """Probe-based fallback for types that aren't keyed by class. - - ``dataclass``/``attrs``/``msgspec.Struct`` rely on ``is_*`` runtime probes - rather than ``isinstance`` against a single base type, so they can't live - in the type-keyed registry. - """ - value_type = type(value) - encoder = _STRUCTURAL_ENCODER_CACHE.get(value_type) - if encoder is not None: - return encoder(value) - if value_type in _STRUCTURAL_ENCODER_CACHE: - return None - - if is_dataclass_instance(value): - _STRUCTURAL_ENCODER_CACHE[value_type] = dataclass_to_dict - return dataclass_to_dict(value) - if is_attrs_instance(value): - _STRUCTURAL_ENCODER_CACHE[value_type] = _dump_attrs_instance - return _dump_attrs_instance(value) - if is_msgspec_struct(value): - _STRUCTURAL_ENCODER_CACHE[value_type] = _dump_msgspec_struct_fields - return _dump_msgspec_struct_fields(value) - _STRUCTURAL_ENCODER_CACHE[value_type] = None - return None - - -def _enc_hook(type_encoders: "Mapping[type, Callable[[Any], Any]]") -> "Callable[[Any], Any]": - """Build an MRO-walking enc_hook bound to the supplied registry. - - Walks ``value.__class__.__mro__[:-1]`` (skipping ``object``) and returns - the first matching encoder. Falls through to structural probes - (dataclass / attrs / msgspec.Struct), then raises - ``TypeError("unsupported JSON value: ...")`` — strict sqlspec semantics, - divergent from advanced-alchemy's ``str`` fallback. - """ - - def enc_hook(value: Any) -> Any: - for base in value.__class__.__mro__[:-1]: - encoder = type_encoders.get(base) - if encoder is not None: - return encoder(value) - structural = _structural_encoder(value) - if structural is not None: - return structural - msg = f"unsupported JSON value: {type(value).__name__}" - raise TypeError(msg) - - return enc_hook + optional_encoders[pgproto.UUID] = str + if NUMPY_INSTALLED: + ndarray = cast("type[Any]", import_optional_attr("numpy", "ndarray")) + generic = cast("type[Any]", import_optional_attr("numpy", "generic")) + optional_encoders[ndarray] = numpy_array_enc_hook + optional_encoders[generic] = numpy_array_enc_hook + if PYDANTIC_INSTALLED: + base_model = cast("type[Any]", import_optional_attr("pydantic", "BaseModel")) + optional_encoders[base_model] = _dump_pydantic_model + dict.update(self, optional_encoders) + self._loaded = True -_DEFAULT_ENC_HOOK: Final["Callable[[Any], Any]"] = _enc_hook(DEFAULT_TYPE_ENCODERS) +_STRUCTURAL_ENCODER_CACHE: Final["dict[type[Any], StructuralEncoder | None]"] = {} class JSONSerializer(Protocol): @@ -325,7 +213,9 @@ def decode(self, data: "str | bytes", *, decode_bytes: bool = True) -> Any: _orjson_fallback_serializer: "OrjsonSerializer | None" = None + _stdlib_fallback_serializer: "StandardLibSerializer | None" = None + _default_serializer: "JSONSerializer | None" = None @@ -473,6 +363,130 @@ def decode_json(data: "str | bytes", *, decode_bytes: bool = True) -> Any: return get_default_serializer().decode(data, decode_bytes=decode_bytes) +def _uuid_utils_type() -> "type[Any] | None": + return cast("type[Any] | None", import_optional_attr("uuid_utils", "UUID")) + + +_UUID_UTILS_TYPE: "type[Any] | None" = _uuid_utils_type() + + +def _dump_pydantic_model(value: Any) -> Any: + if hasattr(value, "model_dump"): + return value.model_dump(mode="json") + return value.dict() + + +def _dump_msgspec_struct_fields(value: Any) -> "dict[str, Any]": + return { + field_name: field_val + for field_name in value.__struct_fields__ + if (field_val := value.__getattribute__(field_name)) is not UNSET + } + + +def _default_type_encoders() -> "dict[type, Callable[[Any], Any]]": + """Construct the default type encoder registry. + + Mirrors ``advanced_alchemy.utils.serialization.DEFAULT_TYPE_ENCODERS`` in + shape, with one deliberate divergence: ``Decimal`` encodes to ``float`` to + preserve SQLSpec's historical JSON normalization behavior. + """ + encoders: dict[type, Callable[[Any], Any]] = { + dt.datetime: convert_datetime_to_gmt_iso, + dt.date: convert_date_to_iso, + dt.time: lambda v: v.isoformat(), + dt.timedelta: lambda v: v.total_seconds(), + Decimal: float, + uuid_mod.UUID: str, + Path: str, + PurePath: str, + IPv4Address: str, + IPv4Interface: str, + IPv4Network: str, + IPv6Address: str, + IPv6Interface: str, + IPv6Network: str, + frozenset: list, + set: list, + bytes: lambda v: v.decode("utf-8", errors="replace"), + enum.Enum: lambda v: v.value, + } + + if _UUID_UTILS_TYPE is not None: + encoders[_UUID_UTILS_TYPE] = str + + return encoders + + +DEFAULT_TYPE_ENCODERS: Final["dict[type, Callable[[Any], Any]]"] = _LazyTypeEncoders(_default_type_encoders()) +_STRUCTURAL_ENCODER_CACHE_MAX_SIZE: Final[int] = 1024 + + +def _reset_structural_encoder_cache() -> None: + _STRUCTURAL_ENCODER_CACHE.clear() + + +def _dump_attrs_instance(value: Any) -> Any: + attrs_asdict = import_optional_attr("attrs", "asdict") + return attrs_asdict(value, recurse=True) + + +def _structural_encoder(value: Any) -> Any: + """Probe-based fallback for types that aren't keyed by class. + + ``dataclass``/``attrs``/``msgspec.Struct`` rely on ``is_*`` runtime probes + rather than ``isinstance`` against a single base type, so they can't live + in the type-keyed registry. + """ + value_type = type(value) + encoder = _STRUCTURAL_ENCODER_CACHE.get(value_type) + if encoder is not None: + return encoder(value) + if value_type in _STRUCTURAL_ENCODER_CACHE: + return None + + if len(_STRUCTURAL_ENCODER_CACHE) >= _STRUCTURAL_ENCODER_CACHE_MAX_SIZE: + _STRUCTURAL_ENCODER_CACHE.clear() + if is_dataclass_instance(value): + _STRUCTURAL_ENCODER_CACHE[value_type] = dataclass_to_dict + return dataclass_to_dict(value) + if is_attrs_instance(value): + _STRUCTURAL_ENCODER_CACHE[value_type] = _dump_attrs_instance + return _dump_attrs_instance(value) + if is_msgspec_struct(value): + _STRUCTURAL_ENCODER_CACHE[value_type] = _dump_msgspec_struct_fields + return _dump_msgspec_struct_fields(value) + _STRUCTURAL_ENCODER_CACHE[value_type] = None + return None + + +def _enc_hook(type_encoders: "Mapping[type, Callable[[Any], Any]]") -> "Callable[[Any], Any]": + """Build an MRO-walking enc_hook bound to the supplied registry. + + Walks ``value.__class__.__mro__[:-1]`` (skipping ``object``) and returns + the first matching encoder. Falls through to structural probes + (dataclass / attrs / msgspec.Struct), then raises + ``TypeError("unsupported JSON value: ...")`` — strict sqlspec semantics, + divergent from advanced-alchemy's ``str`` fallback. + """ + + def enc_hook(value: Any) -> Any: + for base in value.__class__.__mro__[:-1]: + encoder = type_encoders.get(base) + if encoder is not None: + return encoder(value) + structural = _structural_encoder(value) + if structural is not None: + return structural + msg = f"unsupported JSON value: {type(value).__name__}" + raise TypeError(msg) + + return enc_hook + + +_DEFAULT_ENC_HOOK: Final["Callable[[Any], Any]"] = _enc_hook(DEFAULT_TYPE_ENCODERS) + + def _is_explicit_unsupported_error(exc: Exception) -> bool: return "unsupported json value" in str(exc).lower() diff --git a/sqlspec/utils/serializers/_schema.py b/sqlspec/utils/serializers/_schema.py index 19c4ad3bd..6cc5617ff 100644 --- a/sqlspec/utils/serializers/_schema.py +++ b/sqlspec/utils/serializers/_schema.py @@ -10,8 +10,11 @@ from sqlspec.typing import MSGSPEC_INSTALLED, UNSET, ArrowReturnFormat, Empty, msgspec_fields from sqlspec.utils.arrow_helpers import convert_dict_to_arrow +from sqlspec.utils.schema import _reset_schema_caches from sqlspec.utils.serializers._json import _dump_attrs_instance as _dump_attrs +from sqlspec.utils.serializers._json import _reset_structural_encoder_cache from sqlspec.utils.type_guards import ( + _reset_msgspec_rename_caches, dataclass_to_dict, has_dict_attribute, is_attrs_instance, @@ -38,16 +41,7 @@ _PRIMITIVE_TYPES: Final[tuple[type[Any], ...]] = (str, bytes, int, float, bool) _PRIMITIVE_TYPES_SET: Final[frozenset[type[Any]]] = frozenset(_PRIMITIVE_TYPES) _SCHEMA_SERIALIZER_CACHE_MAX_SIZE: Final[int] = 1000 - - -def _is_truthy(value: "str | None") -> bool: - if value is None: - return False - normalized = value.strip().lower() - return normalized in {"1", "true", "yes", "on"} - - -_METRICS_ENABLED: Final[bool] = _is_truthy(os.getenv(DEBUG_ENV_FLAG)) +_DATACLASS_FIELDS_CACHE_MAX_SIZE: Final[int] = 1024 class _SerializerCacheMetrics: @@ -115,8 +109,11 @@ def to_arrow( _SERIALIZER_LOCK: RLock = RLock() + _SCHEMA_SERIALIZERS: "OrderedDict[tuple[type[Any] | None, bool, bool], SchemaSerializer]" = OrderedDict() + _SERIALIZER_METRICS = _SerializerCacheMetrics() + _DATACLASS_FIELDS_CACHE: "dict[type[Any], tuple[Field[Any], ...]]" = {} @@ -125,16 +122,6 @@ def get_collection_serializer( ) -> "SchemaSerializer": """Return cached serializer pipeline for the provided sample object.""" key = _make_serializer_key(sample, exclude_unset, wire_format) - pipeline = _SCHEMA_SERIALIZERS.get(key) - if pipeline is not None: - try: - _SCHEMA_SERIALIZERS.move_to_end(key) - except KeyError: - pipeline = None - else: - _SERIALIZER_METRICS.record_hit(len(_SCHEMA_SERIALIZERS)) - return pipeline - with _SERIALIZER_LOCK: pipeline = _SCHEMA_SERIALIZERS.get(key) if pipeline is not None: @@ -178,6 +165,9 @@ def reset_serializer_cache() -> None: _SCHEMA_SERIALIZERS.clear() _DATACLASS_FIELDS_CACHE.clear() _SERIALIZER_METRICS.reset() + _reset_schema_caches() + _reset_structural_encoder_cache() + _reset_msgspec_rename_caches() def get_serializer_metrics() -> "dict[str, int]": @@ -212,6 +202,16 @@ def schema_dump(data: Any, *, exclude_unset: bool = True, wire_format: bool = Fa return serializer.dump_one(data) +def _is_truthy(value: "str | None") -> bool: + if value is None: + return False + normalized = value.strip().lower() + return normalized in {"1", "true", "yes", "on"} + + +_METRICS_ENABLED: Final[bool] = _is_truthy(os.getenv(DEBUG_ENV_FLAG)) + + def _make_serializer_key(sample: Any, exclude_unset: bool, wire_format: bool) -> "tuple[type[Any] | None, bool, bool]": if sample is None or isinstance(sample, dict): return (None, exclude_unset, wire_format) @@ -227,6 +227,8 @@ def _dataclass_fields(schema_type: type[Any]) -> "tuple[Field[Any], ...]": if cached is not None: return cached fields = dataclasses_fields(schema_type) + if len(_DATACLASS_FIELDS_CACHE) >= _DATACLASS_FIELDS_CACHE_MAX_SIZE: + _DATACLASS_FIELDS_CACHE.clear() _DATACLASS_FIELDS_CACHE[schema_type] = fields return fields @@ -241,13 +243,38 @@ def _msgspec_field_pairs(schema_type: type[Any], *, wire_format: bool) -> "tuple return tuple((field.name, field.name) for field in msgspec_fields(schema_type)) +def _dump_msgspec_nested(value: Any, *, exclude_unset: bool, wire_format: bool) -> Any: + if is_msgspec_struct(value): + return _dump_msgspec_struct( + value, + field_pairs=_msgspec_field_pairs(type(value), wire_format=wire_format), + exclude_unset=exclude_unset, + wire_format=wire_format, + ) + if isinstance(value, list): + return [_dump_msgspec_nested(item, exclude_unset=exclude_unset, wire_format=wire_format) for item in value] + if isinstance(value, tuple): + return tuple(_dump_msgspec_nested(item, exclude_unset=exclude_unset, wire_format=wire_format) for item in value) + if isinstance(value, dict): + return { + key: _dump_msgspec_nested(item, exclude_unset=exclude_unset, wire_format=wire_format) + for key, item in value.items() + } + return value + + def _dump_msgspec_struct( - value: Any, *, field_pairs: "tuple[tuple[str, str], ...]", exclude_unset: bool + value: Any, *, field_pairs: "tuple[tuple[str, str], ...]", exclude_unset: bool, wire_format: bool = False ) -> "dict[str, Any]": if not exclude_unset: - return {output_name: value.__getattribute__(field_name) for output_name, field_name in field_pairs} + return { + output_name: _dump_msgspec_nested( + value.__getattribute__(field_name), exclude_unset=exclude_unset, wire_format=wire_format + ) + for output_name, field_name in field_pairs + } return { - output_name: field_value + output_name: _dump_msgspec_nested(field_value, exclude_unset=exclude_unset, wire_format=wire_format) for output_name, field_name in field_pairs if (field_value := value.__getattribute__(field_name)) != UNSET } @@ -282,7 +309,7 @@ def _msgspec_dump_function(sample: Any, *, exclude_unset: bool, wire_format: boo field_pairs = _msgspec_field_pairs(type(sample), wire_format=wire_format) return cast( "Callable[[Any], dict[str, Any]]", - partial(_dump_msgspec_struct, field_pairs=field_pairs, exclude_unset=exclude_unset), + partial(_dump_msgspec_struct, field_pairs=field_pairs, exclude_unset=exclude_unset, wire_format=wire_format), ) diff --git a/sqlspec/utils/sync_tools.py b/sqlspec/utils/sync_tools.py index 4845ebbae..2cf7503f6 100644 --- a/sqlspec/utils/sync_tools.py +++ b/sqlspec/utils/sync_tools.py @@ -32,6 +32,23 @@ else: uvloop = None # type: ignore[assignment,unused-ignore] +__all__ = ( + "ASYNC_THREAD_LIMIT_ENV", + "DEFAULT_ASYNC_THREAD_LIMIT", + "NO_VALUE", + "CapacityLimiter", + "NoValue", + "async_", + "await_", + "enable_default_async_thread_pool", + "ensure_async_", + "get_default_async_executor", + "get_next", + "run_", + "set_default_async_executor", + "shutdown_default_async_executor", + "with_ensure_async_", +) ReturnT = TypeVar("ReturnT") ParamSpecT = ParamSpec("ParamSpecT") @@ -62,6 +79,16 @@ def __init__(self, total_tokens: int) -> None: self._semaphore_instance: asyncio.Semaphore | None = None self._pid: int | None = None + async def __aenter__(self) -> None: + """Async context manager entry.""" + await self.acquire() + + async def __aexit__( + self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" + ) -> None: + """Async context manager exit.""" + self.release() + @property def _semaphore(self) -> asyncio.Semaphore: """Lazy initialization of asyncio.Semaphore with per-process tracking. @@ -76,14 +103,6 @@ def _semaphore(self) -> asyncio.Semaphore: self._pid = current_pid return self._semaphore_instance - async def acquire(self) -> None: - """Acquire a token from the semaphore.""" - await self._semaphore.acquire() - - def release(self) -> None: - """Release a token back to the semaphore.""" - self._semaphore.release() - @property def total_tokens(self) -> int: """Get the total number of tokens available.""" @@ -95,15 +114,13 @@ def total_tokens(self, value: int) -> None: self._semaphore_instance = None self._pid = None - async def __aenter__(self) -> None: - """Async context manager entry.""" - await self.acquire() + async def acquire(self) -> None: + """Acquire a token from the semaphore.""" + await self._semaphore.acquire() - async def __aexit__( - self, exc_type: "type[BaseException] | None", exc_val: "BaseException | None", exc_tb: "TracebackType | None" - ) -> None: - """Async context manager exit.""" - self.release() + def release(self) -> None: + """Release a token back to the semaphore.""" + self._semaphore.release() _default_async_executor: concurrent.futures.ThreadPoolExecutor | None = None @@ -335,6 +352,12 @@ def _resolve_managed_async_thread_limit() -> int: return max(1, max_workers) +def _run_in_new_loop(partial_f: "Callable[[], Coroutine[Any, Any, ReturnT]]") -> "ReturnT": + if uvloop and sys.platform != "win32": + return uvloop.run(partial_f()) # pyright: ignore[reportUnknownMemberType] + return asyncio.run(partial_f()) + + class _RunWrapper(Generic[ParamSpecT, ReturnT]): __slots__ = ("__dict__", "_function") @@ -351,13 +374,16 @@ def __call__(self, *args: "ParamSpecT.args", **kwargs: "ParamSpecT.kwargs") -> " if loop is not None: if loop.is_running(): - with concurrent.futures.ThreadPoolExecutor() as executor: - future = executor.submit(asyncio.run, partial_f()) + ctx = contextvars.copy_context() + executor = get_default_async_executor() + if executor is not None: + future = executor.submit(ctx.run, _run_in_new_loop, partial_f) + return future.result() + with concurrent.futures.ThreadPoolExecutor() as fallback_executor: + future = fallback_executor.submit(ctx.run, _run_in_new_loop, partial_f) return future.result() - return asyncio.run(partial_f()) - if uvloop and sys.platform != "win32": - return uvloop.run(partial_f()) # pyright: ignore[reportUnknownMemberType] - return asyncio.run(partial_f()) + return _run_in_new_loop(partial_f) + return _run_in_new_loop(partial_f) class _AwaitWrapper(Generic[ParamSpecT, ReturnT]): @@ -382,20 +408,12 @@ def __call__(self, *args: "ParamSpecT.args", **kwargs: "ParamSpecT.kwargs") -> " typed_partial = cast("Callable[[], Coroutine[Any, Any, ReturnT]]", partial_f) return portal.call(typed_partial) if loop.is_running(): - try: - current_task = asyncio.current_task(loop=loop) - except RuntimeError: - current_task = None - - if current_task is not None: - if self._raise_sync_error: - msg = "await_ cannot be called from within an async task running on the same event loop. Use 'await' instead." - raise RuntimeError(msg) - portal = get_global_portal() - typed_partial = cast("Callable[[], Coroutine[Any, Any, ReturnT]]", partial_f) - return portal.call(typed_partial) - future = asyncio.run_coroutine_threadsafe(partial_f(), loop) - return future.result() + if self._raise_sync_error: + msg = "await_ cannot be called from within an async task running on the same event loop. Use 'await' instead." + raise RuntimeError(msg) + portal = get_global_portal() + typed_partial = cast("Callable[[], Coroutine[Any, Any, ReturnT]]", partial_f) + return portal.call(typed_partial) if self._raise_sync_error: msg = "Cannot run async function" raise RuntimeError(msg) @@ -431,7 +449,6 @@ async def _run_sync( loop = asyncio.get_running_loop() ctx = contextvars.copy_context() call = functools.partial(ctx.run, self._function, *args, **kwargs) - # Equivalent to run_in_executor(), without Python 3.14's deprecated debug-loop coroutine probe. return await asyncio.wrap_future(executor.submit(call), loop=loop) diff --git a/sqlspec/utils/text.py b/sqlspec/utils/text.py index cf5678648..fc8e38554 100644 --- a/sqlspec/utils/text.py +++ b/sqlspec/utils/text.py @@ -1,8 +1,7 @@ """Text processing utilities for SQLSpec. Provides functions for string manipulation including case conversion, -slugification, and email validation. Used primarily for identifier -generation and data validation. +slugification, and SQL identifier quoting and normalization. """ import re @@ -156,6 +155,8 @@ def normalize_identifier(identifier: str, dialect: str) -> str: return value[1:-1].replace('""', '"') if value_length >= _MIN_QUOTED_IDENTIFIER_LENGTH and value[0] == value[-1] == "`": return value[1:-1].replace("``", "`") + if value_length >= _MIN_QUOTED_IDENTIFIER_LENGTH and value[0] == "[" and value[-1] == "]": + return value[1:-1].replace("]]", "]") normalized_dialect = dialect.lower().replace("-", "_") if normalized_dialect in {"postgres", "postgresql", "cockroach", "cockroachdb"}: diff --git a/sqlspec/utils/type_converters.py b/sqlspec/utils/type_converters.py index 2c1f38cf4..c5a6aec64 100644 --- a/sqlspec/utils/type_converters.py +++ b/sqlspec/utils/type_converters.py @@ -9,7 +9,7 @@ from sqlspec.utils.uuids import uuid_from_int if TYPE_CHECKING: - import datetime + import datetime as dt from collections.abc import Callable, Sequence @@ -30,7 +30,9 @@ class _UUIDLike(Protocol): ) JSON_NESTED_TYPES: "tuple[type[Any], ...]" = (dict, list, tuple) + DEFAULT_DECIMAL_MODE: str = "preserve" + _DECIMAL_NORMALIZER_DISPATCHER = TypeDispatcher["Callable[['_DecimalNormalizer', Any], Any]"]() @@ -51,53 +53,7 @@ def convert_decimal(self, value: "decimal.Decimal") -> Any: return self._decimal_converter(value) -def _normalize_decimal_value(normalizer: "_DecimalNormalizer", value: Any) -> Any: - return normalizer.convert_decimal(value) - - -def _normalize_decimal_list(normalizer: "_DecimalNormalizer", value: Any) -> Any: - normalized_list: list[Any] | None = None - for index, item in enumerate(value): - normalized_item = normalizer(item) - if normalized_list is None: - if normalized_item is item: - continue - normalized_list = list(value[:index]) - normalized_list.append(normalized_item) - return value if normalized_list is None else normalized_list - - -def _normalize_decimal_tuple(normalizer: "_DecimalNormalizer", value: Any) -> Any: - normalized_tuple: list[Any] | None = None - for index, item in enumerate(value): - normalized_item = normalizer(item) - if normalized_tuple is None: - if normalized_item is item: - continue - normalized_tuple = list(value[:index]) - normalized_tuple.append(normalized_item) - return value if normalized_tuple is None else tuple(normalized_tuple) - - -def _normalize_decimal_dict(normalizer: "_DecimalNormalizer", value: Any) -> Any: - normalized_dict: dict[Any, Any] | None = None - for key, item in value.items(): - normalized_item = normalizer(item) - if normalized_dict is None: - if normalized_item is item: - continue - normalized_dict = dict(value) - normalized_dict[key] = normalized_item - return value if normalized_dict is None else normalized_dict - - -_DECIMAL_NORMALIZER_DISPATCHER.register(decimal.Decimal, _normalize_decimal_value) -_DECIMAL_NORMALIZER_DISPATCHER.register(list, _normalize_decimal_list) -_DECIMAL_NORMALIZER_DISPATCHER.register(tuple, _normalize_decimal_tuple) -_DECIMAL_NORMALIZER_DISPATCHER.register(dict, _normalize_decimal_dict) - - -def time_iso_convert(value: "datetime.date | datetime.datetime | datetime.time") -> str: +def time_iso_convert(value: "dt.date | dt.datetime | dt.time") -> str: return value.isoformat() @@ -166,18 +122,6 @@ def build_uuid_coercions(*, native: bool = False) -> "dict[type[Any], Callable[[ return coercions -def _decimal_identity(value: "decimal.Decimal") -> "decimal.Decimal": - return value - - -def _decimal_to_string(value: "decimal.Decimal") -> str: - return str(value) - - -def _decimal_to_float(value: "decimal.Decimal") -> float: - return float(value) - - @final class _JsonListConverter: __slots__ = ("_preserve_arrays", "_serializer") @@ -207,6 +151,67 @@ def __call__(self, value: "tuple[Any, ...]") -> Any: return self._list_converter(list(value)) +def _normalize_decimal_value(normalizer: "_DecimalNormalizer", value: Any) -> Any: + return normalizer.convert_decimal(value) + + +def _normalize_decimal_list(normalizer: "_DecimalNormalizer", value: Any) -> Any: + normalized_list: list[Any] | None = None + for index, item in enumerate(value): + normalized_item = normalizer(item) + if normalized_list is None: + if normalized_item is item: + continue + normalized_list = list(value[:index]) + normalized_list.append(normalized_item) + return value if normalized_list is None else normalized_list + + +def _normalize_decimal_tuple(normalizer: "_DecimalNormalizer", value: Any) -> Any: + normalized_tuple: list[Any] | None = None + for index, item in enumerate(value): + normalized_item = normalizer(item) + if normalized_tuple is None: + if normalized_item is item: + continue + normalized_tuple = list(value[:index]) + normalized_tuple.append(normalized_item) + return value if normalized_tuple is None else tuple(normalized_tuple) + + +def _normalize_decimal_dict(normalizer: "_DecimalNormalizer", value: Any) -> Any: + normalized_dict: dict[Any, Any] | None = None + for key, item in value.items(): + normalized_item = normalizer(item) + if normalized_dict is None: + if normalized_item is item: + continue + normalized_dict = dict(value) + normalized_dict[key] = normalized_item + return value if normalized_dict is None else normalized_dict + + +_DECIMAL_NORMALIZER_DISPATCHER.register(decimal.Decimal, _normalize_decimal_value) + +_DECIMAL_NORMALIZER_DISPATCHER.register(list, _normalize_decimal_list) + +_DECIMAL_NORMALIZER_DISPATCHER.register(tuple, _normalize_decimal_tuple) + +_DECIMAL_NORMALIZER_DISPATCHER.register(dict, _normalize_decimal_dict) + + +def _decimal_identity(value: "decimal.Decimal") -> "decimal.Decimal": + return value + + +def _decimal_to_string(value: "decimal.Decimal") -> str: + return str(value) + + +def _decimal_to_float(value: "decimal.Decimal") -> float: + return float(value) + + def _uuid_to_string(value: object) -> str: return str(value) diff --git a/sqlspec/utils/type_guards.py b/sqlspec/utils/type_guards.py index 34c7f8a1a..901b26457 100644 --- a/sqlspec/utils/type_guards.py +++ b/sqlspec/utils/type_guards.py @@ -89,6 +89,7 @@ from sqlspec.typing import SupportedSchemaModel __all__ = ( + "_reset_msgspec_rename_caches", "dataclass_to_dict", "expression_has_limit", "extract_dataclass_fields", @@ -697,6 +698,7 @@ def is_msgspec_struct_without_field(obj: Any, field_name: str) -> "TypeGuard[Str return all(field.name != field_name for field in fields) +_MSGSPEC_RENAME_CACHE_MAX_SIZE = 1024 _MSGSPEC_RENAME_CONFIG_CACHE: "dict[object, str | None]" = {} _MSGSPEC_RENAME_PATTERN_CACHE: "dict[tuple[str, str], str | None]" = {} @@ -719,6 +721,8 @@ def get_msgspec_rename_config(schema_type: type) -> "str | None": return _MSGSPEC_RENAME_CONFIG_CACHE[schema_type] if not MSGSPEC_INSTALLED or not is_msgspec_struct(schema_type): + if len(_MSGSPEC_RENAME_CONFIG_CACHE) >= _MSGSPEC_RENAME_CACHE_MAX_SIZE: + _MSGSPEC_RENAME_CONFIG_CACHE.pop(next(iter(_MSGSPEC_RENAME_CONFIG_CACHE)), None) _MSGSPEC_RENAME_CONFIG_CACHE[schema_type] = None return None @@ -726,15 +730,21 @@ def get_msgspec_rename_config(schema_type: type) -> "str | None": fields: tuple[Any, ...] = structs.fields(cast("Any", schema_type)) if not fields: + if len(_MSGSPEC_RENAME_CONFIG_CACHE) >= _MSGSPEC_RENAME_CACHE_MAX_SIZE: + _MSGSPEC_RENAME_CONFIG_CACHE.pop(next(iter(_MSGSPEC_RENAME_CONFIG_CACHE)), None) _MSGSPEC_RENAME_CONFIG_CACHE[schema_type] = None return None for field in fields: if field.name != field.encode_name: rename_config = _detect_rename_pattern(field.name, field.encode_name) + if len(_MSGSPEC_RENAME_CONFIG_CACHE) >= _MSGSPEC_RENAME_CACHE_MAX_SIZE: + _MSGSPEC_RENAME_CONFIG_CACHE.pop(next(iter(_MSGSPEC_RENAME_CONFIG_CACHE)), None) _MSGSPEC_RENAME_CONFIG_CACHE[schema_type] = rename_config return rename_config + if len(_MSGSPEC_RENAME_CONFIG_CACHE) >= _MSGSPEC_RENAME_CACHE_MAX_SIZE: + _MSGSPEC_RENAME_CONFIG_CACHE.pop(next(iter(_MSGSPEC_RENAME_CONFIG_CACHE)), None) _MSGSPEC_RENAME_CONFIG_CACHE[schema_type] = None return None @@ -878,7 +888,7 @@ def is_schema_or_dict(obj: Any) -> "TypeGuard[SupportedSchemaModel | dict[str, A def is_schema_with_field(obj: Any, field_name: str) -> "TypeGuard[SupportedSchemaModel]": - """Check if a value is a msgspec Struct or Pydantic model with a specific field. + """Check if a value is a supported schema model with a specific field. Args: obj: Value to check. @@ -887,11 +897,16 @@ def is_schema_with_field(obj: Any, field_name: str) -> "TypeGuard[SupportedSchem Returns: bool """ - return is_msgspec_struct_with_field(obj, field_name) or is_pydantic_model_with_field(obj, field_name) + return ( + is_msgspec_struct_with_field(obj, field_name) + or is_pydantic_model_with_field(obj, field_name) + or is_dataclass_with_field(obj, field_name) + or is_attrs_instance_with_field(obj, field_name) + ) def is_schema_without_field(obj: Any, field_name: str) -> "TypeGuard[SupportedSchemaModel]": - """Check if a value is a msgspec Struct or Pydantic model without a specific field. + """Check if a value is a supported schema model without a specific field. Args: obj: Value to check. @@ -900,11 +915,11 @@ def is_schema_without_field(obj: Any, field_name: str) -> "TypeGuard[SupportedSc Returns: bool """ - return not is_schema_with_field(obj, field_name) + return is_schema(obj) and not is_schema_with_field(obj, field_name) def is_schema_or_dict_with_field(obj: Any, field_name: str) -> "TypeGuard[SupportedSchemaModel | dict[str, Any]]": - """Check if a value is a msgspec Struct, Pydantic model, or dict with a specific field. + """Check if a value is a supported schema model or dict with a specific field. Args: obj: Value to check. @@ -917,7 +932,7 @@ def is_schema_or_dict_with_field(obj: Any, field_name: str) -> "TypeGuard[Suppor def is_schema_or_dict_without_field(obj: Any, field_name: str) -> "TypeGuard[SupportedSchemaModel | dict[str, Any]]": - """Check if a value is a msgspec Struct, Pydantic model, or dict without a specific field. + """Check if a value is a supported schema model or dict without a specific field. Args: obj: Value to check. @@ -926,7 +941,7 @@ def is_schema_or_dict_without_field(obj: Any, field_name: str) -> "TypeGuard[Sup Returns: bool """ - return not is_schema_or_dict_with_field(obj, field_name) + return is_schema_or_dict(obj) and not is_schema_or_dict_with_field(obj, field_name) def is_dto_data(v: Any) -> "TypeGuard[DTODataStub[Any]]": @@ -1431,6 +1446,12 @@ def has_migration_config(obj: Any) -> "TypeGuard[HasMigrationConfigProtocol]": return isinstance(obj, HasMigrationConfigProtocol) +def _reset_msgspec_rename_caches() -> None: + """Clear internal msgspec rename configuration caches.""" + _MSGSPEC_RENAME_CONFIG_CACHE.clear() + _MSGSPEC_RENAME_PATTERN_CACHE.clear() + + def _detect_rename_pattern(field_name: str, encode_name: str) -> "str | None": """Detect the rename pattern by comparing field name transformations. @@ -1455,5 +1476,7 @@ def _detect_rename_pattern(field_name: str, encode_name: str) -> "str | None": else: result = None + if len(_MSGSPEC_RENAME_PATTERN_CACHE) >= _MSGSPEC_RENAME_CACHE_MAX_SIZE: + _MSGSPEC_RENAME_PATTERN_CACHE.pop(next(iter(_MSGSPEC_RENAME_PATTERN_CACHE)), None) _MSGSPEC_RENAME_PATTERN_CACHE[key] = result return result diff --git a/sqlspec/utils/uuids.py b/sqlspec/utils/uuids.py index eb0d554e2..897b20bd2 100644 --- a/sqlspec/utils/uuids.py +++ b/sqlspec/utils/uuids.py @@ -28,7 +28,7 @@ from uuid import uuid5 as _stdlib_uuid5 from sqlspec.typing import NANOID_INSTALLED, UUID_UTILS_INSTALLED -from sqlspec.utils.module_loader import import_optional +from sqlspec.utils.module_loader import import_optional, module_available __all__ = ( "NAMESPACE_DNS", @@ -64,10 +64,13 @@ def uuid_from_string(value: str) -> "UUID": Returns: A standard-library UUID suitable for native database drivers. """ - module = _uuid_utils_native_mod + module = _get_uuid_utils_native_mod() if module is None: return UUID(value) - return UUID(int=module.UUID(value).int) + try: + return UUID(int=module.UUID(value).int) + except ValueError: + return UUID(value) def uuid_from_bytes(value: bytes) -> "UUID": @@ -110,7 +113,7 @@ def uuid3(name: str, namespace: "UUID | None" = None) -> "UUID": Returns: A deterministic UUID based on namespace and name. """ - module = _uuid_utils_mod + module = _get_uuid_utils_mod() namespace_value = NAMESPACE_DNS if namespace is None else namespace if module is None: return _stdlib_uuid3(namespace_value, name) @@ -126,7 +129,7 @@ def uuid4() -> "UUID": Returns: A randomly generated UUID. """ - module = _uuid_utils_mod + module = _get_uuid_utils_mod() if module is None: return _stdlib_uuid4() return cast("UUID", module.uuid4()) @@ -145,7 +148,7 @@ def uuid5(name: str, namespace: "UUID | None" = None) -> "UUID": Returns: A deterministic UUID based on namespace and name. """ - module = _uuid_utils_mod + module = _get_uuid_utils_mod() namespace_value = NAMESPACE_DNS if namespace is None else namespace if module is None: return _stdlib_uuid5(namespace_value, name) @@ -165,7 +168,7 @@ def uuid6() -> "UUID": Returns: A time-ordered UUID, or a random UUID if time-ordered generation unavailable. """ - module = _uuid_utils_mod + module = _get_uuid_utils_mod() if module is not None: return cast("UUID", module.uuid6()) @@ -195,7 +198,7 @@ def uuid7() -> "UUID": Returns: A time-ordered UUID, or a random UUID if time-ordered generation unavailable. """ - module = _uuid_utils_mod + module = _get_uuid_utils_mod() if module is not None: return cast("UUID", module.uuid7()) @@ -226,7 +229,7 @@ def nanoid() -> str: A 21-character Nano ID string, or 32-character UUID hex if fastnanoid unavailable. """ - module = _fastnanoid_mod + module = _get_fastnanoid_mod() if module is None: warnings.warn( "fastnanoid not installed, falling back to uuid4.hex for Nano ID generation. " @@ -238,5 +241,23 @@ def nanoid() -> str: return cast("str", module.generate()) +def _get_uuid_utils_mod() -> "Any | None": + if _uuid_utils_mod is not None and module_available("uuid_utils"): + return _uuid_utils_mod + return import_optional("uuid_utils.compat") if module_available("uuid_utils") else None + + +def _get_uuid_utils_native_mod() -> "Any | None": + if _uuid_utils_native_mod is not None and module_available("uuid_utils"): + return _uuid_utils_native_mod + return import_optional("uuid_utils") if module_available("uuid_utils") else None + + +def _get_fastnanoid_mod() -> "Any | None": + if _fastnanoid_mod is not None and module_available("fastnanoid"): + return _fastnanoid_mod + return import_optional("fastnanoid") if module_available("fastnanoid") else None + + def _nanoid_impl() -> str: return _stdlib_uuid4().hex diff --git a/tests/integration/adapters/_shared/_driver_type_system.py b/tests/integration/adapters/_shared/_driver_type_system.py index c335d89ef..dbbfeac05 100644 --- a/tests/integration/adapters/_shared/_driver_type_system.py +++ b/tests/integration/adapters/_shared/_driver_type_system.py @@ -68,11 +68,16 @@ class SourceEquivalenceCase: } _MIGRATED_DRIVER_FEATURE_ADAPTERS = ( + "adbc", "aiomysql", "aiosqlite", "arrow_odbc", "asyncmy", + "asyncpg", "bigquery", + "cockroach_asyncpg", + "cockroach_psycopg", + "db2", "duckdb", "mssql_python", "mysqlconnector", @@ -85,9 +90,23 @@ class SourceEquivalenceCase: "sqlite", ) -# Downstream migrations populate the consumed-key registry adapter by adapter. DRIVER_FEATURE_CONSUMED_KEYS: dict[str, tuple[str, ...]] = dict.fromkeys(DRIVER_FEATURE_TYPED_DICTS, ()) + DRIVER_FEATURE_CONSUMED_KEYS.update({ + "adbc": ( + "json_serializer", + "enable_cast_detection", + "enable_strict_type_coercion", + "strict_type_coercion", + "enable_arrow_extension_types", + "arrow_extension_types", + "enable_pgvector", + "enable_paradedb", + "enable_pg_textsearch", + "enable_events", + "on_connection_create", + "events_backend", + ), "aiomysql": ( "json_serializer", "json_deserializer", @@ -143,6 +162,27 @@ class SourceEquivalenceCase: "events_backend", "enable_local_infile_bulk_load", ), + "asyncpg": ( + "json_serializer", + "json_deserializer", + "enable_json_codecs", + "enable_pgvector", + "enable_paradedb", + "enable_pg_textsearch", + "enable_cloud_sql", + "cloud_sql_instance", + "cloud_sql_enable_iam_auth", + "cloud_sql_ip_type", + "enable_alloydb", + "alloydb_instance_uri", + "enable_alloydb_iam_auth", + "alloydb_ip_type", + "enable_events", + "events_backend", + "on_connection_create", + "pgbouncer", + "type_codecs", + ), "bigquery": ( "connection_instance", "on_connection_create", @@ -161,6 +201,48 @@ class SourceEquivalenceCase: "enable_native_storage", "native_export_connection", ), + "cockroach_asyncpg": ( + "enable_native_storage", + "native_storage_csv_options", + "enable_auto_retry", + "max_retries", + "retry_delay_base_ms", + "retry_delay_max_ms", + "enable_retry_logging", + "enable_follower_reads", + "default_staleness", + "json_serializer", + "json_deserializer", + "enable_json_codecs", + "enable_pgvector", + "on_connection_create", + "enable_events", + "events_backend", + ), + "cockroach_psycopg": ( + "enable_native_storage", + "native_storage_csv_options", + "enable_auto_retry", + "max_retries", + "retry_delay_base_ms", + "retry_delay_max_ms", + "enable_retry_logging", + "enable_follower_reads", + "default_staleness", + "json_serializer", + "json_deserializer", + "on_connection_create", + "enable_events", + "events_backend", + ), + "db2": ( + "json_serializer", + "json_deserializer", + "on_connection_create", + "enable_events", + "events_backend", + "enable_lowercase_column_names", + ), "duckdb": ( "extensions", "secrets", @@ -171,7 +253,14 @@ class SourceEquivalenceCase: "enable_events", "events_backend", ), - "mssql_python": ("use_pool", "json_serializer", "json_deserializer", "on_connection_create", "enable_events"), + "mssql_python": ( + "use_pool", + "json_serializer", + "json_deserializer", + "on_connection_create", + "enable_events", + "events_backend", + ), "mysqlconnector": ( "json_serializer", "json_deserializer", @@ -483,21 +572,12 @@ class SourceEquivalenceCase: SOURCE_EQUIVALENCE_ALLOWLIST: dict[str, frozenset[str]] = { case.allowlist_key: frozenset() for case in SOURCE_EQUIVALENCE_CASES } -# mysqlconnector intentionally resolves error codes from errno instead of args[0]. + SOURCE_EQUIVALENCE_ALLOWLIST["mysql_four_way:create_mapped_exception"] = frozenset({ "sqlspec.adapters.mysqlconnector.core" }) -def _load_symbol(module_path: str, symbol_name: str) -> Any: - try: - module = importlib.import_module(module_path) - return getattr(module, symbol_name) - except (AttributeError, ImportError) as exc: - msg = f"Unable to load {symbol_name} from {module_path}: {exc}" - raise AssertionError(msg) from exc - - def get_driver_feature_keys(adapter: str) -> tuple[str, ...]: """Return the canonical DriverFeatures keys declared by an adapter TypedDict.""" module_path, typed_dict_name = DRIVER_FEATURE_TYPED_DICTS[adapter] @@ -515,13 +595,6 @@ def get_driver_feature_consumed_keys(adapter: str) -> tuple[str, ...]: class _DocstringStripper(ast.NodeTransformer): - def _strip_body(self, body: list[ast.stmt]) -> list[ast.stmt]: - if body and isinstance(body[0], ast.Expr): - value = body[0].value - if isinstance(value, ast.Constant) and isinstance(value.value, str): - return body[1:] - return body - def visit_Module(self, node: ast.Module) -> ast.Module: node.body = self._strip_body(node.body) return cast("ast.Module", self.generic_visit(node)) @@ -538,6 +611,13 @@ def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: node.body = self._strip_body(node.body) return cast("ast.ClassDef", self.generic_visit(node)) + def _strip_body(self, body: list[ast.stmt]) -> list[ast.stmt]: + if body and isinstance(body[0], ast.Expr): + value = body[0].value + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return body[1:] + return body + def normalize_source(source: str) -> str: """Normalize Python source so source-equality checks ignore docstrings and formatting.""" @@ -548,15 +628,6 @@ def normalize_source(source: str) -> str: return ast.unparse(stripped).strip() -def _load_symbol_source(module_path: str, symbol_name: str) -> str: - symbol = _load_symbol(module_path, symbol_name) - try: - return inspect.getsource(symbol) - except OSError as exc: - msg = f"Unable to read source for {symbol_name} from {module_path}: {exc}" - raise AssertionError(msg) from exc - - def assert_source_equivalent(case: SourceEquivalenceCase) -> None: """Assert that every unallowlisted module exports the same normalized source.""" allowed_modules = SOURCE_EQUIVALENCE_ALLOWLIST.get(case.allowlist_key, frozenset()) @@ -590,3 +661,21 @@ def assert_source_equivalent(case: SourceEquivalenceCase) -> None: details = "\n\n".join(mismatches) msg = f"{case.case_id} source mismatch for {case.symbol}:\n{details}" raise AssertionError(msg) + + +def _load_symbol(module_path: str, symbol_name: str) -> Any: + try: + module = importlib.import_module(module_path) + return getattr(module, symbol_name) + except (AttributeError, ImportError) as exc: + msg = f"Unable to load {symbol_name} from {module_path}: {exc}" + raise AssertionError(msg) from exc + + +def _load_symbol_source(module_path: str, symbol_name: str) -> str: + symbol = _load_symbol(module_path, symbol_name) + try: + return inspect.getsource(symbol) + except OSError as exc: + msg = f"Unable to read source for {symbol_name} from {module_path}: {exc}" + raise AssertionError(msg) from exc diff --git a/tests/unit/adapters/test_adbc/test_adbc_config_normalization.py b/tests/unit/adapters/test_adbc/test_adbc_config_normalization.py index 5eebfbbde..1db3918c4 100644 --- a/tests/unit/adapters/test_adbc/test_adbc_config_normalization.py +++ b/tests/unit/adapters/test_adbc/test_adbc_config_normalization.py @@ -3,9 +3,10 @@ from typing import Any, get_type_hints from unittest.mock import MagicMock +import pytest from adbc_driver_bigquery import DatabaseOptions -from sqlspec.adapters.adbc import AdbcConfig +from sqlspec.adapters.adbc import AdbcConfig, AdbcDriver from sqlspec.adapters.adbc.config import AdbcConnectionParams from sqlspec.adapters.adbc.core import ( build_connection_config, @@ -13,6 +14,7 @@ resolve_driver_name_from_config, ) from sqlspec.core import ParameterStyle +from sqlspec.exceptions import SQLSpecError def _resolve_driver_name(config: AdbcConfig) -> str: @@ -482,3 +484,50 @@ def test_flightsql_native_shortcuts_preserve_explicit_options() -> None: "adbc.flight.sql.rpc.call_header.x-token": "explicit", "adbc.flight.sql.rpc.timeout_seconds.query": "9", } + + +def test_adbc_config_respects_connection_instance_without_closing() -> None: + """connection_instance must be reused by create_connection and not closed on context exit.""" + conn = MagicMock() + conn.adbc_get_info.return_value = {"vendor_name": "sqlite", "driver_name": "sqlite"} + config = AdbcConfig(connection_config={"driver_name": "sqlite"}, connection_instance=conn) + + assert config.create_connection() is conn + with config.provide_connection() as yielded: + assert yielded is conn + conn.close.assert_not_called() + + with config.provide_session() as session: + assert session.connection is conn + conn.close.assert_not_called() + + +def test_adbc_config_skips_extension_connection_when_all_probes_disabled(monkeypatch: Any) -> None: + """When all PostgreSQL extension probes are disabled, _detect_extensions_if_needed must not connect.""" + config = AdbcConfig( + connection_config={"driver_name": "postgres", "uri": "postgresql://localhost/test"}, + driver_features={"enable_pgvector": False, "enable_paradedb": False, "enable_pg_textsearch": False}, + ) + + def fail_connect(_self: AdbcConfig) -> None: + raise AssertionError("create_connection should not be called when all probes are disabled") + + monkeypatch.setattr(AdbcConfig, "create_connection", fail_connect) + config._detect_extensions_if_needed() # pyright: ignore[reportPrivateUsage] + assert config._pgvector_available is False # pyright: ignore[reportPrivateUsage] + assert config._paradedb_available is False # pyright: ignore[reportPrivateUsage] + assert config.pg_textsearch_available is False + + +def test_adbc_driver_standalone_applies_features_and_enforces_strict_coercion() -> None: + """Standalone AdbcDriver populates default driver_features, aligns dialect, and honors strict_type_coercion.""" + conn = MagicMock() + conn.adbc_get_info.return_value = {"vendor_name": "duckdb", "driver_name": "duckdb"} + + driver = AdbcDriver(conn, driver_features={"strict_type_coercion": True}) + assert driver.dialect == "duckdb" + assert str(driver.statement_config.dialect) == "duckdb" + assert "json_serializer" in driver.driver_features + + with pytest.raises(SQLSpecError): + driver.prepare_driver_parameters([object()], driver.statement_config) diff --git a/tests/unit/adapters/test_adbc/test_adbc_serialization.py b/tests/unit/adapters/test_adbc/test_adbc_serialization.py index 170ffd1d1..d45887f92 100644 --- a/tests/unit/adapters/test_adbc/test_adbc_serialization.py +++ b/tests/unit/adapters/test_adbc/test_adbc_serialization.py @@ -125,3 +125,13 @@ def test_pg_textsearch_converter_keeps_postgres_json_and_array_handling() -> Non converter = ADBCOutputConverter("pg_textsearch") assert converter.convert_dict({"value": 1}) == to_json({"value": 1}) assert converter.convert_sequence([1, None, 3]) == [1, None, 3] + + +def test_adbc_output_converter_respects_custom_json_serializer_and_nested_containers() -> None: + """ADBCOutputConverter should use custom json_serializer and convert nested containers.""" + pg_converter = ADBCOutputConverter("postgres", json_serializer=lambda val: f"json:{val}") + assert pg_converter.convert_dict({"a": 1}) == "json:{'a': 1}" + assert pg_converter.convert_sequence([{"a": 1}, [2, {"b": 3}]]) == ["json:{'a': 1}", [2, "json:{'b': 3}"]] + + duck_converter = ADBCOutputConverter("duckdb", json_serializer=lambda val: f"duck:{val}") + assert duck_converter.convert_dict({"nested": (1, 2)}) == {"nested": [1, 2]} diff --git a/tests/unit/adapters/test_adbc/test_adk_dialect_support.py b/tests/unit/adapters/test_adbc/test_adk_dialect_support.py index a0453ec2d..953f744b1 100644 --- a/tests/unit/adapters/test_adbc/test_adk_dialect_support.py +++ b/tests/unit/adapters/test_adbc/test_adk_dialect_support.py @@ -1,9 +1,16 @@ """Tests for ADBC ADK store dialect-specific DDL generation.""" +from datetime import UTC, datetime +from typing import Any +from unittest.mock import MagicMock + import pytest from sqlspec.adapters.adbc import AdbcConfig from sqlspec.adapters.adbc.adk import AdbcADKMemoryStore, AdbcADKStore +from sqlspec.adapters.adbc.adk.store import _postgresql_store_sql +from sqlspec.adapters.adbc.events.store import AdbcEventQueueStore +from sqlspec.adapters.adbc.litestar.store import ADBCStore pytestmark = [pytest.mark.xdist_group("sqlite"), pytest.mark.adbc, pytest.mark.integration] @@ -296,10 +303,80 @@ def test_store_placeholder_formatting_preserves_sql_text(store_type: type, drive def test_store_placeholder_formatting_reuses_cached_sql() -> None: - from sqlspec.adapters.adbc.adk.store import _postgresql_store_sql - _postgresql_store_sql.cache_clear() sql = "SELECT ?::jsonb /* cached store query */" assert _postgresql_store_sql(sql) == "SELECT $1::jsonb /* cached store query */" assert _postgresql_store_sql(sql) == "SELECT $1::jsonb /* cached store query */" assert _postgresql_store_sql.cache_info().hits == 1 + + +def test_detect_dialect_bigquery_and_ddl() -> None: + """AdbcADKStore and AdbcADKMemoryStore should detect BigQuery and generate BigQuery DDL.""" + config = AdbcConfig(connection_config={"driver_name": "bigquery"}) + store = AdbcADKStore(config) + mem_store = AdbcADKMemoryStore(config) + + assert store._dialect == "bigquery" # pyright: ignore[reportPrivateUsage] + assert mem_store.dialect == "bigquery" + assert "STRING" in store._sessions_table_ddl() # pyright: ignore[reportPrivateUsage] + assert "JSON" in store._events_table_ddl() # pyright: ignore[reportPrivateUsage] + assert "STRING" in mem_store._memory_table_ddl() # pyright: ignore[reportPrivateUsage] + + +def test_sqlite_adk_store_encodes_and_decodes_timestamps() -> None: + """AdbcADKStore on SQLite encodes timestamps as floats and decodes them to UTC datetimes.""" + config = AdbcConfig(connection_config={"driver_name": "sqlite", "uri": ":memory:"}) + store = AdbcADKStore(config) + now = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) + + encoded = store._encode_timestamp(now) # pyright: ignore[reportPrivateUsage] + assert isinstance(encoded, float) + decoded = store._decode_timestamp(encoded) # pyright: ignore[reportPrivateUsage] + assert decoded == now + + +def test_adbc_litestar_store_handles_pg_extensions_and_bigquery_snowflake(monkeypatch: Any) -> None: + """ADBCStore should recognize Postgres extension dialects, omit indexes on BigQuery/Snowflake, and use select_one_or_none.""" + config = AdbcConfig(connection_config={"driver_name": "bigquery"}) + store = ADBCStore(config) + store._dialect = "bigquery" # pyright: ignore[reportPrivateUsage] + + ddl = store._table_ddl() # pyright: ignore[reportPrivateUsage] + assert "CREATE INDEX" not in ddl + assert store._drop_table_sql() == [f"DROP TABLE IF EXISTS {store._table_name}"] # pyright: ignore[reportPrivateUsage] + + store._dialect = "pgvector" # pyright: ignore[reportPrivateUsage] + assert store._get_param_placeholder(1) == "$1" # pyright: ignore[reportPrivateUsage] + assert "BYTEA" in store._table_ddl() # pyright: ignore[reportPrivateUsage] + + mock_driver = MagicMock() + mock_driver.select_one_or_none.return_value = None + mock_session = MagicMock() + mock_session.__enter__.return_value = mock_driver + mock_session.__exit__.return_value = None + monkeypatch.setattr(AdbcConfig, "provide_session", lambda *_a, **_k: mock_session) + + assert store._expires_in("missing") is None # pyright: ignore[reportPrivateUsage] + mock_driver.select_one_or_none.assert_called_once() + + +def test_adbc_events_store_delegates_dialect_resolution() -> None: + """AdbcEventQueueStore should resolve dialects via resolve_dialect_from_config and preserve flightsql.""" + bq_config = AdbcConfig(connection_config={"driver_name": "bigquery"}, extension_config={"events": {}}) + assert AdbcEventQueueStore(bq_config).dialect == "bigquery" + + gizmo_sqlite_config = AdbcConfig( + connection_config={"uri": "grpc+tls://localhost:31337", "gizmosql_backend": "sqlite"}, + extension_config={"events": {}}, + ) + assert AdbcEventQueueStore(gizmo_sqlite_config).dialect == "sqlite" + + so_config = AdbcConfig( + connection_config={"driver_name": "/opt/lib/libadbc_driver_postgresql.so"}, extension_config={"events": {}} + ) + assert AdbcEventQueueStore(so_config).dialect == "postgres" + + flight_config = AdbcConfig( + connection_config={"driver_name": "flightsql", "uri": "grpc://localhost:8815"}, extension_config={"events": {}} + ) + assert AdbcEventQueueStore(flight_config).dialect == "flightsql" diff --git a/tests/unit/adapters/test_adbc/test_arrow_streaming.py b/tests/unit/adapters/test_adbc/test_arrow_streaming.py index 170a80e22..7d97784b4 100644 --- a/tests/unit/adapters/test_adbc/test_arrow_streaming.py +++ b/tests/unit/adapters/test_adbc/test_arrow_streaming.py @@ -1,11 +1,12 @@ """Unit tests for ADBC Arrow streaming paths.""" -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast import pyarrow as pa import pytest +from adbc_driver_manager import AdbcStatusCode, DatabaseError -from sqlspec.adapters.adbc.driver import AdbcDriver +from sqlspec.adapters.adbc.driver import AdbcDriver, AdbcSelectStreamSource from sqlspec.exceptions import SQLSpecError if TYPE_CHECKING: @@ -25,7 +26,7 @@ class _AdbcStreamingCursor: def __init__(self) -> None: self.closed = False self.executed: list[tuple[str, object]] = [] - self.ingest_calls: list[tuple[str, object, str]] = [] + self.ingest_calls: list[tuple[str, object, str, dict[str, Any]]] = [] def execute(self, sql: str, parameters: object = None) -> None: self.executed.append((sql, parameters)) @@ -38,8 +39,8 @@ def fetch_arrow_table(self) -> pa.Table: msg = "reader formats should use fetch_record_batch(), not fetch_arrow_table()" raise AssertionError(msg) - def adbc_ingest(self, table: str, source: object, *, mode: str) -> int: - self.ingest_calls.append((table, source, mode)) + def adbc_ingest(self, table: str, source: object, *, mode: str, **kwargs: Any) -> int: + self.ingest_calls.append((table, source, mode, kwargs)) return 3 def close(self) -> None: @@ -94,10 +95,11 @@ def test_load_from_arrow_passes_record_batch_reader_to_adbc_ingest() -> None: job = driver.load_from_arrow("target_table", reader) - target, source, mode = connection.cursor_obj.ingest_calls[0] + target, source, mode, kwargs = connection.cursor_obj.ingest_calls[0] assert target == "target_table" assert source is reader assert mode == "create_append" + assert kwargs == {} assert job.telemetry["rows_processed"] == 3 assert job.telemetry["destination"] == "target_table" @@ -134,3 +136,90 @@ def cursor(self) -> _PostgresErrorCursor: with pytest.raises((RuntimeError, SQLSpecError)): driver.select_to_arrow("SELECT 1", return_format="table") assert connection.rolled_back is True + + +def test_select_stream_start_closes_cursor_and_rolls_back_postgres_on_failure() -> None: + """AdbcSelectStreamSource.start() must close cursor and roll back Postgres when execute raises.""" + + class _FailingCursor: + def __init__(self, conn: "_PostgresConn") -> None: + self.connection = conn + self.closed = False + + def execute(self, sql: str, parameters: object = None) -> None: + raise DatabaseError("query failed", status_code=AdbcStatusCode.UNKNOWN) + + def close(self) -> None: + self.closed = True + + class _PostgresConn: + def __init__(self) -> None: + self.rolled_back = False + self.last_cursor: _FailingCursor | None = None + + def adbc_get_info(self) -> dict[str, str]: + return {"vendor_name": "PostgreSQL", "driver_name": "adbc_driver_postgresql"} + + def rollback(self) -> None: + self.rolled_back = True + + def cursor(self) -> _FailingCursor: + self.last_cursor = _FailingCursor(self) + return self.last_cursor + + conn = _PostgresConn() + driver = AdbcDriver(cast("AdbcConnection", conn), dialect="postgres") + source = AdbcSelectStreamSource(driver, "SELECT 1", []) + + with pytest.raises(SQLSpecError): + source.start() + + assert conn.last_cursor is not None + assert conn.last_cursor.closed is True + assert conn.rolled_back is True + + +def test_load_from_arrow_splits_schema_qualified_table_name() -> None: + """load_from_arrow should pass db_schema_name and catalog_name to adbc_ingest for qualified targets.""" + connection = _AdbcStreamingConnection() + driver = AdbcDriver( + cast("AdbcConnection", connection), + dialect="postgres", + driver_features={"storage_capabilities": _STORAGE_CAPABILITIES}, + ) + table = pa.table({"x": [1, 2]}) + + driver.load_from_arrow("analytics.events", table) + target, _source, _mode, kwargs = connection.cursor_obj.ingest_calls[0] + assert target == "events" + assert kwargs == {"db_schema_name": "analytics"} + + driver.load_from_arrow("warehouse.analytics.events", table) + target2, _source2, _mode2, kwargs2 = connection.cursor_obj.ingest_calls[1] + assert target2 == "events" + assert kwargs2 == {"catalog_name": "warehouse", "db_schema_name": "analytics"} + + +def test_adbc_driver_commit_and_rollback_map_errors_and_reset_transaction_state() -> None: + """commit and rollback must map AdbcError to SQLSpecError and reset _transaction_active in finally.""" + + class _FailingTxConnection: + def adbc_get_info(self) -> dict[str, str]: + return {"vendor_name": "sqlite", "driver_name": "sqlite"} + + def commit(self) -> None: + raise DatabaseError("commit failed", status_code=AdbcStatusCode.UNKNOWN) + + def rollback(self) -> None: + raise DatabaseError("rollback failed", status_code=AdbcStatusCode.UNKNOWN) + + driver = AdbcDriver(cast("AdbcConnection", _FailingTxConnection()), dialect="sqlite") + driver._transaction_active = True # pyright: ignore[reportPrivateUsage] + with pytest.raises(SQLSpecError): + driver.commit() + assert driver._transaction_active is False # pyright: ignore[reportPrivateUsage] + + driver._transaction_active = True # pyright: ignore[reportPrivateUsage] + with pytest.raises(SQLSpecError): + driver.rollback() + assert driver._transaction_active is False # pyright: ignore[reportPrivateUsage] diff --git a/tests/unit/adapters/test_adbc/test_core.py b/tests/unit/adapters/test_adbc/test_core.py index 5a6aeef78..c8dd08dda 100644 --- a/tests/unit/adapters/test_adbc/test_core.py +++ b/tests/unit/adapters/test_adbc/test_core.py @@ -1,6 +1,7 @@ """Unit tests for ADBC core execute-many helpers.""" from collections.abc import Sequence +from decimal import Decimal from types import SimpleNamespace from typing import Any, cast from uuid import UUID @@ -10,16 +11,21 @@ from sqlspec.adapters.adbc import AdbcDriver from sqlspec.adapters.adbc import core as adbc_core from sqlspec.adapters.adbc.core import ( - _prepare_batch_with_casts, + build_connection_config, collect_rows, + driver_from_uri, + driver_kind_from_uri, get_statement_config, + prepare_batch_with_casts, prepare_parameters_with_casts, prepare_postgres_parameters, prepare_postgres_uuid_bindings, resolve_column_names, + resolve_dialect_from_config, resolve_many_rowcount, ) -from sqlspec.core import ParameterStyle +from sqlspec.core import SQL, ParameterStyle +from sqlspec.dialects import PGTextSearch from sqlspec.exceptions import ( DeadlockError, OperationalError, @@ -174,7 +180,7 @@ def fake_factory(dialect: str, cache_size: int = 5000) -> FakeConverter: monkeypatch.setattr(adbc_core, "get_adbc_type_converter", fake_factory) - prepared = _prepare_batch_with_casts( + prepared = prepare_batch_with_casts( [(1, {"name": "alice"}), (2, {"name": "bob"}), (3, {"name": "carol"})], {1: "INT"}, statement_config, @@ -354,8 +360,6 @@ def test_pg_textsearch_retains_postgres_transaction_and_parameter_behavior() -> def test_pg_textsearch_class_and_instance_resolve_as_postgres_family() -> None: - from sqlspec.dialects import PGTextSearch - for dialect in (PGTextSearch, PGTextSearch()): name = adbc_core.resolve_dialect_name(dialect) assert adbc_core.is_postgres_dialect(name) @@ -369,3 +373,41 @@ def test_adbc_postgres_uuid_binding_runs_across_mypyc_boundary() -> None: assert sql == "SELECT CAST($1 AS UUID)" assert parameters == [str(value)] + + +def test_create_mapped_exception_maps_serialization_conflict_from_message() -> None: + """Message-only serialization conflict patterns map to SerializationConflictError.""" + error = DatabaseError("Transaction aborted due to concurrent update", status_code=AdbcStatusCode.UNKNOWN) + + mapped = adbc_core.create_mapped_exception(error) + + assert isinstance(mapped, SerializationConflictError) + + +def test_decimal_coercion_preserves_decimal_instance() -> None: + """Decimal values should remain Decimal instances so Arrow decimal fidelity is preserved.""" + amount = Decimal("123.45") + assert adbc_core._BASE_TYPE_COERCION_MAP[Decimal](amount) is amount # pyright: ignore[reportPrivateUsage] + config = get_statement_config("postgres") + assert config.parameter_config.type_coercion_map[Decimal](amount) is amount + + +def test_duckdb_copy_to_inlines_file_path_parameter() -> None: + """DuckDB statement config should inline COPY file target parameters via AST transformer.""" + config = get_statement_config("duckdb") + stmt = SQL("COPY (SELECT ? AS id) TO ? (FORMAT PARQUET)", 7, "/tmp/out.parquet", statement_config=config) + compiled_sql, compiled_params = stmt.compile() + + assert "'/tmp/out.parquet'" in compiled_sql + assert compiled_params == [7] + + +def test_case_insensitive_uri_and_grpc_tls_resolution() -> None: + """URI helpers and build_connection_config must handle uppercase schemes and grpc+tls://.""" + assert driver_from_uri("POSTGRESQL://localhost/db") == "adbc_driver_postgresql.dbapi.connect" + assert driver_kind_from_uri("GRPC+TLS://localhost:31337") == "flightsql" + assert resolve_dialect_from_config({"uri": "POSTGRESQL://localhost/db"}) == "postgres" + assert resolve_dialect_from_config({"uri": "GRPC+TLS://localhost:31337"}) == "duckdb" + + built = build_connection_config({"uri": "grpc+tls://localhost:31337", "tls_skip_verify": True}) + assert built["db_kwargs"]["adbc.flight.sql.client_option.tls_skip_verify"] == "true" diff --git a/tests/unit/adapters/test_adbc/test_extension_detection.py b/tests/unit/adapters/test_adbc/test_extension_detection.py index a00306af4..dec810343 100644 --- a/tests/unit/adapters/test_adbc/test_extension_detection.py +++ b/tests/unit/adapters/test_adbc/test_extension_detection.py @@ -5,14 +5,9 @@ from pytest import MonkeyPatch from sqlspec.adapters.adbc.config import AdbcConfig -from sqlspec.adapters.adbc.core import ( - apply_driver_features, - build_postgres_extension_probe_names, - detect_postgres_extensions, - get_statement_config, - resolve_postgres_extension_state, -) +from sqlspec.adapters.adbc.core import apply_driver_features, detect_postgres_extensions, get_statement_config from sqlspec.core import StatementConfig +from sqlspec.core.config_runtime import build_postgres_extension_probe_names, resolve_postgres_extension_state class _Cursor: @@ -47,7 +42,6 @@ def test_apply_driver_features_sets_pgvector_default() -> None: statement_config = get_statement_config("postgres") _, features = apply_driver_features(statement_config, None) assert "enable_pgvector" in features - # PGVECTOR_INSTALLED is an OptionalDependencyFlag (truthy/falsy) assert features["enable_pgvector"] is not None diff --git a/tests/unit/adapters/test_aiomysql/test_driver.py b/tests/unit/adapters/test_aiomysql/test_driver.py index b89da9454..304d3f3ec 100644 --- a/tests/unit/adapters/test_aiomysql/test_driver.py +++ b/tests/unit/adapters/test_aiomysql/test_driver.py @@ -68,3 +68,32 @@ async def test_begin_uses_the_native_call_instead_of_a_cursor_statement() -> Non assert connection.begins == 1 assert connection.executed == [] + + +async def test_aiomysql_create_pool_resolves_database_and_db_conflict(monkeypatch: pytest.MonkeyPatch) -> None: + """AiomysqlConfig._create_pool normalizes database to db without raising duplicate keyword conflicts.""" + import aiomysql + + from sqlspec.adapters.aiomysql.config import AiomysqlConfig + + captured_kwargs: dict[str, Any] = {} + + async def _fake_create_pool(**kwargs: Any) -> Any: + captured_kwargs.update(kwargs) + return object() + + monkeypatch.setattr(aiomysql, "create_pool", _fake_create_pool) + config = AiomysqlConfig(connection_config={"host": "localhost", "database": "canonical_db", "db": "legacy_db"}) + await config._create_pool() + assert captured_kwargs["db"] == "legacy_db" + assert "database" not in captured_kwargs + + +def test_aiomysql_signature_namespace_includes_cursors() -> None: + """AiomysqlConfig.get_signature_namespace includes AiomysqlDictCursor and AiomysqlRawCursor.""" + from sqlspec.adapters.aiomysql.config import AiomysqlConfig + + config = AiomysqlConfig(connection_config={"host": "localhost", "db": "test"}) + ns = config.get_signature_namespace() + assert "AiomysqlDictCursor" in ns + assert "AiomysqlRawCursor" in ns diff --git a/tests/unit/adapters/test_aiosqlite/test_driver.py b/tests/unit/adapters/test_aiosqlite/test_driver.py index 1c804aabd..958254589 100644 --- a/tests/unit/adapters/test_aiosqlite/test_driver.py +++ b/tests/unit/adapters/test_aiosqlite/test_driver.py @@ -8,6 +8,7 @@ import aiosqlite import pytest +import sqlspec.adapters.aiosqlite as aiosqlite_adapter from sqlspec.adapters.aiosqlite._typing import AiosqliteCursor from sqlspec.adapters.aiosqlite.core import ( build_profile, @@ -413,3 +414,26 @@ async def test_aiosqlite_begin_honors_mode_and_rejects_invalid_mode() -> None: with pytest.raises(ValueError, match="Transaction mode"): await driver.begin(mode=cast("Any", "INVALID")) assert connection.statements == ["BEGIN DEFERRED", "BEGIN EXCLUSIVE"] + + +def test_aiosqlite_adapter_exports_pool_types() -> None: + for name in ( + "AiosqliteConnectionPool", + "AiosqliteConnectTimeoutError", + "AiosqlitePoolClosedError", + "AiosqlitePoolConnection", + ): + assert name in aiosqlite_adapter.__all__ + assert getattr(aiosqlite_adapter, name) is not None + + +async def test_aiosqlite_pool_tracks_in_flight_creations_and_connection_registry() -> None: + pool = AiosqliteConnectionPool(connection_parameters={"database": ":memory:"}) + try: + assert pool._in_flight_creations == 0 + async with pool.get_connection() as conn: + assert conn is not None + assert pool.size() == 1 + finally: + await pool.close() + assert pool.size() == 0 diff --git a/tests/unit/adapters/test_arrow_odbc/test_adk_store.py b/tests/unit/adapters/test_arrow_odbc/test_adk_store.py index 128dae029..9aa80febd 100644 --- a/tests/unit/adapters/test_arrow_odbc/test_adk_store.py +++ b/tests/unit/adapters/test_arrow_odbc/test_adk_store.py @@ -109,7 +109,7 @@ def capture(_store: ArrowOdbcADKStore, sql: str, params: "tuple[Any, ...]" = ()) def test_arrow_odbc_list_sessions_binds_order_and_page(monkeypatch: "pytest.MonkeyPatch") -> None: - """SQL Server row limiting binds the offset before the row count.""" + """SQL Server row limiting inlines the validated integer offset and row count.""" store, calls = _session_store_with_capture(monkeypatch) store.list_sessions("app", "u1", order_by="create_time", descending=False, limit=10, offset=20) @@ -119,9 +119,9 @@ def test_arrow_odbc_list_sessions_binds_order_and_page(monkeypatch: "pytest.Monk "SELECT id, app_name, user_id, state, create_time, update_time " "FROM [dbo].[adk_session] WHERE app_name = ? AND user_id = ? " "ORDER BY create_time ASC, id ASC " - "OFFSET ? ROWS FETCH NEXT ? ROWS ONLY" + "OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY" ) - assert params == ("app", "u1", 20, 10) + assert params == ("app", "u1") def test_arrow_odbc_list_sessions_defaults_to_recent_first_without_a_page(monkeypatch: "pytest.MonkeyPatch") -> None: @@ -142,8 +142,8 @@ def test_arrow_odbc_list_sessions_pages_an_unfiltered_listing(monkeypatch: "pyte store.list_sessions("app", limit=5) sql, params = calls[0] - assert _normalized(sql).endswith("ORDER BY update_time DESC, id DESC OFFSET ? ROWS FETCH NEXT ? ROWS ONLY") - assert params == ("app", 0, 5) + assert _normalized(sql).endswith("ORDER BY update_time DESC, id DESC OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY") + assert params == ("app",) def test_arrow_odbc_list_sessions_zero_limit_never_queries(monkeypatch: "pytest.MonkeyPatch") -> None: diff --git a/tests/unit/adapters/test_arrow_odbc/test_config.py b/tests/unit/adapters/test_arrow_odbc/test_config.py index 5020f38ca..983d2b8d9 100644 --- a/tests/unit/adapters/test_arrow_odbc/test_config.py +++ b/tests/unit/adapters/test_arrow_odbc/test_config.py @@ -9,13 +9,24 @@ from arrow_odbc import TextEncoding import sqlspec.adapters.arrow_odbc.config as arrow_odbc_config -from sqlspec.adapters.arrow_odbc import ArrowOdbcConfig, ArrowOdbcDriver, build_connection_config +from sqlspec.adapters.arrow_odbc import ( + ArrowOdbcConfig, + ArrowOdbcDriver, + apply_driver_features, + build_connection_config, + build_statement_config, + resolve_dialect_from_dbms_name, +) from sqlspec.adapters.arrow_odbc.core import _extract_sqlstate, create_mapped_exception from sqlspec.exceptions import ( + CheckViolationError, DatabaseConnectionError, DataError, + ForeignKeyViolationError, ImproperConfigurationError, IntegrityError, + PermissionDeniedError, + QueryTimeoutError, SQLParsingError, ) @@ -57,19 +68,19 @@ def test_explicit_field_outranks_the_same_option_inside_the_connection_string() def test_build_connection_config_merges_port_with_connection_string_server() -> None: - """Discrete port must be attached to the server defined in connection_string.""" - connection_string, _ = build_connection_config({"connection_string": "Server=host;UID=u;PWD=p;", "port": 1433}) + """Discrete port must be attached to the server defined in connection_string for SQL Server.""" + connection_string, _ = build_connection_config( + {"connection_string": "Server=host;UID=u;PWD=p;", "port": 1433}, dialect="mssql" + ) assert "Server=host,1433" in connection_string def test_build_connection_config_discrete_server_overrides_connection_string() -> None: - """Discrete server must override server in connection_string.""" - connection_string, _ = build_connection_config({ - "connection_string": "Server=oldhost;UID=u;PWD=p;", - "server": "newhost", - "port": 14333, - }) + """Discrete server must override server in connection_string for SQL Server.""" + connection_string, _ = build_connection_config( + {"connection_string": "Server=oldhost;UID=u;PWD=p;", "server": "newhost", "port": 14333}, dialect="mssql" + ) assert "Server=newhost,14333" in connection_string assert "oldhost" not in connection_string @@ -192,3 +203,119 @@ def test_sqlstate_classes_map_to_sqlspec_exceptions(sqlstate: str, expected: typ def test_a_five_character_token_in_prose_is_not_read_as_a_sqlstate(message: str) -> None: """Only the driver's own State field carries a SQLSTATE, so prose must not be mined for one.""" assert _extract_sqlstate(message) is None + + +def test_non_mssql_connection_config_keeps_separate_port_keyword() -> None: + """Non-SQL-Server dialects emit separate Server and Port keywords instead of Server=host,port.""" + connection_string, _ = build_connection_config({ + "driver": "PostgreSQL Unicode", + "server": "pg.internal", + "port": 5432, + "database": "app", + }) + + assert "Server=pg.internal;" in connection_string + assert "Port=5432;" in connection_string + assert "Server=pg.internal,5432" not in connection_string + + +def test_mssql_inferred_connection_config_uses_comma_port_syntax() -> None: + """SQL Server drivers combine server and port with a comma when dialect is inferred.""" + connection_string, _ = build_connection_config({"driver": "FreeTDS", "server": "sql.internal", "port": 1433}) + + assert "Server=sql.internal,1433" in connection_string + assert "Port=" not in connection_string + + +@pytest.mark.parametrize( + ("dbms_name", "expected_dialect"), [("FreeTDS", "mssql"), ("libtdsodbc.so", "mssql"), ("psqlodbcw.so", "postgres")] +) +def test_resolve_dialect_from_dbms_name_recognizes_common_odbc_drivers(dbms_name: str, expected_dialect: str) -> None: + assert resolve_dialect_from_dbms_name(dbms_name) == expected_dialect + + +def test_config_reads_extra_options_and_preserves_driver_over_dsn() -> None: + """ArrowOdbcConfig inspects extra for connection_string/autocommit and prioritizes driver over dsn.""" + config = ArrowOdbcConfig( + connection_config={ + "extra": {"connection_string": "Driver={PostgreSQL Unicode};Server=localhost;", "autocommit": False} + } + ) + assert config.statement_config.dialect == "postgres" + assert config.driver_features["connection_autocommit"] is False + + driver_and_dsn = ArrowOdbcConfig(connection_config={"driver": "ODBC Driver 18 for SQL Server", "dsn": "analytics"}) + assert driver_and_dsn.driver_features["dbms_name"] == "ODBC Driver 18 for SQL Server" + assert driver_and_dsn.statement_config.dialect == "tsql" + + +@pytest.mark.parametrize( + ("error_text", "expected_type"), + [ + ("State: 23514, Native error: 0, Message: violates check constraint", CheckViolationError), + ("State: 42501, Native error: 0, Message: permission denied for table items", PermissionDeniedError), + ("State: HYT00, Native error: 0, Message: timeout expired", QueryTimeoutError), + ("State: HYT01, Native error: 0, Message: connection timeout expired", DatabaseConnectionError), + ("State: 42000, Native error: 229, Message: [SQL Server]SELECT permission was denied", PermissionDeniedError), + ( + "State: 42000, Native error: 230, Message: [SQL Server]SELECT permission was denied on column", + PermissionDeniedError, + ), + ( + "State: 23000, Native error: 547, Message: [SQL Server]The INSERT statement conflicted with the CHECK constraint", + CheckViolationError, + ), + ( + "State: 23000, Native error: 547, Message: [SQL Server]The INSERT statement conflicted with the FOREIGN KEY constraint", + ForeignKeyViolationError, + ), + ], +) +def test_create_mapped_exception_specific_sqlstates_and_native_codes( + error_text: str, expected_type: type[Exception] +) -> None: + mapped = create_mapped_exception(Exception(error_text)) + assert isinstance(mapped, expected_type) + + +def test_apply_driver_features_applies_json_serializers_to_explicit_statement_config() -> None: + """Custom JSON serializers in driver_features apply even when statement_config is explicitly provided.""" + + def custom_serializer(value: Any) -> str: + return f"custom:{value}" + + def custom_deserializer(raw: str) -> dict[str, str]: + return {"decoded": raw} + + explicit_statement_config = build_statement_config(dialect="postgres") + + updated_config, _ = apply_driver_features( + explicit_statement_config, {"json_serializer": custom_serializer, "json_deserializer": custom_deserializer} + ) + assert updated_config.parameter_config.json_serializer is custom_serializer + assert updated_config.parameter_config.json_deserializer is custom_deserializer + + config = ArrowOdbcConfig( + connection_config={"dsn": "pg"}, + statement_config=explicit_statement_config, + driver_features={"json_serializer": custom_serializer, "json_deserializer": custom_deserializer}, + ) + assert config.statement_config.parameter_config.json_serializer is custom_serializer + assert config.statement_config.parameter_config.json_deserializer is custom_deserializer + + +def test_create_connection_wraps_driver_pooling_failure_in_improper_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Failures inside _apply_driver_pooling during create_connection raise ImproperConfigurationError.""" + monkeypatch.setattr(arrow_odbc_config, "_DRIVER_POOLING_ENABLED", False) + + def broken_pooling() -> None: + msg = "ODBC environment already initialized" + raise RuntimeError(msg) + + monkeypatch.setattr(arrow_odbc_config, "enable_odbc_connection_pooling", broken_pooling) + config = ArrowOdbcConfig(connection_config={"dsn": "analytics"}, driver_features={"enable_driver_pooling": True}) + + with pytest.raises(ImproperConfigurationError, match="Could not configure arrow-odbc connection"): + config.create_connection() diff --git a/tests/unit/adapters/test_arrow_odbc/test_db2_stores.py b/tests/unit/adapters/test_arrow_odbc/test_db2_stores.py index 16c8af0e0..7159547c8 100644 --- a/tests/unit/adapters/test_arrow_odbc/test_db2_stores.py +++ b/tests/unit/adapters/test_arrow_odbc/test_db2_stores.py @@ -315,8 +315,8 @@ def test_db2_adk_session_roundtrip() -> None: "UPDATE adk_session SET state = ?, update_time = ? WHERE app_name = ? AND user_id = ? AND id = ?", ['{"a":2}', DB2_TIMESTAMP, "app", "u", "s1"], ) - assert calls[4][0].endswith("ORDER BY update_time DESC, id DESC OFFSET ? ROWS FETCH NEXT ? ROWS ONLY") - assert calls[4][1] == ["app", "u", "10", "5"] + assert calls[4][0].endswith("ORDER BY update_time DESC, id DESC OFFSET 10 ROWS FETCH NEXT 5 ROWS ONLY") + assert calls[4][1] == ["app", "u"] def test_db2_adk_state_upsert_uses_merge_without_holdlock() -> None: diff --git a/tests/unit/adapters/test_arrow_odbc/test_driver.py b/tests/unit/adapters/test_arrow_odbc/test_driver.py index 988a754ea..e76016fc7 100644 --- a/tests/unit/adapters/test_arrow_odbc/test_driver.py +++ b/tests/unit/adapters/test_arrow_odbc/test_driver.py @@ -20,13 +20,15 @@ ) from sqlspec.adapters.arrow_odbc._typing import ArrowOdbcError from sqlspec.adapters.arrow_odbc.data_dictionary import ArrowOdbcDataDictionary -from sqlspec.core import SQL, LimitOffsetFilter, OrderByFilter +from sqlspec.adapters.arrow_odbc.driver import ArrowOdbcStreamSource, _reader_to_table +from sqlspec.core import SQL, ArrowResult, LimitOffsetFilter, OrderByFilter from sqlspec.data_dictionary import DDLResult, MetadataFidelity, MetadataSource, MetadataSupport from sqlspec.exceptions import ( DatabaseConnectionError, DataError, DeadlockError, ForeignKeyViolationError, + ImproperConfigurationError, NotNullViolationError, OperationalError, PermissionDeniedError, @@ -291,7 +293,8 @@ def test_arrow_odbc_data_dictionary_catalog_support_is_explicitly_unavailable_wi assert catalog.source == MetadataSource.DRIVER_METADATA assert catalog.warnings assert profile.get("columns").support == MetadataSupport.SUPPORTED - assert profile.get("columns").fidelity == MetadataFidelity.PARTIAL + assert profile.get("columns").fidelity == MetadataFidelity.NATIVE + assert profile.get("columns").source == MetadataSource.CATALOG assert profile.get("ddl").support == MetadataSupport.UNSUPPORTED @@ -802,14 +805,14 @@ def test_arrow_odbc_type_helper_public_import() -> None: def test_arrow_odbc_driver_slots_default_values() -> None: - """Slot defaults match the previous driver_features.get() fallbacks.""" + """Slot defaults match apply_driver_features defaults.""" connection = FakeConnection() driver = ArrowOdbcDriver(cast("ArrowOdbcConnection", connection), driver_features={}) assert driver._chunk_size_val == 65_536 - assert driver._max_batch_bytes is None - assert driver._max_text_size_val is None - assert driver._max_binary_size_val is None + assert driver._max_batch_bytes == 512 * 1024 * 1024 + assert driver._max_text_size_val == 1024 * 1024 + assert driver._max_binary_size_val == 1024 * 1024 assert driver._use_concurrent_fetch is True assert driver._query_timeout_sec_val is None @@ -1033,3 +1036,244 @@ def prepend_comment(sql: str, parameters: Any) -> tuple[str, Any]: call = connection.read_calls[-1] assert call["query"] == "/* trace */ SELECT TOP (2) id FROM t WHERE id > ?" assert call["parameters"] == ["7"] + + +@pytest.mark.parametrize( + ("dbms_name", "expected_begin", "expected_commit", "expected_rollback"), + [ + ("Microsoft SQL Server", "BEGIN TRANSACTION", "COMMIT TRANSACTION", "ROLLBACK TRANSACTION"), + ("PostgreSQL Unicode", "BEGIN", "COMMIT", "ROLLBACK"), + ("SQLite", "BEGIN", "COMMIT", "ROLLBACK"), + ], +) +def test_autocommit_transactions_emit_dialect_sql_statements( + dbms_name: str, expected_begin: str, expected_commit: str, expected_rollback: str +) -> None: + """Under autocommit=True, begin/commit/rollback emit dialect-appropriate SQL statements.""" + connection = FakeConnection() + driver = ArrowOdbcDriver( + cast("ArrowOdbcConnection", connection), driver_features={"dbms_name": dbms_name, "connection_autocommit": True} + ) + + driver.begin() + assert driver._transaction_active is True + driver.commit() + assert driver._transaction_active is False + + driver.begin() + driver.rollback() + assert driver._transaction_active is False + + assert connection.executed == [ + (expected_begin, None), + (expected_commit, None), + (expected_begin, None), + (expected_rollback, None), + ] + assert connection.commit_calls == 0 + assert connection.rollback_calls == 0 + + +@pytest.mark.parametrize("dbms_name", ["IBM DB2", "Oracle in OraClient21Home1"]) +def test_autocommit_transactions_rejected_for_db2_and_oracle(dbms_name: str) -> None: + """Db2 and Oracle reject explicit begin() when connection_autocommit=True.""" + connection = FakeConnection() + driver = ArrowOdbcDriver( + cast("ArrowOdbcConnection", connection), driver_features={"dbms_name": dbms_name, "connection_autocommit": True} + ) + + with pytest.raises(ImproperConfigurationError, match="autocommit"): + driver.begin() + + +@pytest.mark.parametrize( + "dbms_name", ["Microsoft SQL Server", "PostgreSQL Unicode", "SQLite", "IBM DB2", "Oracle in OraClient21Home1"] +) +def test_manual_commit_transactions_use_odbc_end_tran_without_sql_begin(dbms_name: str) -> None: + """Under connection_autocommit=False, begin() is a state no-op and commit/rollback use ODBC API.""" + connection = FakeConnection() + driver = ArrowOdbcDriver( + cast("ArrowOdbcConnection", connection), + driver_features={"dbms_name": dbms_name, "connection_autocommit": False}, + ) + + driver.begin() + assert driver._transaction_active is True + assert connection.executed == [] + + driver.commit() + assert driver._transaction_active is False + assert connection.commit_calls == 1 + + driver.begin() + driver.rollback() + assert driver._transaction_active is False + assert connection.rollback_calls == 1 + assert connection.executed == [] + + +def test_arrow_exception_mapped_in_exception_handler_and_stream_source() -> None: + """pyarrow.ArrowException during batch reading or streaming is mapped to SQLSpecError.""" + + class ArrowBrokenReader: + schema = pa.schema([("id", pa.int64())]) + + def __init__(self) -> None: + self.closed = False + + def __iter__(self) -> Iterator[pa.RecordBatch]: + return self + + def __next__(self) -> pa.RecordBatch: + msg = "State: 22018, Native error: 0, Message: invalid character value for cast" + raise pa.ArrowInvalid(msg) + + def close(self) -> None: + self.closed = True + + raw_reader = ArrowBrokenReader() + + class BrokenStreamConnection(FakeConnection): + def read_arrow_batches(self, **kwargs: Any) -> Any: + self.read_calls.append(kwargs) + return raw_reader + + connection = BrokenStreamConnection() + driver = ArrowOdbcDriver(cast("ArrowOdbcConnection", connection)) + source = ArrowOdbcStreamSource(driver, "SELECT id FROM items", None, 10) + source.start() + + with pytest.raises(DataError): + source.fetch_chunk() + + source.close() + assert raw_reader.closed is True + + +def test_select_to_storage_writes_arrow_table_to_destination(tmp_path: Any) -> None: + """select_to_storage exports query results via the Arrow storage pipeline.""" + connection = FakeConnection() + driver = ArrowOdbcDriver(cast("ArrowOdbcConnection", connection)) + destination = str(tmp_path / "export.parquet") + + job = driver.select_to_storage("SELECT x FROM items", destination, format_hint="parquet") + + assert job.status == "completed" + assert job.telemetry["rows_processed"] == 3 + + +@pytest.mark.parametrize("binary_val", [b"raw", bytearray(b"raw"), memoryview(b"raw")]) +def test_binary_parameter_raises_data_error(binary_val: Any) -> None: + """Passing raw binary parameters raises DataError instead of binding repr() text.""" + connection = FakeConnection() + driver = ArrowOdbcDriver(cast("ArrowOdbcConnection", connection)) + + with pytest.raises(DataError, match="Binary parameter binding is not supported"): + driver.execute("INSERT INTO blobs (payload) VALUES (?)", (binary_val,)) + + +def test_execute_statement_forwards_query_timeout_sec() -> None: + """_execute_statement passes query_timeout_sec when configured in driver_features.""" + recorded: list[tuple[str, Any, Any]] = [] + + class TimeoutAwareConnection(FakeConnection): + def execute(self, query: str, parameters: Any = None, query_timeout_sec: Any = None) -> None: + recorded.append((query, parameters, query_timeout_sec)) + + connection = TimeoutAwareConnection() + driver = ArrowOdbcDriver(cast("ArrowOdbcConnection", connection), driver_features={"query_timeout_sec": 15}) + + driver.execute("DELETE FROM items WHERE x = ?", (1,)) + + assert recorded == [("DELETE FROM items WHERE x = ?", ["1"], 15)] + + +def test_dispatch_execute_script_does_not_rebind_parameters_across_multiple_statements() -> None: + """Multi-statement execute_script does not pass the full parameter list to each split statement.""" + connection = FakeConnection() + driver = ArrowOdbcDriver( + cast("ArrowOdbcConnection", connection), driver_features={"dbms_name": "PostgreSQL Unicode"} + ) + + driver.execute_script("DELETE FROM items; DELETE FROM archive;") + assert connection.executed == [("DELETE FROM items", None), ("DELETE FROM archive", None)] + + +def test_reader_to_table_preserves_schema_on_empty_iterable_reader() -> None: + """_reader_to_table preserves schema metadata when an iterable reader yields zero batches.""" + + class EmptyIterableReader: + schema = pa.Table.from_pydict({ + "id": pa.array([], type=pa.int64()), + "name": pa.array([], type=pa.string()), + }).schema + + def __iter__(self) -> Iterator[pa.RecordBatch]: + return iter(()) + + table = _reader_to_table(EmptyIterableReader()) + assert table.num_rows == 0 + assert table.column_names == ["id", "name"] + + +def test_data_dictionary_dialect_and_lowercase_column_probe(monkeypatch: pytest.MonkeyPatch) -> None: + """Data dictionary tracks runtime dialect, normalizes probed columns, and disables concurrent fetch on probes.""" + + class UpperSchemaConnection(FakeConnection): + def read_arrow_batches(self, **kwargs: Any) -> FakeReader: + self.read_calls.append(kwargs) + empty_table = pa.Table.from_pydict({ + "ITEM_ID": pa.array([], type=pa.int64()), + "ITEM_NAME": pa.array([], type=pa.string()), + }) + return FakeReader(empty_table) + + connection = UpperSchemaConnection() + driver = ArrowOdbcDriver( + cast("ArrowOdbcConnection", connection), + driver_features={"dbms_name": "IBM DB2", "enable_lowercase_column_names": True}, + ) + + def fail_get_query(self: ArrowOdbcDataDictionary, domain: str, operation: str) -> Any: + raise SQLFileNotFoundError(f"{domain}/{operation}") + + monkeypatch.setattr(ArrowOdbcDataDictionary, "get_query", fail_get_query) + + assert driver.data_dictionary.dialect == "db2" + columns = driver.data_dictionary.get_columns(driver, table="items") + assert [col["column_name"] for col in columns] == ["item_id", "item_name"] + assert connection.read_calls[-1]["fetch_concurrently"] is False + + profile = driver.data_dictionary.get_metadata_capabilities(driver, domains=("columns",)) + assert profile.get("columns").fidelity == MetadataFidelity.PARTIAL + + +def test_odbc_type_to_arrow_handles_wide_and_out_of_range_decimals() -> None: + """Decimals up to 38 use decimal128, 39..76 use decimal256, and out-of-range fall back to string.""" + assert odbc_type_to_arrow("decimal", precision=18, scale=4) == pa.decimal128(18, 4) + assert odbc_type_to_arrow("decimal", precision=50, scale=10) == pa.decimal256(50, 10) + assert odbc_type_to_arrow("decimal", precision=100, scale=10) == pa.string() + assert odbc_type_to_arrow("numeric", precision=0, scale=0) == pa.string() + assert odbc_type_to_arrow("numeric", precision=10, scale=20) == pa.string() + + +def test_load_from_arrow_streams_record_batch_reader_and_bulk_insert_unwraps_arrow_result() -> None: + """load_from_arrow passes RecordBatchReader directly to insert_into_table and bulk_insert_arrow unwraps ArrowResult.""" + inserted_readers: list[tuple[str, Any]] = [] + + class StreamingInsertConnection(FakeConnection): + def insert_into_table(self, **kwargs: Any) -> None: + inserted_readers.append((kwargs["table"], kwargs["reader"])) + + connection = StreamingInsertConnection() + driver = ArrowOdbcDriver(cast("ArrowOdbcConnection", connection)) + table = pa.table({"id": [1, 2]}) + reader = pa.RecordBatchReader.from_batches(table.schema, table.to_batches()) + + driver.load_from_arrow("items", reader) + assert len(inserted_readers) == 1 + assert inserted_readers[0][1] is reader + + arrow_result = ArrowResult(statement=SQL("SELECT 1"), data=table, rows_affected=2) + driver.bulk_insert_arrow("items", arrow_result) + assert connection.insert_calls == [("items", 65536, table)] diff --git a/tests/unit/adapters/test_arrow_odbc/test_tsql_stores.py b/tests/unit/adapters/test_arrow_odbc/test_tsql_stores.py index a8eacffba..8fc63b632 100644 --- a/tests/unit/adapters/test_arrow_odbc/test_tsql_stores.py +++ b/tests/unit/adapters/test_arrow_odbc/test_tsql_stores.py @@ -1,6 +1,6 @@ """Characterization tests for the SQL Server statements of the arrow-odbc extension stores.""" -from datetime import UTC, datetime, timedelta +from datetime import UTC, date, datetime, timedelta from typing import Any import pyarrow as pa @@ -9,9 +9,10 @@ pytest.importorskip("arrow_odbc") from sqlspec.adapters.arrow_odbc import ArrowOdbcConfig -from sqlspec.adapters.arrow_odbc.adk.store import ArrowOdbcADKMemoryStore, ArrowOdbcADKStore +from sqlspec.adapters.arrow_odbc.adk.store import ArrowOdbcADKMemoryStore, ArrowOdbcADKStore, _datetime_value from sqlspec.adapters.arrow_odbc.events.store import ArrowOdbcEventQueueStore from sqlspec.adapters.arrow_odbc.litestar import ArrowOdbcStore +from sqlspec.adapters.arrow_odbc.litestar.store import _TsqlSessionSql from tests.unit.adapters.test_arrow_odbc._db2_fakes import ( EMPTY_RESULT, ISO_TIMESTAMP, @@ -508,3 +509,110 @@ def test_tsql_adk_memory_create_tables_and_insert_without_owner() -> None: "INSERT INTO [dbo].[adk_memory] ( id, session_id, app_name, user_id, scope, event_id, author, timestamp, " "content_json, content_text, metadata_json, inserted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ) + + +async def test_tsql_session_get_parses_string_expiry_and_deletes_expired_in_same_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Expired string timestamps trigger in-session deletion without opening a nested session.""" + expired_iso = (datetime.now(UTC) - timedelta(hours=1)).isoformat() + connection = FakeArrowOdbcConnection( + result=EMPTY_RESULT, + responder=ScriptedResponder(( + "SELECT data, expires_at FROM sess", + pa.table({"data": ["dg=="], "expires_at": [expired_iso]}), + )), + ) + config = ArrowOdbcConfig( + connection_config={"connection_string": MSSQL_CONNECTION_STRING}, + connection_instance=as_connection(connection), + extension_config={"litestar": {"session_table": "sess"}}, + ) + store = ArrowOdbcStore(config) + session_entries = 0 + original_provide_session = ArrowOdbcConfig.provide_session + + def counting_provide_session(self: ArrowOdbcConfig, *args: Any, **kwargs: Any) -> Any: + nonlocal session_entries + session_entries += 1 + return original_provide_session(self, *args, **kwargs) + + monkeypatch.setattr(ArrowOdbcConfig, "provide_session", counting_provide_session) + + assert await store.get("k") is None + assert session_entries == 1 + assert normalized_calls(connection) == [ + ("SELECT data, expires_at FROM sess WHERE session_id = ?", ["k"]), + ("DELETE FROM sess WHERE session_id = ?", ["k"]), + ] + assert connection.commit_calls == 1 + + +def test_tsql_adk_append_event_and_update_state_rolls_back_when_session_missing() -> None: + """append_event_and_update_state rolls back when the target session does not exist.""" + connection = FakeArrowOdbcConnection(result=EMPTY_RESULT) + store = ArrowOdbcADKStore(_tsql_adk_config(connection)) + + with pytest.raises(ValueError, match="Session s1 not found"): + store.append_event_and_update_state(_ADK_EVENT, "app", "u", "s1", {"a": 3}) + + assert connection.rollback_calls == 1 + assert connection.commit_calls == 0 + + +def test_tsql_adk_memory_delete_missing_table_returns_zero_and_search_escapes_wildcards( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Memory store deletes return 0 on missing table and search_entries escapes LIKE wildcards.""" + monkeypatch.setattr("sqlspec.adapters.arrow_odbc.driver.ArrowOdbcError", FakeOdbcError) + missing_error = FakeOdbcError("State: 42S02, Native error: 208, Message: Invalid object name 'dbo.adk_memory'.") + missing_store = ArrowOdbcADKMemoryStore(_tsql_adk_config(FakeArrowOdbcConnection(error=missing_error))) + assert missing_store.delete_entries_by_session("s1") == 0 + assert missing_store.delete_entries_older_than(7) == 0 + + connection = FakeArrowOdbcConnection(result=EMPTY_RESULT) + store = ArrowOdbcADKMemoryStore(_tsql_adk_config(connection)) + store.search_entries("100%_done[now]", "app", "u", limit=5) + _, params = normalized_calls(connection)[0] + assert params == ["app", "u", "%100[%]_done[[]now]_%".replace("_done", "[_]done").replace("]_", "]")] + + +def test_schema_qualified_tables_across_tsql_stores() -> None: + """Schema-qualified table names produce valid schema/table splits and bare constraint/index names.""" + connection = FakeArrowOdbcConnection(result=EMPTY_RESULT) + config = ArrowOdbcConfig( + connection_config={"connection_string": MSSQL_CONNECTION_STRING}, + connection_instance=as_connection(connection), + extension_config={"events": {"queue_table": "custom.app_events"}}, + ) + adk_store = ArrowOdbcADKStore(config) + adk_store._session_table = "custom.adk_session" # pyright: ignore[reportPrivateUsage] + memory_store = ArrowOdbcADKMemoryStore(config) + memory_store._memory_table = "custom.adk_memory" # pyright: ignore[reportPrivateUsage] + litestar_store = ArrowOdbcStore(config) + litestar_store._sql = _TsqlSessionSql("custom.sess") # pyright: ignore[reportPrivateUsage] + event_store = ArrowOdbcEventQueueStore(config) + + session_ddl = adk_store._sessions_table_ddl() # pyright: ignore[reportPrivateUsage] + assert "[custom].[adk_session]" in session_ddl + assert "[pk_adk_session_row_id]" in session_ddl + + memory_ddl = memory_store._memory_table_ddl() # pyright: ignore[reportPrivateUsage] + assert "[custom].[adk_memory]" in memory_ddl + assert "[pk_adk_memory_id]" in memory_ddl + + litestar_ddl = litestar_store._table_ddl() # pyright: ignore[reportPrivateUsage] + assert "SCHEMA_ID(N'custom')" in litestar_ddl + assert "IX_sess_expires_at" in litestar_ddl + + event_ddls = event_store.create_statements() + assert "OBJECT_ID(N'[custom].[app_events]', N'U')" in event_ddls[0] + assert "OBJECT_ID(N'[custom].[app_events]')" in event_ddls[1] + assert "idx_custom_app_events_channel_status" in event_ddls[1] + + +def test_datetime_value_accepts_date_and_rejects_invalid_types() -> None: + """_datetime_value converts datetime.date to midnight UTC and raises TypeError on invalid types.""" + assert _datetime_value(date(2026, 5, 10)) == datetime(2026, 5, 10, 0, 0, tzinfo=UTC) + with pytest.raises(TypeError, match="Unsupported datetime value"): + _datetime_value(12345) diff --git a/tests/unit/adapters/test_async_adapters.py b/tests/unit/adapters/test_async_adapters.py index 2c0db6f2d..961884164 100644 --- a/tests/unit/adapters/test_async_adapters.py +++ b/tests/unit/adapters/test_async_adapters.py @@ -240,7 +240,7 @@ async def test_async_dispatch_no_dead_processed_state_cast(aiosqlite_async_drive async def test_async_driver_releases_pooled_statement(aiosqlite_async_driver: AiosqliteDriver) -> None: - """Pooled statements should be reset after dispatch execution.""" + """Pooled statements stay intact when attached to SQLResult and reset when released on failure.""" seed = "SELECT * FROM users WHERE id = ?" aiosqlite_async_driver.prepare_statement( seed, (1,), statement_config=aiosqlite_async_driver.statement_config, kwargs={} @@ -251,12 +251,28 @@ async def test_async_driver_releases_pooled_statement(aiosqlite_async_driver: Ai assert pooled._pooled is True - await aiosqlite_async_driver.dispatch_statement_execution(pooled, aiosqlite_async_driver.connection) + result = await aiosqlite_async_driver.dispatch_statement_execution(pooled, aiosqlite_async_driver.connection) - assert pooled._raw_sql == "" - assert pooled._processed_state is Empty - assert pooled._filters == [] - assert pooled._statement_config is get_default_config() + assert result.statement is pooled + assert pooled._pooled is False + assert pooled._raw_sql == seed + + failing_seed = "SELECT * FROM nonexistent_table WHERE id = ?" + aiosqlite_async_driver.prepare_statement( + failing_seed, (1,), statement_config=aiosqlite_async_driver.statement_config, kwargs={} + ) + failing_pooled = aiosqlite_async_driver.prepare_statement( + failing_seed, (2,), statement_config=aiosqlite_async_driver.statement_config, kwargs={} + ) + assert failing_pooled._pooled is True + + with pytest.raises(Exception): + await aiosqlite_async_driver.dispatch_statement_execution(failing_pooled, aiosqlite_async_driver.connection) + + assert failing_pooled._raw_sql == "" + assert failing_pooled._processed_state is Empty + assert failing_pooled._filters == [] + assert failing_pooled._statement_config is get_default_config() async def test_async_driver_transaction_management(aiosqlite_async_driver: AiosqliteDriver) -> None: diff --git a/tests/unit/adapters/test_asyncmy/test_driver.py b/tests/unit/adapters/test_asyncmy/test_driver.py index 3ba8a226a..454265359 100644 --- a/tests/unit/adapters/test_asyncmy/test_driver.py +++ b/tests/unit/adapters/test_asyncmy/test_driver.py @@ -29,3 +29,72 @@ def test_connection_in_transaction_defaults_false_without_server_status() -> Non """_connection_in_transaction() returns False when connection has no server_status attribute.""" driver = AsyncmyDriver(connection=cast("Any", _StatelessConnection())) assert driver._connection_in_transaction() is False + + +def test_mysql_connection_errno_takes_precedence_over_sqlstate_08() -> None: + """MySQL errno 2006/2013 maps to ConnectionTimeoutError even when SQLSTATE starts with 08.""" + from sqlspec.adapters.asyncmy.core import create_mapped_exception + from sqlspec.exceptions import ConnectionTimeoutError, DatabaseConnectionError + + class _FakeExc(Exception): + pass + + lost_exc = _FakeExc(2013, "08S01", "Lost connection to MySQL server during query") + assert isinstance(create_mapped_exception(lost_exc), ConnectionTimeoutError) + + refused_exc = _FakeExc(2003, "08001", "Can't connect to MySQL server") + assert isinstance(create_mapped_exception(refused_exc), DatabaseConnectionError) + + +def test_can_use_local_infile_records_rejects_binary_and_timedelta_values() -> None: + """can_use_local_infile_records returns False when records contain binary or timedelta values.""" + from datetime import timedelta + + from sqlspec.adapters.asyncmy.core import can_use_local_infile_records + + assert can_use_local_infile_records(None, [(1, "clean"), (2, "ok")]) is True + assert can_use_local_infile_records(None, [(1, b"raw_bytes")]) is False + assert can_use_local_infile_records(None, [(1, timedelta(seconds=5))]) is False + + +async def test_provide_connection_rolls_back_dirty_transaction_on_release() -> None: + """AsyncmyConfig.provide_connection rolls back dirty transaction state before releasing connection.""" + from sqlspec.adapters.asyncmy.config import AsyncmyConfig + + class _DirtyConn: + def __init__(self) -> None: + self.server_status = 1 + self.rolled_back = False + + async def rollback(self) -> None: + self.rolled_back = True + self.server_status = 0 + + class _AcquireCtx: + def __init__(self, conn: _DirtyConn, released: list[Any]) -> None: + self._conn = conn + self._released = released + + async def __aenter__(self) -> _DirtyConn: + return self._conn + + async def __aexit__(self, *_: object) -> None: + self._released.append(self._conn) + + class _FakePool: + def __init__(self, conn: _DirtyConn) -> None: + self._conn = conn + self.released: list[Any] = [] + + def acquire(self) -> _AcquireCtx: + return _AcquireCtx(self._conn, self.released) + + conn = _DirtyConn() + pool = _FakePool(conn) + config = AsyncmyConfig( + connection_config={"host": "localhost", "database": "test"}, connection_instance=cast("Any", pool) + ) + async with config.provide_connection() as acquired: + assert cast("object", acquired) is conn + assert conn.rolled_back is True + assert pool.released == [conn] diff --git a/tests/unit/adapters/test_asyncpg/test_cloud_connectors.py b/tests/unit/adapters/test_asyncpg/test_cloud_connectors.py index 7d132279e..be6dcd2a4 100644 --- a/tests/unit/adapters/test_asyncpg/test_cloud_connectors.py +++ b/tests/unit/adapters/test_asyncpg/test_cloud_connectors.py @@ -536,3 +536,45 @@ async def test_pool_close_without_connectors() -> None: mock_pool.close.assert_called_once() assert config._cloud_sql_connector is None assert config._alloydb_connector is None + + +def test_cloud_sql_empty_segments_rejected() -> None: + """Cloud SQL instance with empty segments like '::' should raise ImproperConfigurationError.""" + with patch("sqlspec.adapters.asyncpg.config.CLOUD_SQL_CONNECTOR_INSTALLED", True): + with pytest.raises(ImproperConfigurationError, match="Invalid Cloud SQL instance format"): + AsyncpgConfig( + connection_config={"dsn": "postgresql://localhost/test"}, + driver_features={"enable_cloud_sql": True, "cloud_sql_instance": "::"}, + ) + + +def test_alloydb_incomplete_uri_rejected() -> None: + """AlloyDB URI missing required resource segments should raise ImproperConfigurationError.""" + with patch("sqlspec.adapters.asyncpg.config.ALLOYDB_CONNECTOR_INSTALLED", True): + with pytest.raises(ImproperConfigurationError, match="Invalid AlloyDB instance URI format"): + AsyncpgConfig( + connection_config={"dsn": "postgresql://localhost/test"}, + driver_features={"enable_alloydb": True, "alloydb_instance_uri": "projects/my-proj"}, + ) + + +async def test_cloud_sql_connector_cleaned_up_when_create_pool_fails(mock_cloud_sql_module) -> None: + """If asyncpg_create_pool raises, the initialized Cloud SQL connector should be closed and reset.""" + with patch("sqlspec.adapters.asyncpg.config.CLOUD_SQL_CONNECTOR_INSTALLED", True): + mock_connector = MagicMock() + mock_connector.close_async = AsyncMock() + mock_cloud_sql_module.return_value = mock_connector + + with patch("sqlspec.adapters.asyncpg.config.asyncpg_create_pool", new_callable=AsyncMock) as mock_create_pool: + mock_create_pool.side_effect = RuntimeError("pool init failed") + + config = AsyncpgConfig( + connection_config={"user": "testuser", "password": "testpass", "database": "testdb"}, + driver_features={"enable_cloud_sql": True, "cloud_sql_instance": "project:region:instance"}, + ) + + with pytest.raises(RuntimeError, match="pool init failed"): + await config._create_pool() + + mock_connector.close_async.assert_awaited_once() + assert config._cloud_sql_connector is None diff --git a/tests/unit/adapters/test_asyncpg/test_config.py b/tests/unit/adapters/test_asyncpg/test_config.py index a670cbedc..ec00b7cab 100644 --- a/tests/unit/adapters/test_asyncpg/test_config.py +++ b/tests/unit/adapters/test_asyncpg/test_config.py @@ -6,8 +6,14 @@ import pytest from asyncpg.pool import PoolConnectionProxy, PoolConnectionProxyMeta +import sqlspec.adapters.asyncpg.config as config_mod from sqlspec.adapters.asyncpg._typing import AsyncpgSessionContext -from sqlspec.adapters.asyncpg.config import AsyncpgConfig, AsyncpgConnectionConfig, AsyncpgPoolConfig +from sqlspec.adapters.asyncpg.config import ( + AsyncpgConfig, + AsyncpgConnectionConfig, + AsyncpgDriverFeatures, + AsyncpgPoolConfig, +) from sqlspec.adapters.asyncpg.core import ( build_connection_config, build_postgres_extension_probe_names, @@ -310,3 +316,76 @@ def test_asyncpg_config_normalizes_aliases() -> None: assert "conninfo" not in config.connection_config assert "dbname" not in config.connection_config assert "username" not in config.connection_config + + +def test_asyncpg_driver_features_contract_retains_configured_keys() -> None: + """AsyncpgConfig retains on_connection_create and type_codecs and populates event defaults.""" + assert "connection_instance" not in AsyncpgDriverFeatures.__annotations__ + + async def hook(_: object) -> None: + return None + + codecs = [{"typename": "citext", "encoder": str, "decoder": str}] + config = AsyncpgConfig(driver_features={"on_connection_create": hook, "type_codecs": codecs}) + + assert config.driver_features["on_connection_create"] is hook + assert config.driver_features["type_codecs"] == codecs + assert config.driver_features["enable_events"] is True + assert config.driver_features["events_backend"] == "notify" + + +async def test_asyncpg_user_init_callback_chains_after_internal_init(monkeypatch: pytest.MonkeyPatch) -> None: + """Custom connection_config['init'] should run after _init_connection in both _create_pool and create_connection.""" + order: list[str] = [] + + async def user_init(_: object) -> None: + order.append("user_init") + + captured_init: dict[str, Any] = {} + + async def fake_create_pool(*, init: Any = None, **_: Any) -> object: + captured_init["init"] = init + return object() + + monkeypatch.setattr(config_mod, "asyncpg_create_pool", fake_create_pool) + + config = AsyncpgConfig( + connection_config={"dsn": "postgresql://localhost/test", "init": user_init}, + driver_features={ + "enable_json_codecs": False, + "enable_pgvector": False, + "enable_paradedb": False, + "enable_pg_textsearch": False, + "on_connection_create": AsyncMock(side_effect=lambda _: order.append("hook")), + }, + ) + + await config._create_pool() # pyright: ignore[reportPrivateUsage] + await captured_init["init"](AsyncMock()) + assert order == ["hook", "user_init"] + + order.clear() + monkeypatch.setattr(config_mod, "asyncpg_connect", AsyncMock(return_value=AsyncMock())) + await config.create_connection() + assert order == ["hook", "user_init"] + + +async def test_asyncpg_extension_probe_retries_after_transient_error() -> None: + """A failed extension probe should not permanently poison _pgvector_available.""" + failing_conn = AsyncMock() + failing_conn.fetch.side_effect = RuntimeError("transient connection error") + healthy_conn = AsyncMock() + healthy_conn.fetch.return_value = [{"extname": "pg_textsearch"}] + + config = AsyncpgConfig( + driver_features={"enable_json_codecs": False, "enable_pgvector": False, "enable_paradedb": False} + ) + + await config._init_connection(failing_conn) # pyright: ignore[reportPrivateUsage] + assert config._pgvector_available is None # pyright: ignore[reportPrivateUsage] + assert config.pg_textsearch_available is False + + await config._init_connection(healthy_conn) # pyright: ignore[reportPrivateUsage] + assert config._pgvector_available is False # pyright: ignore[reportPrivateUsage] + assert config.pg_textsearch_available is True + assert config.statement_config.dialect == "pg_textsearch" diff --git a/tests/unit/adapters/test_asyncpg/test_transaction_state.py b/tests/unit/adapters/test_asyncpg/test_transaction_state.py index 44d3f59fe..6ac4c9341 100644 --- a/tests/unit/adapters/test_asyncpg/test_transaction_state.py +++ b/tests/unit/adapters/test_asyncpg/test_transaction_state.py @@ -1,6 +1,8 @@ """asyncpg transaction control uses the native transaction handle.""" import inspect +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from typing import Any, cast from unittest.mock import AsyncMock, MagicMock @@ -8,7 +10,12 @@ import pytest from sqlspec.adapters.asyncpg.config import AsyncpgConfig +from sqlspec.adapters.asyncpg.core import AsyncpgStreamSource from sqlspec.adapters.asyncpg.driver import AsyncpgDriver +from sqlspec.adapters.asyncpg.events.backend import AsyncpgEventsBackend, AsyncpgHybridEventsBackend +from sqlspec.adapters.asyncpg.litestar.store import AsyncpgStore +from sqlspec.core import SQL, StatementConfig, StatementStack +from sqlspec.exceptions import DeadlockError pytestmark = pytest.mark.anyio @@ -104,6 +111,153 @@ async def test_a_stream_can_open_a_transaction_after_driver_begin() -> None: assert connection.is_in_transaction() is True +async def test_stream_source_reuses_outer_transaction_without_savepoint() -> None: + """AsyncpgStreamSource should not start a nested savepoint when already in a transaction.""" + connection = _FakeConnection() + connection.cursor = AsyncMock(return_value=MagicMock(fetch=AsyncMock(return_value=[]))) # type: ignore[attr-defined] + driver = AsyncpgDriver(cast("Any", connection)) + + await driver.begin() + source = AsyncpgStreamSource(driver, "SELECT 1", (), 10) + await source.start() + await source.close() + + assert connection.transaction_calls == ["start"] + + +async def test_stream_source_suppresses_rollback_error_on_cursor_failure() -> None: + """AsyncpgStreamSource should suppress rollback errors when cursor creation fails.""" + tx = MagicMock() + tx.start = AsyncMock() + tx.rollback = AsyncMock(side_effect=RuntimeError("connection lost")) + conn = MagicMock() + conn.transaction = MagicMock(return_value=tx) + conn.is_in_transaction = MagicMock(return_value=False) + conn.cursor = AsyncMock(side_effect=ValueError("cursor failed")) + driver = AsyncpgDriver(cast("Any", conn)) + + source = AsyncpgStreamSource(driver, "SELECT 1", (), 10) + with pytest.raises(ValueError, match="cursor failed"): + await source.start() + assert source._transaction is None # pyright: ignore[reportPrivateUsage] + + +async def test_begin_resets_stale_transaction_and_maps_exceptions() -> None: + """begin() should reset stale _transaction when connection is not in transaction and map errors.""" + connection = _FakeConnection() + driver = AsyncpgDriver(cast("Any", connection)) + await driver.begin() + connection._depth = 0 + await driver.begin() + assert connection.transaction_calls == ["start", "start"] + + failing_tx = MagicMock() + failing_tx.start = AsyncMock(side_effect=asyncpg.exceptions.DeadlockDetectedError("deadlock")) + failing_conn = MagicMock() + failing_conn.transaction = MagicMock(return_value=failing_tx) + failing_conn.is_in_transaction = MagicMock(return_value=False) + failing_driver = AsyncpgDriver(cast("Any", failing_conn)) + with pytest.raises(DeadlockError): + await failing_driver.begin() + + +async def test_commit_and_rollback_skip_raw_sql_when_not_in_transaction() -> None: + """commit() and rollback() should not execute raw COMMIT/ROLLBACK when connection is not in transaction.""" + connection = _FakeConnection() + driver = AsyncpgDriver(cast("Any", connection)) + + await driver.commit() + await driver.rollback() + + assert connection.statements == [] + + +async def test_execute_stack_uses_cached_statement_without_manual_prepare() -> None: + """execute_stack should use _execute_cached_statement and parse affected rows without connection.prepare.""" + conn = MagicMock() + conn.is_in_transaction = MagicMock(return_value=True) + conn.execute = AsyncMock(return_value="UPDATE 3") + conn.prepare = AsyncMock() + driver = AsyncpgDriver(cast("Any", conn)) + + stack = StatementStack().push_execute("UPDATE items SET name = :name WHERE id = :id", {"name": "a", "id": 1}) + results = await driver.execute_stack(stack) + + assert len(results) == 1 + assert results[0].rows_affected == 3 + conn.prepare.assert_not_awaited() + conn.execute.assert_awaited_once() + + +async def test_copy_operation_parses_quoted_table_with_dot_and_forwards_timeout() -> None: + """COPY FROM STDIN should preserve quoted table names containing periods and forward timeouts.""" + conn = MagicMock() + conn.copy_to_table = AsyncMock() + driver = AsyncpgDriver(cast("Any", conn)) + + config = StatementConfig(dialect="postgres", execution_args={"postgres_copy_data": "1\talpha\n", "timeout": 7.5}) + stmt = SQL('COPY "my.schema"."my.table" (id, name) FROM STDIN', statement_config=config) + await driver._handle_copy_operation(cast("Any", conn), stmt) # pyright: ignore[reportPrivateUsage] + + conn.copy_to_table.assert_awaited_once() + call = conn.copy_to_table.await_args + assert call.args == ("my.table",) + assert call.kwargs["schema_name"] == "my.schema" + assert call.kwargs["timeout"] == 7.5 + + +def test_litestar_store_ddl_folds_autovacuum_into_create_table() -> None: + """AsyncpgStore._table_ddl should include autovacuum settings inside WITH (...) without ALTER TABLE.""" + config = AsyncpgConfig( + extension_config={ + "litestar": { + "fillfactor": 75, + "autovacuum_vacuum_scale_factor": 0.04, + "autovacuum_analyze_scale_factor": 0.01, + } + } + ) + store = AsyncpgStore(config) + ddl = store._table_ddl() # pyright: ignore[reportPrivateUsage] + + assert "ALTER TABLE" not in ddl + assert "fillfactor = 75" in ddl + assert "autovacuum_vacuum_scale_factor = 0.04" in ddl + assert "autovacuum_analyze_scale_factor = 0.01" in ddl + + +async def test_events_backends_manage_transactions_correctly() -> None: + """Hybrid _publish_durable wraps in begin/commit while native publish omits spurious commit.""" + calls: list[str] = [] + driver = MagicMock() + driver.begin = AsyncMock(side_effect=lambda: calls.append("begin")) + driver.execute = AsyncMock(side_effect=lambda *_: calls.append("execute")) + driver.commit = AsyncMock(side_effect=lambda: calls.append("commit")) + driver.rollback = AsyncMock(side_effect=lambda: calls.append("rollback")) + + @asynccontextmanager + async def fake_session(*_a: Any, **_k: Any) -> AsyncIterator[Any]: + yield driver + + config = MagicMock() + config.is_async = True + config.get_observability_runtime.return_value = MagicMock() + config.provide_session = fake_session + + queue = MagicMock() + queue._insert_statement = "INSERT INTO q VALUES (:event_id)" + queue._statement_config = StatementConfig(dialect="postgres") + + hybrid = AsyncpgHybridEventsBackend(config, queue) + await hybrid.publish("chan", {"x": 1}) + assert calls == ["begin", "execute", "execute", "commit"] + + calls.clear() + native = AsyncpgEventsBackend(config) + await native.publish("chan", {"x": 1}) + assert calls == ["execute"] + + async def test_create_connection_consumes_no_pool_slot(monkeypatch: pytest.MonkeyPatch) -> None: sentinel = MagicMock() connect = AsyncMock(return_value=sentinel) diff --git a/tests/unit/adapters/test_asyncpg/test_type_handlers.py b/tests/unit/adapters/test_asyncpg/test_type_handlers.py index 6e13195ef..6b360b625 100644 --- a/tests/unit/adapters/test_asyncpg/test_type_handlers.py +++ b/tests/unit/adapters/test_asyncpg/test_type_handlers.py @@ -6,7 +6,7 @@ import pytest from sqlspec.adapters.asyncpg.config import register_json_codecs, register_pgvector_support -from sqlspec.adapters.asyncpg.core import create_mapped_exception +from sqlspec.adapters.asyncpg.core import create_mapped_exception, invoke_prepared_statement from sqlspec.exceptions import OperationCancelledError, PermissionDeniedError, QueryTimeoutError, UniqueViolationError from sqlspec.utils.serializers import from_json, to_json @@ -41,12 +41,34 @@ async def test_register_json_codecs_success() -> None: } assert json_call.kwargs["encoder"]({"value": 1}) == b'{"value":1}' + assert json_call.kwargs["encoder"]("console") == b'"console"' + assert json_call.kwargs["encoder"]('{"pre":"serialized"}') == b'{"pre":"serialized"}' + assert json_call.kwargs["encoder"](b'\x01{"value":1}') == b'{"value":1}' assert json_call.kwargs["decoder"](b'{"value":1}') == {"value": 1} assert jsonb_call.kwargs["encoder"]({"value": 1}) == b'\x01{"value":1}' + assert jsonb_call.kwargs["encoder"]("console") == b'\x01"console"' + assert jsonb_call.kwargs["encoder"]('{"pre":"serialized"}') == b'\x01{"pre":"serialized"}' assert jsonb_call.kwargs["encoder"](b'\x01{"value":1}') == b'\x01{"value":1}' assert jsonb_call.kwargs["decoder"](b'\x01{"value":1}') == {"value": 1} +@pytest.mark.anyio +async def test_invoke_prepared_statement_unpacks_dict_parameters_positionally() -> None: + """Dict parameters passed to invoke_prepared_statement should unpack values positionally.""" + prepared = MagicMock() + prepared.fetch = AsyncMock(return_value=[{"id": 1}]) + prepared.get_statusmsg = MagicMock(return_value="UPDATE 2") + + rows = await invoke_prepared_statement(prepared, {"p1": "alpha", "p2": 42}, fetch=True, timeout=1.5) + assert rows == [{"id": 1}] + prepared.fetch.assert_awaited_once_with("alpha", 42, timeout=1.5) + + prepared.fetch.reset_mock() + status = await invoke_prepared_statement(prepared, {"p1": "beta"}, fetch=False, timeout=2.0) + assert status == "UPDATE 2" + prepared.fetch.assert_awaited_once_with("beta", timeout=2.0) + + @pytest.mark.anyio async def test_register_json_codecs_handles_exception() -> None: """Test that JSON codec registration handles exceptions gracefully.""" diff --git a/tests/unit/adapters/test_bigquery/test_core.py b/tests/unit/adapters/test_bigquery/test_core.py index ebc858abc..7383e5aac 100644 --- a/tests/unit/adapters/test_bigquery/test_core.py +++ b/tests/unit/adapters/test_bigquery/test_core.py @@ -3,26 +3,49 @@ import importlib from collections.abc import ItemsView, Iterable, Iterator, Mapping from contextlib import nullcontext +from datetime import UTC, datetime from types import SimpleNamespace from typing import Any, cast +from unittest.mock import patch import pytest +from google.cloud import bigquery from google.cloud.bigquery import QueryJobConfig +from google.cloud.exceptions import Forbidden, GoogleCloudError +from sqlspec.adapters.bigquery import BigQueryConfig +from sqlspec.adapters.bigquery._typing import BigQueryCursor, BigQuerySessionContext +from sqlspec.adapters.bigquery.adk.store import BigQueryADKStore from sqlspec.adapters.bigquery.core import ( _COPY_JOB_FIELDS, DEFAULT_REQUEST_TIMEOUT, BigQueryStreamSource, + build_dml_rowcount, build_profile, build_retry, collect_rows, copy_job_config, + create_mapped_exception, + create_parameters, driver_profile, resolve_column_names, run_query_job, + try_bulk_insert, ) +from sqlspec.adapters.bigquery.data_dictionary import BigQueryDataDictionary from sqlspec.adapters.bigquery.driver import BigQueryDriver -from sqlspec.core import ParameterStyle +from sqlspec.adapters.bigquery.events.store import BigQueryEventQueueStore +from sqlspec.adapters.bigquery.litestar.store import BigQueryStore +from sqlspec.core import ParameterStyle, TypedParameter +from sqlspec.exceptions import ( + ImproperConfigurationError, + NotFoundError, + OperationalError, + PermissionDeniedError, + QueryTimeoutError, + SQLParsingError, + UniqueViolationError, +) from sqlspec.utils.serializers import to_json @@ -566,3 +589,334 @@ def test_native_default_job_config_guides_fast_path() -> None: driver = BigQueryDriver(cast("Any", connection)) assert driver._default_query_job_config is native_defaults assert driver._can_use_query_and_wait(SQL("SELECT 1"), "SELECT 1") is False + + +def test_exception_mapping_permission_denied_unauthenticated_and_timeout() -> None: + """Verify BigQuery exception mapping: 403 -> PermissionDeniedError, timeout -> QueryTimeoutError, 503 -> OperationalError.""" + forbidden = cast("Any", Forbidden)("Access Denied", errors=[{"reason": "accessDenied"}]) + timeout_err = _cloud_error("Query timed out", code=504, errors=[{"reason": "timeout"}]) + server_err = _cloud_error("Service unavailable", code=503, errors=[{"reason": "backendError"}]) + syntax_err = _cloud_error("Syntax error at [1:5]", code=400, errors=[{"reason": "invalidQuery"}]) + conflict_err = _cloud_error("Already exists", code=409, errors=[{"reason": "duplicate"}]) + not_found_err = _cloud_error("Not found", code=404, errors=[{"reason": "notFound"}]) + + assert isinstance(create_mapped_exception(forbidden), PermissionDeniedError) + assert isinstance(create_mapped_exception(timeout_err), QueryTimeoutError) + assert isinstance(create_mapped_exception(server_err), OperationalError) + assert isinstance(create_mapped_exception(syntax_err), SQLParsingError) + assert isinstance(create_mapped_exception(conflict_err), UniqueViolationError) + assert isinstance(create_mapped_exception(not_found_err), NotFoundError) + + +def test_adk_store_maps_google_cloud_errors_and_returns_dml_rowcount(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify BigQueryADKStore._run_query maps GoogleCloudError and delete helpers return affected rows.""" + config = BigQueryConfig( + connection_config={"project": "p", "dataset_id": "ds"}, extension_config={"adk": {"enable_memory": False}} + ) + store = BigQueryADKStore(config) + + class _Job: + num_dml_affected_rows = 4 + + def result(self) -> list[dict[str, Any]]: + return [] + + class _Client: + def query(self, _sql: str, **_kwargs: Any) -> _Job: + return _Job() + + monkeypatch.setattr(config, "create_connection", _Client) + deleted = store.delete_expired_events(datetime.now(UTC)) + assert deleted == 4 + + class _FailingClient: + def query(self, _sql: str, **_kwargs: Any) -> Any: + raise cast("Any", Forbidden)("Permission denied", errors=[{"reason": "accessDenied"}]) + + monkeypatch.setattr(config, "create_connection", _FailingClient) + with pytest.raises(PermissionDeniedError): + store.delete_expired_events(datetime.now(UTC)) + + +def test_adk_store_json_param_avoids_double_encoding_and_infers_owner_id_type(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify _json_param passes parsed JSON value to ScalarQueryParameter and infers INT64 owner_id.""" + custom_serialized: list[Any] = [] + + def custom_to_json(val: Any) -> str: + custom_serialized.append(val) + return '{"ok":true}' + + config = BigQueryConfig( + connection_config={"project": "p", "dataset_id": "ds"}, + driver_features={"json_serializer": custom_to_json}, + extension_config={"adk": {"enable_memory": False, "owner_id_column": "owner_id INT64"}}, + ) + store = BigQueryADKStore(config) + param = store._json_param("state", {"ok": True}) + assert param.type_ == "JSON" + assert param.value == {"ok": True} + assert custom_serialized == [{"ok": True}] + + captured_params: list[Any] = [] + + def capture_query(_self: BigQueryADKStore, _sql: str, parameters: Any = None) -> list[dict[str, Any]]: + captured_params.extend(parameters or []) + return [] + + monkeypatch.setattr(BigQueryADKStore, "_run_query", capture_query) + store.create_session("s1", "app", "u1", {"a": 1}, owner_id=99) + owner_param = next(p for p in captured_params if p.name == "owner_id") + assert owner_param.type_ == "INT64" + assert owner_param.value == 99 + + +def test_bigquery_driver_session_transactions_and_dry_run() -> None: + """Verify BigQueryDriver begin/commit/rollback session propagation and dry_run bytes processed.""" + queries: list[tuple[str, Any]] = [] + + class _Job: + statement_type = "SCRIPT" + schema: list[Any] = [] + total_bytes_processed = 2048 + session_info = SimpleNamespace(session_id="bq-session-123") + + def result(self, **_kwargs: Any) -> list[Any]: + return [] + + class _Conn: + def query(self, sql: str, *, job_config: Any = None, **_kwargs: Any) -> _Job: + queries.append((sql, job_config)) + return _Job() + + driver = BigQueryDriver(cast("Any", _Conn())) + assert driver._connection_in_transaction() is False + + driver.begin() + assert driver._connection_in_transaction() is True + assert driver._session_id == "bq-session-123" + assert queries[-1][0] == "BEGIN TRANSACTION" + assert queries[-1][1].create_session is True + + driver.commit() + assert driver._connection_in_transaction() is False + assert queries[-1][0] == "COMMIT TRANSACTION" + conn_props = queries[-1][1].connection_properties + assert any(p.key == "session_id" and p.value == "bq-session-123" for p in conn_props) + + estimated = driver.dry_run("SELECT 1") + assert estimated == 2048 + assert queries[-1][1].dry_run is True + assert queries[-1][1].use_query_cache is False + + +def test_select_to_arrow_reader_handles_empty_batches_and_local_emulator(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify select_to_arrow('reader') does not re-execute on empty batches and checks native_only on emulator.""" + query_calls = 0 + + class _EmptyRowIterator: + schema = [SimpleNamespace(name="id")] + + def to_arrow_iterable(self, *, bqstorage_client: Any = None) -> Any: + del bqstorage_client + return iter(()) + + class _Job: + statement_type = "SELECT" + schema = [SimpleNamespace(name="id")] + + def result(self, **_kwargs: Any) -> _EmptyRowIterator: + return _EmptyRowIterator() + + class _Conn: + _connection = SimpleNamespace(API_BASE_URL="https://bigquery.googleapis.com") + + def query(self, _sql: str, **_kwargs: Any) -> _Job: + nonlocal query_calls + query_calls += 1 + return _Job() + + driver = BigQueryDriver(cast("Any", _Conn())) + result = driver.select_to_arrow("SELECT id FROM t WHERE 1=0", return_format="reader") + assert query_calls == 1 + assert result.get_data().read_all().num_rows == 0 + assert result.get_data().schema.names == ["id"] + + local_conn = _Conn() + local_conn._connection = SimpleNamespace(API_BASE_URL="http://127.0.0.1:9050") + local_driver = BigQueryDriver(cast("Any", local_conn)) + monkeypatch.setattr("sqlspec.adapters.bigquery.driver.storage_api_available", lambda: False) + with pytest.raises(ImproperConfigurationError): + local_driver.select_to_arrow("SELECT 1", return_format="reader", native_only=True) + + +def test_parameter_creation_resolves_typed_empty_arrays_and_json_dicts() -> None: + """Verify create_parameters handles TypedParameter([], list[int]) and JSON dicts.""" + params = { + "empty_int_list": TypedParameter([], list[int]), + "null_int_list": TypedParameter([None], list[int]), + "nested": {"outer": {"inner": 42}, "tags": ["a", "b"]}, + } + bq_params = create_parameters(params, json_serializer=to_json) + by_name = {p.name: p for p in bq_params} + + assert isinstance(by_name["empty_int_list"], bigquery.ArrayQueryParameter) + assert by_name["empty_int_list"].array_type == "INT64" + assert isinstance(by_name["null_int_list"], bigquery.ArrayQueryParameter) + assert by_name["null_int_list"].array_type == "INT64" + assert isinstance(by_name["nested"], bigquery.ScalarQueryParameter) + assert by_name["nested"].type_ == "JSON" + + +def test_default_dataset_qualifies_unqualified_tables_in_bulk_insert_and_stores() -> None: + """Verify default_dataset qualifies tables across bulk insert, data dictionary, events, and Litestar.""" + loaded_tables: list[str] = [] + + class _LoadJob: + output_rows = 2 + + def result(self, **_kwargs: Any) -> None: + return None + + class _Conn: + def load_table_from_file(self, _buf: Any, table_id: str, **_kwargs: Any) -> _LoadJob: + loaded_tables.append(table_id) + return _LoadJob() + + rowcount = try_bulk_insert( + cast("Any", _Conn()), + "INSERT INTO users (id, name) VALUES (@p1, @p2)", + [{"p1": 1, "p2": "a"}, {"p1": 2, "p2": "b"}], + None, + allow_parse=True, + default_dataset="my_proj.my_ds", + ) + assert rowcount == 2 + assert loaded_tables == ["my_proj.my_ds.users"] + + config = BigQueryConfig( + connection_config={"project": "my_proj", "dataset_id": "my_ds"}, + extension_config={"litestar": {"require_partition_filter": True}}, + ) + events_store = BigQueryEventQueueStore(config) + assert events_store.table_name == "my_ds.sqlspec_event_queue" + assert "my_ds.sqlspec_event_queue" in events_store.create_statements()[0] + + litestar_store = BigQueryStore(config) + assert litestar_store.table_name == "my_ds.litestar_session" + executed_sql: list[str] = [] + + class _FakeDriver: + def execute(self, sql: str, **_kwargs: Any) -> Any: + executed_sql.append(sql) + return SimpleNamespace(get_affected_count=lambda: 1) + + cast("Any", config).provide_session = lambda *_args, **_kwargs: nullcontext(_FakeDriver()) + litestar_store._delete_expired() + assert "my_ds.litestar_session" in executed_sql[0] + assert "1970-01-01" in executed_sql[0] + + dd = BigQueryDataDictionary() + dd_queries: list[tuple[str, dict[str, Any]]] = [] + + class _DDDriver: + _default_query_job_config = SimpleNamespace(default_dataset="my_proj.my_ds") + + def select(self, query: str, **kwargs: Any) -> list[Any]: + dd_queries.append((query, kwargs)) + return [] + + dd.get_tables(cast("Any", _DDDriver())) + assert dd_queries[0][1]["schema_name"] == "my_ds" + assert "`my_proj.my_ds.INFORMATION_SCHEMA.TABLES`" in dd_queries[0][0] + + +def test_bigquery_stream_source_closes_and_cancels_active_job() -> None: + """Verify BigQueryStreamSource.close cancels active query job and BigQueryCursor cleans up safely.""" + cancelled: list[bool] = [] + + class _Job: + state = "RUNNING" + schema = [SimpleNamespace(name="x")] + + def cancel(self) -> None: + cancelled.append(True) + + def result(self, **_kwargs: Any) -> Any: + return SimpleNamespace(pages=iter([[{"x": 1}]])) + + class _Driver: + connection = SimpleNamespace() + _job_retry = None + _job_retry_deadline = 0.0 + _job_result_timeout = None + + def handle_database_exceptions(self) -> Any: + return nullcontext() + + def _check_pending_exception(self, _handler: Any) -> None: + return None + + def _run_query_job(self, *_args: Any) -> _Job: + return _Job() + + source = BigQueryStreamSource(cast("Any", _Driver()), "SELECT 1 AS x", None, 10) + source.start() + chunk = source.fetch_chunk() + assert chunk == [{"x": 1}] + source.close() + assert cancelled == [True] + + cursor = BigQueryCursor(cast("Any", SimpleNamespace())) + cursor.job = cast("Any", _Job()) + with cursor: + pass + assert cursor.job is None + assert len(cancelled) == 2 + + +def test_bigquery_session_context_rolls_back_on_exception_when_in_transaction() -> None: + """Verify BigQuerySessionContext.__exit__ rolls back active transaction on exception.""" + rolled_back: list[bool] = [] + released: list[bool] = [] + + class _Driver: + def __init__(self, **_kwargs: Any) -> None: + pass + + def rollback(self) -> None: + rolled_back.append(True) + + def _connection_in_transaction(self) -> bool: + return True + + ctx = BigQuerySessionContext( + acquire_connection=lambda: SimpleNamespace(), + release_connection=lambda *_args, **_kwargs: released.append(True), + statement_config=BigQueryDriver(cast("Any", SimpleNamespace())).statement_config, + driver_features={}, + prepare_driver=lambda d: d, + ) + with ( + pytest.raises(RuntimeError, match="boom"), + patch("sqlspec.adapters.bigquery._typing._get_driver_type", return_value=_Driver), + ctx, + ): + raise RuntimeError("boom") + + assert rolled_back == [True] + assert released == [True] + + +def test_build_dml_rowcount_preserves_zero_affected_rows() -> None: + """Verify build_dml_rowcount returns 0 when num_dml_affected_rows is 0 instead of falling back.""" + job_zero = SimpleNamespace(num_dml_affected_rows=0) + job_none = SimpleNamespace(num_dml_affected_rows=None, total_rows=0) + assert build_dml_rowcount(job_zero, 5) == 0 + assert build_dml_rowcount(job_none, 5) == 5 + + +def _cloud_error(message: str, *, code: int, errors: list[dict[str, str]] | None = None) -> GoogleCloudError: + err: Any = cast("Any", GoogleCloudError)(message) + err.code = code + err._errors = errors or [] + return cast("GoogleCloudError", err) diff --git a/tests/unit/adapters/test_cockroach_asyncpg/test_config.py b/tests/unit/adapters/test_cockroach_asyncpg/test_config.py index 692bf8793..93eb123a0 100644 --- a/tests/unit/adapters/test_cockroach_asyncpg/test_config.py +++ b/tests/unit/adapters/test_cockroach_asyncpg/test_config.py @@ -14,12 +14,15 @@ from sqlspec.adapters.cockroach_asyncpg import ( CockroachAsyncpgConfig, CockroachAsyncpgConnectionConfig, + CockroachAsyncpgDriver, CockroachAsyncpgDriverFeatures, CockroachAsyncpgPoolConfig, CockroachAsyncpgRetryConfig, build_connection_config, ) -from sqlspec.core import StatementConfig +from sqlspec.adapters.cockroach_asyncpg.adk.store import CockroachAsyncpgADKMemoryStore, CockroachAsyncpgADKStore +from sqlspec.adapters.cockroach_asyncpg.litestar.store import CockroachAsyncpgStore +from sqlspec.core import SQL, StatementConfig from sqlspec.exceptions import ImproperConfigurationError @@ -390,3 +393,159 @@ def test_cockroach_asyncpg_config_normalizes_aliases() -> None: assert "conninfo" not in config.connection_config assert "dbname" not in config.connection_config assert "username" not in config.connection_config + + +def test_cockroach_asyncpg_build_connection_config_cluster_and_extra() -> None: + """build_connection_config should route cluster to server_settings.options and flatten extra.""" + cfg = build_connection_config({ + "host": "localhost", + "cluster": "brave-hippo-42", + "server_settings": {"application_name": "sqlspec"}, + "extra": {"statement_cache_size": 0}, + }) + assert cfg["server_settings"] == {"application_name": "sqlspec", "options": "--cluster=brave-hippo-42"} + assert cfg["statement_cache_size"] == 0 + assert "cluster" not in cfg + assert "extra" not in cfg + + +def test_cockroach_asyncpg_preserves_on_connection_create_in_driver_features() -> None: + """CockroachAsyncpgConfig should preserve on_connection_create in driver_features.""" + + async def _hook(_conn: Any) -> None: + return None + + config = CockroachAsyncpgConfig(driver_features={"on_connection_create": _hook}) + assert config.driver_features["on_connection_create"] is _hook + + +def test_cockroach_asyncpg_retry_config_rejects_negative_values() -> None: + """CockroachAsyncpgRetryConfig.from_features should reject negative values.""" + with pytest.raises(ImproperConfigurationError, match="max_retries"): + CockroachAsyncpgRetryConfig.from_features({"max_retries": -1}) + with pytest.raises(ImproperConfigurationError, match="base_delay_ms"): + CockroachAsyncpgRetryConfig.from_features({"retry_delay_base_ms": -5.0}) + with pytest.raises(ImproperConfigurationError, match="max_delay_ms"): + CockroachAsyncpgRetryConfig.from_features({"retry_delay_max_ms": -10.0}) + + +async def test_cockroach_asyncpg_follower_read_auto_transaction_commits_and_rolls_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto-started follower-read transactions must commit after read and rollback on error.""" + in_tx = False + + def _in_tx(_self: Any) -> bool: + return in_tx + + async def _begin(_self: Any) -> None: + nonlocal in_tx + in_tx = True + + async def _commit(_self: Any) -> None: + nonlocal in_tx + in_tx = False + + async def _rollback(_self: Any) -> None: + nonlocal in_tx + in_tx = False + + monkeypatch.setattr(CockroachAsyncpgDriver, "_connection_in_transaction", _in_tx) + monkeypatch.setattr(CockroachAsyncpgDriver, "begin", _begin) + monkeypatch.setattr(CockroachAsyncpgDriver, "commit", _commit) + monkeypatch.setattr(CockroachAsyncpgDriver, "rollback", _rollback) + + conn = MagicMock() + conn.fetch = AsyncMock(return_value=[]) + driver = CockroachAsyncpgDriver( + connection=conn, driver_features={"enable_follower_reads": True, "default_staleness": "'-10s'"} + ) + stmt = SQL("SELECT 1") + await driver.dispatch_execute(conn, stmt) + assert in_tx is False + + conn.fetch = AsyncMock(side_effect=RuntimeError("boom")) + with pytest.raises(RuntimeError, match="boom"): + await driver.dispatch_execute(conn, stmt) + assert in_tx is False + + +async def test_cockroach_asyncpg_adk_memory_insert_12_and_13_placeholders() -> None: + """CockroachAsyncpgADKMemoryStore.insert_memory_entries must bind 12 or 13 placeholders inside a transaction.""" + executed: list[tuple[str, tuple[Any, ...]]] = [] + + class _FakeTx: + async def __aenter__(self) -> None: + return None + + async def __aexit__(self, *_args: Any) -> None: + return None + + class _FakeConn: + def transaction(self) -> _FakeTx: + return _FakeTx() + + async def execute(self, sql: str, *args: Any) -> str: + executed.append((sql, args)) + return "INSERT 0 1" + + class _FakeCtx: + async def __aenter__(self) -> _FakeConn: + return _FakeConn() + + async def __aexit__(self, *_args: Any) -> None: + return None + + config = CockroachAsyncpgConfig(extension_config={"adk": {"enable_memory": True}}) + monkeypatch_conn = MagicMock(return_value=_FakeCtx()) + object.__setattr__(config, "provide_connection", monkeypatch_conn) + store = CockroachAsyncpgADKMemoryStore(config) + entry: Any = { + "id": "m1", + "session_id": "s1", + "app_name": "app", + "user_id": "u1", + "scope": "user", + "event_id": "e1", + "author": "user", + "timestamp": "2026-01-01T00:00:00Z", + "content_json": {}, + "content_text": "hello", + "metadata_json": None, + "inserted_at": "2026-01-01T00:00:00Z", + } + count = await store.insert_memory_entries([entry]) + assert count == 1 + assert "$12" in executed[0][0] + assert "$13" not in executed[0][0] + assert len(executed[0][1]) == 12 + + executed.clear() + config_with_owner = CockroachAsyncpgConfig( + extension_config={"adk": {"enable_memory": True, "owner_id_column": "tenant_id UUID"}} + ) + object.__setattr__(config_with_owner, "provide_connection", monkeypatch_conn) + store_with_owner = CockroachAsyncpgADKMemoryStore(config_with_owner) + count_owner = await store_with_owner.insert_memory_entries([entry], owner_id="t1") + assert count_owner == 1 + assert "$13" in executed[0][0] + assert len(executed[0][1]) == 13 + + +async def test_cockroach_asyncpg_adk_locality_validation_and_schema_qualified_ddl() -> None: + """ADK and Litestar stores must validate LOCALITY clauses and sanitize schema-qualified index names.""" + bad_config = CockroachAsyncpgConfig(extension_config={"adk": {"table_locality": "GLOBAL; DROP TABLE users"}}) + bad_store = CockroachAsyncpgADKStore(bad_config) + with pytest.raises(ImproperConfigurationError, match="LOCALITY"): + await bad_store._sessions_table_ddl() + + good_config = CockroachAsyncpgConfig(extension_config={"adk": {"table_locality": "REGIONAL BY ROW AS region"}}) + adk_store = CockroachAsyncpgADKStore(good_config) + adk_store._session_table = "tenant_a.adk_sessions" + ddl = await adk_store._sessions_table_ddl() + assert "idx_tenant_a_adk_sessions_app_user" in ddl + assert "LOCALITY REGIONAL BY ROW AS region" in ddl + + litestar_store = CockroachAsyncpgStore(good_config) + litestar_store._table_name = "tenant_a.litestar_sessions" + assert "idx_tenant_a_litestar_sessions_expires_at" in litestar_store._table_ddl() diff --git a/tests/unit/adapters/test_cockroach_psycopg/test_config.py b/tests/unit/adapters/test_cockroach_psycopg/test_config.py index 61332f93e..45d747d0c 100644 --- a/tests/unit/adapters/test_cockroach_psycopg/test_config.py +++ b/tests/unit/adapters/test_cockroach_psycopg/test_config.py @@ -23,7 +23,16 @@ CockroachPsycopgSyncSessionContext, build_connection_config, ) +from sqlspec.adapters.cockroach_psycopg.adk.store import ( + CockroachPsycopgAsyncADKMemoryStore, + CockroachPsycopgAsyncADKStore, + CockroachPsycopgSyncADKMemoryStore, + CockroachPsycopgSyncADKStore, +) from sqlspec.adapters.cockroach_psycopg.config import default_statement_config +from sqlspec.adapters.cockroach_psycopg.core import build_native_export +from sqlspec.adapters.cockroach_psycopg.litestar.store import CockroachPsycopgSyncStore +from sqlspec.core import SQL from sqlspec.exceptions import ImproperConfigurationError @@ -666,3 +675,138 @@ def test_cockroach_async_config_normalizes_aliases() -> None: assert config.connection_config["dbname"] == "override" assert "connection_string" not in config.connection_config assert "db" not in config.connection_config + + +def test_cockroach_psycopg_create_pool_merges_extra_and_preserves_hook(monkeypatch: pytest.MonkeyPatch) -> None: + """CockroachPsycopgSyncConfig should merge extra into pool kwargs and keep on_connection_create in driver_features.""" + captured: dict[str, Any] = {} + + def _fake_pool(_conninfo: str, **kwargs: Any) -> object: + captured.update(kwargs) + return object() + + def _hook(_conn: Any) -> None: + return None + + monkeypatch.setattr("sqlspec.adapters.cockroach_psycopg.config.ConnectionPool", _fake_pool) + config = CockroachPsycopgSyncConfig( + connection_config={"host": "localhost", "extra": {"application_name": "sqlspec"}}, + driver_features={"on_connection_create": _hook}, + ) + assert config.driver_features["on_connection_create"] is _hook + config.create_pool() + assert captured["kwargs"]["application_name"] == "sqlspec" + assert "extra" not in captured["kwargs"] + + +def test_cockroach_psycopg_native_export_escapes_literal_percent() -> None: + """build_native_export must escape literal % when parameters are bound.""" + cmd, bound = build_native_export( + "SELECT id FROM items WHERE code LIKE 'A%' AND tenant = %s", ["t1"], "s3://bucket/export", "parquet", {} + ) + assert "LIKE 'A%%'" in cmd + assert "tenant = %s" in cmd + assert bound == ["s3://bucket/export", "t1"] + + +def test_cockroach_psycopg_follower_read_auto_transaction_commits_and_rolls_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Auto-started follower-read transactions in CockroachPsycopgSyncDriver must commit or rollback.""" + in_tx = False + + def _in_tx(_self: Any) -> bool: + return in_tx + + def _begin(_self: Any) -> None: + nonlocal in_tx + in_tx = True + + def _commit(_self: Any) -> None: + nonlocal in_tx + in_tx = False + + def _rollback(_self: Any) -> None: + nonlocal in_tx + in_tx = False + + monkeypatch.setattr(CockroachPsycopgSyncDriver, "_connection_in_transaction", _in_tx) + monkeypatch.setattr(CockroachPsycopgSyncDriver, "begin", _begin) + monkeypatch.setattr(CockroachPsycopgSyncDriver, "commit", _commit) + monkeypatch.setattr(CockroachPsycopgSyncDriver, "rollback", _rollback) + + conn = MagicMock() + cursor = MagicMock() + col = MagicMock() + col.name = "val" + col.type_code = 23 + cursor.description = [col] + cursor.fetchall.return_value = [{"val": 1}] + driver = CockroachPsycopgSyncDriver( + connection=conn, driver_features={"enable_follower_reads": True, "default_staleness": "'-10s'"} + ) + stmt = SQL("SELECT 1") + driver.dispatch_execute(cursor, stmt) + assert in_tx is False + + cursor.execute.side_effect = RuntimeError("boom") + with pytest.raises(RuntimeError, match="boom"): + driver.dispatch_execute(cursor, stmt) + assert in_tx is False + + +async def test_cockroach_psycopg_adk_stores_commit_ddl_and_validate_locality() -> None: + """CockroachPsycopg ADK and memory stores must commit DDL and validate LOCALITY clauses.""" + commits: list[str] = [] + + class _FakeSyncDriver: + def execute_script(self, _sql: str) -> None: + return None + + def commit(self) -> None: + commits.append("sync") + + class _FakeSyncSession: + def __enter__(self) -> _FakeSyncDriver: + return _FakeSyncDriver() + + def __exit__(self, *_args: Any) -> None: + return None + + class _FakeAsyncDriver: + async def execute_script(self, _sql: str) -> None: + return None + + async def commit(self) -> None: + commits.append("async") + + class _FakeAsyncSession: + async def __aenter__(self) -> _FakeAsyncDriver: + return _FakeAsyncDriver() + + async def __aexit__(self, *_args: Any) -> None: + return None + + sync_cfg = CockroachPsycopgSyncConfig(extension_config={"adk": {"enable_memory": True}}) + object.__setattr__(sync_cfg, "provide_session", lambda *_a, **_kw: _FakeSyncSession()) + CockroachPsycopgSyncADKStore(sync_cfg).create_tables() + CockroachPsycopgSyncADKMemoryStore(sync_cfg).create_tables() + + async_cfg = CockroachPsycopgAsyncConfig(extension_config={"adk": {"enable_memory": True}}) + object.__setattr__(async_cfg, "provide_session", lambda *_a, **_kw: _FakeAsyncSession()) + await CockroachPsycopgAsyncADKStore(async_cfg).create_tables() + await CockroachPsycopgAsyncADKMemoryStore(async_cfg).create_tables() + + assert commits == ["sync", "sync", "async", "async"] + + bad_cfg = CockroachPsycopgSyncConfig(extension_config={"adk": {"table_locality": "GLOBAL; DROP TABLE x"}}) + with pytest.raises(ImproperConfigurationError, match="LOCALITY"): + CockroachPsycopgSyncADKStore(bad_cfg)._sessions_table_ddl() + + good_cfg = CockroachPsycopgSyncConfig(extension_config={"adk": {"table_locality": "GLOBAL"}}) + adk_store = CockroachPsycopgSyncADKStore(good_cfg) + adk_store._session_table = "tenant_a.adk_sessions" + assert "idx_tenant_a_adk_sessions_app_user" in adk_store._sessions_table_ddl() + litestar_store = CockroachPsycopgSyncStore(good_cfg) + litestar_store._table_name = "tenant_a.litestar_sessions" + assert "idx_tenant_a_litestar_sessions_expires_at" in litestar_store._table_ddl() diff --git a/tests/unit/adapters/test_db2/test_adk.py b/tests/unit/adapters/test_db2/test_adk.py index c28104cd5..872989e2b 100644 --- a/tests/unit/adapters/test_db2/test_adk.py +++ b/tests/unit/adapters/test_db2/test_adk.py @@ -269,28 +269,25 @@ async def test_get_session_returns_none_when_table_missing(db2_mode: DriverMode) async def test_delete_entries_older_than_binds_cutoff(db2_mode: DriverMode) -> None: - """Memory retention binds a naive-UTC cutoff computed from the day count.""" - count = FakeDb2Cursor(rows=[(2,)], description=db2_description("row_count")) - store, config = _memory_store(db2_mode, count, FakeDb2Cursor(rowcount=2)) + """Memory retention binds a naive-UTC cutoff computed from the day count in a single DELETE.""" + store, config = _memory_store(db2_mode, FakeDb2Cursor(rowcount=2)) assert await db2_mode.call(store.delete_entries_older_than, 30, app_name="app") == 2 - (count_sql, count_params), (delete_sql, delete_params) = config.executed - _assert_naive_utc_near(count_params[0], _utc_naive_now() - timedelta(days=30)) - assert count_params[1] == "app" - assert delete_params == count_params + [(delete_sql, delete_params)] = config.executed + _assert_naive_utc_near(delete_params[0], _utc_naive_now() - timedelta(days=30)) + assert delete_params[1] == "app" assert delete_sql.startswith(f"DELETE FROM {store.memory_table} WHERE inserted_at < ?") - assert "COUNT(*)" in count_sql async def test_memory_store_search_query(db2_mode: DriverMode) -> None: - """Memory search matches case-insensitively with POSSTR and caps rows with FETCH FIRST.""" + """Memory search matches case-insensitively with LOCATE and typed CAST and caps rows with FETCH FIRST.""" store, config = _memory_store(db2_mode) assert await db2_mode.call(store.search_entries, query="hello", app_name="test_app", user_id="user_1") == [] [(sql, params)] = config.executed - assert "POSSTR(LOWER(content_text), LOWER(?)) > 0" in sql + assert "LOCATE(LOWER(CAST(? AS VARCHAR(4000))), LOWER(content_text)) > 0" in sql assert "FETCH FIRST 20 ROWS ONLY" in sql assert params == ("test_app", "user_1", "hello") @@ -306,17 +303,16 @@ async def test_memory_store_search_query(db2_mode: DriverMode) -> None: async def test_retention_deletes_bind_naive_utc_cutoff( db2_mode: DriverMode, method: str, table_attr: str, column: str ) -> None: - """Retention deletes bind the aware cutoff as naive UTC and report the counted rows.""" - count = FakeDb2Cursor(rows=[(4,)], description=db2_description("row_count")) - store, config = _session_store(db2_mode, count) + """Retention deletes bind the aware cutoff as naive UTC and report the deleted rows.""" + store, config = _session_store(db2_mode, FakeDb2Cursor(rowcount=4)) cutoff = datetime(2026, 9, 24, 14, tzinfo=timezone(timedelta(hours=2))) assert await db2_mode.call(getattr(store, method), cutoff, app_name="app") == 4 - (_, count_params), (delete_sql, delete_params) = config.executed + [(delete_sql, delete_params)] = config.executed table = getattr(store, table_attr) assert delete_sql == f"DELETE FROM {table} WHERE {column} < ? AND app_name = ?" - assert count_params == delete_params == (datetime(2026, 9, 24, 12), "app") + assert delete_params == (datetime(2026, 9, 24, 12), "app") @pytest.mark.parametrize( @@ -477,9 +473,7 @@ async def test_memory_search_decodes_rows_and_session_delete_counts(db2_mode: Dr "inserted_at", ) store, config = _memory_store( - db2_mode, - FakeDb2Cursor(rows=[row], description=db2_description(*columns)), - FakeDb2Cursor(rows=[(1,)], description=db2_description("row_count")), + db2_mode, FakeDb2Cursor(rows=[row], description=db2_description(*columns)), FakeDb2Cursor(rowcount=1) ) [memory] = await db2_mode.call(store.search_entries, query="hi", app_name="app", user_id="u1", scope_filter="user") @@ -609,3 +603,43 @@ async def test_memory_empty_inputs_skip_the_database(db2_mode: DriverMode) -> No assert await db2_mode.call(store.insert_memory_entries, []) == 0 assert await db2_mode.call(store.search_entries, query="q", app_name="app", user_id="u1", limit=0) == [] assert config.executed == [] + + +async def test_memory_operations_on_missing_table_return_empty_or_zero(db2_mode: DriverMode) -> None: + """Memory search and delete operations against an absent table return empty or zero.""" + + def missing_cursor() -> FakeDb2Cursor: + return FakeDb2Cursor(error=db2_error(-204, "42704", UNDEFINED_NAME_TEXT)) + + store1, _ = _memory_store(db2_mode, missing_cursor()) + assert await db2_mode.call(store1.search_entries, query="q", app_name="app", user_id="u1") == [] + + store2, _ = _memory_store(db2_mode, missing_cursor()) + assert await db2_mode.call(store2.delete_entries_by_session, "s1") == 0 + + store3, _ = _memory_store(db2_mode, missing_cursor()) + assert await db2_mode.call(store3.delete_entries_older_than, 7) == 0 + + +async def test_schema_qualified_adk_tables_sanitize_constraint_and_index_names(db2_mode: DriverMode) -> None: + """Schema-qualified ADK table names produce dot-free constraint and index names.""" + store, _ = _session_store(db2_mode) + store._session_table = "app.adk_session" + store._events_table = "app.adk_event" + store._app_state_table = "app.adk_app_state" + store._user_state_table = "app.adk_user_state" + store._metadata_table = "app.adk_metadata" + mem_store, _ = _memory_store(db2_mode) + mem_store._memory_table = "app.adk_memory" + + sessions_ddl = await db2_mode.call(store._sessions_table_ddl) + events_ddl = await db2_mode.call(store._events_table_ddl) + memory_ddl = await db2_mode.call(mem_store._memory_table_ddl) + + assert "CONSTRAINT pk_app_adk_session_id" in sessions_ddl + assert "CONSTRAINT pk_app_adk_event_id" in events_ddl + assert "CONSTRAINT fk_app_adk_event_session" in events_ddl + assert "CONSTRAINT pk_app_adk_memory_id" in memory_ddl + assert "CONSTRAINT uq_app_adk_memory_event" in memory_ddl + assert all("." not in index_name for index_name, _, _ in store._index_specs()) + assert all("." not in index_name for index_name, _, _ in mem_store._memory_index_specs()) diff --git a/tests/unit/adapters/test_db2/test_async_pool.py b/tests/unit/adapters/test_db2/test_async_pool.py index 994a510f2..4cdde9b9d 100644 --- a/tests/unit/adapters/test_db2/test_async_pool.py +++ b/tests/unit/adapters/test_db2/test_async_pool.py @@ -257,6 +257,7 @@ async def test_close_closes_idle_and_released_connections(fake_ibm_db: FakeModul await pool.release(busy) assert busy.sync_connection.closed is True assert (pool.size(), pool.checked_out()) == (0, 0) + assert pool._semaphore_instance is None with pytest.raises(DatabaseConnectionError, match="Db2 async connection pool is closed"): await pool.acquire() diff --git a/tests/unit/adapters/test_db2/test_config.py b/tests/unit/adapters/test_db2/test_config.py index 2b0957d58..a0dba3b8d 100644 --- a/tests/unit/adapters/test_db2/test_config.py +++ b/tests/unit/adapters/test_db2/test_config.py @@ -1,5 +1,6 @@ """Tests for IBM Db2 database configuration.""" +from datetime import datetime, timedelta, timezone from typing import Any, cast import pytest @@ -15,7 +16,7 @@ Db2SyncConfig, Db2SyncConnectionContext, ) -from sqlspec.adapters.db2.core import _parse_db2_dsn, build_dsn_string +from sqlspec.adapters.db2.core import _parse_db2_dsn, build_connection_config, build_dsn_string from sqlspec.adapters.db2.driver import Db2AsyncDriver, Db2SyncDriver from sqlspec.adapters.db2.migrations import Db2AsyncMigrationTracker, Db2SyncMigrationTracker from sqlspec.adapters.db2.pool import Db2AsyncConnectionPool, Db2SyncConnectionPool @@ -384,3 +385,60 @@ async def test_async_create_connection_is_standalone(fake_ibm_db: FakeModules) - finally: await config.close_pool() assert connection.closed is False + + +@pytest.mark.parametrize( + "connection_config", + [ + {"database": "d", "port": 0}, + {"database": "d", "port": -1}, + {"database": "d", "port": 65536}, + {"database": "d", "connect_timeout": -1}, + ], + ids=["port-zero", "port-negative", "port-too-high", "negative-timeout"], +) +def test_out_of_range_port_and_timeout_are_rejected(connection_config: "dict[str, Any]") -> None: + """Port outside 1..65535 and negative connect_timeout are rejected.""" + with pytest.raises(ImproperConfigurationError): + build_connection_config(connection_config) + + +def test_build_connection_config_strips_async_pool_keys() -> None: + """build_connection_config accepts Db2AsyncPoolParams and strips max_size and acquire_timeout.""" + normalized = build_connection_config({"database": "SAMPLE", "max_size": 5, "acquire_timeout": 10.0}) + + assert normalized == {"database": "SAMPLE"} + + +@pytest.mark.anyio +async def test_config_driver_features_defaults_and_events_extension(db2_mode: DriverMode) -> None: + """Driver features default enable_events and events_backend and wire extension_config['events'].""" + default_config = db2_mode.config(connection_config={"database": "SAMPLE"}) + assert default_config.driver_features["enable_events"] is True + assert default_config.driver_features["events_backend"] == "poll_queue" + + explicit_events = db2_mode.config( + connection_config={"database": "SAMPLE"}, + driver_features={"enable_events": True, "events_backend": "poll_queue"}, + ) + assert explicit_events.extension_config["events"]["backend"] == "poll_queue" + + disabled_events = db2_mode.config( + connection_config={"database": "SAMPLE"}, + driver_features={"enable_events": False}, + extension_config={"events": {"backend": "poll_queue"}}, + ) + assert "events" not in disabled_events.extension_config + + +@pytest.mark.anyio +async def test_statement_config_coerces_aware_datetime_to_naive_utc(db2_mode: DriverMode) -> None: + """The Db2 statement parameter profile coerces timezone-aware datetimes to naive UTC.""" + config = db2_mode.config(connection_config={"database": "SAMPLE"}) + type_coercion_map = config.statement_config.parameter_config.type_coercion_map + assert datetime in type_coercion_map + + aware = datetime(2026, 9, 24, 14, 30, tzinfo=timezone(timedelta(hours=2))) + coerced = type_coercion_map[datetime](aware) + assert coerced == datetime(2026, 9, 24, 12, 30) + assert coerced.tzinfo is None diff --git a/tests/unit/adapters/test_db2/test_driver.py b/tests/unit/adapters/test_db2/test_driver.py index 451291fa6..e5e3d5bf1 100644 --- a/tests/unit/adapters/test_db2/test_driver.py +++ b/tests/unit/adapters/test_db2/test_driver.py @@ -3,13 +3,22 @@ Every test runs in both driver modes through the ``db2_mode`` fixture. """ +from typing import Any + import pytest -from sqlspec.adapters.db2.core import default_statement_config +from sqlspec.adapters.db2.core import create_mapped_exception, default_statement_config, resolve_column_names from sqlspec.adapters.db2.data_dictionary import Db2AsyncDataDictionary, Db2SyncDataDictionary from sqlspec.core import SQL from sqlspec.driver import AsyncRowStream, SyncRowStream -from sqlspec.exceptions import SQLParsingError, TransactionError, UniqueViolationError +from sqlspec.exceptions import ( + DataError, + ForeignKeyViolationError, + QueryTimeoutError, + SQLParsingError, + TransactionError, + UniqueViolationError, +) from tests.unit.adapters.test_db2._fakes import ( DriverMode, FakeDb2Connection, @@ -133,6 +142,26 @@ async def test_dispatch_execute_script(db2_mode: DriverMode) -> None: assert [call[0] for call in cursor.executed] == ["CREATE TABLE t1 (id INT)", "CREATE TABLE t2 (id INT)"] +async def test_dispatch_execute_script_slices_parameters_per_statement(db2_mode: DriverMode) -> None: + """Multi-statement scripts slice positional parameters across statements by placeholder count.""" + cursor = FakeDb2Cursor() + driver = db2_mode.driver(FakeDb2Connection(lambda: cursor), statement_config=default_statement_config) + statement = SQL( + "INSERT INTO t1 VALUES (?); DELETE FROM t2; INSERT INTO t1 VALUES (?, ?);", + (10, 20, 30), + statement_config=default_statement_config, + is_script=True, + ) + + await db2_mode.call(driver.dispatch_execute_script, db2_mode.cursor(cursor), statement) + + assert cursor.executed == [ + ("INSERT INTO t1 VALUES (?)", (10,)), + ("DELETE FROM t2", None), + ("INSERT INTO t1 VALUES (?, ?)", (20, 30)), + ] + + async def test_dispatch_select_stream(db2_mode: DriverMode) -> None: """dispatch_select_stream reads rows in fetchmany chunks and closes the cursor.""" rows = [(1, "Ada"), (2, "Grace"), (3, "Margaret")] @@ -179,6 +208,26 @@ async def test_exception_handler_maps_db2_error(db2_mode: DriverMode) -> None: assert isinstance(handler.pending_exception, UniqueViolationError) +@pytest.mark.parametrize( + ("sqlcode", "sqlstate", "expected_cls"), + [ + (-532, "23504", ForeignKeyViolationError), + (-302, "22001", DataError), + (-433, "22001", DataError), + (-952, "57014", QueryTimeoutError), + ], +) +def test_create_mapped_exception_maps_additional_sqlcodes( + sqlcode: int, sqlstate: str, expected_cls: type[Exception] +) -> None: + """Additional Db2 SQLCODEs (-532, -302, -433, -952) map to their specific exception classes.""" + err = db2_error(sqlcode, sqlstate, f"SQL0{abs(sqlcode)}N error") + + mapped = create_mapped_exception(err) + + assert isinstance(mapped, expected_cls) + + async def test_driver_execute_raises_mapped_exception(db2_mode: DriverMode) -> None: """driver.execute translates database errors into mapped SQLSpecError.""" cursor = FakeDb2Cursor(error=db2_error(-803, "23505", DUPLICATE_KEY_TEXT, cls=FakeDb2IntegrityError)) @@ -219,20 +268,39 @@ async def test_select_stream_lowercases_implicit_uppercase_columns(db2_mode: Dri assert rows == [{"id": 1, "name": "Ada"}, {"id": 2, "name": "Grace"}] -async def test_repeated_select_keeps_lowercase_columns(db2_mode: DriverMode) -> None: - """Re-executing a cached query returns the same lowercase keys and rowcounts as the first execution.""" - driver = db2_mode.driver( - FakeDb2Connection(lambda: FakeDb2Cursor(rows=[(1,)], description=db2_description("schema_name"), rowcount=4)) - ) +async def test_repeated_select_keeps_lowercase_columns_and_normalizes_list_params(db2_mode: DriverMode) -> None: + """Re-executing a cached query normalizes list parameters to tuples and keeps lowercase keys.""" + executed_params: list[Any] = [] - first = await db2_mode.call(driver.execute, "SELECT schema_name FROM t WHERE id = ?", (1,)) - second = await db2_mode.call(driver.execute, "SELECT schema_name FROM t WHERE id = ?", (1,)) - first_update = await db2_mode.call(driver.execute, "UPDATE t SET a = ? WHERE id = ?", (1, 2)) - second_update = await db2_mode.call(driver.execute, "UPDATE t SET a = ? WHERE id = ?", (1, 2)) + def make_cursor() -> FakeDb2Cursor: + return FakeDb2Cursor(rows=[(1,)], description=db2_description("schema_name"), rowcount=4) + + conn = FakeDb2Connection(make_cursor) + driver = db2_mode.driver(conn) + + first = await db2_mode.call(driver.execute, "SELECT schema_name FROM t WHERE id = ?", [1]) + second = await db2_mode.call(driver.execute, "SELECT schema_name FROM t WHERE id = ?", [1]) + first_update = await db2_mode.call(driver.execute, "UPDATE t SET a = ? WHERE id = ?", [1, 2]) + second_update = await db2_mode.call(driver.execute, "UPDATE t SET a = ? WHERE id = ?", [1, 2]) + + for cursor in conn.cursors: + for _, params in cursor.executed: + executed_params.append(params) assert first.get_data() == [{"schema_name": 1}] assert second.get_data() == [{"schema_name": 1}] assert (first_update.rows_affected, second_update.rows_affected) == (4, 4) + assert all(isinstance(params, tuple) for params in executed_params) + + +def test_resolve_column_names_caps_cache_at_256_entries() -> None: + """resolve_column_names evicts oldest entries when cache reaches 256 descriptions.""" + cache: dict[int, tuple[Any, list[str]]] = {} + descriptions = [[(f"COL_{i}",)] for i in range(300)] + for desc in descriptions: + resolve_column_names(desc, cache, lowercase=True) + + assert len(cache) <= 256 async def test_driver_exposes_matching_data_dictionary(db2_mode: DriverMode) -> None: diff --git a/tests/unit/adapters/test_db2/test_migrations.py b/tests/unit/adapters/test_db2/test_migrations.py index 2bb525c4d..1d8f17907 100644 --- a/tests/unit/adapters/test_db2/test_migrations.py +++ b/tests/unit/adapters/test_db2/test_migrations.py @@ -141,8 +141,17 @@ async def test_ensure_tracking_table_creates_only_when_absent( ("ddl_migrations", (None, "DDL_MIGRATIONS")), ("AppMigrations", (None, "APPMIGRATIONS")), ("App.Tracker", ("APP", "TRACKER")), + ('"QuotedMigrations"', (None, "QuotedMigrations")), + ('app."QuotedMigrations"', ("APP", "QuotedMigrations")), + ], + ids=[ + "qualified", + "unqualified", + "mixed-case", + "mixed-case-qualified", + "quoted-mixed-case", + "qualified-quoted-mixed-case", ], - ids=["qualified", "unqualified", "mixed-case", "mixed-case-qualified"], ) async def test_probe_binds_catalog_folded_schema_and_table( db2_mode: DriverMode, table_name: str, expected: "tuple[str | None, str]" diff --git a/tests/unit/adapters/test_db2/test_transactions.py b/tests/unit/adapters/test_db2/test_transactions.py index 1e73de987..e4f621e8d 100644 --- a/tests/unit/adapters/test_db2/test_transactions.py +++ b/tests/unit/adapters/test_db2/test_transactions.py @@ -165,7 +165,8 @@ async def test_session_context_exit_rolls_back_open_work( context = db2_mode.session_context(connection, released, autocommit_baseline=autocommit, begin_transaction=begin) async with db2_mode.enter(context) as driver: - assert driver._connection_in_transaction() is begin + assert driver._transaction_active is begin + assert driver._connection_in_transaction() is True await db2_mode.call(driver.execute, INSERT) assert connection.rollbacks == 1 diff --git a/tests/unit/adapters/test_duckdb/test_adk_store.py b/tests/unit/adapters/test_duckdb/test_adk_store.py index 064d7dac4..7bba2d05b 100644 --- a/tests/unit/adapters/test_duckdb/test_adk_store.py +++ b/tests/unit/adapters/test_duckdb/test_adk_store.py @@ -1,6 +1,7 @@ # pyright: reportPrivateUsage=false """Unit tests for DuckDB ADK store extension configuration.""" +from datetime import UTC, datetime from textwrap import dedent from typing import Any, NotRequired, Self, cast, get_args, get_origin from unittest.mock import MagicMock @@ -9,6 +10,7 @@ import sqlspec.adapters.duckdb.adk as duckdb_adk from sqlspec.adapters.duckdb.adk import DuckdbADKMemoryStore, DuckdbADKStore +from sqlspec.adapters.duckdb.litestar.store import DuckdbStore from sqlspec.config import ADKConfig @@ -244,3 +246,62 @@ def test_duckdb_list_sessions_rejects_invalid_options_before_connecting(options: store.list_sessions("app", **options) assert conn.calls == [] + + +def test_duckdb_adk_delete_helpers_use_returning_row_count() -> None: + """_delete_expired_events, _delete_idle_sessions, and _delete_idle_user_states use DELETE ... RETURNING 1.""" + + class _DeleteConn: + def __init__(self) -> None: + self.queries: list[str] = [] + + def execute(self, sql: str, params: Any = None) -> "_DeleteConn": + self.queries.append(" ".join(sql.split())) + return self + + def fetchall(self) -> list[tuple[int]]: + return [(1,), (1,)] + + def commit(self) -> None: + pass + + def __enter__(self) -> "Self": + return self + + def __exit__(self, *_: Any) -> None: + return None + + conn = _DeleteConn() + config = _mock_config() + config.provide_connection = lambda *_a, **_k: conn + store = DuckdbADKStore(config) + cutoff = datetime.now(UTC) + + assert store._delete_expired_events(cutoff) == 2 + assert store._delete_idle_sessions(cutoff) == 2 + assert store._delete_idle_user_states(cutoff) == 2 + assert all("RETURNING 1" in q for q in conn.queries if q.startswith("DELETE FROM adk_session")) + + +def test_duckdb_adk_ensure_fts_extension_loads_before_installing() -> None: + """_ensure_fts_extension should try LOAD fts first and skip INSTALL fts when already installed.""" + store = DuckdbADKMemoryStore(_mock_config({"memory_use_fts": True})) + executed: list[str] = [] + + class _Conn: + def execute(self, sql: str) -> None: + executed.append(sql) + + assert store._ensure_fts_extension(_Conn()) is True + assert executed == ["LOAD fts"] + + +def test_duckdb_litestar_store_uses_timestamptz_and_normalizes_utc() -> None: + """DuckdbStore DDL and queries use TIMESTAMPTZ/CURRENT_TIMESTAMP and normalize naive timestamps to UTC.""" + store = DuckdbStore(_mock_config()) + ddl = store._table_ddl() + assert "TIMESTAMPTZ" in ddl + naive = datetime(2026, 1, 1, 12, 0, 0) + converted = store._timestamp_to_datetime(naive) + assert converted is not None + assert converted.tzinfo == UTC diff --git a/tests/unit/adapters/test_duckdb/test_arrow_streaming.py b/tests/unit/adapters/test_duckdb/test_arrow_streaming.py index dd0bd74fe..b363ad17c 100644 --- a/tests/unit/adapters/test_duckdb/test_arrow_streaming.py +++ b/tests/unit/adapters/test_duckdb/test_arrow_streaming.py @@ -217,3 +217,28 @@ def test_load_from_arrow_quotes_qualified_and_special_identifiers() -> None: rows = driver.execute('SELECT id, name FROM "custom schema"."target table" ORDER BY id').get_data() assert rows == [{"id": 1, "name": "one"}, {"id": 2, "name": "two"}] + + +def test_select_to_arrow_reader_survives_interleaved_driver_queries() -> None: + """A RecordBatchReader returned outside a transaction must use an isolated cursor.""" + with _seed_driver() as driver: + result = driver.select_to_arrow( + "SELECT id, name FROM arrow_streaming ORDER BY id", return_format="reader", batch_size=2 + ) + driver.execute("SELECT 42") + batches = list(result.data) + assert sum(batch.num_rows for batch in batches) == 5 + + +def test_load_from_arrow_overwrite_rolls_back_truncate_on_insert_failure() -> None: + """When overwrite=True fails during INSERT, the preceding TRUNCATE must be rolled back.""" + with _seed_driver() as driver: + driver.execute("CREATE OR REPLACE TABLE atomic_target (id INTEGER NOT NULL)") + driver.execute("INSERT INTO atomic_target VALUES (1), (2)") + bad_table = pa.table({"id": [10, None]}) + + with pytest.raises(Exception): + driver.load_from_arrow("atomic_target", bad_table, overwrite=True) + + rows = driver.execute("SELECT id FROM atomic_target ORDER BY id").get_data() + assert rows == [{"id": 1}, {"id": 2}] diff --git a/tests/unit/adapters/test_duckdb/test_config.py b/tests/unit/adapters/test_duckdb/test_config.py index f4b4fc8b3..05c302176 100644 --- a/tests/unit/adapters/test_duckdb/test_config.py +++ b/tests/unit/adapters/test_duckdb/test_config.py @@ -18,6 +18,7 @@ build_connection_config, build_statement_config, default_statement_config, + resolve_rowcount, ) from sqlspec.adapters.duckdb.driver import DuckDBDriver @@ -194,3 +195,35 @@ def custom_serializer(_: object) -> str: with config.provide_session(): pass assert mock_apply.call_count == 1 + + +def test_bool_type_coercion_preserves_native_bool() -> None: + """Boolean parameters must remain native Python bools rather than coercing to int.""" + coercion_map = default_statement_config.parameter_config.type_coercion_map + assert coercion_map[bool](True) is True + assert coercion_map[bool](False) is False + + config = DuckDBConfig(connection_config={"database": ":memory:"}) + with config.provide_session() as driver: + row = driver.select_one("SELECT typeof(?) AS t, ? AS v", True, True) + assert row["t"] == "BOOLEAN" + assert row["v"] is True + + +def test_apply_driver_features_sets_events_defaults() -> None: + """apply_driver_features should default enable_events=True and events_backend='poll_queue'.""" + _, features = apply_driver_features(default_statement_config, None) + assert features["enable_events"] is True + assert features["events_backend"] == "poll_queue" + + +def test_resolve_rowcount_falls_back_to_cursor_rowcount_when_fetchone_returns_none() -> None: + """resolve_rowcount must consult cursor.rowcount when cursor.fetchone() returns None without raising.""" + + class _NoneFetchCursor: + rowcount = 4 + + def fetchone(self) -> None: + return None + + assert resolve_rowcount(_NoneFetchCursor()) == 4 diff --git a/tests/unit/adapters/test_duckdb/test_pool.py b/tests/unit/adapters/test_duckdb/test_pool.py index b59c2a1d2..3493ca273 100644 --- a/tests/unit/adapters/test_duckdb/test_pool.py +++ b/tests/unit/adapters/test_duckdb/test_pool.py @@ -182,17 +182,17 @@ def install_extension(self, extension: str, **kwargs: Any) -> None: def test_create_connection_creates_persistent_scoped_secret(tmp_path) -> None: - secret_name = f"s3_secret_{uuid4().hex}" + secret_name = f"http_secret_{uuid4().hex}" pool = DuckDBConnectionPool( {"database": ":memory:", "secret_directory": str(tmp_path)}, secrets=[ { "name": secret_name, - "secret_type": "s3", + "secret_type": "http", "provider": "config", "persistent": True, - "scope": "s3://sqlspec-test-bucket/", - "value": {"key_id": "abc", "secret": "xyz", "region": "us-east-1"}, + "scope": "https://sqlspec-test-bucket/", + "value": {"bearer_token": "abc"}, } ], ) @@ -205,7 +205,7 @@ def test_create_connection_creates_persistent_scoped_secret(tmp_path) -> None: finally: connection.close() - assert row == (secret_name, "s3", ["s3://sqlspec-test-bucket/"], True) + assert row == (secret_name, "http", ["https://sqlspec-test-bucket/"], True) def test_create_connection_raises_when_secret_verification_fails(monkeypatch: pytest.MonkeyPatch) -> None: @@ -572,3 +572,34 @@ def test_create_connection_leaves_thread_storage_state_intact() -> None: assert db.driver_features["_duckdb_storage_extensions"] == recorded finally: standalone.close() + + +def test_on_connection_create_replacement_connection_is_used(monkeypatch: pytest.MonkeyPatch) -> None: + """new_connection must use a replacement connection returned by on_connection_create.""" + raw_conn = _FakeDuckDBConnection() + wrapped_conn = _FakeDuckDBConnection() + monkeypatch.setattr("sqlspec.adapters.duckdb.pool.duckdb.connect", lambda **_: raw_conn) + + pool = DuckDBConnectionPool( + connection_config={"database": ":memory:"}, + on_connection_create=lambda _conn: wrapped_conn, # type: ignore[arg-type,return-value] + ) + created: object = pool._create_connection() + assert created is wrapped_conn + + +def test_reentrant_get_connection_preserves_outer_checkout() -> None: + """Nested get_connection checkouts on the same thread must not commit or close the outer checkout early.""" + pool = DuckDBConnectionPool(connection_config={"database": ":memory:"}) + try: + with pool.get_connection() as outer_conn: + outer_conn.execute("CREATE TABLE nested_test (id INTEGER)") + outer_conn.execute("BEGIN TRANSACTION") + outer_conn.execute("INSERT INTO nested_test VALUES (1)") + with pool.get_connection() as inner_conn: + assert inner_conn is outer_conn + inner_conn.execute("INSERT INTO nested_test VALUES (2)") + outer_conn.rollback() + assert outer_conn.execute("SELECT COUNT(*) FROM nested_test").fetchone() == (0,) + finally: + pool.close() diff --git a/tests/unit/adapters/test_duckdb/test_transaction_state.py b/tests/unit/adapters/test_duckdb/test_transaction_state.py index bd00cf3f9..1c23853cd 100644 --- a/tests/unit/adapters/test_duckdb/test_transaction_state.py +++ b/tests/unit/adapters/test_duckdb/test_transaction_state.py @@ -2,11 +2,12 @@ from collections.abc import Generator +import duckdb as raw_duckdb import pytest from sqlspec import StatementStack from sqlspec.adapters.duckdb import DuckDBConfig, DuckDBDriver -from sqlspec.exceptions import ImproperConfigurationError +from sqlspec.exceptions import ImproperConfigurationError, SQLSpecError from sqlspec.service import SQLSpecSyncService pytestmark = pytest.mark.xdist_group("duckdb") @@ -80,3 +81,34 @@ def test_nested_service_transaction_reports_missing_savepoints(duckdb_stack_sess pytest.fail("nested block entered") session.execute("INSERT INTO stack_txn_table (id) VALUES (1)") assert _table_count(duckdb_stack_session) == 1 + + +def test_commit_and_rollback_map_duckdb_errors_and_reset_transaction_active() -> None: + """begin, commit, and rollback must map duckdb.Error and reset _transaction_active in finally.""" + + class _FailingConn: + def cursor(self) -> "_FailingConn": + return self + + def execute(self, sql: str) -> None: + raise raw_duckdb.TransactionException("transaction conflict") + + def commit(self) -> None: + raise raw_duckdb.TransactionException("commit conflict") + + def rollback(self) -> None: + raise raw_duckdb.TransactionException("rollback failure") + + driver = DuckDBDriver(connection=_FailingConn()) # type: ignore[arg-type] + with pytest.raises(SQLSpecError): + driver.begin() + + driver._transaction_active = True # pyright: ignore[reportPrivateUsage] + with pytest.raises(SQLSpecError): + driver.commit() + assert driver._transaction_active is False # pyright: ignore[reportPrivateUsage] + + driver._transaction_active = True # pyright: ignore[reportPrivateUsage] + with pytest.raises(SQLSpecError): + driver.rollback() + assert driver._transaction_active is False # pyright: ignore[reportPrivateUsage] diff --git a/tests/unit/adapters/test_mssql_python/test_adk_store.py b/tests/unit/adapters/test_mssql_python/test_adk_store.py index 2288ca35c..766bb2af7 100644 --- a/tests/unit/adapters/test_mssql_python/test_adk_store.py +++ b/tests/unit/adapters/test_mssql_python/test_adk_store.py @@ -13,12 +13,6 @@ from sqlspec.extensions.adk.memory.store import BaseSyncADKMemoryStore -def _mock_config(adk_config: dict[str, object] | None = None) -> MagicMock: - config = MagicMock() - config.extension_config = {"adk": adk_config or {}} - return config - - def test_mssql_python_adk_exports_sync_store_type() -> None: """The adapter exposes a sync store that implements the current ADK base.""" @@ -105,23 +99,6 @@ def test_sync_store_uses_tsql_upsert_and_top_limit() -> None: assert "LIMIT" not in events_sql -def _normalized(sql: str) -> str: - return " ".join(sql.split()) - - -def _session_store_with_capture( - monkeypatch: "pytest.MonkeyPatch", -) -> "tuple[MssqlPythonADKStore, list[tuple[str, tuple[Any, ...]]]]": - calls: list[tuple[str, tuple[Any, ...]]] = [] - - def capture(_store: MssqlPythonADKStore, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": - calls.append((sql, tuple(params))) - return [] - - monkeypatch.setattr(MssqlPythonADKStore, "_execute_fetchall", capture) - return MssqlPythonADKStore(_mock_config()), calls - - def test_mssql_python_list_sessions_binds_order_and_page(monkeypatch: "pytest.MonkeyPatch") -> None: """SQL Server row limiting binds the offset before the row count.""" store, calls = _session_store_with_capture(monkeypatch) @@ -312,3 +289,118 @@ def test_disabled_memory_store_rejects_operations_without_connecting() -> None: store.search_entries("query", "app", "user") config.provide_connection.assert_not_called() config.provide_session.assert_not_called() + + +def test_mssql_python_adk_search_entries_escapes_tsql_like_wildcards(monkeypatch: "pytest.MonkeyPatch") -> None: + """search_entries must escape \\, %, _, and [ in LIKE patterns and include ESCAPE N'\\\\'.""" + calls: list[tuple[str, tuple[Any, ...]]] = [] + + def capture(_store: MssqlPythonADKMemoryStore, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": + calls.append((sql, tuple(params))) + return [] + + monkeypatch.setattr(MssqlPythonADKMemoryStore, "_execute_fetchall", capture) + store = MssqlPythonADKMemoryStore(_mock_config()) + store.search_entries(r"50%_off[1]\test", "app", "u1", limit=5) + + sql, params = calls[0] + assert "LIKE ? ESCAPE N'\\'" in _normalized(sql) + assert params[-1] == r"%50\%\_off\[1]\\test%" + + +def test_mssql_python_adk_store_handles_dict_rows_and_schema_qualified_tables( + monkeypatch: "pytest.MonkeyPatch", +) -> None: + """ADK store should support dict rows and split schema.table for DDL and constraints.""" + from datetime import datetime + + store = MssqlPythonADKStore(_mock_config({"native_json": False})) + store._session_table = "custom.adk_sessions" + store._events_table = "custom.adk_events" + sessions_ddl = store._sessions_table_ddl() + events_ddl = store._events_table_ddl() + assert "SCHEMA_ID(N'custom')" in sessions_ddl + assert "CREATE TABLE [custom].[adk_sessions]" in sessions_ddl + assert "CONSTRAINT [pk_adk_sessions_row_id] PRIMARY KEY (row_id)" in sessions_ddl + assert "REFERENCES [custom].[adk_sessions](id)" in events_ddl + + now = datetime.now(tz=UTC) + dict_row = { + "id": "s1", + "app_name": "app", + "user_id": "u1", + "state": '{"k": "v"}', + "create_time": now, + "update_time": now, + } + monkeypatch.setattr(MssqlPythonADKStore, "_execute_fetchone", lambda *_a, **_kw: dict_row) + session = store.get_session("app", "u1", "s1") + assert session is not None + assert session["id"] == "s1" + assert session["state"] == {"k": "v"} + + +def test_mssql_python_adk_create_tables_reconciles_schema_and_memory_insert_rolls_back_on_error( + monkeypatch: "pytest.MonkeyPatch", +) -> None: + """create_tables calls reconcile_schema(assume_existing=True) and insert_memory_entries rolls back on error.""" + from datetime import datetime + + config = _mock_config({"native_json": False}) + store = MssqlPythonADKStore(config) + reconciled: list[bool] = [] + monkeypatch.setattr( + MssqlPythonADKStore, + "reconcile_schema", + lambda _self, *, assume_existing=False: reconciled.append(assume_existing), + ) + store.create_tables() + assert reconciled == [True] + + mem_config = _mock_config({"native_json": False}) + conn = mem_config.provide_connection.return_value.__enter__.return_value + cursor = conn.cursor.return_value + cursor.execute.side_effect = RuntimeError("db failure") + mem_store = MssqlPythonADKMemoryStore(mem_config) + now = datetime.now(tz=UTC) + entry = cast( + "Any", + { + "id": "mem-1", + "session_id": "sess-1", + "app_name": "app", + "user_id": "user-1", + "event_id": "evt-1", + "timestamp": now, + "content_json": {"text": "hello"}, + "content_text": "hello", + "metadata_json": None, + "inserted_at": now, + }, + ) + with pytest.raises(RuntimeError, match="db failure"): + mem_store.insert_memory_entries([entry]) + conn.rollback.assert_called_once() + + +def _mock_config(adk_config: dict[str, object] | None = None) -> MagicMock: + config = MagicMock() + config.extension_config = {"adk": adk_config or {}} + return config + + +def _normalized(sql: str) -> str: + return " ".join(sql.split()) + + +def _session_store_with_capture( + monkeypatch: "pytest.MonkeyPatch", +) -> "tuple[MssqlPythonADKStore, list[tuple[str, tuple[Any, ...]]]]": + calls: list[tuple[str, tuple[Any, ...]]] = [] + + def capture(_store: MssqlPythonADKStore, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": + calls.append((sql, tuple(params))) + return [] + + monkeypatch.setattr(MssqlPythonADKStore, "_execute_fetchall", capture) + return MssqlPythonADKStore(_mock_config()), calls diff --git a/tests/unit/adapters/test_mssql_python/test_config.py b/tests/unit/adapters/test_mssql_python/test_config.py index a96c1d3db..3e729c1c1 100644 --- a/tests/unit/adapters/test_mssql_python/test_config.py +++ b/tests/unit/adapters/test_mssql_python/test_config.py @@ -372,3 +372,104 @@ def test_close_pool_clears_connection_instance(monkeypatch: pytest.MonkeyPatch) config.close_pool() assert config.connection_instance is None + + +def test_session_clean_exit_rolls_back_active_transaction() -> None: + """A session exiting cleanly with an uncommitted transaction must roll back before release.""" + calls: list[str] = [] + + class _Connection: + autocommit = False + + def cursor(self) -> object: + return object() + + def rollback(self) -> None: + calls.append("rollback") + + def commit(self) -> None: + calls.append("commit") + + context = MssqlPythonSessionContext( + acquire_connection=cast("Any", _Connection), + release_connection=lambda _conn, **_kwargs: calls.append("release"), + statement_config=MssqlPythonConfig().statement_config, + driver_features={}, + prepare_driver=lambda driver: driver, + ) + + with context as session: + session.begin() + + assert calls == ["rollback", "release"] + + +def test_connection_context_rolls_back_on_exception(monkeypatch: pytest.MonkeyPatch) -> None: + """provide_connection should roll back the connection when exiting with an exception.""" + rolled_back = False + + class _Conn(DummyConnection): + def rollback(self) -> None: + nonlocal rolled_back + rolled_back = True + + conn = _Conn() + monkeypatch.setattr(_mssql_pool, "_POOLING_PARAMS", None) + monkeypatch.setattr("sqlspec.adapters.mssql_python.pool.MSSQL_PYTHON_MODULE.pooling", lambda **_: None) + monkeypatch.setattr("sqlspec.adapters.mssql_python.pool.MSSQL_PYTHON_MODULE.connect", lambda *_a, **_kw: conn) + config = MssqlPythonConfig(connection_config={"server": "localhost"}) + + with pytest.raises(RuntimeError), config.provide_connection(): + raise RuntimeError + + assert rolled_back is True + assert conn.closed is True + + +def test_pool_disabled_does_not_mutate_process_wide_pooling(monkeypatch: pytest.MonkeyPatch) -> None: + """enabled=False preserves connection_string without calling MSSQL_PYTHON_MODULE.pooling.""" + pooling_calls: list[dict[str, Any]] = [] + monkeypatch.setattr(_mssql_pool, "_POOLING_PARAMS", None) + monkeypatch.setattr( + "sqlspec.adapters.mssql_python.pool.MSSQL_PYTHON_MODULE.pooling", lambda **kw: pooling_calls.append(kw) + ) + + pool = MssqlPythonConnectionPool(connection_string="Server=localhost;", enabled=False) + + assert pool.connection_string == "Server=localhost;" + assert pooling_calls == [] + + +def test_config_applies_driver_features_to_custom_statement_config() -> None: + """driver_features json_serializer/json_deserializer should apply even when statement_config is passed explicitly.""" + from sqlspec.core import StatementConfig + + def custom_to_json(val: Any) -> str: + return f"custom:{val}" + + def custom_from_json(val: str) -> Any: + return {"raw": val} + + custom_stmt = StatementConfig(dialect="tsql") + config = MssqlPythonConfig( + connection_config={"server": "localhost"}, + statement_config=custom_stmt, + driver_features={"json_serializer": custom_to_json, "json_deserializer": custom_from_json}, + ) + + assert config.statement_config.parameter_config.json_serializer is custom_to_json + assert config.statement_config.parameter_config.json_deserializer is custom_from_json + + +def test_config_event_runtime_hints_and_exports() -> None: + """MssqlPythonConfig exposes poll_queue event runtime hints and does not re-export MssqlPythonConnectionPool in config.__all__.""" + import sqlspec.adapters.mssql_python.config as config_mod + + config = MssqlPythonConfig(connection_config={"server": "localhost"}) + hints = config.get_event_runtime_hints() + + assert hints.poll_interval == 0.25 + assert hints.lease_seconds == 5 + assert hints.select_for_update is False + assert hints.skip_locked is False + assert "MssqlPythonConnectionPool" not in config_mod.__all__ diff --git a/tests/unit/adapters/test_mssql_python/test_core.py b/tests/unit/adapters/test_mssql_python/test_core.py index 92fa5f558..b08278174 100644 --- a/tests/unit/adapters/test_mssql_python/test_core.py +++ b/tests/unit/adapters/test_mssql_python/test_core.py @@ -7,8 +7,13 @@ from sqlspec.exceptions import ( CheckViolationError, DatabaseConnectionError, + DataError, ForeignKeyViolationError, NotNullViolationError, + PermissionDeniedError, + QueryTimeoutError, + SerializationConflictError, + SQLParsingError, UniqueViolationError, ) from sqlspec.utils.config_tools import parse_odbc_connection_string @@ -282,3 +287,50 @@ def test_extract_error_number_from_string_regex() -> None: assert extract_error_number(Exception("Transaction was deadlocked on lock resources (1205)")) == 1205 assert extract_error_number(Exception("Msg 4712, Level 16, State 1")) == 4712 assert extract_error_number(Exception("No numbers here")) is None + + +def test_extract_error_number_from_nested_tuple() -> None: + """extract_error_number extracts integer from nested tuple args.""" + assert extract_error_number(Exception((2627, b"Violation of UNIQUE KEY constraint"))) == 2627 + assert extract_error_number(Exception((0, "Msg 1222, Level 16, State 51"))) == 1222 + + +@pytest.mark.parametrize( + ("code", "expected_type"), + [ + (102, SQLParsingError), + (105, SQLParsingError), + (156, SQLParsingError), + (207, SQLParsingError), + (208, SQLParsingError), + (241, DataError), + (242, DataError), + (245, DataError), + (2628, DataError), + (8115, DataError), + (8152, DataError), + (1222, QueryTimeoutError), + (3960, SerializationConflictError), + (229, PermissionDeniedError), + (230, PermissionDeniedError), + (262, PermissionDeniedError), + ], +) +def test_create_mapped_exception_maps_expanded_sql_server_error_codes( + code: int, expected_type: type[Exception] +) -> None: + """Expanded SQL Server error codes map to their specific SQLSpec exception types.""" + mapped = create_mapped_exception(Exception(code, f"SQL Server failure ({code})")) + assert isinstance(mapped, expected_type) + + +def test_build_connection_config_forwards_unmapped_kwargs_without_connection_string() -> None: + """Unmapped non-ODBC kwargs should be forwarded to connect_kwargs when building from discrete parts.""" + connection_string, connect_kwargs = build_connection_config({ + "server": "localhost", + "database": "app", + "attrs_before": {1256: b"token"}, + }) + + assert connection_string == "Server=localhost;Database=app;" + assert connect_kwargs == {"attrs_before": {1256: b"token"}} diff --git a/tests/unit/adapters/test_mssql_python/test_driver.py b/tests/unit/adapters/test_mssql_python/test_driver.py index e26eb4f23..54452a17c 100644 --- a/tests/unit/adapters/test_mssql_python/test_driver.py +++ b/tests/unit/adapters/test_mssql_python/test_driver.py @@ -16,10 +16,17 @@ class DummyCursor: def __init__(self) -> None: self.closed = False self.executed: list[tuple[str, Any]] = [] + self.rows: list[Any] = [] def execute(self, sql: str, parameters: Any = None) -> None: self.executed.append((sql, parameters)) + def executemany(self, sql: str, parameters: Any = None) -> None: + self.executed.append((sql, parameters)) + + def fetchall(self) -> list[Any]: + return list(self.rows) + def close(self) -> None: self.closed = True @@ -49,7 +56,7 @@ class _FakeRow: def __init__(self, *values: Any) -> None: self.values = values - def __iter__(self): # type: ignore[no-untyped-def] + def __iter__(self): return iter(self.values) @@ -87,3 +94,51 @@ def test_collect_rows_materializes_mssql_python_rows_as_tuples() -> None: assert type(rows[0]) is tuple assert column_names == ["id", "name"] assert row_count == 1 + + +def test_dispatch_execute_many_falls_back_to_parameter_count_when_rowcount_negative() -> None: + """dispatch_execute_many should fall back to len(parameters) when cursor.rowcount is -1.""" + from sqlspec.adapters.mssql_python.core import default_statement_config + from sqlspec.core import SQL + + connection = DummyConnection() + connection.cursor_obj.rowcount = -1 + driver = MssqlPythonDriver(cast("Any", connection), statement_config=default_statement_config) + statement = SQL( + "INSERT INTO dbo.users (id) VALUES (?)", + [(1,), (2,), (3,)], + statement_config=default_statement_config, + is_many=True, + ) + + result = driver.dispatch_execute_many(cast("Any", connection.cursor_obj), statement) + + assert result.rowcount_override == 3 + assert result.is_many_result is True + + +def test_execute_cache_hit_collects_rows_and_driver_profile_is_registered() -> None: + """_execute_cache_hit should normalize parameters, collect rows, and driver profile 'mssql_python' is registered.""" + from types import SimpleNamespace + + from sqlspec.core import get_driver_profile + from sqlspec.core.statement import ProcessedState + + connection = DummyConnection() + connection.cursor_obj.description = [("id",), ("name",)] + connection.cursor_obj.rows = [_FakeRow(1, "Ada")] + driver = MssqlPythonDriver(cast("Any", connection)) + cached = SimpleNamespace( + compiled_sql="SELECT id, name FROM dbo.users WHERE id = ?", + expression=None, + operation_type="SELECT", + operation_profile=SimpleNamespace(returns_rows=True, modifies_rows=False), + input_named_parameters=(), + processed_state=ProcessedState("", ()), + ) + + result = driver._execute_cache_hit("SELECT id, name FROM dbo.users WHERE id = ?", (1,), cast("Any", cached)) + + assert result.get_data() == [{"id": 1, "name": "Ada"}] + assert connection.cursor_obj.executed == [("SELECT id, name FROM dbo.users WHERE id = ?", (1,))] + assert get_driver_profile("mssql_python") is not None diff --git a/tests/unit/adapters/test_mssql_python/test_events_store.py b/tests/unit/adapters/test_mssql_python/test_events_store.py index 5d5e646d1..c6044a624 100644 --- a/tests/unit/adapters/test_mssql_python/test_events_store.py +++ b/tests/unit/adapters/test_mssql_python/test_events_store.py @@ -7,10 +7,6 @@ from sqlspec.adapters.mssql_python.events.store import MssqlPythonEventQueueStore, _object_name -def _config(extension_config: dict[str, Any] | None = None) -> Any: - return SimpleNamespace(extension_config=extension_config or {"events": {}}) - - def test_event_queue_store_uses_tsql_column_types_and_idempotency() -> None: """Event queue DDL should use T-SQL types and sys catalog guards.""" store = MssqlPythonEventQueueStore(cast(MssqlPythonConfig, _config())) @@ -48,3 +44,7 @@ def test_event_queue_guards_use_configured_schema_and_index() -> None: assert store.drop_statements() == [ "IF OBJECT_ID(N'[app].[app_events]', N'U') IS NOT NULL DROP TABLE app.app_events;" ] + + +def _config(extension_config: dict[str, Any] | None = None) -> Any: + return SimpleNamespace(extension_config=extension_config or {"events": {}}) diff --git a/tests/unit/adapters/test_mssql_python/test_litestar_store.py b/tests/unit/adapters/test_mssql_python/test_litestar_store.py index 3f35c3060..ba1aa3ccd 100644 --- a/tests/unit/adapters/test_mssql_python/test_litestar_store.py +++ b/tests/unit/adapters/test_mssql_python/test_litestar_store.py @@ -133,7 +133,7 @@ async def test_mssql_python_store_create_table_bridges_sync_session(monkeypatch: await store.create_table() assert len(session.scripts) == 1 - assert "CREATE TABLE mssql_sessions" in session.scripts[0] + assert "CREATE TABLE [dbo].[mssql_sessions]" in session.scripts[0] assert session.commits == 1 @@ -145,7 +145,7 @@ async def test_mssql_python_store_set_uses_merge_with_positional_parameters() -> await store.set("session-1", "payload", expires_in=60) sql, parameters = connection.executed[0] - assert "MERGE INTO mssql_sessions" in sql + assert "MERGE INTO [dbo].[mssql_sessions]" in sql assert parameters[0] == "session-1" assert parameters[1] == b"payload" assert isinstance(parameters[2], datetime) @@ -161,7 +161,7 @@ async def test_mssql_python_store_get_renews_unexpired_session() -> None: data = await store.get("session-1", renew_for=60) assert data == b"payload" - assert any("UPDATE mssql_sessions" in sql for sql, _ in connection.executed) + assert any("UPDATE [dbo].[mssql_sessions]" in sql for sql, _ in connection.executed) assert connection.commits == 1 @@ -185,3 +185,23 @@ async def test_mssql_python_store_delete_expired_returns_rows_affected() -> None assert count == 3 assert connection.commits == 1 + + +def test_mssql_python_store_supports_schema_qualified_and_quoted_table_names() -> None: + """Litestar store DDL should split schema.table and escape single quotes in metadata probes.""" + store = MssqlPythonStore(cast("Any", FakeConfig())) + store._table_name = "auth.user's_sessions" + + ddl = store._table_ddl() + drop_sql = store._drop_table_sql() + + assert "SCHEMA_ID(N'auth')" in ddl + assert "WHERE name = N'user''s_sessions'" in ddl + assert "CREATE TABLE [auth].[user's_sessions]" in ddl + + assert "CREATE INDEX [IX_user's_sessions_expires_at] ON [auth].[user's_sessions](expires_at)" in " ".join( + ddl.split() + ) + assert drop_sql == [ + "IF OBJECT_ID(N'[auth].[user''s_sessions]', N'U') IS NOT NULL DROP TABLE [auth].[user's_sessions];" + ] diff --git a/tests/unit/adapters/test_mssql_python/test_migration_schema.py b/tests/unit/adapters/test_mssql_python/test_migration_schema.py index 3168a1795..0eda21f29 100644 --- a/tests/unit/adapters/test_mssql_python/test_migration_schema.py +++ b/tests/unit/adapters/test_mssql_python/test_migration_schema.py @@ -136,3 +136,21 @@ def test_schema_restore_retries_original_schema_after_failure( driver.reset_migration_session_schema() assert cursor.executed == completed assert connection.commits == 1 + + +def test_mssql_python_migration_schema_handles_none_row_and_none_current_schema() -> None: + """set_migration_session_schema should no-op if fetchone returns None and default current_schema=None to 'dbo'.""" + cursor = FakeCursor(current_schema=cast("Any", None)) + driver = MssqlPythonDriver(cast("Any", FakeConnection(cursor))) + driver.set_migration_session_schema("tenant") + driver.reset_migration_session_schema() + assert cursor.executed[-1] == ("ALTER USER [sqlspec_migrator] WITH DEFAULT_SCHEMA = [dbo];", None) + + class _EmptyCursor(FakeCursor): + def fetchone(self) -> tuple[Any, ...] | None: + return None + + empty_cursor = _EmptyCursor() + empty_driver = MssqlPythonDriver(cast("Any", FakeConnection(empty_cursor))) + empty_driver.set_migration_session_schema("tenant") + assert len(empty_cursor.executed) == 1 diff --git a/tests/unit/adapters/test_mssql_python/test_migrations.py b/tests/unit/adapters/test_mssql_python/test_migrations.py index 1e68402c3..4e589456f 100644 --- a/tests/unit/adapters/test_mssql_python/test_migrations.py +++ b/tests/unit/adapters/test_mssql_python/test_migrations.py @@ -3,15 +3,18 @@ from typing import Any, cast from sqlspec.adapters.mssql_python.config import MssqlPythonConfig -from sqlspec.adapters.mssql_python.migrations import MssqlPythonSyncMigrationTracker, _split_schema_table +from sqlspec.adapters.mssql_python.core import split_schema_and_table +from sqlspec.adapters.mssql_python.migrations import MssqlPythonSyncMigrationTracker class FakeSyncMigrationDriver: """Minimal sync driver for migration tracker tests.""" - def __init__(self) -> None: + def __init__(self, driver_features: "dict[str, Any] | None" = None) -> None: self.scripts: list[str] = [] self.commits = 0 + if driver_features is not None: + self.driver_features = driver_features def execute_script(self, statement: str) -> None: self.scripts.append(statement) @@ -33,6 +36,7 @@ def test_sync_tracker_uses_tsql_idempotent_create_table() -> None: ddl = "\n".join(driver.scripts) assert "IF NOT EXISTS (SELECT 1 FROM sys.tables" in ddl assert "SCHEMA_ID('dbo')" in ddl + assert "CREATE TABLE [__migrations_test]" in ddl assert "NVARCHAR(32)" in ddl assert "DATETIME2(6)" in ddl assert "TIMESTAMP" not in ddl @@ -45,4 +49,27 @@ def test_mssql_python_configs_use_tsql_migration_trackers() -> None: def test_split_schema_table_preserves_bracket_quoted_dots() -> None: - assert _split_schema_table("[dbo.schema].[migration.table]") == ("dbo.schema", "migration.table") + assert split_schema_and_table("[dbo.schema].[migration.table]") == ("dbo.schema", "migration.table") + + +def test_add_column_statement_text_quotes_identifiers_and_guards_not_null() -> None: + """_add_column_statement_text should bracket-quote identifiers and only emit NOT NULL when a default exists.""" + tracker = MssqlPythonSyncMigrationTracker(version_table_name="custom.migrations") + ddl_no_default = tracker._add_column_statement_text("execution_sequence") + ddl_with_default = tracker._add_column_statement_text("applied_at") + + assert ddl_no_default == "ALTER TABLE [custom].[migrations] ADD [execution_sequence] INT NULL;" + assert ( + ddl_with_default + == "ALTER TABLE [custom].[migrations] ADD [applied_at] DATETIME2(6) DEFAULT SYSUTCDATETIME() NOT NULL;" + ) + + +def test_commit_if_needed_respects_autocommit_driver_features() -> None: + """_commit_if_needed should skip manual commit when autocommit is enabled in driver_features.""" + driver = FakeSyncMigrationDriver(driver_features={"autocommit": True}) + tracker = MssqlPythonSyncMigrationTracker(version_table_name="__migrations_test") + + tracker.ensure_tracking_table(cast("Any", driver)) + + assert driver.commits == 0 diff --git a/tests/unit/adapters/test_mssql_store_parity.py b/tests/unit/adapters/test_mssql_store_parity.py index e8c117fa4..194f29cce 100644 --- a/tests/unit/adapters/test_mssql_store_parity.py +++ b/tests/unit/adapters/test_mssql_store_parity.py @@ -18,14 +18,6 @@ from sqlspec.adapters.pymssql.litestar import PymssqlStore -def _normalized(sql: str) -> str: - return " ".join(sql.split()) - - -def _qmark(sql: str) -> str: - return sql.replace("%s", "?") - - def test_adk_session_and_event_ddl_match() -> None: """ADK session and event table DDL and indexes match across drivers.""" mssql_cfg = MssqlPythonConfig(extension_config={"adk": {"native_json": False}}) @@ -95,3 +87,25 @@ def test_litestar_store_ddl_matches() -> None: assert _normalized(MssqlPythonStore(mssql_cfg)._table_ddl()) == _normalized(PymssqlStore(pymssql_cfg)._table_ddl()) assert MssqlPythonStore(mssql_cfg)._drop_table_sql() == PymssqlStore(pymssql_cfg)._drop_table_sql() + + +def test_create_mapped_exception_ast_parity() -> None: + """create_mapped_exception in mssql_python.core and pymssql.core must remain AST-equivalent.""" + import ast + import inspect + + from sqlspec.adapters.mssql_python import core as mssql_core + from sqlspec.adapters.pymssql import core as pymssql_core + + mssql_ast = ast.dump(ast.parse(inspect.getsource(mssql_core.create_mapped_exception))) + pymssql_ast = ast.dump(ast.parse(inspect.getsource(pymssql_core.create_mapped_exception))) + assert mssql_ast == pymssql_ast + assert mssql_core._ERROR_CODE_MAPPING == pymssql_core._ERROR_CODE_MAPPING + + +def _normalized(sql: str) -> str: + return " ".join(sql.split()) + + +def _qmark(sql: str) -> str: + return sql.replace("%s", "?") diff --git a/tests/unit/adapters/test_mysqlconnector/test_config.py b/tests/unit/adapters/test_mysqlconnector/test_config.py index 5eac4f9d1..8dee4eb8d 100644 --- a/tests/unit/adapters/test_mysqlconnector/test_config.py +++ b/tests/unit/adapters/test_mysqlconnector/test_config.py @@ -371,7 +371,7 @@ def test_build_connection_config_parses_key_value_dsn() -> None: assert cfg["password"] == "kvpass" assert cfg["host"] == "kvhost" assert cfg["database"] == "kvdb" - assert cfg["port"] == "3309" + assert cfg["port"] == 3309 def test_sync_config_with_dsn_does_not_forward_dsn_to_connect(monkeypatch: pytest.MonkeyPatch) -> None: @@ -484,3 +484,39 @@ def __init__(self, **kwargs: Any) -> None: assert len(pool_kwargs) == 1 assert pool_kwargs[0]["pool_size"] == 0 + + +def test_charset_defaults_to_utf8mb4() -> None: + assert MysqlConnectorSyncConfig().connection_config["charset"] == "utf8mb4" + assert MysqlConnectorAsyncConfig().connection_config["charset"] == "utf8mb4" + + +def test_sync_close_pool_drains_cnx_queue() -> None: + import queue + from typing import cast + + conn1 = MagicMock() + conn2 = MagicMock() + cnx_queue: queue.Queue[Any] = queue.Queue() + cnx_queue.put_nowait(conn1) + cnx_queue.put_nowait(conn2) + fake_pool = SimpleNamespace(_cnx_queue=cnx_queue) + config = MysqlConnectorSyncConfig(connection_instance=cast("Any", fake_pool)) + + config.close_pool() + + conn1.close.assert_called_once() + conn2.close.assert_called_once() + assert config.connection_instance is None + + +def test_sync_connection_context_rolls_back_when_pool_reset_session_disabled() -> None: + pooled_conn = MagicMock(in_transaction=True) + fake_pool = MagicMock(get_connection=MagicMock(return_value=pooled_conn)) + config = MysqlConnectorSyncConfig(connection_config={"pool_reset_session": False}, connection_instance=fake_pool) + + with config.provide_connection() as conn: + assert conn is pooled_conn + + pooled_conn.rollback.assert_called_once() + pooled_conn.close.assert_called_once() diff --git a/tests/unit/adapters/test_mysqlconnector/test_driver.py b/tests/unit/adapters/test_mysqlconnector/test_driver.py index 8225b7696..d3a5a7444 100644 --- a/tests/unit/adapters/test_mysqlconnector/test_driver.py +++ b/tests/unit/adapters/test_mysqlconnector/test_driver.py @@ -5,7 +5,9 @@ import pytest +from sqlspec.adapters.mysqlconnector._typing import MysqlConnectorError from sqlspec.adapters.mysqlconnector.driver import MysqlConnectorAsyncDriver, MysqlConnectorSyncDriver +from sqlspec.exceptions import DeadlockError class _InvalidBoolean: @@ -29,3 +31,13 @@ def test_connection_in_transaction_does_not_infer_from_autocommit(driver_cls: ty driver = driver_cls(cast(Any, connection)) assert driver._connection_in_transaction() is False + + +def test_sync_commit_maps_mysqlconnector_deadlock_error() -> None: + def _fail_commit() -> None: + raise MysqlConnectorError(errno=1213, msg="Deadlock found when trying to get lock") + + connection = SimpleNamespace(commit=_fail_commit, in_transaction=True) + driver = MysqlConnectorSyncDriver(cast(Any, connection)) + with pytest.raises(DeadlockError): + driver.commit() diff --git a/tests/unit/adapters/test_oracledb/test_json_handlers.py b/tests/unit/adapters/test_oracledb/test_json_handlers.py index 9912f6080..e8d81c651 100644 --- a/tests/unit/adapters/test_oracledb/test_json_handlers.py +++ b/tests/unit/adapters/test_oracledb/test_json_handlers.py @@ -4,6 +4,7 @@ from functools import partial from unittest.mock import Mock +import oracledb import pytest from sqlspec.adapters.oracledb._json_handlers import ( @@ -153,10 +154,8 @@ def test_input_handler_routes_dict_to_db_type_blob_on_12c() -> None: cursor.var.assert_called_once_with(oracledb.DB_TYPE_BLOB, arraysize=2, inconverter=json_converter_in_blob) -def test_input_handler_routes_dict_to_db_type_clob_pre_12c() -> None: - """Dict bound on Oracle 11g (no IS JSON constraint) should use DB_TYPE_CLOB.""" - import oracledb - +def test_input_handler_routes_dict_to_db_type_blob_pre_12c() -> None: + """Dict bound on Oracle 11g (BLOB_PLAIN storage) should use DB_TYPE_BLOB.""" cursor = _mock_cursor_with_major(11) cursor_var = Mock() cursor.var = Mock(return_value=cursor_var) @@ -164,13 +163,24 @@ def test_input_handler_routes_dict_to_db_type_clob_pre_12c() -> None: result = json_input_type_handler(cursor, {"foo": "bar"}, 2) assert result is cursor_var - cursor.var.assert_called_once_with(oracledb.DB_TYPE_CLOB, arraysize=2, inconverter=json_converter_in_clob) + cursor.var.assert_called_once_with(oracledb.DB_TYPE_BLOB, arraysize=2, inconverter=json_converter_in_blob) + + +def test_input_handler_respects_compatible_major_on_21c() -> None: + """Oracle 21c with COMPATIBLE=19.0.0 must route JSON to DB_TYPE_BLOB instead of DB_TYPE_JSON.""" + cursor = _mock_cursor_with_major(21) + cursor.connection._sqlspec_oracle_compatible_major = 19 + cursor_var = Mock() + cursor.var = Mock(return_value=cursor_var) + + result = json_input_type_handler(cursor, {"foo": "bar"}, 1) + + assert result is cursor_var + cursor.var.assert_called_once_with(oracledb.DB_TYPE_BLOB, arraysize=1, inconverter=json_converter_in_blob) def test_input_handler_defaults_to_db_type_json_when_major_unknown() -> None: """When server version is unknown (None), default to DB_TYPE_JSON (21c+ assumption).""" - import oracledb - cursor = _mock_cursor_with_major(None) cursor_var = Mock() cursor.var = Mock(return_value=cursor_var) @@ -183,8 +193,6 @@ def test_input_handler_defaults_to_db_type_json_when_major_unknown() -> None: def test_input_handler_routes_list() -> None: """list[dict] should be claimed by the JSON handler.""" - import oracledb - cursor = _mock_cursor_with_major(21) cursor_var = Mock() cursor.var = Mock(return_value=cursor_var) @@ -195,6 +203,18 @@ def test_input_handler_routes_list() -> None: cursor.var.assert_called_once_with(oracledb.DB_TYPE_JSON, arraysize=1) +def test_input_handler_claims_empty_list_and_mixed_list() -> None: + """Empty list [] and mixed numeric/non-numeric list [1, 'two'] must be claimed by JSON handler.""" + cursor = _mock_cursor_with_major(21) + cursor_var = Mock() + cursor.var = Mock(return_value=cursor_var) + + assert json_input_type_handler(cursor, [], 1) is cursor_var + assert json_input_type_handler(cursor, [1, "two", {"k": "v"}], 1) is cursor_var + assert cursor.var.call_count == 2 + cursor.var.assert_called_with(oracledb.DB_TYPE_JSON, arraysize=1) + + def test_input_handler_does_not_claim_list_of_floats() -> None: """list[float] (vector embedding) MUST NOT be claimed by JSON handler.""" cursor = _mock_cursor_with_major(21) diff --git a/tests/unit/adapters/test_oracledb/test_litestar_store.py b/tests/unit/adapters/test_oracledb/test_litestar_store.py index 11f6f0530..df71233c7 100644 --- a/tests/unit/adapters/test_oracledb/test_litestar_store.py +++ b/tests/unit/adapters/test_oracledb/test_litestar_store.py @@ -151,3 +151,15 @@ def test_oracle_sync_store_set_large_blob_single_statement() -> None: assert parameters["data"] == (DB_TYPE_BLOB, large_payload) assert parameters["expires_in_seconds"] == 60 assert connection.commits == 1 + + +def test_oracle_litestar_store_table_ddl_sanitizes_and_truncates_index_name() -> None: + """_table_ddl replaces dots with underscores and truncates index identifier to <=30 chars.""" + config = _storage_config({}) + store = OracleSyncStore(config) + store._table_name = "my_very_long_schema.oracle_sessions_table" + + ddl = store._table_ddl() + + assert "CREATE INDEX idx_my_very_long_schema_oracle" in ddl + assert "idx_my_very_long_schema.oracle" not in ddl diff --git a/tests/unit/adapters/test_oracledb/test_oracle_adk_store.py b/tests/unit/adapters/test_oracledb/test_oracle_adk_store.py index d527a7eb0..24bb54dc4 100644 --- a/tests/unit/adapters/test_oracledb/test_oracle_adk_store.py +++ b/tests/unit/adapters/test_oracledb/test_oracle_adk_store.py @@ -5,6 +5,7 @@ from decimal import Decimal from typing import Any, NotRequired, Self, cast, get_args, get_origin from unittest.mock import MagicMock +from uuid import uuid4 import pytest @@ -21,6 +22,7 @@ from sqlspec.adapters.oracledb.adk.store import _event_data_column_ddl from sqlspec.adapters.oracledb.data_dictionary import OracleVersionCache, OracleVersionInfo from sqlspec.config import ADKConfig +from sqlspec.exceptions import SQLSpecError def _mock_config(adk_config: dict[str, object]) -> MagicMock: @@ -330,7 +332,7 @@ def test_oracle_adk_create_tables_checks_existence() -> None: def test_oracle_adk_create_tables_creates_absent_tables() -> None: - """create_tables still issues CREATE TABLE for tables the dictionary omits.""" + """create_tables issues CREATE TABLE with ORA-00955 race guards for tables the dictionary omits.""" store, driver, _ = _sync_store_with_driver() driver.data_dictionary.get_tables.return_value = [] @@ -340,7 +342,7 @@ def test_oracle_adk_create_tables_creates_absent_tables() -> None: driver.data_dictionary.get_tables.assert_called_once() executed = " ".join(str(call.args[0]) for call in driver.execute_script.call_args_list) assert "CREATE TABLE" in executed.upper() - assert "SQLCODE != -955" not in executed + assert "SQLCODE != -955" in executed class _RecordingCursor: @@ -381,6 +383,12 @@ def __init__(self, cursor: _RecordingCursor) -> None: def cursor(self) -> _RecordingCursor: return self._cursor + def commit(self) -> None: + return None + + async def commit_async(self) -> None: + return None + def __enter__(self) -> "Self": return self @@ -527,3 +535,92 @@ def test_oracle_sync_list_sessions_rejects_invalid_options(options: "dict[str, A store.list_sessions("app", **options) assert cursor.calls == [] + + +def test_oracle_sync_upsert_state_avoids_dual_state_bind_and_coerces_large_payload() -> None: + """MERGE statements must not bind :state inside SELECT FROM DUAL and must coerce >2KB BLOBs.""" + cursor = _RecordingCursor() + conn = _RecordingConnection(cursor) + lob_mock = MagicMock() + conn.createlob = MagicMock(return_value=lob_mock) # type: ignore[attr-defined] + config = _config_for_storage(JSONStorageType.BLOB_JSON) + config.extension_config = {"adk": {}} + config.provide_connection = lambda *_a, **_k: conn + store = OracleSyncADKStore(config) + + large_state = {"blob": "x" * 5000} + store.upsert_app_state("app", large_state) + store.upsert_user_state("app", "u1", large_state) + + app_sql, app_params = cursor.calls[0] + user_sql, user_params = cursor.calls[1] + assert ":state AS state FROM DUAL" not in app_sql + assert ":state AS state FROM DUAL" not in user_sql + assert app_params["state"] is lob_mock + assert user_params["state"] is lob_mock + + +def test_oracle_adk_memory_store_validates_table_identifier_and_drops_actual_indexes() -> None: + """Memory stores validate _memory_table and drop the actual created index names.""" + bad_config = _mock_config({"memory_table": "bad; DROP TABLE x"}) + with pytest.raises((SQLSpecError, ValueError)): + OracleSyncADKMemoryStore(bad_config) + with pytest.raises((SQLSpecError, ValueError)): + OracleAsyncADKMemoryStore(bad_config) + + fts_config = _mock_config({"memory_use_fts": True}) + sync_store = OracleSyncADKMemoryStore(fts_config) + drop_sql = " ".join(sync_store._drop_memory_table_sql()) + assert "idx_adk_memory_app_scope_user_time" in drop_sql + assert "idx_adk_memory_scope" in drop_sql + assert "idx_adk_memory_fts" in drop_sql + assert "idx_adk_memory_app_user_time" not in drop_sql + + +def test_oracle_adk_memory_store_preserves_raw_owner_id() -> None: + """insert_memory_entries binds owner_id directly without str() coercion.""" + cursor = _RecordingCursor() + conn = _RecordingConnection(cursor) + config = _config_for_storage(JSONStorageType.JSON_NATIVE) + config.extension_config = {"adk": {"owner_id_column": "owner_id RAW(16)"}} + config.provide_connection = lambda *_a, **_k: conn + store = OracleSyncADKMemoryStore(config) + + owner_uuid = uuid4() + now = datetime.now(UTC) + store.insert_memory_entries( + [ + { + "id": "m1", + "session_id": "s1", + "app_name": "app", + "user_id": "u1", + "scope": "user", + "event_id": "e1", + "author": "user", + "timestamp": now, + "content_json": {"a": 1}, + "content_text": "hello", + "metadata_json": None, + "inserted_at": now, + "embedding": None, + } + ], + owner_id=owner_uuid, + ) + + _, params = cursor.calls[0] + assert params["owner_id"] is owner_uuid + + +def test_oracle_adk_owner_id_column_ddl_escapes_single_quotes() -> None: + """owner_id_column DDL with single-quoted defaults is escaped inside EXECUTE IMMEDIATE.""" + config = _mock_config({"owner_id_column": "owner_id VARCHAR2(32) DEFAULT 'anon' NOT NULL"}) + session_store = OracleSyncADKStore(config) + memory_store = OracleSyncADKMemoryStore(config) + + session_ddl = session_store._sessions_table_ddl_for_type(JSONStorageType.JSON_NATIVE) + memory_ddl = memory_store._memory_table_ddl_for_type(JSONStorageType.JSON_NATIVE) + + assert "DEFAULT ''anon'' NOT NULL" in session_ddl + assert "DEFAULT ''anon'' NOT NULL" in memory_ddl diff --git a/tests/unit/adapters/test_oracledb/test_pipeline_helpers.py b/tests/unit/adapters/test_oracledb/test_pipeline_helpers.py index 1a2c91110..7e51014e9 100644 --- a/tests/unit/adapters/test_oracledb/test_pipeline_helpers.py +++ b/tests/unit/adapters/test_oracledb/test_pipeline_helpers.py @@ -1,21 +1,28 @@ # pyright: reportCallIssue=false from collections.abc import Callable from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock +import oracledb import pytest -from sqlspec import StatementStack +from sqlspec import SQL, StatementStack from sqlspec.adapters.oracledb._typing import OracleAsyncConnection, OracleSyncConnection from sqlspec.adapters.oracledb.core import ( + _coerce_sync_row_values, # pyright: ignore[reportPrivateUsage] + build_insert_statement, build_pipeline_stack_result, + create_mapped_exception, default_statement_config, normalize_execute_many_parameters_async, normalize_execute_many_parameters_sync, ) from sqlspec.adapters.oracledb.data_dictionary import OracledbAsyncDataDictionary from sqlspec.adapters.oracledb.driver import OracleAsyncDriver, OracleSyncDriver +from sqlspec.adapters.oracledb.events._hub import OracleAsyncAQHub, OracleSyncAQHub from sqlspec.data_dictionary import VersionInfo from sqlspec.driver import StackExecutionObserver +from sqlspec.exceptions import DatabaseConnectionError, DataError, OperationalError, SQLParsingError pytest.importorskip("oracledb") @@ -261,3 +268,119 @@ def test_normalize_execute_many_normalize_execute_many_parameters_passes_empty_t normalizer: Callable[[object], object], ) -> None: assert normalizer([]) == [] + + +def test_dml_returning_into_avoids_fetchall_and_uses_pipeline_add_execute() -> None: + """DML RETURNING INTO with cursor.description=None must not call fetchall or pipeline.add_fetchall.""" + driver = _make_sync_pipeline_driver() + stmt = SQL( + "INSERT INTO items (name) VALUES (:name) RETURNING id INTO :out_id", + {"name": "a", "out_id": 1}, + statement_config=default_statement_config, + ) + + cursor = MagicMock() + cursor.description = None + cursor.rowcount = 1 + cursor.fetchall.side_effect = oracledb.InterfaceError("DPY-1003") + + exec_result = driver.dispatch_execute(cursor, stmt) + assert exec_result.rowcount_override == 1 + cursor.fetchall.assert_not_called() + + stack = StatementStack().push_execute("INSERT INTO items (name) VALUES (:name) RETURNING id INTO :out_id") + compiled = driver._prepare_pipeline_operation(stack.operations[0]) + assert compiled.returns_rows is False + + pipeline = MagicMock() + driver._add_pipeline_operation(pipeline, compiled) + pipeline.add_execute.assert_called_once() + pipeline.add_fetchall.assert_not_called() + + +async def test_aq_hubs_reset_session_on_database_error_and_sync_pool_hook() -> None: + """AQ hubs reset cached session/queues on OracleDatabaseError and _pool_destroying_hook is synchronous.""" + sync_config = MagicMock() + sync_hub = OracleSyncAQHub( + sync_config, queue_name_template="AQ_{channel}", visibility=1, default_visibility=1, wait_ceiling=5 + ) + fake_queue = MagicMock() + fake_queue.deqone.side_effect = oracledb.DatabaseError("DPY-4011: connection closed") + sync_hub._queues["ch1"] = fake_queue + sync_hub._session_driver = MagicMock() + sync_hub._session_cm = MagicMock() + + assert sync_hub.dequeue("ch1", 0) is None + assert sync_hub._queues == {} + assert sync_hub._session_driver is None + + async_config = MagicMock() + async_hub = OracleAsyncAQHub( + async_config, queue_name_template="AQ_{channel}", visibility=1, default_visibility=1, wait_ceiling=5 + ) + fake_async_queue = MagicMock() + fake_async_queue.deqone = AsyncMock(side_effect=oracledb.DatabaseError("DPY-4011")) + async_hub._queues["ch1"] = fake_async_queue + async_hub._session_driver = MagicMock() + async_hub._session_cm = MagicMock() + + assert await async_hub.dequeue("ch1", 0) is None + assert async_hub._queues == {} + assert async_hub._session_driver is None + + async_hub._queues["ch1"] = fake_async_queue + hook_ret = async_hub._pool_destroying_hook({}) + assert hook_ret is None + assert async_hub._queues == {} + + +def test_build_insert_statement_quotes_identifiers_and_sync_driver_has_no_native_pipeline_exec() -> None: + """build_insert_statement quotes reserved identifiers and OracleSyncDriver omits _execute_stack_native.""" + sql = build_insert_statement("my_schema.my_table", ["id", "date", "level"]) + assert sql == 'INSERT INTO my_schema.my_table (id, "date", "level") VALUES (:1, :2, :3)' + assert "_execute_stack_native" not in OracleSyncDriver.__dict__ + + +def test_create_mapped_exception_maps_ora_and_dpy_codes_and_lob_errors_propagate() -> None: + """Map ORA-00942, ORA-12899, ORA-03113, DPY-* and propagate LOB read errors.""" + err_942 = MagicMock() + err_942.code = 942 + err_942.full_code = "ORA-00942" + assert isinstance(create_mapped_exception(oracledb.DatabaseError(err_942)), (OperationalError, SQLParsingError)) + + err_12899 = MagicMock() + err_12899.code = 12899 + err_12899.full_code = "ORA-12899" + assert isinstance(create_mapped_exception(oracledb.DatabaseError(err_12899)), DataError) + + err_3113 = MagicMock() + err_3113.code = 3113 + err_3113.full_code = "ORA-03113" + assert isinstance(create_mapped_exception(oracledb.DatabaseError(err_3113)), DatabaseConnectionError) + + err_dpy = MagicMock() + err_dpy.code = 0 + err_dpy.full_code = "DPY-4011" + assert isinstance(create_mapped_exception(oracledb.OperationalError(err_dpy)), DatabaseConnectionError) + + broken_lob = MagicMock() + broken_lob.read.side_effect = oracledb.DatabaseError("ORA-01555: snapshot too old") + with pytest.raises(oracledb.DatabaseError): + _coerce_sync_row_values((broken_lob,)) + + +def test_dispatch_execute_script_filters_named_binds_per_statement() -> None: + """Multi-statement script only binds variables referenced in each individual statement.""" + driver = _make_sync_pipeline_driver() + cursor = MagicMock() + stmt = SQL( + "INSERT INTO t1 VALUES (:a); INSERT INTO t2 VALUES (:b);", + {"a": 1, "b": 2}, + statement_config=default_statement_config, + ) + + driver.dispatch_execute_script(cursor, stmt) + + assert cursor.execute.call_count == 2 + assert cursor.execute.call_args_list[0].args[1] == {"a": 1} + assert cursor.execute.call_args_list[1].args[1] == {"b": 2} diff --git a/tests/unit/adapters/test_oracledb/test_uuid_handlers.py b/tests/unit/adapters/test_oracledb/test_uuid_handlers.py index 56fa5497b..3a5d326dc 100644 --- a/tests/unit/adapters/test_oracledb/test_uuid_handlers.py +++ b/tests/unit/adapters/test_oracledb/test_uuid_handlers.py @@ -250,6 +250,5 @@ def test_uuid_handlers_use_generic_chained_wrappers() -> None: assert callable(connection.inputtypehandler) assert callable(connection.outputtypehandler) - # oracledb keys its calling convention on inspect.signature parameter count. assert len(inspect.signature(connection.outputtypehandler).parameters) == 2 assert len(inspect.signature(connection.inputtypehandler).parameters) == 3 diff --git a/tests/unit/adapters/test_oracledb/test_vector_handlers.py b/tests/unit/adapters/test_oracledb/test_vector_handlers.py index 1b2dd3a11..728d7bdde 100644 --- a/tests/unit/adapters/test_oracledb/test_vector_handlers.py +++ b/tests/unit/adapters/test_oracledb/test_vector_handlers.py @@ -14,6 +14,10 @@ import pytest +from sqlspec.adapters.oracledb._vector_handlers import ( + _input_type_handler, # pyright: ignore[reportPrivateUsage] + _pack_sequence_converter, # pyright: ignore[reportPrivateUsage] +) from sqlspec.typing import NUMPY_INSTALLED @@ -379,10 +383,22 @@ def test_pack_sequence_converter_float_list() -> None: def test_pack_sequence_converter_int8_range() -> None: """_pack_sequence_converter packs small int lists as int8.""" - from sqlspec.adapters.oracledb._vector_handlers import _pack_sequence_converter # pyright: ignore[reportPrivateUsage] - result = _pack_sequence_converter([-128, 0, 127]) assert isinstance(result, array.array) assert result.typecode == "b" assert list(result) == [-128, 0, 127] + + +def test_input_handler_rejects_heterogeneous_list_and_pre_23ai_and_disabled_flag() -> None: + """Reject mixed lists, pre-23ai servers, and connections with enable_numpy_vectors=False.""" + cursor = _mock_cursor() + assert _input_type_handler(cursor, [1.0, "two"], 1) is None + + pre_23_cursor = _mock_cursor() + pre_23_cursor.connection._sqlspec_oracle_major = 19 + assert _input_type_handler(pre_23_cursor, [1.0, 2.0], 1) is None + + disabled_cursor = _mock_cursor() + disabled_cursor.connection._sqlspec_enable_numpy_vectors = False + assert _input_type_handler(disabled_cursor, [1.0, 2.0], 1) is None diff --git a/tests/unit/adapters/test_psqlpy/test_adk_store.py b/tests/unit/adapters/test_psqlpy/test_adk_store.py index a10f9ae74..0f75db54f 100644 --- a/tests/unit/adapters/test_psqlpy/test_adk_store.py +++ b/tests/unit/adapters/test_psqlpy/test_adk_store.py @@ -5,8 +5,10 @@ from unittest.mock import MagicMock import pytest +from psqlpy.exceptions import ConnectionExecuteError, DatabaseError -from sqlspec.adapters.psqlpy.adk import PsqlpyADKConfig, PsqlpyADKStore +from sqlspec.adapters.psqlpy.adk import PsqlpyADKConfig, PsqlpyADKMemoryStore, PsqlpyADKStore +from sqlspec.adapters.psqlpy.adk.store import _is_psqlpy_database_error, _is_table_missing_error from sqlspec.config import ADKConfig @@ -23,9 +25,14 @@ def test_psqlpy_adk_config_types_adapter_local_optimizations() -> None: assert cast("Any", PsqlpyADKConfig).__optional_keys__ - cast("Any", ADKConfig).__optional_keys__ == { "autovacuum_analyze_scale_factor", "autovacuum_vacuum_scale_factor", + "enable_bm25", "enable_event_generated_columns", "enable_covering_indexes", "fillfactor", + "scann_num_leaves", + "scann_quantizer", + "vector_dimensions", + "vector_index_type", } for feature_name in ("enable_event_generated_columns", "enable_covering_indexes"): @@ -152,15 +159,38 @@ async def test_psqlpy_list_sessions_rejects_invalid_options_before_connecting(op assert conn.calls == [] -@pytest.mark.parametrize("message, missing", [("relation sessions does not exist", True), ("syntax error", False)]) +@pytest.mark.parametrize( + "message, missing", + [("relation sessions does not exist", True), ('column "foo" does not exist', False), ("syntax error", False)], +) def test_psqlpy_store_classifies_native_database_errors(message: str, missing: bool) -> None: - from psqlpy.exceptions import ConnectionExecuteError, DatabaseError - - from sqlspec.adapters.psqlpy.adk.store import _is_psqlpy_database_error, _is_table_missing_error - database_error = DatabaseError(message) assert _is_psqlpy_database_error(database_error) is True assert _is_table_missing_error(database_error) is missing assert _is_table_missing_error(ConnectionExecuteError(message)) is missing assert _is_psqlpy_database_error(ValueError(message)) is False assert _is_table_missing_error(ValueError(message)) is False + + +async def test_psqlpy_memory_store_disabled_guards_raise_runtime_error() -> None: + """Disabled PsqlpyADKMemoryStore should raise RuntimeError from delete_entries_by_session and delete_entries_older_than.""" + store = PsqlpyADKMemoryStore(_mock_config({"enable_memory": False})) + + with pytest.raises(RuntimeError, match="Memory store is disabled"): + await store.delete_entries_by_session("s1") + + with pytest.raises(RuntimeError, match="Memory store is disabled"): + await store.delete_entries_older_than(7) + + +async def test_psqlpy_memory_store_vector_and_bm25_ddl_parity() -> None: + """PsqlpyADKMemoryStore should emit vector embedding column and BM25/vector index DDL when configured.""" + store = PsqlpyADKMemoryStore( + _mock_config({"vector_dimensions": 768, "vector_index_type": "hnsw", "enable_bm25": True}) + ) + + table_ddl = await store._memory_table_ddl() + + assert "embedding VECTOR(768)" in table_ddl + assert "USING hnsw (embedding vector_cosine_ops)" in table_ddl + assert "USING bm25 (content_text)" in table_ddl diff --git a/tests/unit/adapters/test_psqlpy/test_config.py b/tests/unit/adapters/test_psqlpy/test_config.py index f7a8f324c..e3bbfd3e9 100644 --- a/tests/unit/adapters/test_psqlpy/test_config.py +++ b/tests/unit/adapters/test_psqlpy/test_config.py @@ -259,3 +259,24 @@ def test_psqlpy_config_normalizes_aliases() -> None: assert "conninfo" not in config.connection_config assert "dbname" not in config.connection_config assert "user" not in config.connection_config + + +@pytest.mark.anyio +async def test_psqlpy_session_context_rolls_back_open_transaction_on_exit() -> None: + """PsqlpySessionContext.__aexit__ should roll back an active driver transaction before releasing the connection.""" + conn = AsyncMock() + conn.in_transaction = False + context = PsqlpySessionContext( + acquire_connection=AsyncMock(return_value=conn), + release_connection=AsyncMock(), + statement_config=StatementConfig(dialect="postgres"), + driver_features={}, + prepare_driver=lambda driver: driver, + ) + + async with context as driver: + await driver.begin() + assert driver._connection_in_transaction() is True # pyright: ignore[reportPrivateUsage] + + conn.execute.assert_any_await("ROLLBACK") + assert driver._connection_in_transaction() is False # pyright: ignore[reportPrivateUsage] diff --git a/tests/unit/adapters/test_psqlpy/test_core.py b/tests/unit/adapters/test_psqlpy/test_core.py index ce9ffcf9f..0816849b9 100644 --- a/tests/unit/adapters/test_psqlpy/test_core.py +++ b/tests/unit/adapters/test_psqlpy/test_core.py @@ -4,7 +4,7 @@ from collections.abc import Sequence from decimal import Decimal from types import SimpleNamespace -from typing import Any +from typing import Any, cast import pytest from psqlpy import exceptions as psqlpy_exceptions @@ -21,7 +21,14 @@ ) from sqlspec.core import SQL from sqlspec.driver import CachedQuery -from sqlspec.exceptions import DataError, IntegrityError, OperationalError, PermissionDeniedError, SQLSpecError +from sqlspec.exceptions import ( + ConnectionTimeoutError, + DataError, + IntegrityError, + OperationalError, + PermissionDeniedError, + SQLSpecError, +) @pytest.mark.parametrize( @@ -96,26 +103,32 @@ class MyFloat(float): def test_coerce_numeric_for_write_preserves_identity_when_unchanged() -> None: """Nested payloads without float values should keep their existing container identities.""" - payload = {"items": [1, {"value": Decimal("1.5")}], "meta": ("a", None)} + payload = [[1, (Decimal("1.5"),)], ("a", None)] coerced = coerce_numeric_for_write(payload) assert coerced is payload - assert coerced["items"] is payload["items"] - assert coerced["items"][1] is payload["items"][1] - assert coerced["meta"] is payload["meta"] + assert coerced[0] is payload[0] + assert coerced[0][1] is payload[0][1] + assert coerced[1] is payload[1] def test_coerce_numeric_for_write_copies_only_changed_branch() -> None: - """Numeric write coercion should allocate only along branches containing float values.""" - payload = {"changed": [1.5, {"value": 2.5}], "unchanged": ("a", {"value": Decimal("3.5")})} + """Numeric write coercion should allocate only along list/tuple branches containing float values.""" + payload = [[1.5, (2.5,)], ("a", [Decimal("3.5")])] coerced = coerce_numeric_for_write(payload) - assert coerced == { - "changed": [Decimal("1.5"), {"value": Decimal("2.5")}], - "unchanged": ("a", {"value": Decimal("3.5")}), - } + assert coerced == [[Decimal("1.5"), (Decimal("2.5"),)], ("a", [Decimal("3.5")])] assert coerced is not payload - assert coerced["changed"] is not payload["changed"] - assert coerced["changed"][1] is not payload["changed"][1] - assert coerced["unchanged"] is payload["unchanged"] + assert coerced[0] is not payload[0] + assert coerced[0][1] is not payload[0][1] + assert coerced[1] is payload[1] + + +def test_coerce_numeric_for_write_preserves_dict_floats() -> None: + """JSON dict payloads should retain float values untouched during numeric write coercion.""" + payload: dict[str, Any] = {"score": 1.5, "nested": {"weight": 2.5}} + coerced = cast("dict[str, Any]", coerce_numeric_for_write(payload)) + assert coerced is payload + assert type(coerced["score"]) is float + assert type(coerced["nested"]["weight"]) is float def test_decimal_is_not_registered_for_float_coercion() -> None: @@ -262,10 +275,11 @@ def test_extract_rows_affected_parses_valid_tag() -> None: "INSERT INTO events (id, payload) VALUES ($1, $2)", "UPDATE events SET payload = $1 WHERE id = $2", "DELETE FROM events WHERE id = $1", + "WITH source AS (SELECT $2 AS id) UPDATE events SET payload = $1 FROM source WHERE events.id = source.id", ], ) def test_dml_count_query_wraps_supported_statements(sql: str) -> None: - """Single DML should be wrapped in a one-row PostgreSQL count query.""" + """Single DML (including top-level non-recursive WITH CTEs) should be wrapped in a one-row PostgreSQL count query.""" rewritten = psqlpy_core._dml_count_query(sql) # pyright: ignore[reportPrivateUsage] assert rewritten is not None @@ -312,7 +326,7 @@ def test_dml_count_query_uses_collision_free_cte_alias() -> None: "MERGE INTO events USING source ON events.id = source.id WHEN MATCHED THEN DELETE", "CREATE TABLE events (id INT)", "UPDATE events SET payload = $1 WHERE id = $2 RETURNING id", - "WITH source AS (SELECT $2 AS id) UPDATE events SET payload = $1 FROM source WHERE events.id = source.id", + "WITH RECURSIVE source AS (SELECT 1 AS id UNION ALL SELECT id + 1 FROM source WHERE id < 2) UPDATE events SET payload = $1 FROM source WHERE events.id = source.id", ], ) def test_dml_count_query_bypasses_unsupported_or_returning_statements(sql: str) -> None: @@ -323,3 +337,12 @@ def test_dml_count_query_bypasses_unsupported_or_returning_statements(sql: str) def test_dml_count_query_surfaces_parse_errors() -> None: """Invalid compiled SQL should return None rather than raising during count query rewrite.""" assert psqlpy_core._dml_count_query("UPDATE events SET payload =") is None # pyright: ignore[reportPrivateUsage] + + +def test_create_mapped_exception_maps_connection_timeout_and_data_parse_errors() -> None: + """Connection timeouts should map to ConnectionTimeoutError and DataError with 'parse' should stay DataError.""" + conn_err = psqlpy_exceptions.Error("connection timed out while connecting to server") + assert type(psqlpy_core.create_mapped_exception(conn_err)) is ConnectionTimeoutError + + data_err = psqlpy_exceptions.DataError("invalid input syntax for type json: failed to parse value") + assert type(psqlpy_core.create_mapped_exception(data_err)) is DataError diff --git a/tests/unit/adapters/test_psycopg/test_cloud_connectors.py b/tests/unit/adapters/test_psycopg/test_cloud_connectors.py index a9e68efbf..5f52266aa 100644 --- a/tests/unit/adapters/test_psycopg/test_cloud_connectors.py +++ b/tests/unit/adapters/test_psycopg/test_cloud_connectors.py @@ -6,7 +6,8 @@ from unittest.mock import MagicMock, patch import pytest -from psycopg import Connection +from psycopg import Connection, Cursor +from psycopg.rows import dict_row import sqlspec.adapters.psycopg.config as psycopg_config from sqlspec.adapters.psycopg.config import PsycopgAsyncConfig, PsycopgSyncConfig @@ -222,22 +223,50 @@ def test_alloydb_connector_cleanup(mock_alloydb_module: MagicMock) -> None: assert config._alloydb_connector is None -@pytest.mark.anyio -async def test_async_config_does_not_validate_or_route_alloydb(monkeypatch: pytest.MonkeyPatch) -> None: - """PsycopgAsyncConfig should remain unaffected by sync-only AlloyDB support.""" - _CapturedAsyncPool.calls.clear() - _CapturedAsyncPool.open_calls = 0 - monkeypatch.setattr(psycopg_config, "AsyncConnectionPool", _CapturedAsyncPool) +def test_alloydb_connection_class_forwards_connect_options( + monkeypatch: pytest.MonkeyPatch, mock_alloydb_module: MagicMock +) -> None: + """_AlloyDBPsycopgConnection.connect should forward autocommit, prepare_threshold, row_factory, and cursor_factory.""" + mock_connector = MagicMock() + expected_connection = MagicMock() + mock_connector.connect.return_value = expected_connection + mock_alloydb_module.return_value = mock_connector + _CapturedSyncPool.calls.clear() + monkeypatch.setattr(psycopg_config, "ConnectionPool", _CapturedSyncPool) - config = PsycopgAsyncConfig( - connection_config={"dbname": "app"}, - driver_features={"enable_alloydb": True, "alloydb_instance_uri": "invalid-format"}, - ) + with patch("sqlspec.adapters.psycopg.config.ALLOYDB_CONNECTOR_INSTALLED", True): + config = PsycopgSyncConfig( + connection_config={"user": "testuser", "password": "testpass", "dbname": "app"}, + driver_features={ + "enable_alloydb": True, + "alloydb_instance_uri": "projects/p/locations/r/clusters/c/instances/i", + }, + ) + config._create_pool() - await config._create_pool() + _conninfo, pool_kwargs = _CapturedSyncPool.calls[-1] + connection_class = pool_kwargs["connection_class"] - conninfo, pool_kwargs = _CapturedAsyncPool.calls[-1] - assert conninfo == "" - assert pool_kwargs["kwargs"] == {"dbname": "app"} - assert "connection_class" not in pool_kwargs - assert config.driver_features["enable_alloydb"] is True + conn = connection_class.connect( + "ignored", autocommit=True, prepare_threshold=0, row_factory=dict_row, cursor_factory=Cursor + ) + assert conn is expected_connection + assert expected_connection.autocommit is True + assert expected_connection.prepare_threshold == 0 + assert expected_connection.row_factory is dict_row + assert expected_connection.cursor_factory is Cursor + + +def test_async_config_rejects_enable_alloydb() -> None: + """PsycopgAsyncConfig should raise ImproperConfigurationError when enable_alloydb is True.""" + with pytest.raises( + ImproperConfigurationError, + match="Google AlloyDB connector integration \\(enable_alloydb\\) is only supported on PsycopgSyncConfig", + ): + PsycopgAsyncConfig( + connection_config={"dbname": "app"}, + driver_features={ + "enable_alloydb": True, + "alloydb_instance_uri": "projects/p/locations/r/clusters/c/instances/i", + }, + ) diff --git a/tests/unit/adapters/test_psycopg/test_config.py b/tests/unit/adapters/test_psycopg/test_config.py index 6b211b495..971ce872b 100644 --- a/tests/unit/adapters/test_psycopg/test_config.py +++ b/tests/unit/adapters/test_psycopg/test_config.py @@ -322,11 +322,16 @@ async def test_psycopg_async_pool_preserves_conninfo_with_explicit_connection_kw def test_psycopg_sync_pool_forwards_lifecycle_options(monkeypatch: pytest.MonkeyPatch) -> None: - """Sync pool creation should route psycopg-pool lifecycle options to the pool.""" + """Sync pool creation should route psycopg-pool lifecycle options to the pool and chain custom configure.""" + seen: list[str] = [] + + def custom_configure(_: object) -> None: + seen.append("user") + connection_config: PsycopgPoolParams = { "dbname": "app", "connection_class": Connection, - "configure": _configure_sync, + "configure": custom_configure, "check": _check_sync, "reset": _reset_sync, "close_returns": True, @@ -337,12 +342,20 @@ def test_psycopg_sync_pool_forwards_lifecycle_options(monkeypatch: pytest.Monkey _CapturedSyncPool.calls.clear() monkeypatch.setattr(psycopg_config, "ConnectionPool", _CapturedSyncPool) - PsycopgSyncConfig(connection_config=connection_config)._create_pool() # pyright: ignore[reportPrivateUsage] + config = PsycopgSyncConfig( + connection_config=connection_config, driver_features={"on_connection_create": lambda _: seen.append("internal")} + ) + config._create_pool() # pyright: ignore[reportPrivateUsage] _conninfo, pool_kwargs = _CapturedSyncPool.calls[-1] assert pool_kwargs["kwargs"] == {"dbname": "app"} assert pool_kwargs["connection_class"] is Connection - assert pool_kwargs["configure"] is _configure_sync + assert callable(pool_kwargs["configure"]) + conn = MagicMock() + conn.autocommit = True + conn.execute.return_value.fetchall.return_value = [] + pool_kwargs["configure"](conn) + assert seen == ["internal", "user"] assert pool_kwargs["check"] is _check_sync assert pool_kwargs["reset"] is _reset_sync assert pool_kwargs["close_returns"] is True @@ -352,10 +365,18 @@ def test_psycopg_sync_pool_forwards_lifecycle_options(monkeypatch: pytest.Monkey @pytest.mark.anyio async def test_psycopg_async_pool_forwards_lifecycle_options(monkeypatch: pytest.MonkeyPatch) -> None: - """Async pool creation should route psycopg-pool lifecycle options to the pool.""" + """Async pool creation should route psycopg-pool lifecycle options to the pool and chain custom configure.""" + seen: list[str] = [] + + async def custom_configure(_: object) -> None: + seen.append("user") + + async def internal_hook(_: object) -> None: + seen.append("internal") + connection_config: PsycopgPoolParams = { "dbname": "app", - "configure": _configure_async, + "configure": custom_configure, "check": _check_async, "reset": _reset_async, "close_returns": True, @@ -367,11 +388,22 @@ async def test_psycopg_async_pool_forwards_lifecycle_options(monkeypatch: pytest _CapturedAsyncPool.open_calls = 0 monkeypatch.setattr(psycopg_config, "AsyncConnectionPool", _CapturedAsyncPool) - await PsycopgAsyncConfig(connection_config=connection_config)._create_pool() # pyright: ignore[reportPrivateUsage] + config = PsycopgAsyncConfig( + connection_config=connection_config, driver_features={"on_connection_create": internal_hook} + ) + await config._create_pool() # pyright: ignore[reportPrivateUsage] _conninfo, pool_kwargs = _CapturedAsyncPool.calls[-1] assert pool_kwargs["kwargs"] == {"dbname": "app"} - assert pool_kwargs["configure"] is _configure_async + assert callable(pool_kwargs["configure"]) + conn = AsyncMock() + conn.autocommit = True + cursor = MagicMock() + cursor.fetchall = AsyncMock(return_value=[]) + conn.execute = AsyncMock(return_value=cursor) + configure_fn = cast("Any", pool_kwargs["configure"]) + await configure_fn(conn) + assert seen == ["internal", "user"] assert pool_kwargs["check"] is _check_async assert pool_kwargs["reset"] is _reset_async assert pool_kwargs["close_returns"] is True @@ -595,3 +627,61 @@ def test_async_config_normalizes_aliases() -> None: assert config.connection_config["dbname"] == "override" assert "connection_string" not in config.connection_config assert "db" not in config.connection_config + + +def test_psycopg_sync_configure_connection_supports_dict_row_and_rolls_back_after_hook() -> None: + """_configure_connection should handle dict_row probe results and roll back after user hooks when autocommit=False.""" + events: list[str] = [] + connection = MagicMock() + connection.autocommit = False + connection.execute.return_value.fetchall.return_value = [{"extname": "pg_textsearch"}] + connection.rollback.side_effect = lambda: events.append("rollback") + + config = PsycopgSyncConfig( + driver_features={ + "enable_pgvector": False, + "enable_paradedb": False, + "enable_pg_textsearch": True, + "on_connection_create": lambda _: events.append("hook"), + } + ) + + config._configure_connection(connection) # pyright: ignore[reportPrivateUsage] + + assert config.pg_textsearch_available is True + assert config.statement_config.dialect == "pg_textsearch" + assert events == ["hook", "rollback"] + + +@pytest.mark.anyio +async def test_psycopg_async_configure_connection_supports_dict_row_and_rolls_back_after_hook() -> None: + """_configure_async_connection should handle dict_row probe results and roll back after user hooks when autocommit=False.""" + events: list[str] = [] + connection = AsyncMock() + connection.autocommit = False + cursor = MagicMock() + cursor.fetchall = AsyncMock(return_value=[{"extname": "pg_textsearch"}]) + connection.execute = AsyncMock(return_value=cursor) + + async def record_rollback() -> None: + events.append("rollback") + + async def record_hook(_: object) -> None: + events.append("hook") + + connection.rollback = AsyncMock(side_effect=record_rollback) + + config = PsycopgAsyncConfig( + driver_features={ + "enable_pgvector": False, + "enable_paradedb": False, + "enable_pg_textsearch": True, + "on_connection_create": record_hook, + } + ) + + await config._configure_async_connection(connection) # pyright: ignore[reportPrivateUsage] + + assert config.pg_textsearch_available is True + assert config.statement_config.dialect == "pg_textsearch" + assert events == ["hook", "rollback"] diff --git a/tests/unit/adapters/test_psycopg/test_core.py b/tests/unit/adapters/test_psycopg/test_core.py index 908caeeb5..3b3e7ca8f 100644 --- a/tests/unit/adapters/test_psycopg/test_core.py +++ b/tests/unit/adapters/test_psycopg/test_core.py @@ -7,12 +7,15 @@ from typing import Any, cast import pytest +from psycopg import errors as pg_errors from sqlspec.adapters.psycopg.core import ( build_async_pipeline_execution_result, build_copy_from_command, build_pipeline_execution_result, create_mapped_exception, + default_statement_config, + escape_literal_percent, resolve_many_rowcount, ) from sqlspec.exceptions import ConnectionTimeoutError, SQLParsingError, UniqueViolationError @@ -65,9 +68,6 @@ def test_build_copy_from_command_preserves_quoted_dots_in_table_identifier() -> def test_create_mapped_exception_dispatches_native_psycopg_error() -> None: """Native psycopg error types should resolve through the adapter-local dispatch table.""" - pytest.importorskip("psycopg") - from psycopg import errors as pg_errors - error = pg_errors.SyntaxError("syntax error at or near SELECT") mapped = create_mapped_exception(error) assert isinstance(mapped, SQLParsingError) @@ -77,8 +77,6 @@ def test_create_mapped_exception_dispatches_native_psycopg_error() -> None: def test_create_mapped_exception_dispatches_native_psycopg_subclass() -> None: """Native psycopg subclasses should use cached MRO dispatch without changing mappings.""" - pytest.importorskip("psycopg") - from psycopg import errors as pg_errors class CustomUniqueViolation(pg_errors.UniqueViolation): pass @@ -204,10 +202,41 @@ async def _run() -> None: @pytest.mark.parametrize("error_name, sqlstate", [("AdminShutdown", "57P01"), ("CannotConnectNow", "57P03")]) def test_create_mapped_exception_dispatches_connection_errors(error_name: str, sqlstate: str) -> None: - from psycopg import errors - - error = getattr(errors, error_name)("server unavailable") + error = getattr(pg_errors, error_name)("server unavailable") mapped = create_mapped_exception(error) assert isinstance(mapped, ConnectionTimeoutError) assert mapped.__cause__ is error assert sqlstate in str(mapped) + + +def test_build_pipeline_execution_result_fetches_rows_and_types_when_cursor_description_present() -> None: + """Pipeline execution should collect rows and column_types when cursor.description is present even if returns_rows() is False.""" + + class _Statement: + def returns_rows(self) -> bool: + return False + + class _Cursor: + def __init__(self) -> None: + self.description = [SimpleNamespace(name="id", type_code=23)] + + def fetchall(self) -> list[tuple[int]]: + return [(42,)] + + result = build_pipeline_execution_result( + cast("Any", _Statement()), + _Cursor(), + column_types_resolver=lambda desc: {c.name: str(c.type_code) for c in desc}, + ) + assert result.is_select_result is True + assert result.selected_data == [(42,)] + assert result.column_names == ["id"] + assert result.column_types == {"id": "23"} + + +def test_escape_literal_percent_preserves_placeholders_and_doubles_literals() -> None: + """escape_literal_percent should escape literal % signs while keeping %s placeholders intact.""" + validator = default_statement_config.parameter_validator + sql = "SELECT * FROM items WHERE label LIKE '100%' AND code = %s" + escaped = escape_literal_percent(sql, ("abc",), validator) + assert escaped == "SELECT * FROM items WHERE label LIKE '100%%' AND code = %s" diff --git a/tests/unit/adapters/test_psycopg/test_driver.py b/tests/unit/adapters/test_psycopg/test_driver.py index 74b2e9536..4cdf238c7 100644 --- a/tests/unit/adapters/test_psycopg/test_driver.py +++ b/tests/unit/adapters/test_psycopg/test_driver.py @@ -11,7 +11,8 @@ from sqlspec.adapters.psycopg.config import PsycopgAsyncConfig, PsycopgSyncConfig from sqlspec.adapters.psycopg.driver import PsycopgAsyncDriver, PsycopgSyncDriver -from sqlspec.exceptions import SQLSpecError +from sqlspec.core import SQL +from sqlspec.exceptions import SerializationConflictError, SQLSpecError if TYPE_CHECKING: from sqlspec.adapters.psycopg._typing import PsycopgAsyncConnection, PsycopgSyncConnection @@ -405,3 +406,57 @@ async def test_async_logical_transaction_is_reported_while_libpq_is_idle() -> No await driver.begin() assert driver._connection_in_transaction() is True # pyright: ignore[reportPrivateUsage] + + +def test_sync_inerror_status_is_not_active_transaction_and_rolls_back_on_begin() -> None: + """TRANSACTION_STATUS_INERROR (3) should not report an active transaction and should roll back on begin().""" + connection = _SyncTransactionConnection(transaction_status=3) + driver = _sync_driver(connection) + + assert driver._connection_in_transaction() is False # pyright: ignore[reportPrivateUsage] + driver.begin() + + assert connection.rollback_calls == 1 + assert connection.autocommit_calls == [False] + assert driver._transaction_active is True # pyright: ignore[reportPrivateUsage] + + +async def test_async_inerror_status_is_not_active_transaction_and_rolls_back_on_begin() -> None: + """TRANSACTION_STATUS_INERROR (3) should not report an active transaction and should roll back on async begin().""" + connection = _AsyncTransactionConnection(transaction_status=3) + driver = _async_driver(connection) + + assert driver._connection_in_transaction() is False # pyright: ignore[reportPrivateUsage] + await driver.begin() + + assert connection.rollback_calls == 1 + assert connection.autocommit_calls == [False] + assert driver._transaction_active is True # pyright: ignore[reportPrivateUsage] + + +def test_sync_commit_maps_typed_psycopg_exception() -> None: + """commit() should preserve typed SQLSpec exceptions mapped from psycopg errors.""" + connection = _SyncTransactionConnection(commit_error=psycopg.errors.SerializationFailure("could not serialize")) + driver = _sync_driver(connection) + driver.begin() + + with pytest.raises(SerializationConflictError, match="serialization failure"): + driver.commit() + + +def test_sync_copy_to_stdout_decodes_memoryview_chunks_and_reports_rowcount() -> None: + """COPY ... TO STDOUT should decode memoryview chunks without str(memoryview) corruption.""" + driver = _sync_driver(_SyncTransactionConnection()) + cursor = MagicMock() + cursor.rowcount = 2 + copy_ctx = MagicMock() + copy_ctx.__enter__.return_value = [memoryview(b"1\talice\n"), memoryview(b"2\tbob\n")] + copy_ctx.__exit__.return_value = False + cursor.copy.return_value = copy_ctx + + statement = SQL("COPY users TO STDOUT") + statement.compile() + result = driver.dispatch_special_handling(cursor, statement) + assert result is not None + assert result.get_data() == [{"copy_output": "1\talice\n2\tbob\n"}] + assert result.rows_affected == 2 diff --git a/tests/unit/adapters/test_pymssql/test_adk_store.py b/tests/unit/adapters/test_pymssql/test_adk_store.py index a83e81b2e..581fd4830 100644 --- a/tests/unit/adapters/test_pymssql/test_adk_store.py +++ b/tests/unit/adapters/test_pymssql/test_adk_store.py @@ -10,29 +10,6 @@ from sqlspec.adapters.pymssql.adk import PymssqlADKStore -def _mock_config(adk_config: "dict[str, object] | None" = None) -> MagicMock: - config = MagicMock() - config.extension_config = {"adk": adk_config or {}} - return config - - -def _normalized(sql: str) -> str: - return " ".join(sql.split()) - - -def _session_store_with_capture( - monkeypatch: "pytest.MonkeyPatch", -) -> "tuple[PymssqlADKStore, list[tuple[str, tuple[Any, ...]]]]": - calls: list[tuple[str, tuple[Any, ...]]] = [] - - def capture(_store: PymssqlADKStore, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": - calls.append((sql, tuple(params))) - return [] - - monkeypatch.setattr(PymssqlADKStore, "_execute_fetchall", capture) - return PymssqlADKStore(_mock_config()), calls - - def test_pymssql_list_sessions_binds_order_and_page(monkeypatch: "pytest.MonkeyPatch") -> None: """SQL Server row limiting binds the offset before the row count.""" store, calls = _session_store_with_capture(monkeypatch) @@ -135,3 +112,118 @@ def test_pymssql_adk_memory_store_insert_handles_none_metadata_and_missing_autho params = cursor.execute.call_args.args[1] assert params[6] is None assert params[10] is None + + +def test_pymssql_adk_store_requests_tuple_cursor_and_supports_dict_rows() -> None: + """PymssqlADKStore must request as_dict=False from PymssqlCursor and handle dict rows if returned.""" + from datetime import datetime + from typing import cast + + config = _mock_config({"native_json": False}) + cursor_kwargs: list[dict[str, Any]] = [] + now = datetime.now(tz=UTC) + dict_row = { + "id": "s1", + "app_name": "app", + "user_id": "u1", + "state": '{"ok": true}', + "create_time": now, + "update_time": now, + } + + class _FakeRawCursor: + def execute(self, _sql: str, _params: Any = None) -> None: + return None + + def fetchone(self) -> Any: + return dict_row + + def close(self) -> None: + return None + + class _FakeConn: + def cursor(self, **kwargs: Any) -> _FakeRawCursor: + cursor_kwargs.append(kwargs) + return _FakeRawCursor() + + config.provide_connection.return_value.__enter__.return_value = _FakeConn() + store = PymssqlADKStore(cast("Any", config)) + + session = store.get_session("app", "u1", "s1") + + assert cursor_kwargs == [{"as_dict": False}] + assert session is not None + assert session["id"] == "s1" + assert session["state"] == {"ok": True} + + +def test_pymssql_adk_search_entries_escapes_tsql_like_wildcards_and_rolls_back_on_insert_failure( + monkeypatch: "pytest.MonkeyPatch", +) -> None: + """PymssqlADKMemoryStore escapes T-SQL LIKE wildcards and rolls back failed inserts.""" + from datetime import datetime + from typing import cast + + from sqlspec.adapters.pymssql.adk import PymssqlADKMemoryStore + + calls: list[tuple[str, tuple[Any, ...]]] = [] + + def capture(_store: PymssqlADKMemoryStore, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": + calls.append((sql, tuple(params))) + return [] + + monkeypatch.setattr(PymssqlADKMemoryStore, "_execute_fetchall", capture) + store = PymssqlADKMemoryStore(_mock_config({"native_json": False})) + store.search_entries(r"50%_off[1]\test", "app", "u1", limit=5) + + sql, params = calls[0] + assert "LIKE %s ESCAPE N'\\'" in _normalized(sql) + assert params[-1] == r"%50\%\_off\[1]\\test%" + + mem_config = _mock_config({"native_json": False}) + conn = mem_config.provide_connection.return_value.__enter__.return_value + cursor = conn.cursor.return_value + cursor.execute.side_effect = RuntimeError("db failure") + mem_store = PymssqlADKMemoryStore(mem_config) + now = datetime.now(tz=UTC) + entry = cast( + "Any", + { + "id": "mem-1", + "session_id": "sess-1", + "app_name": "app", + "user_id": "user-1", + "event_id": "evt-1", + "timestamp": now, + "content_json": {"text": "hello"}, + "content_text": "hello", + "metadata_json": None, + "inserted_at": now, + }, + ) + with pytest.raises(RuntimeError, match="db failure"): + mem_store.insert_memory_entries([entry]) + conn.rollback.assert_called_once() + + +def _mock_config(adk_config: "dict[str, object] | None" = None) -> MagicMock: + config = MagicMock() + config.extension_config = {"adk": adk_config or {}} + return config + + +def _normalized(sql: str) -> str: + return " ".join(sql.split()) + + +def _session_store_with_capture( + monkeypatch: "pytest.MonkeyPatch", +) -> "tuple[PymssqlADKStore, list[tuple[str, tuple[Any, ...]]]]": + calls: list[tuple[str, tuple[Any, ...]]] = [] + + def capture(_store: PymssqlADKStore, sql: str, params: "tuple[Any, ...]" = ()) -> "list[Any]": + calls.append((sql, tuple(params))) + return [] + + monkeypatch.setattr(PymssqlADKStore, "_execute_fetchall", capture) + return PymssqlADKStore(_mock_config()), calls diff --git a/tests/unit/adapters/test_pymssql/test_config.py b/tests/unit/adapters/test_pymssql/test_config.py index 7ddf6e783..07a9e0a49 100644 --- a/tests/unit/adapters/test_pymssql/test_config.py +++ b/tests/unit/adapters/test_pymssql/test_config.py @@ -44,7 +44,7 @@ def test_config_defaults_server_port_and_features() -> None: assert config.connection_config["port"] == 1433 assert config.driver_type is PymssqlDriver assert config.supports_transactional_ddl is True - assert config.supports_native_arrow_export is False + assert config.supports_native_arrow_export is True assert config.driver_features["enable_events"] is True @@ -132,3 +132,29 @@ def test_pymssql_runtime_aliases_resolve_to_installed_classes() -> None: assert namespace["PymssqlConnection"] is pymssql.Connection assert namespace["PymssqlRawCursor"] is pymssql.Cursor assert isinstance(object(), PymssqlConnection) is False + + +def test_pymssql_session_clean_exit_rolls_back_active_transaction() -> None: + """PymssqlSessionContext rolls back an active transaction even when exiting without an exception.""" + from typing import Any, cast + + from sqlspec.adapters.pymssql._typing import PymssqlSessionContext + from tests.unit.adapters.test_pymssql._fakes import FakeConnection + + calls: list[str] = [] + conn = FakeConnection() + conn.autocommit(False) + + context = PymssqlSessionContext( + acquire_connection=lambda: cast("Any", conn), + release_connection=lambda _c, **_kw: calls.append("release"), + statement_config=PymssqlConfig().statement_config, + driver_features={}, + prepare_driver=lambda driver: driver, + ) + + with context as session: + session.begin() + + assert conn.rollbacks == 1 + assert calls == ["release"] diff --git a/tests/unit/adapters/test_pymssql/test_core.py b/tests/unit/adapters/test_pymssql/test_core.py index b8abaa6be..aac791580 100644 --- a/tests/unit/adapters/test_pymssql/test_core.py +++ b/tests/unit/adapters/test_pymssql/test_core.py @@ -5,11 +5,13 @@ import pytest from sqlspec.adapters.pymssql.core import ( + build_connection_config, build_insert_statement, collect_rows, create_mapped_exception, default_statement_config, driver_profile, + escape_literal_percent, extract_error_number, format_identifier, normalize_execute_many_parameters, @@ -19,8 +21,13 @@ from sqlspec.exceptions import ( CheckViolationError, DatabaseConnectionError, + DataError, ForeignKeyViolationError, NotNullViolationError, + PermissionDeniedError, + QueryTimeoutError, + SerializationConflictError, + SQLParsingError, UniqueViolationError, ) @@ -185,3 +192,63 @@ def test_normalize_execute_parameters_preserves_tuples() -> None: """normalize_execute_parameters passes tuples through directly.""" params = (1, "Alice") assert normalize_execute_parameters(params) is params + + +def test_extract_error_number_from_nested_tuple() -> None: + """extract_error_number extracts integer from nested tuple args.""" + assert extract_error_number(Exception((2627, b"Violation of UNIQUE KEY constraint"))) == 2627 + + +@pytest.mark.parametrize( + ("code", "expected_type"), + [ + (102, SQLParsingError), + (105, SQLParsingError), + (156, SQLParsingError), + (207, SQLParsingError), + (208, SQLParsingError), + (241, DataError), + (242, DataError), + (245, DataError), + (2628, DataError), + (8115, DataError), + (8152, DataError), + (1222, QueryTimeoutError), + (3960, SerializationConflictError), + (229, PermissionDeniedError), + (230, PermissionDeniedError), + (262, PermissionDeniedError), + ], +) +def test_create_mapped_exception_maps_expanded_sql_server_error_codes( + code: int, expected_type: type[Exception] +) -> None: + """Expanded SQL Server error codes map to their specific SQLSpec exception types.""" + mapped = create_mapped_exception(Exception(code, f"SQL Server failure ({code})")) + assert isinstance(mapped, expected_type) + + +def test_escape_literal_percent_preserves_placeholders_and_escapes_literals() -> None: + """escape_literal_percent doubles literal % while keeping %s placeholders intact when parameters exist.""" + validator = default_statement_config.parameter_validator + sql_with_params = "SELECT * FROM dbo.items WHERE discount LIKE '50%' AND category = %s" + assert ( + escape_literal_percent(sql_with_params, ("books",), validator) + == "SELECT * FROM dbo.items WHERE discount LIKE '50%%' AND category = %s" + ) + sql_without_params = "SELECT * FROM dbo.items WHERE discount LIKE '50%'" + assert escape_literal_percent(sql_without_params, (), validator) == sql_without_params + + +def test_build_connection_config_merges_extra_mapping() -> None: + """build_connection_config pops and merges the extra mapping without overriding explicit keys.""" + cfg = build_connection_config({ + "server": "sql.local", + "timeout": 10, + "extra": {"timeout": 99, "appname": "analytics", "ignored": None}, + }) + assert cfg["server"] == "sql.local" + assert cfg["timeout"] == 10 + assert cfg["appname"] == "analytics" + assert "extra" not in cfg + assert "ignored" not in cfg diff --git a/tests/unit/adapters/test_pymssql/test_driver.py b/tests/unit/adapters/test_pymssql/test_driver.py index f746aee83..452b462ae 100644 --- a/tests/unit/adapters/test_pymssql/test_driver.py +++ b/tests/unit/adapters/test_pymssql/test_driver.py @@ -1,6 +1,6 @@ """pymssql driver tests.""" -from typing import cast +from typing import Any, cast import pytest from pymssql import IntegrityError as PymssqlIntegrityError @@ -278,3 +278,65 @@ def fail(sql: str, parameters: object = None) -> None: assert sum(sql == "BEGIN TRANSACTION" for sql, _ in connection.cursor_obj.calls) == 1 driver.rollback() assert connection.cursor_obj.calls[-1] == ("IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION", None) + + +def test_pymssql_driver_escapes_literal_percent_across_execution_paths() -> None: + """dispatch_execute, dispatch_execute_script, _execute_cache_hit, and select_stream must escape literal %.""" + from types import SimpleNamespace + + from sqlspec.adapters.pymssql.core import default_statement_config + from sqlspec.adapters.pymssql.driver import PymssqlDriver + + cursor = FakeCursor(rows=[(1, "Ada")], description=[("id",), ("name",)]) + driver = PymssqlDriver(cast("PymssqlConnection", FakeConnection(cursor)), statement_config=default_statement_config) + + stmt = SQL( + "SELECT id, name FROM dbo.users WHERE name LIKE '50%' AND id = ?", 1, statement_config=default_statement_config + ) + driver.dispatch_execute(cast("PymssqlRawCursor", cursor), stmt) + assert cursor.calls[-1] == ("SELECT id, name FROM dbo.users WHERE name LIKE '50%%' AND id = %s", (1,)) + + script_stmt = SQL("UPDATE dbo.users SET note = '100%' WHERE id = ?", 1, statement_config=default_statement_config) + driver.dispatch_execute_script(cast("PymssqlRawCursor", cursor), script_stmt) + assert cursor.calls[-1] == ("UPDATE dbo.users SET note = '100%%' WHERE id = %s", (1,)) + + from sqlspec.core.statement import ProcessedState + + cached = SimpleNamespace( + compiled_sql="SELECT id, name FROM dbo.users WHERE name LIKE '50%' AND id = %s", + expression=None, + operation_type="SELECT", + operation_profile=SimpleNamespace(returns_rows=True, modifies_rows=False), + input_named_parameters=(), + processed_state=ProcessedState("", ()), + ) + driver._execute_cache_hit( + "SELECT id, name FROM dbo.users WHERE name LIKE '50%' AND id = %s", [7], cast("Any", cached) + ) + assert cursor.calls[-1] == ("SELECT id, name FROM dbo.users WHERE name LIKE '50%%' AND id = %s", (7,)) + + with driver.select_stream( + "SELECT id, name FROM dbo.users WHERE name LIKE '50%' AND id = ?", 9, native_only=True + ) as stream: + list(stream) + assert cursor.calls[-1] == ("SELECT id, name FROM dbo.users WHERE name LIKE '50%%' AND id = %s", (9,)) + + +def test_pymssql_load_from_arrow_truncates_and_inserts_batches() -> None: + """load_from_arrow should support overwrite mode and batch insert Arrow records via executemany.""" + pa = pytest.importorskip("pyarrow") + from sqlspec.adapters.pymssql.config import PymssqlConfig + from sqlspec.adapters.pymssql.driver import PymssqlDriver + + cursor = FakeCursor(rowcount=2) + connection = FakeConnection(cursor) + driver = PymssqlDriver(cast("PymssqlConnection", connection), driver_features=PymssqlConfig().driver_features) + table = pa.table({"id": [1, 2], "label": ["50%", "100%"]}) + + job = driver.load_from_arrow("dbo.metrics", table, overwrite=True) + + assert job.telemetry["rows_processed"] == 2 + assert cursor.calls[0] == ("DELETE FROM [dbo].[metrics]", None) + assert cursor.many_calls == [ + ("INSERT INTO [dbo].[metrics] ([id], [label]) VALUES (%s, %s)", [(1, "50%"), (2, "100%")]) + ] diff --git a/tests/unit/adapters/test_pymssql/test_extensions.py b/tests/unit/adapters/test_pymssql/test_extensions.py index 6e65c424c..ab8787ecd 100644 --- a/tests/unit/adapters/test_pymssql/test_extensions.py +++ b/tests/unit/adapters/test_pymssql/test_extensions.py @@ -33,12 +33,36 @@ def test_litestar_store_ddl_is_tsql_idempotent() -> None: ddl = store._table_ddl() assert "IF NOT EXISTS" in ddl - assert "CREATE TABLE litestar_session" in ddl + assert "CREATE TABLE [dbo].[litestar_session]" in ddl assert "VARBINARY(MAX)" in ddl assert "SYSUTCDATETIME()" in ddl assert "%s" not in ddl +def test_event_and_litestar_stores_escape_single_quotes_in_metadata_probes() -> None: + """Event and Litestar stores should escape single quotes inside N'...' literals.""" + from sqlspec.adapters.pymssql.events.store import PymssqlEventQueueStore + from sqlspec.adapters.pymssql.litestar.store import PymssqlStore + + event_store = PymssqlEventQueueStore( + PymssqlConfig(extension_config={"events": {"queue_table": "app.queue_events"}}) + ) + event_store._table_name = "app.queue's_events" + event_statements = event_store.create_statements() + assert "OBJECT_ID(N'[app].[queue''s_events]', N'U')" in event_statements[0] + assert "OBJECT_ID(N'[app].[queue''s_events]')" in event_statements[1] + assert "OBJECT_ID(N'[app].[queue''s_events]', N'U')" in event_store.drop_statements()[0] + + litestar_store = PymssqlStore( + PymssqlConfig(extension_config={"litestar": {"session_table": "litestar_session", "manage_schema": False}}) + ) + litestar_store._table_name = "auth.user's_sessions" + litestar_ddl = litestar_store._table_ddl() + assert "SCHEMA_ID(N'auth')" in litestar_ddl + assert "WHERE name = N'user''s_sessions'" in litestar_ddl + assert "CREATE TABLE [auth].[user's_sessions]" in litestar_ddl + + @pytest.mark.anyio async def test_litestar_store_async_methods_bridge_sync_operations(monkeypatch: pytest.MonkeyPatch) -> None: """The async Litestar interface should bridge to sync methods through async_.""" diff --git a/tests/unit/adapters/test_pymssql/test_migration_schema.py b/tests/unit/adapters/test_pymssql/test_migration_schema.py index a62346fb8..8798d3115 100644 --- a/tests/unit/adapters/test_pymssql/test_migration_schema.py +++ b/tests/unit/adapters/test_pymssql/test_migration_schema.py @@ -116,3 +116,21 @@ def test_pymssql_reset_without_set_is_noop() -> None: driver.reset_migration_session_schema() assert cursor.executed == [] + + +def test_pymssql_migration_schema_handles_none_row_and_none_current_schema() -> None: + """set_migration_session_schema should no-op if fetchone returns None and default current_schema=None to 'dbo'.""" + cursor = FakeCursor(current_schema=cast("Any", None)) + driver = PymssqlDriver(cast("Any", FakeConnection(cursor))) + driver.set_migration_session_schema("tenant") + driver.reset_migration_session_schema() + assert cursor.executed[-1] == ("ALTER USER [sqlspec_migrator] WITH DEFAULT_SCHEMA = [dbo];", None) + + class _EmptyCursor(FakeCursor): + def fetchone(self) -> Any: + return None + + empty_cursor = _EmptyCursor() + empty_driver = PymssqlDriver(cast("Any", FakeConnection(empty_cursor))) + empty_driver.set_migration_session_schema("tenant") + assert len(empty_cursor.executed) == 1 diff --git a/tests/unit/adapters/test_pymssql/test_migrations.py b/tests/unit/adapters/test_pymssql/test_migrations.py index fb6000d84..90dd12fd5 100644 --- a/tests/unit/adapters/test_pymssql/test_migrations.py +++ b/tests/unit/adapters/test_pymssql/test_migrations.py @@ -3,15 +3,18 @@ from typing import Any, cast from sqlspec.adapters.pymssql.config import PymssqlConfig -from sqlspec.adapters.pymssql.migrations import PymssqlSyncMigrationTracker, _split_schema_table +from sqlspec.adapters.pymssql.core import split_schema_and_table +from sqlspec.adapters.pymssql.migrations import PymssqlSyncMigrationTracker class FakeSyncMigrationDriver: """Minimal sync driver for migration tracker tests.""" - def __init__(self) -> None: + def __init__(self, driver_features: "dict[str, Any] | None" = None) -> None: self.scripts: list[str] = [] self.commits = 0 + if driver_features is not None: + self.driver_features = driver_features def execute_script(self, statement: str) -> None: self.scripts.append(statement) @@ -33,6 +36,7 @@ def test_sync_tracker_uses_tsql_idempotent_create_table() -> None: ddl = "\n".join(driver.scripts) assert "IF NOT EXISTS (SELECT 1 FROM sys.tables" in ddl assert "SCHEMA_ID('dbo')" in ddl + assert "CREATE TABLE [__migrations_test]" in ddl assert "NVARCHAR(32)" in ddl assert "DATETIME2(6)" in ddl assert "TIMESTAMP" not in ddl @@ -45,4 +49,27 @@ def test_pymssql_configs_use_tsql_migration_trackers() -> None: def test_split_schema_table_preserves_bracket_quoted_dots() -> None: - assert _split_schema_table("[dbo.schema].[migration.table]") == ("dbo.schema", "migration.table") + assert split_schema_and_table("[dbo.schema].[migration.table]") == ("dbo.schema", "migration.table") + + +def test_add_column_statement_text_quotes_identifiers_and_guards_not_null() -> None: + """_add_column_statement_text should bracket-quote identifiers and only emit NOT NULL when a default exists.""" + tracker = PymssqlSyncMigrationTracker(version_table_name="custom.migrations") + ddl_no_default = tracker._add_column_statement_text("execution_sequence") + ddl_with_default = tracker._add_column_statement_text("applied_at") + + assert ddl_no_default == "ALTER TABLE [custom].[migrations] ADD [execution_sequence] INT NULL;" + assert ( + ddl_with_default + == "ALTER TABLE [custom].[migrations] ADD [applied_at] DATETIME2(6) DEFAULT SYSUTCDATETIME() NOT NULL;" + ) + + +def test_commit_if_needed_respects_autocommit_driver_features() -> None: + """_commit_if_needed should skip manual commit when autocommit is enabled in driver_features.""" + driver = FakeSyncMigrationDriver(driver_features={"autocommit": True}) + tracker = PymssqlSyncMigrationTracker(version_table_name="__migrations_test") + + tracker.ensure_tracking_table(cast("Any", driver)) + + assert driver.commits == 0 diff --git a/tests/unit/adapters/test_pymssql/test_pool.py b/tests/unit/adapters/test_pymssql/test_pool.py index 219101c9e..617cd6e28 100644 --- a/tests/unit/adapters/test_pymssql/test_pool.py +++ b/tests/unit/adapters/test_pymssql/test_pool.py @@ -49,14 +49,40 @@ def connect(self, **kwargs: Any) -> FakeConnection: monkeypatch.setattr(pool_module, "pymssql", fake_module) monkeypatch.setattr(PymssqlConnectionPool, "_is_connection_alive", lambda *_: False) - pool = PymssqlConnectionPool({"server": "sql.example.test"}, health_check_interval=-1.0) + pool = PymssqlConnectionPool({"server": "sql.example.test"}, health_check_interval=0.5) assert cast(object, pool.acquire()) is first + pool._thread_local.last_used = 0.0 # pyright: ignore[reportPrivateUsage] assert cast(object, pool.acquire()) is second assert first.closed is True assert len(fake_module.connect_calls) == 2 +def test_pool_zero_health_check_interval_disables_health_check(monkeypatch) -> None: + """health_check_interval=0 should disable idle health checks.""" + import sqlspec.adapters.pymssql.pool as pool_module + from sqlspec.adapters.pymssql.pool import PymssqlConnectionPool + + connection = FakeConnection() + fake_module = FakePymssqlModule(connection) + monkeypatch.setattr(pool_module, "pymssql", fake_module) + health_checks = 0 + + def _fail_if_called(*_args: Any) -> bool: + nonlocal health_checks + health_checks += 1 + return False + + monkeypatch.setattr(PymssqlConnectionPool, "_is_connection_alive", _fail_if_called) + pool = PymssqlConnectionPool({"server": "sql.example.test"}, health_check_interval=0) + + assert cast(object, pool.acquire()) is connection + pool._thread_local.last_used = 0.0 # pyright: ignore[reportPrivateUsage] + assert cast(object, pool.acquire()) is connection + assert health_checks == 0 + assert len(fake_module.connect_calls) == 1 + + def test_pool_close_removes_thread_local_connection(monkeypatch) -> None: """close() should close and forget the current thread's connection.""" import sqlspec.adapters.pymssql.pool as pool_module @@ -86,21 +112,20 @@ def connect(self, **kwargs: Any) -> FakeConnection: monkeypatch.setattr(pool_module, "pymssql", SequencedModule()) pool = PymssqlConnectionPool({"server": "sql.example.test"}) opened: list[FakeConnection] = [] - barrier = threading.Barrier(3) + release_barrier = threading.Barrier(3) def _open() -> None: opened.append(cast(FakeConnection, pool.acquire())) - barrier.wait() + release_barrier.wait() workers = [threading.Thread(target=_open) for _ in range(2)] for worker in workers: worker.start() - barrier.wait() + release_barrier.wait() for worker in workers: worker.join() assert len({id(connection) for connection in opened}) == 2 - assert pool.size() == 2 pool.close() @@ -108,6 +133,34 @@ def _open() -> None: assert [connection.closed for connection in opened] == [True, True] +def test_pool_prunes_dead_thread_connections_and_tracks_checked_out(monkeypatch) -> None: + """Dead worker threads should have their connections closed and pruned, and get_connection tracks checked_out.""" + import sqlspec.adapters.pymssql.pool as pool_module + from sqlspec.adapters.pymssql.pool import PymssqlConnectionPool + + class SequencedModule(FakePymssqlModule): + def connect(self, **kwargs: Any) -> FakeConnection: + self.connect_calls.append(kwargs) + return FakeConnection() + + monkeypatch.setattr(pool_module, "pymssql", SequencedModule()) + pool = PymssqlConnectionPool({"server": "sql.example.test"}) + worker_connections: list[FakeConnection] = [] + + def _worker() -> None: + with pool.get_connection() as conn: + worker_connections.append(cast(FakeConnection, conn)) + assert pool.checked_out() == 1 + + thread = threading.Thread(target=_worker) + thread.start() + thread.join() + + assert pool.checked_out() == 0 + assert pool.size() == 0 + assert worker_connections[0].closed is True + + def test_pool_registry_does_not_grow_across_replacements(monkeypatch) -> None: """Recycle and health-check replacement must deregister the connection they discard.""" import sqlspec.adapters.pymssql.pool as pool_module @@ -120,10 +173,11 @@ def connect(self, **kwargs: Any) -> FakeConnection: monkeypatch.setattr(pool_module, "pymssql", SequencedModule()) monkeypatch.setattr(PymssqlConnectionPool, "_is_connection_alive", lambda *_: False) - pool = PymssqlConnectionPool({"server": "sql.example.test"}, health_check_interval=-1.0) + pool = PymssqlConnectionPool({"server": "sql.example.test"}, health_check_interval=0.5) for _ in range(4): pool.acquire() + pool._thread_local.last_used = 0.0 # pyright: ignore[reportPrivateUsage] assert len(pool._connection_registry) == 1 # pyright: ignore[reportPrivateUsage] diff --git a/tests/unit/adapters/test_pymysql/test_config.py b/tests/unit/adapters/test_pymysql/test_config.py index ebd94990a..22ceb0f23 100644 --- a/tests/unit/adapters/test_pymysql/test_config.py +++ b/tests/unit/adapters/test_pymysql/test_config.py @@ -3,7 +3,7 @@ import ssl import threading from collections.abc import Mapping -from typing import NotRequired, get_args, get_origin, get_type_hints +from typing import Any, NotRequired, get_args, get_origin, get_type_hints from unittest.mock import MagicMock import pytest @@ -256,3 +256,41 @@ def test_pool_discards_connection_after_rollback_failure() -> None: assert pool.acquire() is replacement finally: pool.close() + + +def test_pool_checked_out_tracks_acquire_and_release_and_retires_unbuffered() -> None: + conn = MagicMock(server_status=0, unbuffered_active=False) + unbuffered_conn = MagicMock(server_status=0, unbuffered_active=True) + factory = MagicMock(side_effect=[conn, unbuffered_conn]) + pool = PyMysqlConnectionPool({}, connection_factory=factory) + try: + assert pool.checked_out() == 0 + acquired: Any = pool.acquire() + assert acquired is conn + assert pool.checked_out() == 1 + pool.release(acquired) + assert pool.checked_out() == 0 + + acquired.unbuffered_active = True + reacquired: Any = pool.acquire() + assert reacquired is acquired + pool.release(reacquired) + acquired.close.assert_called_once() + assert pool.checked_out() == 0 + finally: + pool.close() + + +def test_pool_new_connection_closes_connection_when_hook_raises() -> None: + raw_conn = MagicMock(server_status=0) + + def _bad_hook(_conn: object) -> None: + raise RuntimeError("hook boom") + + pool = PyMysqlConnectionPool({}, on_connection_create=_bad_hook, connection_factory=lambda: raw_conn) + try: + with pytest.raises(RuntimeError, match="hook boom"): + pool.acquire() + raw_conn.close.assert_called_once() + finally: + pool.close() diff --git a/tests/unit/adapters/test_pymysql/test_driver.py b/tests/unit/adapters/test_pymysql/test_driver.py index 09dadc9b1..09b3271af 100644 --- a/tests/unit/adapters/test_pymysql/test_driver.py +++ b/tests/unit/adapters/test_pymysql/test_driver.py @@ -2,10 +2,13 @@ from typing import Any, cast +import pytest from pymysql.constants import SERVER_STATUS from sqlspec import StatementStack from sqlspec.adapters.pymysql import PyMysqlDriver +from sqlspec.adapters.pymysql._typing import PyMysqlMySQLError +from sqlspec.exceptions import DeadlockError class _FakeCursor: @@ -90,3 +93,15 @@ def test_connection_in_transaction_returns_false_when_server_status_unreadable() connection = _FakeConnection(server_status=None, autocommit=False) assert _driver(connection)._connection_in_transaction() is False + + +def test_commit_maps_pymysql_deadlock_error() -> None: + connection = _FakeConnection() + + def _fail_commit() -> None: + raise PyMysqlMySQLError(1213, "Deadlock found when trying to get lock") + + setattr(connection, "commit", _fail_commit) + driver = _driver(connection) + with pytest.raises(DeadlockError): + driver.commit() diff --git a/tests/unit/adapters/test_spanner/test_core.py b/tests/unit/adapters/test_spanner/test_core.py index f174e1a2c..e63b9600b 100644 --- a/tests/unit/adapters/test_spanner/test_core.py +++ b/tests/unit/adapters/test_spanner/test_core.py @@ -1,23 +1,51 @@ """Unit tests for Spanner core performance helpers.""" +import base64 +from contextlib import nullcontext +from datetime import UTC, date, datetime +from decimal import Decimal from types import SimpleNamespace from typing import Any, cast +from unittest.mock import MagicMock +from uuid import UUID import pytest +from google.api_core import exceptions as api_exceptions +from google.cloud.spanner_v1 import param_types from google.cloud.spanner_v1.data_types import JsonObject from google.cloud.spanner_v1.types.type import TypeCode +from sqlspec.adapters.spanner._typing import SpannerSyncSessionContext +from sqlspec.adapters.spanner.adk.artifact_store import SpannerAsyncADKArtifactStore, SpannerSyncADKArtifactStore +from sqlspec.adapters.spanner.adk.store import SpannerSyncADKMemoryStore, SpannerSyncADKStore +from sqlspec.adapters.spanner.config import SpannerSyncConfig, SpannerSyncConnectionContext from sqlspec.adapters.spanner.core import ( build_param_type_signature, build_statement_config, + chunk_mutation_rows, + coerce_params, collect_rows, + create_mapped_exception, + execute_partitioned_dml_sync, + infer_param_types, is_query_statement, resolve_column_names, resolve_row_plan, resolve_transaction_completion, ) +from sqlspec.adapters.spanner.data_dictionary import SpannerSyncDataDictionary from sqlspec.adapters.spanner.driver import SpannerSyncDriver +from sqlspec.adapters.spanner.litestar.store import SpannerSyncStore +from sqlspec.adapters.spanner.type_converter import spanner_json, spanner_to_uuid from sqlspec.core import SQL, TypedParameter +from sqlspec.exceptions import ( + DeadlockError, + ImproperConfigurationError, + OperationalError, + PermissionDeniedError, + QueryTimeoutError, + UniqueViolationError, +) from sqlspec.utils.serializers import from_json @@ -235,3 +263,339 @@ def test_then_return_statement_returns_rows() -> None: statement.compile() assert statement.returns_rows() + + +def test_exception_mapping_permission_denied_unauthenticated_and_timeout() -> None: + """Verify 403/401 -> PermissionDeniedError, 504 -> QueryTimeoutError, 503/429 -> OperationalError.""" + forbidden = cast("Any", api_exceptions.PermissionDenied)("access denied") + unauthorized = cast("Any", api_exceptions.Unauthenticated)("invalid token") + deadline = cast("Any", api_exceptions.DeadlineExceeded)("timed out") + unavailable = cast("Any", api_exceptions.ServiceUnavailable)("unavailable") + aborted = cast("Any", api_exceptions.Aborted)("aborted") + too_many = cast("Any", api_exceptions.TooManyRequests)("quota") + already_exists = cast("Any", api_exceptions.AlreadyExists)("exists") + + assert isinstance(create_mapped_exception(forbidden), PermissionDeniedError) + assert isinstance(create_mapped_exception(unauthorized), PermissionDeniedError) + assert isinstance(create_mapped_exception(deadline), QueryTimeoutError) + assert isinstance(create_mapped_exception(unavailable), OperationalError) + assert isinstance(create_mapped_exception(aborted), DeadlockError) + assert isinstance(create_mapped_exception(too_many), OperationalError) + assert isinstance(create_mapped_exception(already_exists), UniqueViolationError) + + +def test_connection_context_maps_implicit_commit_and_suppresses_rollback_errors() -> None: + """Verify SpannerSyncConnectionContext maps commit GoogleAPICallError and suppresses rollback errors.""" + + class _AbortingTxn: + _transaction_id = b"tx1" + _mutations: list[Any] = [] + committed = None + rolled_back = False + + def __enter__(self) -> Any: + return self + + def begin(self) -> None: + return None + + def commit(self) -> None: + raise cast("Any", api_exceptions.Aborted)("aborted on commit") + + def rollback(self) -> None: + raise cast("Any", api_exceptions.ServiceUnavailable)("rollback failed") + + session_obj = SimpleNamespace(transaction=_AbortingTxn) + sessions_manager = MagicMock() + sessions_manager.get_session.return_value = session_obj + database = SimpleNamespace(sessions_manager=sessions_manager) + + config = MagicMock() + config.get_database.return_value = database + config.driver_features = {} + + ctx = SpannerSyncConnectionContext(config, transaction=True) + with pytest.raises(DeadlockError), ctx: + pass + + ctx_rollback = SpannerSyncConnectionContext(config, transaction=True) + with pytest.raises(RuntimeError, match="user boom"), ctx_rollback: + raise RuntimeError("user boom") + + +def test_execute_script_passes_configured_timeout_to_ddl() -> None: + """Verify dispatch_execute_script forwards configured timeout to execute_ddl_sync.""" + ddl_timeouts: list[Any] = [] + + class _Operation: + def result(self, timeout: Any = None) -> None: + ddl_timeouts.append(timeout) + + class _Database: + def update_ddl(self, _statements: list[str]) -> _Operation: + return _Operation() + + db = _Database() + + class _TxConn: + _session = SimpleNamespace(_database=db) + + def execute_update(self, *_args: Any, **_kwargs: Any) -> int: + return 1 + + driver = SpannerSyncDriver(cast("Any", _TxConn()), driver_features={"timeout": 42.0}) + script = SQL("CREATE TABLE t1 (id INT64) PRIMARY KEY (id); CREATE TABLE t2 (id INT64) PRIMARY KEY (id);") + result = driver.dispatch_execute_script(driver.connection, script) + + assert result.statement_count == 2 + assert ddl_timeouts == [42.0] + + +def test_driver_mutation_helpers_and_partitioned_dml() -> None: + """Verify write_insert/write_upsert/write_update/write_delete and execute_partitioned_dml.""" + mutations: list[tuple[str, str, list[str], Any]] = [] + pdml_calls: list[tuple[str, Any, Any]] = [] + + class _Database: + def execute_partitioned_dml( + self, sql: str, *, params: Any = None, param_types: Any = None, **_kwargs: Any + ) -> int: + pdml_calls.append((sql, params, param_types)) + return 17 + + db = _Database() + + class _TxConn: + _session = SimpleNamespace(_database=db) + + def execute_update(self, *_args: Any, **_kwargs: Any) -> int: + return 1 + + def insert(self, table: str, columns: list[str], values: Any) -> None: + mutations.append(("insert", table, columns, values)) + + def insert_or_update(self, table: str, columns: list[str], values: Any) -> None: + mutations.append(("upsert", table, columns, values)) + + def update(self, table: str, columns: list[str], values: Any) -> None: + mutations.append(("update", table, columns, values)) + + def delete(self, table: str, keyset: Any) -> None: + mutations.append(("delete", table, [], keyset)) + + driver = SpannerSyncDriver(cast("Any", _TxConn())) + driver.write_insert("users", ["id", "name"], [(1, "a")]) + driver.write_upsert("users", ["id", "name"], [(1, "b")]) + driver.write_update("users", ["id", "name"], [(1, "c")]) + driver.write_delete("users", "all_keys") + affected = driver.execute_partitioned_dml("DELETE FROM users WHERE active = @active", {"active": False}) + + assert [m[0] for m in mutations] == ["insert", "upsert", "update", "delete"] + assert affected == 17 + assert pdml_calls[0][1] == {"active": False} + assert pdml_calls[0][2] == {"active": param_types.BOOL} + + direct_affected = execute_partitioned_dml_sync(db, "DELETE FROM users WHERE TRUE") + assert direct_affected == 17 + + +def test_type_inference_and_coercion_cover_sequences_decimals_dates_uuids_and_typed_nulls() -> None: + """Verify non-list sequences, typed None, Decimal/date/UUID array elements, and custom json_serializer.""" + uid = UUID("12345678-1234-5678-1234-567812345678") + params = { + "tuple_ints": (1, 2, 3), + "decimals": [Decimal("1.25")], + "dates": [date(2026, 5, 10)], + "uuids": [uid], + "leading_none": [None, 7], + "null_int": TypedParameter(None, int), + } + coerced = coerce_params(params) + inferred = infer_param_types(params) + + assert coerced is not None + assert coerced["tuple_ints"] == [1, 2, 3] + assert coerced["uuids"] == [str(uid)] + assert coerced["null_int"] is None + array_type = cast("Any", param_types.Array) + assert inferred["tuple_ints"] == array_type(param_types.INT64) + assert inferred["decimals"] == array_type(param_types.NUMERIC) + assert inferred["dates"] == array_type(param_types.DATE) + assert inferred["uuids"] == array_type(param_types.STRING) + assert inferred["leading_none"] == array_type(param_types.INT64) + assert inferred["null_int"] == param_types.INT64 + + custom_calls: list[Any] = [] + + def _custom_json(val: Any) -> str: + custom_calls.append(val) + return '{"x":2}' + + wrapped = spanner_json({"x": 1}, json_serializer=_custom_json) + assert dict(wrapped) == {"x": 2} + assert custom_calls == [{"x": 1}] + assert spanner_to_uuid(uid.bytes) == uid + + +def test_chunk_mutation_rows_respects_mutation_limit() -> None: + """Verify chunk_mutation_rows splits rows when cells exceed Spanner's 80,000 mutation limit.""" + columns = [f"c{i}" for i in range(10)] + rows = [tuple(range(10)) for _ in range(8001)] + chunks = chunk_mutation_rows(columns, rows, coerce_row=lambda d: d) + assert [len(c) for c in chunks] == [8000, 1] + + +def test_litestar_store_uses_read_session_and_maps_transaction_errors() -> None: + """Verify SpannerSyncStore uses provide_read_session, sharded exists, and maps write errors.""" + read_calls = 0 + now = datetime(2026, 5, 10, 12, 0, tzinfo=UTC) + encoded_val = base64.b64encode(b"val").decode("ascii") + + class _ReadDriver: + def select_one_or_none(self, *_args: Any, **_kwargs: Any) -> dict[str, Any]: + return {"data": encoded_val, "expires_at": now} + + config = MagicMock() + config.extension_config = {"litestar": {"shard_count": 4}} + + def provide_read_session() -> Any: + nonlocal read_calls + read_calls += 1 + return nullcontext(_ReadDriver()) + + config.provide_read_session = provide_read_session + database = MagicMock() + database.run_in_transaction.side_effect = cast("Any", api_exceptions.PermissionDenied)("denied") + config.get_database.return_value = database + + store = SpannerSyncStore(config) + assert "shard_id = MOD(FARM_FINGERPRINT(@session_id), 4)" in store._build_exists_sql() + assert store._get("k1") == b"val" + assert store._exists("k1") is True + assert read_calls == 2 + + with pytest.raises(PermissionDeniedError): + store._delete("k1") + + +def test_adk_store_honors_custom_json_serializers_and_maps_write_errors() -> None: + """Verify ADK stores scope deletes, avoid tautologies, and map errors.""" + serialize_calls: list[Any] = [] + deserialize_calls: list[str] = [] + + def custom_to_json(val: Any) -> str: + serialize_calls.append(val) + return '{"custom":true}' + + def custom_from_json(val: str) -> Any: + deserialize_calls.append(val) + return {"decoded": val} + + config = MagicMock() + config.extension_config = {"adk": {"shard_count": 4}} + config.driver_features = {"json_serializer": custom_to_json, "json_deserializer": custom_from_json} + database = MagicMock() + database.run_in_transaction.side_effect = cast("Any", api_exceptions.PermissionDenied)("denied") + config.get_database.return_value = database + + store = SpannerSyncADKStore(config) + assert store._json_serializer is custom_to_json + assert store._json_deserializer is custom_from_json + + delete_stmts = store._build_delete_session_statements("app", "user", "s1") + assert "app_name = @app_name AND user_id = @user_id" in delete_stmts[0][0] + + query_result = store._build_list_sessions_query("app", "user") + assert query_result is not None + list_sql, _, _ = query_result + assert "FARM_FINGERPRINT(id)" not in list_sql + + with pytest.raises(PermissionDeniedError): + store._run_write([("DELETE FROM adk_session WHERE TRUE", {}, {})]) + + +def test_adk_memory_store_uses_snapshot_reads_and_vector_index_ddl() -> None: + """Verify SpannerSyncADKMemoryStore uses snapshot read and vector index DDL.""" + config = MagicMock() + config.extension_config = {"adk": {"vector_dimensions": 128, "vector_index_enabled": True}} + config.driver_features = {} + config.provide_connection.return_value = nullcontext(SimpleNamespace(execute_sql=lambda *_a, **_kw: [])) + store = SpannerSyncADKMemoryStore(config) + + assert store.search_entries("hello", "app", "user") == [] + config.provide_connection.assert_called_once_with(transaction=False) + + ddl_stmts = store._build_memory_table_ddl() + assert any("WHERE embedding IS NOT NULL" in s for s in ddl_stmts) + + +def test_adk_artifact_store_maps_database_errors() -> None: + """Verify SpannerSyncADKArtifactStore uses snapshot read and maps GoogleAPICallError.""" + config = MagicMock() + config.extension_config = {"adk": {}} + config.driver_features = {} + config.provide_connection.side_effect = cast("Any", api_exceptions.PermissionDenied)("denied") + + store = SpannerSyncADKArtifactStore(config) + assert isinstance(store, SpannerSyncADKArtifactStore) + with pytest.raises(PermissionDeniedError): + store.list_artifact_keys("app", "user") + config.provide_connection.assert_called_once_with(transaction=False) + + async_store = SpannerAsyncADKArtifactStore(config) + assert isinstance(async_store, SpannerAsyncADKArtifactStore) + + +def test_spanner_data_dictionary_passes_dialect_to_queries() -> None: + """Verify SpannerSyncDataDictionary passes dialect to get_query.""" + dd = SpannerSyncDataDictionary() + captured: list[str] = [] + + class _Driver: + def select(self, query: Any, **_kwargs: Any) -> list[Any]: + captured.append(str(query)) + return [] + + assert dd.get_tables(cast("Any", _Driver())) == [] + assert captured + + +def test_spanner_session_context_passes_active_driver_connection_on_exit() -> None: + """Verify SpannerSyncSessionContext releases the active driver connection on exit.""" + released_conns: list[tuple[Any, Any]] = [] + initial_conn = SimpleNamespace(name="initial") + renewed_conn = SimpleNamespace(name="renewed") + + context = SpannerSyncSessionContext( + acquire_connection=lambda: initial_conn, + release_connection=lambda conn, **kwargs: released_conns.append((conn, kwargs.get("exc_type"))), + statement_config=SpannerSyncDriver(cast("Any", SimpleNamespace())).statement_config, + driver_features={}, + prepare_driver=lambda drv: drv, + ) + + with pytest.raises(RuntimeError, match="boom"), context as drv: + drv.connection = cast("Any", renewed_conn) + raise RuntimeError("boom") + + assert released_conns == [(renewed_conn, RuntimeError)] + + +def test_spanner_config_rejects_min_sessions_and_closes_client_on_close_pool() -> None: + """Verify SpannerSyncConfig rejects min_sessions and closes client on close_pool.""" + with pytest.raises(ImproperConfigurationError, match="min_sessions"): + SpannerSyncConfig(connection_config={"project": "p", "instance_id": "i", "database_id": "d", "min_sessions": 2}) + + closed: list[str] = [] + fake_client = SimpleNamespace(close=lambda: closed.append("client")) + config = SpannerSyncConfig( + connection_config={"project": "p", "instance_id": "i", "database_id": "d", "max_sessions": 2} + ) + config._client = cast("Any", fake_client) + config.connection_instance = cast("Any", SimpleNamespace(clear=lambda: closed.append("pool"))) + assert callable(config.provide_read_session) + assert callable(config.provide_write_session) + config.close_pool() + + assert closed == ["pool", "client"] + assert config._client is None diff --git a/tests/unit/adapters/test_spanner/test_litestar_store.py b/tests/unit/adapters/test_spanner/test_litestar_store.py index 5ee45e82b..6b6954d0d 100644 --- a/tests/unit/adapters/test_spanner/test_litestar_store.py +++ b/tests/unit/adapters/test_spanner/test_litestar_store.py @@ -99,12 +99,12 @@ def test_get_uses_snapshot_session() -> None: config = MagicMock() config.extension_config = {"litestar": {"session_table": "sess"}} - config.provide_session.return_value = cm + config.provide_read_session.return_value = cm store = SpannerSyncStore(config) result = store._get("s1") # pyright: ignore - config.provide_session.assert_called_once_with() + config.provide_read_session.assert_called_once_with() assert result is None @@ -116,12 +116,12 @@ def test_exists_uses_snapshot_session() -> None: config = MagicMock() config.extension_config = {"litestar": {"session_table": "sess"}} - config.provide_session.return_value = cm + config.provide_read_session.return_value = cm store = SpannerSyncStore(config) result = store._exists("s1") # pyright: ignore - config.provide_session.assert_called_once_with() + config.provide_read_session.assert_called_once_with() assert result is True @@ -189,7 +189,7 @@ async def test_async_store_set_delete_delete_all_delete_expired() -> None: async def test_async_store_get_exists_expires_in() -> None: - """Verify SpannerAsyncStore read operations use async provide_session and optional renewal.""" + """Verify SpannerAsyncStore read operations use async provide_read_session and optional renewal.""" db = _mock_async_database() future_time = datetime.now(UTC) + timedelta(seconds=120) driver = MagicMock() @@ -198,7 +198,7 @@ async def test_async_store_get_exists_expires_in() -> None: config = MagicMock() config.extension_config = {"litestar": {"session_table": "sess", "shard_count": 4}} config.get_database = AsyncMock(return_value=db) - config.provide_session.side_effect = lambda: _async_context_manager_yielding(driver) + config.provide_read_session.side_effect = lambda: _async_context_manager_yielding(driver) store = SpannerAsyncStore(config) val = await store.get("s1", renew_for=60) diff --git a/tests/unit/adapters/test_sqlite/test_driver.py b/tests/unit/adapters/test_sqlite/test_driver.py index 7f3f4f114..7d06b9fdb 100644 --- a/tests/unit/adapters/test_sqlite/test_driver.py +++ b/tests/unit/adapters/test_sqlite/test_driver.py @@ -5,8 +5,11 @@ import pytest -from sqlspec.adapters.sqlite import SqliteDriver +from sqlspec.adapters.sqlite import SqliteConfig, SqliteDriver from sqlspec.adapters.sqlite.core import SQLITE_CONNECT_SUPPORTS_AUTOCOMMIT, default_statement_config +from sqlspec.adapters.sqlite.pool import SqliteConnectionPool +from sqlspec.adapters.sqlite.type_converter import json_converter, register_type_handlers, unregister_type_handlers +from sqlspec.exceptions import ImproperConfigurationError def test_rowid_eligibility_falls_back_when_table_list_is_unavailable() -> None: @@ -327,3 +330,42 @@ def test_window_registration_checks_native_capability(async_adapter: bool) -> No _register_runtime_objects(cast("Any", SimpleNamespace(_conn=object())), (), (), functions) else: _apply_runtime_setup(cast("Any", object()), {"custom_window_functions": functions}) + + +def test_sqlite_pool_size_and_release_rollback() -> None: + pool = SqliteConnectionPool(connection_parameters={"database": ":memory:"}) + try: + conn = pool.acquire() + assert pool.size() == 1 + conn.execute("CREATE TABLE t (id INTEGER)") + conn.execute("INSERT INTO t VALUES (1)") + pool.release(conn) + assert conn.in_transaction is False + finally: + pool.close() + assert pool.size() == 0 + + +def test_sqlite_config_validates_driver_features() -> None: + with pytest.raises(ImproperConfigurationError, match="custom_functions"): + SqliteConfig( + connection_config={"database": ":memory:"}, + driver_features=cast("Any", {"custom_functions": ["not_a_mapping"]}), + ) + with pytest.raises(ImproperConfigurationError, match="custom_aggregates"): + SqliteConfig( + connection_config={"database": ":memory:"}, + driver_features=cast("Any", {"custom_aggregates": [{"name": "a", "narg": 1}]}), + ) + with pytest.raises(ImproperConfigurationError, match="default_transaction_mode"): + SqliteConfig( + connection_config={"database": ":memory:"}, + driver_features=cast("Any", {"default_transaction_mode": "INVALID"}), + ) + + +def test_sqlite_json_converter_and_unregister() -> None: + assert json_converter(b'{"a": 1}') == {"a": 1} + register_type_handlers() + unregister_type_handlers() + assert (dict, sqlite3.PrepareProtocol) not in sqlite3.adapters diff --git a/tests/unit/adapters/test_sync_adapters.py b/tests/unit/adapters/test_sync_adapters.py index bd0ac6f10..27929f126 100644 --- a/tests/unit/adapters/test_sync_adapters.py +++ b/tests/unit/adapters/test_sync_adapters.py @@ -287,7 +287,7 @@ def test_sync_driver_dispatch_statement_execution_many(sqlite_sync_driver: Sqlit def test_sync_driver_releases_pooled_statement(sqlite_sync_driver: SqliteDriver) -> None: - """Pooled statements should be reset after dispatch execution.""" + """Pooled statements stay intact when attached to SQLResult and reset when released on failure.""" seed = "SELECT * FROM users WHERE id = ?" sqlite_sync_driver.prepare_statement(seed, (1,), statement_config=sqlite_sync_driver.statement_config, kwargs={}) pooled = sqlite_sync_driver.prepare_statement( @@ -296,12 +296,28 @@ def test_sync_driver_releases_pooled_statement(sqlite_sync_driver: SqliteDriver) assert pooled._pooled is True - sqlite_sync_driver.dispatch_statement_execution(pooled, sqlite_sync_driver.connection) + result = sqlite_sync_driver.dispatch_statement_execution(pooled, sqlite_sync_driver.connection) - assert pooled._raw_sql == "" - assert pooled._processed_state is Empty - assert pooled._filters == [] - assert pooled._statement_config is get_default_config() + assert result.statement is pooled + assert pooled._pooled is False + assert pooled._raw_sql == seed + + failing_seed = "SELECT * FROM nonexistent_table WHERE id = ?" + sqlite_sync_driver.prepare_statement( + failing_seed, (1,), statement_config=sqlite_sync_driver.statement_config, kwargs={} + ) + failing_pooled = sqlite_sync_driver.prepare_statement( + failing_seed, (2,), statement_config=sqlite_sync_driver.statement_config, kwargs={} + ) + assert failing_pooled._pooled is True + + with pytest.raises(Exception): + sqlite_sync_driver.dispatch_statement_execution(failing_pooled, sqlite_sync_driver.connection) + + assert failing_pooled._raw_sql == "" + assert failing_pooled._processed_state is Empty + assert failing_pooled._filters == [] + assert failing_pooled._statement_config is get_default_config() def test_sync_driver_transaction_management(sqlite_sync_driver: SqliteDriver) -> None: diff --git a/tests/unit/builder/test_ddl_builder.py b/tests/unit/builder/test_ddl_builder.py index a5cd6d07f..b8c3620a3 100644 --- a/tests/unit/builder/test_ddl_builder.py +++ b/tests/unit/builder/test_ddl_builder.py @@ -308,3 +308,62 @@ def test_oracle_mixed_table_quote_provenance(schema: str) -> None: assert f"FROM {table} tracker#log" in query.sql assert ddl.sql.startswith(f"CREATE TABLE {table} (") + + +def test_create_table_partition_by_renders_valid_partition_clause() -> None: + stmt = sql.create_table("events", dialect="postgres").column("id", "INT").partition_by("RANGE (created_at)").build() + assert 'PARTITION BY RANGE("created_at")' in stmt.sql + assert "'RANGE (created_at)'" not in stmt.sql + + +def test_create_table_composite_primary_key_columns_and_named_pk() -> None: + composite_sql = ( + sql + .create_table("memberships", dialect="postgres") + .column("org_id", "INT", primary_key=True) + .column("user_id", "INT", primary_key=True) + .build() + .sql + ) + assert 'PRIMARY KEY ("org_id", "user_id")' in composite_sql + assert '"org_id" INT PRIMARY KEY' not in composite_sql + + named_pk_sql = ( + sql + .create_table("users", dialect="postgres") + .column("id", "INT", primary_key=True) + .primary_key_constraint("id", name="pk_users") + .build() + .sql + ) + assert 'CONSTRAINT "pk_users" PRIMARY KEY ("id")' in named_pk_sql + assert '"id" INT PRIMARY KEY' not in named_pk_sql + + +def test_alter_table_add_column_position_and_truncate_restrict() -> None: + after_sql = sql.alter_table("users", dialect="mysql").add_column("email", "VARCHAR(255)", after="name").build().sql + assert "AFTER `name`" in after_sql + + first_sql = sql.alter_table("users", dialect="mysql").add_column("id", "INT", first=True).build().sql + assert "FIRST" in first_sql + + restrict_sql = sql.truncate("users", dialect="postgres").restrict().build().sql + assert "RESTRICT" in restrict_sql + + +def test_ddl_rebuilds_after_mutation_and_resets_parameters() -> None: + builder = sql.create_table("users", dialect="postgres").column("id", "INT") + first_sql = builder.build().sql + builder.column("name", "TEXT").if_not_exists() + second_sql = builder.build().sql + assert '"name"' not in first_sql + assert '"name"' in second_sql + assert "IF NOT EXISTS" in second_sql + + union_query = sql.select("id").from_("a").where_eq("x", 1).union(sql.select("id").from_("b").where_eq("y", 2)) + view_builder = sql.create_view("v_ids", dialect="postgres").as_select(union_query) + first_built = view_builder.build() + second_built = view_builder.build(dialect="sqlite") + assert first_built.parameters == second_built.parameters + assert "UNION" in first_built.sql + assert not hasattr(union_query, "_with_ctes") or len(union_query.with_ctes) == 0 diff --git a/tests/unit/builder/test_sql_factory.py b/tests/unit/builder/test_sql_factory.py index f60b3cb23..527a63147 100644 --- a/tests/unit/builder/test_sql_factory.py +++ b/tests/unit/builder/test_sql_factory.py @@ -1057,7 +1057,7 @@ def test_mixed_sql_objects_and_regular_parameters() -> None: def test_sql_raw_parameter_name_conflicts_handled() -> None: - """Test that parameter name conflicts are detected when merging SQL objects.""" + """Test that parameter name conflicts are automatically renamed without collision.""" raw_expr1 = sql.raw("COALESCE(name, :value)", value="default1") raw_expr2 = sql.raw("COALESCE(email, :other_value)", other_value="default2") query = sql.select("id", raw_expr1, raw_expr2).from_("users") @@ -1068,8 +1068,12 @@ def test_sql_raw_parameter_name_conflicts_handled() -> None: assert stmt.parameters["other_value"] == "default2" raw_conflict1 = sql.raw("COALESCE(name, :conflict)", conflict="first") raw_conflict2 = sql.raw("COALESCE(email, :conflict)", conflict="second") - with pytest.raises(SQLBuilderError, match="Parameter name 'conflict' already exists"): - sql.select("id", raw_conflict1, raw_conflict2).from_("users").build() + conflict_stmt = sql.select("id", raw_conflict1, raw_conflict2).from_("users").build() + assert conflict_stmt.parameters == {"conflict": "first", "conflict_1": "second"} + assert ":conflict" in conflict_stmt.sql + assert ":conflict_1" in conflict_stmt.sql + assert isinstance(raw_conflict2, SQL) + assert ":conflict_1" not in raw_conflict2.sql def test_original_user_case_example_regression_test() -> None: @@ -2083,3 +2087,108 @@ def test_join_builder_known_join_types_match_join_for_type() -> None: join_expr = create_join_builder(join_type)("orders").on("orders.user_id = users.id") assert join_expr.args.get("side") == side, join_type assert join_expr.args.get("kind") == kind, join_type + + +def test_build_and_to_statement_parity() -> None: + hinted = sql.select("*").from_("users").with_hint("INDEX(users idx_users_id)", location="table", table="users") + assert "/*+ INDEX(users idx_users_id) */" in hinted.build().sql + assert "/*+ INDEX(users idx_users_id) */" in hinted.to_statement().sql + + with pytest.raises(SQLBuilderError): + sql.delete().to_statement() + with pytest.raises(SQLBuilderError): + sql.update("users").to_statement() + with pytest.raises(SQLBuilderError): + sql.merge("users", dialect="mysql").using("src", alias="s").on("users.id = s.id").to_statement() + + +def test_merge_literal_vs_qualified_column_reference() -> None: + merge_stmt = ( + sql + .merge("users", dialect="postgres") + .using("staging", alias="src") + .on("users.id = src.id") + .when_matched_then_update(status="active.pending", name="src.name") + .when_not_matched_then_insert(columns=["id", "status"], values=["src.id", "new.user"]) + .build() + ) + assert "active.pending" in merge_stmt.parameters.values() + assert "new.user" in merge_stmt.parameters.values() + assert '"src"."name"' in merge_stmt.sql or "src.name" in merge_stmt.sql + + +def test_column_expression_boolean_combinators_do_not_mutate_operands() -> None: + cond_a = sql.column("a") == 1 + cond_b = sql.column("b") == 2 + cond_c = sql.column("c") == 3 + _ = cond_a & cond_b + _ = cond_a | cond_c + _ = ~cond_a + assert cond_a.sqlglot_expression.parent is None + + +def test_vector_distance_postgres_spangres_mysql_oracle() -> None: + col = sql.column("embedding_array") + pg_sql = col.vector_distance([1.0, 2.0], metric="euclidean").sqlglot_expression.sql(dialect="postgres") + assert "CAST(ARRAY[1.0, 2.0] AS VECTOR)" in pg_sql + + spangres_sql = col.vector_distance([1.0, 2.0], metric="cosine").sqlglot_expression.sql(dialect="spangres") + assert "COSINE_DISTANCE" in spangres_sql + + mysql_col_sql = col.vector_distance(sql.column("other_array"), metric="cosine").sqlglot_expression.sql( + dialect="mysql" + ) + assert "STRING_TO_VECTOR" not in mysql_col_sql + + oracle_col_sql = col.vector_distance(sql.column("other_array"), metric="cosine").sqlglot_expression.sql( + dialect="oracle" + ) + assert "TO_VECTOR" not in oracle_col_sql + + +def test_subquery_embedding_preserves_ctes_and_renames_colliding_params() -> None: + cte_sub = ( + sql + .select("id") + .from_("active_u") + .with_cte("active_u", sql.select("id").from_("users").where_eq("status", "active")) + .where_eq("status", "verified") + ) + ins_stmt = sql.insert("archive").columns("id").from_select(cte_sub).build() + assert "WITH" in ins_stmt.sql + assert len(ins_stmt.parameters) == 2 + + upd_sub = sql.select("name").from_("profiles").where_eq("status", "Override") + upd_stmt = sql.update("users").set("status", " initial ").set("name", upd_sub).build() + assert len(upd_stmt.parameters) == 2 + assert set(upd_stmt.parameters.values()) == {" initial ", "Override"} + + +def test_positional_where_placeholders_and_show_parameters() -> None: + qmark_stmt = sql.select("*").from_("users").where("a = ? AND b = ?", 10, 20).build() + assert list(qmark_stmt.parameters.values()) == [10, 20] + + mixed_stmt = sql.select("*").from_("users").where("x = :named AND a = $1", 99, named="foo").build() + assert mixed_stmt.parameters == {"named": "foo", "param_1": 99} + assert ":param_1" in mixed_stmt.sql + + builder = sql.select("*").from_("users") + builder.set_expression(exp.maybe_parse("SELECT * FROM users WHERE a = :p1 AND b = :p10") or exp.Select()) + builder.set_parameters({"p1": "x'y", "p10": "ten", "p": "base", "p_10": "ten_u"}) + rendered = builder.to_sql(show_parameters=True) + assert "'x''y'" in rendered + assert "'ten'" in rendered + assert "'x''y'0" not in rendered + _, next_name = builder.add_parameter("eleven", name=builder.generate_unique_parameter_name("p")) + assert next_name == "p_11" + + +def test_explain_oracle_and_factory_dialect_preservation() -> None: + explain_stmt = Explain("SELECT * FROM users", dialect="oracle").build() + assert explain_stmt.sql.startswith("EXPLAIN PLAN FOR ") + + pg_factory = SQLFactory(dialect="postgresql") + returning_stmt = pg_factory("INSERT INTO users (name) VALUES ('Alice') RETURNING id") + assert returning_stmt.statement_config.dialect == "postgres" + trunc = sql.truncate("users", dialect="postgresql") + assert trunc.dialect_name == "postgres" diff --git a/tests/unit/config/test_kwargs_validation.py b/tests/unit/config/test_kwargs_validation.py index 385fb8872..16c9a22f6 100644 --- a/tests/unit/config/test_kwargs_validation.py +++ b/tests/unit/config/test_kwargs_validation.py @@ -9,8 +9,10 @@ from typing import Any import pytest +from click.testing import CliRunner -from sqlspec.config import AsyncDatabaseConfig, SyncDatabaseConfig +from sqlspec.cli import add_migration_commands, get_sqlspec_group +from sqlspec.config import AsyncDatabaseConfig, NoPoolAsyncConfig, NoPoolSyncConfig, SyncDatabaseConfig class _MiniSyncPooledConfig(SyncDatabaseConfig[Any, Any, Any]): @@ -33,6 +35,14 @@ async def _close_pool(self) -> None: return None +class _MiniNoPoolSyncConfig(NoPoolSyncConfig[Any, Any]): + """Minimal concrete no-pool sync config for construction tests.""" + + +class _MiniNoPoolAsyncConfig(NoPoolAsyncConfig[Any, Any]): + """Minimal concrete no-pool async config for construction tests.""" + + def test_sync_pooled_config_rejects_unexpected_kwarg() -> None: """Sync pooled config raises TypeError on an unexpected keyword argument.""" with pytest.raises(TypeError, match="unexpected_option"): @@ -45,6 +55,60 @@ def test_async_pooled_config_rejects_unexpected_kwarg() -> None: _MiniAsyncPooledConfig(unexpected_option=123) +def test_no_pool_configs_reject_unexpected_kwarg() -> None: + """NoPoolSyncConfig and NoPoolAsyncConfig raise TypeError on an unexpected keyword argument.""" + with pytest.raises(TypeError, match="unexpected_option"): + _MiniNoPoolSyncConfig(unexpected_option=123) + with pytest.raises(TypeError, match="unexpected_option"): + _MiniNoPoolAsyncConfig(unexpected_option=123) + + +def test_distinct_uninitialized_configs_do_not_compare_equal() -> None: + """Distinct config instances with uninitialized pools must not compare equal.""" + cfg1 = _MiniSyncPooledConfig(connection_config={"database": "a.db"}) + cfg2 = _MiniSyncPooledConfig(connection_config={"database": "b.db"}) + assert cfg1 != cfg2 + assert len({cfg1, cfg2}) == 2 + + +def test_cli_preserves_multiple_unnamed_configs(monkeypatch: pytest.MonkeyPatch) -> None: + """CLI must preserve multiple unnamed configs with bind_key=None.""" + cfg1 = _MiniSyncPooledConfig(connection_config={"database": "a.db"}) + cfg2 = _MiniSyncPooledConfig(connection_config={"database": "b.db"}) + monkeypatch.setattr("sqlspec.cli.resolve_config_sync", lambda _path: [cfg1, cfg2]) + group = add_migration_commands(get_sqlspec_group()) + runner = CliRunner() + result = runner.invoke(group, ["--config", "dummy:configs", "--validate-config", "show-config"]) + assert "Successfully loaded 2 config(s)" in result.output + + +def test_cli_multi_config_operation_exits_nonzero_on_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """CLI migration operation across multiple configs must exit non-zero when a config fails.""" + cfg1 = _MiniSyncPooledConfig( + bind_key="failing_db", + connection_config={"database": ":memory:"}, + migration_config={"enabled": True, "script_location": "migrations"}, + ) + cfg2 = _MiniSyncPooledConfig( + bind_key="second_db", + connection_config={"database": ":memory:"}, + migration_config={"enabled": True, "script_location": "migrations"}, + ) + monkeypatch.setattr("sqlspec.cli.resolve_config_sync", lambda _path: [cfg1, cfg2]) + + class _BoomCommands: + def current(self, verbose: bool = False) -> None: + msg = "migration boom" + raise RuntimeError(msg) + + monkeypatch.setattr("sqlspec.migrations.commands.create_migration_commands", lambda **_kw: _BoomCommands()) + group = add_migration_commands(get_sqlspec_group()) + runner = CliRunner() + result = runner.invoke(group, ["--config", "dummy:configs", "show-current-revision"]) + assert result.exit_code != 0 + assert "failing_db" in result.output + + def test_sync_pooled_config_accepts_known_kwargs() -> None: """Sync pooled config still constructs when only known keyword arguments are passed.""" config = _MiniSyncPooledConfig(bind_key="primary", connection_config={"database": ":memory:"}) diff --git a/tests/unit/core/test_compiler.py b/tests/unit/core/test_compiler.py index fd27d2879..785e7b115 100644 --- a/tests/unit/core/test_compiler.py +++ b/tests/unit/core/test_compiler.py @@ -1242,3 +1242,26 @@ def test_c11_mypyc_native_class_pass(basic_statement_config: "StatementConfig") casts = SQLProcessor._parameter_casts(sqlglot.parse_one("SELECT ?")) assert isinstance(casts, dict) + + +def test_compiler_cache_key_includes_expression_override(basic_statement_config: "StatementConfig") -> None: + processor = SQLProcessor(basic_statement_config) + sql = "SELECT * FROM users" + res1 = processor.compile(sql, None) + assert res1.operation_type == "SELECT" + + override_expr = sqlglot.parse_one("DELETE FROM users") + res2 = processor.compile(sql, None, expression=override_expr) + assert res2.operation_type == "DELETE" + + +def test_compiler_cache_isolates_expression_mutations(basic_statement_config: "StatementConfig") -> None: + processor = SQLProcessor(basic_statement_config) + sql = "SELECT id FROM users" + res1 = processor.compile(sql, None) + assert res1.expression is not None + res1.expression.set("where", exp.Where(this=exp.false())) + + res2 = processor.compile(sql, None) + assert res2.expression is not None + assert res2.expression.args.get("where") is None diff --git a/tests/unit/core/test_hashing.py b/tests/unit/core/test_hashing.py index 053f54f18..6981a49a3 100644 --- a/tests/unit/core/test_hashing.py +++ b/tests/unit/core/test_hashing.py @@ -14,6 +14,9 @@ from sqlspec.core import ( SQL, + ParameterStyle, + ParameterStyleConfig, + StatementConfig, StatementFilter, TypedParameter, hash_expression, @@ -558,3 +561,21 @@ def test_hash_parameters_edge_cases() -> None: named_with_typed = {"complex": typed_param_with_dict} result = hash_parameters(named_parameters=named_with_typed) assert isinstance(result, int) + + +def test_hash_parameters_distinguishes_differences_after_third_element() -> None: + batch1 = [(1, "a"), (2, "b"), (3, "c"), (4, "d")] + batch2 = [(1, "a"), (2, "b"), (3, "c"), (4, "DIFFERENT")] + assert hash_parameters(original_parameters=batch1) != hash_parameters(original_parameters=batch2) + + +def test_hash_sql_statement_includes_statement_config() -> None: + cfg1 = StatementConfig( + parameter_config=ParameterStyleConfig(default_parameter_style=ParameterStyle.QMARK), enable_parsing=True + ) + cfg2 = StatementConfig( + parameter_config=ParameterStyleConfig(default_parameter_style=ParameterStyle.NUMERIC), enable_parsing=False + ) + stmt1 = SQL("SELECT 1", statement_config=cfg1) + stmt2 = SQL("SELECT 1", statement_config=cfg2) + assert hash_sql_statement(stmt1) != hash_sql_statement(stmt2) diff --git a/tests/unit/core/test_parameters.py b/tests/unit/core/test_parameters.py index f495cf719..be48c51e4 100644 --- a/tests/unit/core/test_parameters.py +++ b/tests/unit/core/test_parameters.py @@ -1417,6 +1417,7 @@ def test_processor_cache_key_uses_shared_tuple_helper() -> None: False, True, False, + hash(config), ) @@ -3087,3 +3088,65 @@ def test_static_missing_positional_does_not_consume_reserved_alias() -> None: with pytest.raises(SQLSpecError, match="Missing value for positional placeholder"): SQL("SELECT ?, :param_0", {"param_0": 42}).as_script().compile() assert SQL("SELECT ?, :param_0", {"spare": 1, "param_0": 42}).as_script().compile() == ("SELECT 1, 42", None) + + +def test_validator_extract_parameters_respects_named_dollar_quote_tags() -> None: + validator = ParameterValidator() + sql = "SELECT $tag$ :not_a_param $1 ? $tag$, :actual_param FROM users" + params = validator.extract_parameters(sql) + assert len(params) == 1 + assert params[0].name == "actual_param" + assert params[0].style == ParameterStyle.NAMED_COLON + + +def test_processor_cache_key_differentiates_config_flags() -> None: + processor = ParameterProcessor() + strict_config = ParameterStyleConfig( + default_parameter_style=ParameterStyle.NAMED_COLON, + default_execution_parameter_style=ParameterStyle.QMARK, + strict_named_parameters=True, + ) + lenient_config = ParameterStyleConfig( + default_parameter_style=ParameterStyle.NAMED_COLON, + default_execution_parameter_style=ParameterStyle.QMARK, + strict_named_parameters=False, + ) + sql = "SELECT :a, :b" + result = processor.process(sql, {"a": 1}, lenient_config) + assert tuple(result.parameters) == (1,) + with pytest.raises(SQLSpecError, match="Missing named parameter"): + processor.process(sql, {"a": 1}, strict_config) + + +def test_processor_wraps_parameter_types_in_execute_many() -> None: + processor = ParameterProcessor() + seen_types: list[type] = [] + + def decimal_coercer(val: Decimal) -> str: + seen_types.append(type(val)) + return str(val) + + config = ParameterStyleConfig( + default_parameter_style=ParameterStyle.QMARK, + default_execution_parameter_style=ParameterStyle.QMARK, + type_coercion_map={Decimal: decimal_coercer}, + ) + rows = [(Decimal("1.25"),), (Decimal("2.50"),)] + result = processor.process("INSERT INTO items (price) VALUES (?)", rows, config, is_many=True, wrap_types=True) + assert result.applied_wrap_types is True + assert result.parameters == [("1.25",), ("2.50",)] + assert seen_types == [Decimal, Decimal] + + +def test_fingerprint_execute_many_resolves_non_none_types_beyond_first_row() -> None: + batch_a = [(None, 1), (Decimal("1.5"), 2)] + batch_b = [(None, 1), ("string_val", 2)] + fp_a = _processor_module.structural_fingerprint(batch_a, is_many=True) + fp_b = _processor_module.structural_fingerprint(batch_b, is_many=True) + assert fp_a != fp_b + + dict_batch_a = [{"x": None}, {"x": Decimal("1.5")}] + dict_batch_b = [{"x": None}, {"x": "string_val"}] + assert _processor_module.structural_fingerprint( + dict_batch_a, is_many=True + ) != _processor_module.structural_fingerprint(dict_batch_b, is_many=True) diff --git a/tests/unit/core/test_result.py b/tests/unit/core/test_result.py index cc0ef2310..85a19b42c 100644 --- a/tests/unit/core/test_result.py +++ b/tests/unit/core/test_result.py @@ -994,7 +994,54 @@ def test_build_arrow_result_from_reader_reader_format() -> None: @pytest.mark.skipif(not PYARROW_INSTALLED, reason="pyarrow not installed") def test_build_arrow_result_from_reader_batches_format() -> None: reader = pa.table({"id": pa.array(range(5))}).to_reader() - result = build_arrow_result_from_reader(SQL("select 1"), reader, return_format="batches") + result = build_arrow_result_from_reader(SQL("select 1"), reader, return_format="reader") + assert result is not None + reader2 = pa.table({"id": pa.array(range(5))}).to_reader() + result2 = build_arrow_result_from_reader(SQL("select 1"), reader2, return_format="batches") + + assert isinstance(result2.data, list) + assert result2.rows_affected == 5 + + +@pytest.mark.parametrize( + ("op_type", "rows_affected", "expected"), + [ + ("MERGE", 2, True), + ("UPSERT", 1, True), + ("DDL", 0, True), + ("DDL", -1, True), + ("PRAGMA", 0, True), + ("COPY", 5, True), + ("COPY_FROM", 5, True), + ("COPY_TO", 5, True), + ("COMMAND", 0, True), + ("COMMAND", -1, True), + ], +) +def test_sql_result_is_success_non_dml_and_merge_operations( + op_type: OperationType, rows_affected: int, expected: bool +) -> None: + stmt = SQL("SELECT 1") + res = SQLResult(statement=stmt, data=[], rows_affected=rows_affected, operation_type=op_type) + assert res.is_success() is expected + + +def test_sql_result_schema_cache_avoids_rematerialization_and_preserves_prior_dicts() -> None: + @dataclass + class Item: + id: int + name: str + + stmt = SQL("SELECT id, name FROM items") + raw_tuples: list[Any] = [(1, "A"), (2, "B")] + res = SQLResult(statement=stmt, data=raw_tuples, rows_affected=2, column_names=["id", "name"], row_format="tuple") + + dicts_before = res.get_data() + assert res._materialized_dicts is dicts_before + first_item = res.get_first(schema_type=Item) + assert first_item == Item(id=1, name="A") + assert res._materialized_dicts is dicts_before - assert isinstance(result.data, list) - assert result.rows_affected == 5 + items = res.get_data(schema_type=Item) + assert len(items) == 2 + assert res._materialized_dicts is dicts_before diff --git a/tests/unit/core/test_splitter.py b/tests/unit/core/test_splitter.py index ce29c3df8..49bd78344 100644 --- a/tests/unit/core/test_splitter.py +++ b/tests/unit/core/test_splitter.py @@ -281,3 +281,11 @@ def test_db2_script_keeps_begin_atomic_block() -> None: assert len(statements) == 2 assert statements[0].endswith("BEGIN ATOMIC UPDATE u SET c = c + 1; END;") assert statements[1] == "SELECT 1 FROM SYSIBM.SYSDUMMY1" + + +def test_oracle_create_or_replace_procedure_strips_slash_keep_end_semicolon() -> None: + script = "CREATE OR REPLACE PROCEDURE test_proc AS\nBEGIN\n NULL;\nEND;\n/\nSELECT 1 FROM DUAL;" + statements = split_sql_script(script, dialect="oracle", strip_trailing_terminator=True) + assert len(statements) == 2 + assert statements[0].endswith("END;") + assert statements[1] == "SELECT 1 FROM DUAL" diff --git a/tests/unit/core/test_statement.py b/tests/unit/core/test_statement.py index c0d2ba833..050b10408 100644 --- a/tests/unit/core/test_statement.py +++ b/tests/unit/core/test_statement.py @@ -63,6 +63,7 @@ from sqlspec.core.stack import StackOperation, StatementStack from sqlspec.core.statement import _parse_order_item from sqlspec.data_dictionary import ColumnMetadata, ForeignKeyMetadata, IndexMetadata, TableMetadata, VersionInfo +from sqlspec.exceptions import SQLSpecError from sqlspec.typing import Empty from tests.conftest import is_compiled, requires_interpreted @@ -1731,3 +1732,56 @@ def test_statement_builder_owns_its_expression(dialect: str, compiled: bool) -> assert fresh.build().sql == expected assert "FALSE" in builder.build().sql assert "LIMIT 2" in builder.build().sql + + +def test_sql_mixed_positional_and_named_mismatch_raises_error() -> None: + stmt = SQL("SELECT * FROM users WHERE name = :name", 1, name="alice") + with pytest.raises(SQLSpecError, match="Cannot combine positional and named parameters"): + stmt.compile() + + +def test_sql_post_compile_modifier_preserves_positional_placeholder_under_numeric_driver() -> None: + numeric_config = StatementConfig( + dialect="postgres", + parameter_config=ParameterStyleConfig( + default_parameter_style=ParameterStyle.QMARK, + supported_parameter_styles={ParameterStyle.QMARK, ParameterStyle.NAMED_COLON, ParameterStyle.NUMERIC}, + default_execution_parameter_style=ParameterStyle.NUMERIC, + supported_execution_parameter_styles={ParameterStyle.NUMERIC}, + ), + ) + stmt = SQL("SELECT * FROM users WHERE a = ?", 1, statement_config=numeric_config) + compiled_sql, compiled_params = stmt.compile() + assert "$1" in compiled_sql + assert tuple(compiled_params) == (1,) + + modified = stmt.where_eq("b", 2) + mod_sql, mod_params = modified.compile() + assert "$1" in mod_sql + assert "$2" in mod_sql + assert tuple(mod_params) == (1, 2) + + +def test_sql_hash_and_eq_include_dialect_and_filters() -> None: + stmt_pg = SQL("SELECT * FROM users", dialect="postgres") + stmt_sqlite = SQL("SELECT * FROM users", dialect="sqlite") + assert stmt_pg != stmt_sqlite + assert hash(stmt_pg) != hash(stmt_sqlite) + + stmt_filtered = SQL("SELECT * FROM users", LimitOffsetFilter(10, 0), dialect="postgres") + assert stmt_pg != stmt_filtered + assert hash(stmt_pg) != hash(stmt_filtered) + + +def test_statement_config_equality_checks_all_parameter_config_fields() -> None: + cfg1 = StatementConfig( + parameter_config=ParameterStyleConfig( + default_parameter_style=ParameterStyle.NAMED_COLON, strict_named_parameters=True + ) + ) + cfg2 = StatementConfig( + parameter_config=ParameterStyleConfig( + default_parameter_style=ParameterStyle.NAMED_COLON, strict_named_parameters=False + ) + ) + assert cfg1 != cfg2 diff --git a/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py b/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py index c8441c1b5..5d85fe5d6 100644 --- a/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py +++ b/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py @@ -10,6 +10,7 @@ MetadataRisk, MetadataSource, MetadataSupport, + SystemMetadataRedactionPolicy, get_data_dictionary_loader, ) @@ -166,3 +167,42 @@ def test_spanner_get_ddl_uses_admin_api_capability() -> None: assert driver.database.admin_api.requested == ["projects/p/instances/i/databases/d"] assert ddl_capability.source == MetadataSource.NATIVE_API assert MetadataRisk.PRIVILEGED in ddl_capability.risks + + +def test_spanner_googlesql_queries_use_lowercase_aliases_and_safe_binds() -> None: + """Spanner GoogleSQL query packs must use lowercase aliases and :schema_name binds.""" + loader = get_data_dictionary_loader() + objects_sql = loader.get_domain_query_text("spanner", "objects", "by_schema", mode="googlesql") + constraints_sql = loader.get_domain_query_text("spanner", "constraints", "by_schema", mode="googlesql") + sequences_sql = loader.get_domain_query_text("spanner", "sequences", "by_schema", mode="googlesql") + system_sql = loader.get_domain_query_text("spanner", "system", "query_stats_top", mode="googlesql") + + assert objects_sql is not None + assert "@schema_name" not in objects_sql + assert "CAST(:schema_name AS STRING) IS NULL" in objects_sql + assert "AS table_name" in objects_sql + assert constraints_sql is not None and "AS constraint_name" in constraints_sql + assert sequences_sql is not None and "AS sequence_name" in sequences_sql + assert system_sql is not None and "AS query_text" in system_sql + + redacted_row, redacted_fields = SystemMetadataRedactionPolicy(redact_sql_text=True).redact_row({ + "text": "SELECT 1", + "query_text": "SELECT 2", + }) + assert redacted_row["text"] == "[REDACTED]" + assert redacted_row["query_text"] == "[REDACTED]" + assert set(redacted_fields) == {"text", "query_text"} + + +def test_bigquery_tables_and_constraints_avoid_self_fk_recursion_and_cartesian_joins() -> None: + """BigQuery tables and constraints SQL avoid self-FK infinite recursion and composite Cartesian joins.""" + loader = get_data_dictionary_loader() + tables_sql = loader.get_domain_query_text("bigquery", "tables", "by_schema") + constraints_sql = loader.get_domain_query_text("bigquery", "constraints", "by_dataset") + + assert tables_sql is not None + assert "pk_kcu.table_name <> kcu.table_name" in tables_sql + assert "LEFT JOIN dependency_levels" in tables_sql + assert constraints_sql is not None + assert "pk_kcu.ordinal_position" in constraints_sql + assert "ccu.column_name = kcu.column_name" in constraints_sql diff --git a/tests/unit/data_dictionary/test_ddl_dependencies.py b/tests/unit/data_dictionary/test_ddl_dependencies.py index 4a13180c9..e61860750 100644 --- a/tests/unit/data_dictionary/test_ddl_dependencies.py +++ b/tests/unit/data_dictionary/test_ddl_dependencies.py @@ -92,6 +92,45 @@ def test_dependency_graph_reports_cycle_with_edge_kind() -> None: assert "view_reference" in message +def test_sort_dependencies_fifo_stability_and_scc_cycle_isolation() -> None: + """Topological sort preserves input order via priority queue and isolates distinct SCC cycles.""" + root = ObjectIdentity("root", "table", schema="public") + alpha = ObjectIdentity("alpha", "table", schema="public") + beta = ObjectIdentity("beta", "table", schema="public") + gamma = ObjectIdentity("gamma", "table", schema="public") + + ordered_result = sort_dependencies( + (alpha, beta, root, gamma), + ( + DependencyEdge(alpha, root, DependencyEdgeKind.FOREIGN_KEY), + DependencyEdge(gamma, root, DependencyEdgeKind.FOREIGN_KEY), + ), + ) + assert ordered_result.cycles == () + assert ordered_result.ordered == (beta, root, alpha, gamma) + + cycle_a1 = ObjectIdentity("cycle_a1", "table", schema="public") + cycle_a2 = ObjectIdentity("cycle_a2", "table", schema="public") + downstream = ObjectIdentity("downstream_view", "view", schema="public") + cycle_b1 = ObjectIdentity("cycle_b1", "table", schema="public") + cycle_b2 = ObjectIdentity("cycle_b2", "table", schema="public") + + cycle_result = sort_dependencies( + (cycle_a1, cycle_a2, downstream, cycle_b1, cycle_b2), + ( + DependencyEdge(cycle_a1, cycle_a2, DependencyEdgeKind.FOREIGN_KEY), + DependencyEdge(cycle_a2, cycle_a1, DependencyEdgeKind.FOREIGN_KEY), + DependencyEdge(downstream, cycle_a1, DependencyEdgeKind.VIEW_REFERENCE), + DependencyEdge(cycle_b1, cycle_b2, DependencyEdgeKind.FOREIGN_KEY), + DependencyEdge(cycle_b2, cycle_b1, DependencyEdgeKind.FOREIGN_KEY), + ), + ) + assert len(cycle_result.cycles) == 2 + assert cycle_result.cycles[0].objects == (cycle_a1, cycle_a2) + assert downstream not in cycle_result.cycles[0].objects + assert cycle_result.cycles[1].objects == (cycle_b1, cycle_b2) + + def test_ddl_result_requires_fidelity_status_and_dependency_context() -> None: """DDL payloads expose fidelity, source, status, redaction, context, and dependencies.""" sequence = ObjectIdentity("order_id_seq", "sequence", schema="public") diff --git a/tests/unit/data_dictionary/test_mysql_metadata.py b/tests/unit/data_dictionary/test_mysql_metadata.py index 913b02547..8eb350e88 100644 --- a/tests/unit/data_dictionary/test_mysql_metadata.py +++ b/tests/unit/data_dictionary/test_mysql_metadata.py @@ -205,3 +205,31 @@ def test_mariadb_columns_derive_primary_key_flag_from_column_key() -> None: if isinstance(projection, exp.Alias) } assert projections["is_primary"] == "column_key = 'PRI'" + + +def test_mariadb_complete_query_pack_and_mysql_single_version_probe() -> None: + """MariaDB exposes full query pack and MySQL get_version uses a single cached VERSION() probe.""" + loader = DataDictionaryLoader() + for domain, query_name in ( + ("foreign_keys", "by_schema"), + ("foreign_keys", "by_table"), + ("version", "version"), + ("columns", "by_table"), + ("indexes", "by_table"), + ("tables", "by_schema"), + ): + query_text = loader.get_domain_query_text("mariadb", domain, query_name) + assert query_text is not None, f"mariadb/{domain}/{query_name} must exist" + + mariadb_tables = loader.get_domain_query_text("mariadb", "tables", "by_schema") + assert mariadb_tables is not None and "dependency_levels" in mariadb_tables + + driver = _SyncDDLDriver() + dictionary = PyMysqlDataDictionary() + version = dictionary.get_version(cast(Any, driver)) + version_again = dictionary.get_version(cast(Any, driver)) + + assert version is not None + assert version.version_tuple == (8, 4, 0) + assert version_again == version + assert len(driver.statements) == 1 diff --git a/tests/unit/data_dictionary/test_postgres_cockroach_query_packs.py b/tests/unit/data_dictionary/test_postgres_cockroach_query_packs.py index 63eb83bd4..e8cdfb9cd 100644 --- a/tests/unit/data_dictionary/test_postgres_cockroach_query_packs.py +++ b/tests/unit/data_dictionary/test_postgres_cockroach_query_packs.py @@ -106,3 +106,31 @@ def test_cockroach_crdb_internal_query_is_feature_gated_by_default() -> None: assert query.capability.support == MetadataSupport.UNSUPPORTED assert query.capability.risks == (MetadataRisk.VERSION_GATED,) assert query.capability.warnings == ("cockroachdb/crdb_internal/ranges requires supports_crdb_internal_metadata",) + + +def test_postgres_and_cockroach_foreign_keys_and_indexes_contracts() -> None: + """PostgreSQL and CockroachDB foreign_keys, tables, and indexes avoid Cartesian joins and project required columns.""" + loader = DataDictionaryLoader() + + pg_tables = loader.get_domain_query_text("postgres", "tables", "by_schema") + pg_fks = loader.get_domain_query_text("postgres", "foreign_keys", "by_schema") + crdb_fks = loader.get_domain_query_text("cockroachdb", "foreign_keys", "by_schema") + crdb_indexes = loader.get_domain_query_text("cockroachdb", "indexes", "by_schema") + crdb_indexes_by_table = loader.get_domain_query_text("cockroachdb", "indexes", "by_table") + + assert pg_tables is not None + assert "DISTINCT ON (t.oid)" not in pg_tables + assert "fk.confrelid <> fk.conrelid" in pg_tables + assert "LEFT JOIN dependency_levels" in pg_tables + assert pg_fks is not None + assert "pg_catalog.pg_constraint" in pg_fks + assert "unnest(con.conkey, con.confkey) WITH ORDINALITY" in pg_fks + assert crdb_fks is not None + assert "information_schema.referential_constraints" in crdb_fks + assert "pk_kcu.ordinal_position" in crdb_fks + assert crdb_indexes is not None and "AS columns" in crdb_indexes and "AS is_unique" in crdb_indexes + assert ( + crdb_indexes_by_table is not None + and "AS columns" in crdb_indexes_by_table + and "AS is_unique" in crdb_indexes_by_table + ) diff --git a/tests/unit/data_dictionary/test_sqlite_duckdb_metadata.py b/tests/unit/data_dictionary/test_sqlite_duckdb_metadata.py index e7d810141..1af5a53df 100644 --- a/tests/unit/data_dictionary/test_sqlite_duckdb_metadata.py +++ b/tests/unit/data_dictionary/test_sqlite_duckdb_metadata.py @@ -344,3 +344,48 @@ def test_duckdb_columns_ignore_generated_text_in_string_literals(tmp_path: Path) if column["table_name"] in expected } == {(table, name): flag for table, flags in expected.items() for name, flag in flags.items()} config.close_pool() + + +def test_sqlite_and_duckdb_self_referential_fk_tables_and_constraints() -> None: + """Self-referential foreign keys do not cause infinite recursion in get_tables.""" + loader = DataDictionaryLoader() + assert loader.has_domain_queries("sqlite", "constraints") is True + assert loader.has_domain_queries("sqlite", "comments") is False + + connection = sqlite3.connect(":memory:") + connection.row_factory = sqlite3.Row + driver = SqliteDriver(connection) + try: + driver.execute_script(""" + CREATE TABLE categories ( + id INTEGER PRIMARY KEY, + parent_id INTEGER REFERENCES categories(id) + ); + CREATE TABLE items ( + id INTEGER PRIMARY KEY, + category_id INTEGER REFERENCES categories(id) + ); + """) + tables = driver.data_dictionary.get_tables(driver) + assert [t["table_name"] for t in tables] == ["categories", "items"] + finally: + connection.close() + + duckdb = pytest.importorskip("duckdb") + duck_conn = duckdb.connect(":memory:") + duck_driver = DuckDBDriver(duck_conn) + try: + duck_driver.execute_script(""" + CREATE TABLE categories ( + id INTEGER PRIMARY KEY, + parent_id INTEGER REFERENCES categories(id) + ); + CREATE TABLE items ( + id INTEGER PRIMARY KEY, + category_id INTEGER REFERENCES categories(id) + ); + """) + duck_tables = duck_driver.data_dictionary.get_tables(duck_driver) + assert [t["table_name"] for t in duck_tables] == ["categories", "items"] + finally: + duck_conn.close() diff --git a/tests/unit/dialects/test_db2.py b/tests/unit/dialects/test_db2.py index 50847f978..116c6eca6 100644 --- a/tests/unit/dialects/test_db2.py +++ b/tests/unit/dialects/test_db2.py @@ -213,3 +213,50 @@ def test_db2_direct_generation_preserves_query_and_pagination_ownership() -> Non assert union == union_snapshot assert union.args["limit"].expression.parent is union.args["limit"] assert union.sql(dialect="db2") == set_operation_sql(dialect.generator(), union) + + +def test_db2_temporal_system_time_and_business_time_queries() -> None: + """Verify Db2 FOR SYSTEM_TIME and FOR BUSINESS_TIME period specifications parse and round-trip.""" + queries = [ + "SELECT * FROM policy FOR SYSTEM_TIME AS OF '2026-01-01-00.00.00.000000'", + "SELECT * FROM policy FOR SYSTEM_TIME FROM '2025-01-01' TO '2026-01-01'", + "SELECT * FROM policy FOR SYSTEM_TIME BETWEEN '2025-01-01' AND '2026-01-01'", + "SELECT * FROM policy FOR BUSINESS_TIME AS OF '2026-06-01'", + "SELECT * FROM policy FOR BUSINESS_TIME FROM '2025-01-01' TO '2026-01-01'", + "SELECT * FROM policy FOR BUSINESS_TIME BETWEEN '2025-01-01' AND '2026-01-01'", + ] + for sql in queries: + parsed = parse_one(sql, dialect="db2") + version = parsed.find(exp.Version) + assert version is not None + assert parsed.sql(dialect="db2") == sql + + +def test_dateadd_compound_amount_and_string_unit() -> None: + """Verify DATEADD handles compound duration amounts, string-literal units, and Identifier this.""" + anon_expr = exp.Anonymous( + this=exp.to_identifier("DATEADD"), + expressions=[ + exp.Literal.string("day"), + exp.Sub(this=exp.column("a"), expression=exp.column("b")), + exp.column("ts"), + ], + ) + assert anon_expr.sql(dialect="db2") == "ts + (a - b) DAY" + + dateadd_expr = exp.DateAdd( + this=exp.column("ts"), expression=exp.Literal.number(2), unit=exp.Literal.string("month") + ) + assert dateadd_expr.sql(dialect="db2") == "ts + 2 MONTH" + + +def test_db2_tail_permutations_and_xml_and_dbclob_units() -> None: + """Verify Db2 tail clause permutations, trailing comment preservation, XML type, and DBCLOB(1M).""" + assert transpile("SELECT * FROM t WITH UR FOR READ ONLY", read="db2", write="db2")[0] == ( + "SELECT * FROM t FOR READ ONLY WITH UR" + ) + assert transpile("SELECT * FROM t FOR READ ONLY FETCH FIRST 10 ROWS ONLY", read="db2", write="db2")[0] == ( + "SELECT * FROM t FETCH FIRST 10 ROWS ONLY FOR READ ONLY" + ) + assert transpile("SELECT CAST(x AS XML) FROM t", read="db2", write="db2")[0] == "SELECT CAST(x AS XML) FROM t" + assert transpile("CREATE TABLE t (d DBCLOB(1M))", read="db2", write="db2")[0] == "CREATE TABLE t (d DBCLOB(1M))" diff --git a/tests/unit/dialects/test_db2_statement_tail.py b/tests/unit/dialects/test_db2_statement_tail.py index fcb69eefb..2d7f96dc7 100644 --- a/tests/unit/dialects/test_db2_statement_tail.py +++ b/tests/unit/dialects/test_db2_statement_tail.py @@ -79,3 +79,12 @@ def test_fetch_only_and_single_row_optimize_render_canonical_spelling() -> None: assert sqlglot.transpile("SELECT a FROM t FOR FETCH ONLY OPTIMIZE FOR 1 ROW", read="db2", write="db2") == [ "SELECT a FROM t FOR READ ONLY OPTIMIZE FOR 1 ROWS" ] + + +def test_cte_with_clause_not_treated_as_isolation_tail() -> None: + """Verify leading CTE WITH clause is not misidentified as a statement tail.""" + cte_sql = "WITH cte AS (SELECT id FROM t) SELECT id FROM cte" + assert sqlglot.transpile(cte_sql, read="db2", write="db2") == [cte_sql] + + cte_with_ur = "WITH cte AS (SELECT id FROM t) SELECT id FROM cte WITH UR" + assert sqlglot.transpile(cte_with_ur, read="db2", write="db2") == [cte_with_ur] diff --git a/tests/unit/dialects/test_isolation.py b/tests/unit/dialects/test_isolation.py index ed51e57d7..472311a04 100644 --- a/tests/unit/dialects/test_isolation.py +++ b/tests/unit/dialects/test_isolation.py @@ -1,8 +1,13 @@ """Regression test verifying that Spanner dialects do not mutate base dialect behavior.""" +import importlib + from sqlglot import exp, parse_one +import sqlspec.dialects.postgres._generators as pg_gen +import sqlspec.dialects.postgres._operators as pg_ops import sqlspec.dialects.spanner +from sqlspec.builder._vector_distance import VectorDistance from sqlspec.dialects.spanner import _parsers _ = sqlspec.dialects.spanner @@ -61,3 +66,28 @@ def test_spanner_parsers_contain_spanner_property_parsers() -> None: assert "PRIMARY KEY" in _parsers.SpangresParser.PROPERTY_PARSERS assert "ROW" in _parsers.SpangresParser.PROPERTY_PARSERS assert "TTL" in _parsers.SpangresParser.PROPERTY_PARSERS + + +def test_bigquery_ast_nodes_preserved_for_search_and_cosine_distance() -> None: + """Verify BigQuery SEARCH and COSINE_DISTANCE parse to native exp.Search and exp.CosineDistance.""" + parsed = parse_one("SELECT SEARCH(t, 'q'), COSINE_DISTANCE(a, b) FROM tbl", read="bigquery") + assert parsed.find(exp.Search) is not None + assert parsed.find(exp.CosineDistance) is not None + + +def test_spangres_vector_distance_renders_functions_not_pgvector_operators() -> None: + """Verify VectorDistance expressions render as Spanner distance functions in Spangres.""" + cosine_node = VectorDistance(this=exp.column("a"), expression=exp.column("b"), metric="cosine") + l2_node = VectorDistance(this=exp.column("a"), expression=exp.column("b"), metric="euclidean") + ip_node = VectorDistance(this=exp.column("a"), expression=exp.column("b"), metric="inner_product") + assert cosine_node.sql(dialect="spangres") == "COSINE_DISTANCE(a, b)" + assert l2_node.sql(dialect="spangres") == "EUCLIDEAN_DISTANCE(a, b)" + assert ip_node.sql(dialect="spangres") == "DOT_PRODUCT(a, b)" + + +def test_postgres_generators_and_operators_reload_idempotent() -> None: + """Verify importlib.reload on postgres._generators and postgres._operators is idempotent.""" + importlib.reload(pg_ops) + importlib.reload(pg_gen) + assert parse_one("SELECT a <=> b FROM t", dialect="pgvector").sql(dialect="pgvector") == "SELECT a <=> b FROM t" + assert parse_one("SELECT 1", dialect="postgres").sql(dialect="postgres") == "SELECT 1" diff --git a/tests/unit/dialects/test_paradedb.py b/tests/unit/dialects/test_paradedb.py index 5f56c6e37..4f3b16a20 100644 --- a/tests/unit/dialects/test_paradedb.py +++ b/tests/unit/dialects/test_paradedb.py @@ -1,6 +1,6 @@ """Dialect unit tests for the ParadeDB (PostgreSQL + pgvector + pg_search) dialect.""" -from sqlglot import parse_one +from sqlglot import exp, parse_one from sqlglot.dialects.postgres import Postgres import sqlspec.dialects.postgres._paradedb # noqa: F401 @@ -125,3 +125,12 @@ def test_paradedb_tokenizer_inherits_from_postgres_tokenizer() -> None: def test_paradedb_subclasses_postgres() -> None: assert issubclass(ParadeDB, Postgres) + + +def test_paradedb_operator_precedence_with_concatenation() -> None: + """Verify ParadeDB @@@ operator has lower precedence than || string concatenation.""" + parsed = parse_one("SELECT * FROM items WHERE title || ' ' || body @@@ 'shoes'", dialect="paradedb") + where = parsed.args.get("where") + assert isinstance(where, exp.Where) + assert isinstance(where.this, exp.Operator) + assert isinstance(where.this.this, exp.DPipe) diff --git a/tests/unit/dialects/test_pgvector.py b/tests/unit/dialects/test_pgvector.py index a33acec71..32107bacf 100644 --- a/tests/unit/dialects/test_pgvector.py +++ b/tests/unit/dialects/test_pgvector.py @@ -1,8 +1,10 @@ """Dialect unit tests for the PGVector (PostgreSQL + pgvector) dialect.""" -from sqlglot import parse_one +from sqlglot import exp, parse_one +import sqlspec.dialects.postgres._pg_textsearch # noqa: F401 import sqlspec.dialects.postgres._pgvector # noqa: F401 +from sqlspec.dialects.postgres._operators import is_postgres_extension_operator, postgres_extension_operator def _render(sql: str) -> str: @@ -58,3 +60,23 @@ def test_multiple_distance_operators() -> None: rendered = _render(sql) assert "<=>" in rendered assert "<#>" in rendered + + +def test_distance_operator_precedence_with_arithmetic() -> None: + """Verify pgvector distance operators have custom operator precedence (below + and -).""" + parsed = parse_one("SELECT vec1 + vec2 <=> vec3 FROM items", dialect="pgvector") + select_expr = parsed.expressions[0] + assert isinstance(select_expr, exp.Operator) + assert is_postgres_extension_operator(select_expr) + assert postgres_extension_operator(select_expr) == "<=>" + assert isinstance(select_expr.this, exp.Add) + + +def test_pg_textsearch_bm25_operator_isolated_from_postgres_and_pgvector() -> None: + """Verify <@> only parses as BM25 distance in pg_textsearch.""" + bm25_parsed = parse_one("SELECT body <@> 'query' FROM docs", dialect="pg_textsearch") + op_node = bm25_parsed.find(exp.Operator) + assert op_node is not None + assert is_postgres_extension_operator(op_node) + assert postgres_extension_operator(op_node) == "<@>" + assert bm25_parsed.sql(dialect="pg_textsearch") == "SELECT body <@> 'query' FROM docs" diff --git a/tests/unit/dialects/test_registration.py b/tests/unit/dialects/test_registration.py index 4905ae5bc..dc97e86ed 100644 --- a/tests/unit/dialects/test_registration.py +++ b/tests/unit/dialects/test_registration.py @@ -78,10 +78,10 @@ def test_postgres_extension_operator_registration_is_idempotent() -> None: from sqlspec.dialects.postgres._operators import PGVECTOR_OPERATOR_TOKENS, register_postgres_extension_operators register_postgres_extension_operators() - factor_before = PostgresParser.FACTOR + json_operators_before = PostgresParser.JSON_OPERATORS register_postgres_extension_operators() - assert PostgresParser.FACTOR is factor_before - assert all(token in factor_before for token in PGVECTOR_OPERATOR_TOKENS.values()) + assert PostgresParser.JSON_OPERATORS is json_operators_before + assert all(token in json_operators_before for token in PGVECTOR_OPERATOR_TOKENS.values()) def test_spanner_property_parser_registration_is_idempotent() -> None: diff --git a/tests/unit/dialects/test_spangres.py b/tests/unit/dialects/test_spangres.py index 581533301..9be6ff8e6 100644 --- a/tests/unit/dialects/test_spangres.py +++ b/tests/unit/dialects/test_spangres.py @@ -89,3 +89,42 @@ def test_transpile_spangres_ttl_to_googlesql() -> None: rendered = parse_one(OFFICIAL_TTL_DDL, dialect="spangres").sql(dialect="spanner") assert "ROW DELETION POLICY (OLDER_THAN(created_at, INTERVAL 3 DAY))" in rendered assert "TTL INTERVAL" not in rendered + + +def test_spangres_ddl_and_dql_parity() -> None: + """Verify Spangres supports vector/search indexes, change streams, property graphs, GRAPH_TABLE, and SEARCH.""" + statements = [ + ( + "CREATE VECTOR INDEX IF NOT EXISTS idx_emb ON docs (embedding) " + "STORING (user_id) WHERE embedding IS NOT NULL " + "OPTIONS (distance_type = 'COSINE')" + ), + "DROP VECTOR INDEX IF EXISTS idx_emb", + "CREATE SEARCH INDEX idx_fts ON docs (tokens) STORING (title)", + "DROP SEARCH INDEX IF EXISTS idx_fts", + "CREATE CHANGE STREAM cs_docs FOR docs(title, body)", + "ALTER CHANGE STREAM cs_docs SET OPTIONS (retention_period = '36h')", + "DROP CHANGE STREAM cs_docs", + "CREATE PROPERTY GRAPH fin_graph NODE TABLES (Account KEY (id))", + "CREATE OR REPLACE PROPERTY GRAPH fin_graph NODE TABLES (Account KEY (id))", + "DROP PROPERTY GRAPH IF EXISTS fin_graph", + "SELECT id FROM GRAPH_TABLE(fin_graph MATCH (a:Account) WHERE a.id = 1 COLUMNS (a.id AS id))", + "SELECT id FROM docs WHERE SEARCH(tokens, 'query')", + ] + for sql in statements: + parsed = parse_one(sql, dialect="spangres") + assert not isinstance(parsed, exp.Command) + assert parsed.sql(dialect="spangres") == sql + + +def test_transpile_googlesql_create_table_with_non_spanner_property_to_spangres() -> None: + """Verify GoogleSQL table-level PRIMARY KEY is moved into schema and not duplicated in Spangres.""" + parsed = parse_one( + "CREATE TABLE items (id INT64, name STRING(64)) PRIMARY KEY (id), INTERLEAVE IN PARENT parent_items", + dialect="spanner", + ) + assert isinstance(parsed, exp.Create) + props = parsed.args["properties"] + props.append("expressions", exp.Property(this=exp.var(" extra_prop"), value=exp.Literal.string("val"))) + rendered = parsed.sql(dialect="spangres") + assert rendered.count("PRIMARY KEY") == 1 diff --git a/tests/unit/dialects/test_spangres_hints.py b/tests/unit/dialects/test_spangres_hints.py index 0a92df188..1d5681966 100644 --- a/tests/unit/dialects/test_spangres_hints.py +++ b/tests/unit/dialects/test_spangres_hints.py @@ -59,3 +59,21 @@ def test_spangres_aliased_table_hint_and_parse_into() -> None: assert table.args.get("hints") is not None rendered = parsed.sql(dialect="spangres") assert "/*@ FORCE_INDEX=albums_idx */" in rendered + + +def test_spangres_schema_qualified_table_and_join_hints() -> None: + """Verify Spangres attaches hints to schema-qualified tables and JOIN nodes.""" + sql = ( + "SELECT * FROM my_schema.albums /*@ FORCE_INDEX=albums_idx */ " + "JOIN /*@ JOIN_METHOD=HASH_JOIN */ my_schema.singers ON albums.singer_id = singers.id" + ) + parsed = parse_one(sql, dialect="spangres") + table = parsed.find(exp.Table) + assert table is not None + assert table.args.get("hints") is not None + join = parsed.find(exp.Join) + assert join is not None + assert join.args.get("spanner_hint") is not None + rendered = parsed.sql(dialect="spangres") + assert "my_schema.albums /*@ FORCE_INDEX=albums_idx */" in rendered + assert "JOIN /*@ JOIN_METHOD=HASH_JOIN */ my_schema.singers" in rendered diff --git a/tests/unit/dialects/test_spanner.py b/tests/unit/dialects/test_spanner.py index a1f120f19..38047436c 100644 --- a/tests/unit/dialects/test_spanner.py +++ b/tests/unit/dialects/test_spanner.py @@ -11,6 +11,9 @@ from sqlglot import Dialect, exp, parse_one from sqlspec.dialects.spanner import ( + ApproxCosineDistance, + GetNextSequenceValue, + Score, SpannerGraphTable, _generators, _parsers, @@ -192,8 +195,9 @@ def test_spanner_property_moves_after_non_spanner_property() -> None: assert not _is_post_schema_spanner_property(non_spanner_property) properties.set("expressions", [spanner_property, non_spanner_property]) generator = Dialect.get_or_raise("spanner").generator() - _bq_create_transform(generator, parsed) - assert properties.expressions == [non_spanner_property, spanner_property] + rendered = _bq_create_transform(generator, parsed) + assert properties.expressions == [spanner_property, non_spanner_property] + assert rendered.index("enable_change_stream_capture") < rendered.index("INTERLEAVE IN PARENT") def test_dedup_normalize_interval_expression_has_single_definition() -> None: @@ -368,3 +372,39 @@ def test_vector_and_search_expression_builders_and_hidden_tokenlist() -> None: ") PRIMARY KEY (id)" ) assert _render(ddl) == ddl + + +def test_approx_cosine_distance_and_pascal_case_matchers() -> None: + """Verify approx_cosine_distance options and PascalCase _SpannerAnonymousNodeMeta matchers.""" + pos_expr = approx_cosine_distance( + exp.column("embedding"), exp.Placeholder(this="q"), exp.var("JSON '{\"num_leaves_to_search\": 10}'") + ) + assert isinstance(pos_expr, ApproxCosineDistance) + assert pos_expr.sql(dialect="spanner") == ( + "APPROX_COSINE_DISTANCE(embedding, @q, options => JSON '{\"num_leaves_to_search\": 10}')" + ) + + parsed_score = parse_one("SELECT SCORE(tokens, 'rock') FROM albums", dialect="spanner") + score_node = parsed_score.find(Score) + assert score_node is not None + assert isinstance(score_node, Score) + + seq_node = GetNextSequenceValue(this="my_seq") + assert isinstance(seq_node, GetNextSequenceValue) + assert seq_node.sql(dialect="spanner") == "GET_NEXT_SEQUENCE_VALUE(SEQUENCE my_seq)" + + +def test_schema_qualified_spanner_ddl_and_graph_table() -> None: + """Verify schema-qualified names round-trip in sequences, change streams, property graphs, and GRAPH_TABLE.""" + statements = [ + "CREATE SEQUENCE app.user_seq OPTIONS (sequence_kind = 'bit_reversed_positive')", + "ALTER SEQUENCE app.user_seq SET OPTIONS (skip_range_min = 1, skip_range_max = 100)", + "CREATE CHANGE STREAM app.cs_users FOR app.users(name, email)", + "ALTER CHANGE STREAM app.cs_users SET OPTIONS (retention_period = '36h')", + "CREATE PROPERTY GRAPH app.fin_graph NODE TABLES (app.Account KEY (id))", + "SELECT id FROM GRAPH_TABLE(app.fin_graph MATCH (a:Account) WHERE a.id = 'two spaces' COLUMNS (a.id AS id))", + ] + for sql in statements: + parsed = parse_one(sql, dialect="spanner") + assert not isinstance(parsed, exp.Command) + assert parsed.sql(dialect="spanner") == sql diff --git a/tests/unit/dialects/test_spanner_hints.py b/tests/unit/dialects/test_spanner_hints.py index 61d15f541..d0ed07664 100644 --- a/tests/unit/dialects/test_spanner_hints.py +++ b/tests/unit/dialects/test_spanner_hints.py @@ -122,3 +122,22 @@ def test_at_comments_without_equals_on_select_table_and_join_are_not_hints() -> join = parsed.find(exp.Join) assert join is not None assert join.args.get("spanner_hint") is None + + +def test_schema_qualified_table_hint_and_multiple_table_hints() -> None: + """Verify hints attach to schema-qualified tables and render multiple hints without overwriting.""" + sql = "SELECT * FROM my_schema.Albums @{FORCE_INDEX=AlbumsBySinger} WHERE SingerId = 1" + parsed = parse_one(sql, dialect="spanner") + table = parsed.find(exp.Table) + assert table is not None + assert table.args.get("hints") is not None + assert parsed.sql(dialect="spanner") == sql + + multi_sql = "SELECT * FROM Albums @{FORCE_INDEX=AlbumsBySinger} @{GROUPBY_SCAN_OPTIMIZATION=TRUE}" + multi_parsed = parse_one(multi_sql, dialect="spanner") + multi_table = multi_parsed.find(exp.Table) + assert multi_table is not None + assert len(multi_table.args.get("hints") or []) == 2 + rendered = multi_parsed.sql(dialect="spanner") + assert "FORCE_INDEX=AlbumsBySinger" in rendered + assert "GROUPBY_SCAN_OPTIMIZATION=TRUE" in rendered diff --git a/tests/unit/driver/test_count_query.py b/tests/unit/driver/test_count_query.py index e237ac9f5..5c6af9da2 100644 --- a/tests/unit/driver/test_count_query.py +++ b/tests/unit/driver/test_count_query.py @@ -1101,3 +1101,44 @@ def test_with_total_count_with_union_all_custom_alias(mock_driver: "MockSyncDriv count_str = str(modified_sql) assert "row_total" in count_str.lower() + + +def test_count_query_clears_offset_on_simple_select_with_offset(mock_driver: "MockSyncDriver") -> None: + """_count_query strips both LIMIT and OFFSET when building COUNT(*).""" + sql = mock_driver.prepare_statement( + SQL("SELECT id, name FROM users WHERE active = 1 LIMIT 10 OFFSET 20"), + statement_config=mock_driver.statement_config, + ) + sql.compile() + + count_sql = mock_driver._count_query(sql) + compiled_str = count_sql.sql.upper() + assert "LIMIT" not in compiled_str + assert "OFFSET" not in compiled_str + assert "COUNT(*)" in compiled_str + + +def test_with_total_count_reuses_parsed_expression( + mock_driver: "MockSyncDriver", monkeypatch: pytest.MonkeyPatch +) -> None: + """_with_total_count reuses sql_statement.expression instead of re-parsing raw_sql.""" + sql = mock_driver.prepare_statement( + SQL("SELECT id, name FROM users WHERE active = 1"), statement_config=mock_driver.statement_config + ) + sql.compile() + assert sql.expression is not None + + def fail_parse_one(*args: Any, **kwargs: Any) -> Any: + raise AssertionError("sqlglot.parse_one should not be called when expression is present") + + monkeypatch.setattr("sqlglot.parse_one", fail_parse_one) + modified_sql = mock_driver._with_total_count(sql) + assert "_total_count" in modified_sql.sql.lower() + + +def test_extract_total_from_rows_handles_mixed_row_representations(mock_driver: "MockSyncDriver") -> None: + """_extract_total_from_rows handles mixed dict/mapping rows and missing alias safely.""" + rows: list[Any] = [{"id": 1}, UserDict({"id": 2, "_total_count": 5})] + clean_rows, total = mock_driver._extract_total_from_rows(rows) + assert total == 5 + assert clean_rows == [{"id": 1}, {"id": 2}] diff --git a/tests/unit/driver/test_declared_param_validation.py b/tests/unit/driver/test_declared_param_validation.py index 360112ebf..05ce675e8 100644 --- a/tests/unit/driver/test_declared_param_validation.py +++ b/tests/unit/driver/test_declared_param_validation.py @@ -223,3 +223,25 @@ def test_execute_many_first_row_valid_passes(driver: _MockDriver) -> None: ) prepared = driver.prepare_statement(good, ()) assert prepared.is_many + + +def test_execute_many_validates_subsequent_rows_and_preserves_caller_dicts(driver: _MockDriver) -> None: + row_one: dict[str, Any] = {"a": 1} + row_two: dict[str, Any] = {} + bad = SQL( + "select :a", + [row_one, row_two], + is_many=True, + declared_parameters=_declared(ParameterDeclaration("a", "int")), + statement_config=StatementConfig(), + ) + with pytest.raises(SQLSpecError, match="a"): + driver.prepare_statement(bad, ()) + assert row_two == {} + + +def test_prepare_from_sql_does_not_mutate_caller_statement(driver: _MockDriver) -> None: + original = SQL("SELECT * FROM users", statement_config=StatementConfig()) + prepared = driver.prepare_statement(original, (LimitOffsetFilter(limit=10, offset=5),)) + assert prepared is not original + assert "LIMIT" not in original.raw_sql.upper() diff --git a/tests/unit/driver/test_execute_script.py b/tests/unit/driver/test_execute_script.py index dbfbf60d2..b25a08f47 100644 --- a/tests/unit/driver/test_execute_script.py +++ b/tests/unit/driver/test_execute_script.py @@ -4,6 +4,7 @@ from sqlspec.adapters.sqlite.core import default_statement_config from sqlspec.core import SQL +from sqlspec.driver import SyncDriverAdapterBase from tests.conftest import requires_interpreted @@ -111,3 +112,28 @@ def test_dispatch_script_no_double_prepare_sub_statement_returns_processed_cache assert sub_statement.is_processed is True assert getattr(sub_statement, "_is_cache_direct") is True assert sub_statement.get_processed_state().execution_parameters == (1,) + + +def test_dispatch_execute_script_releases_sub_statement_when_statement_fails( + sqlite_sync_driver: Any, monkeypatch: Any +) -> None: + released: list[SQL] = [] + original_release = sqlite_sync_driver._release_pooled_statement + + def track_release(stmt: SQL) -> None: + released.append(stmt) + original_release(stmt) + + def failing_dispatch_execute(self: Any, cursor: Any, statement: SQL) -> Any: + if "FAIL_HERE" in statement.raw_sql: + raise RuntimeError("boom") + return None + + monkeypatch.setattr(sqlite_sync_driver, "_release_pooled_statement", track_release) + monkeypatch.setattr(type(sqlite_sync_driver), "dispatch_execute", failing_dispatch_execute) + script_stmt = sqlite_sync_driver.prepare_statement(SQL("SELECT 1; SELECT 'FAIL_HERE';")).as_script() + try: + SyncDriverAdapterBase.dispatch_execute_script(sqlite_sync_driver, None, script_stmt) + except RuntimeError: + pass + assert len(released) == 2 diff --git a/tests/unit/driver/test_query_cache.py b/tests/unit/driver/test_query_cache.py index 0dedeb24d..6d8a9d654 100644 --- a/tests/unit/driver/test_query_cache.py +++ b/tests/unit/driver/test_query_cache.py @@ -327,7 +327,7 @@ def _fake_dispatch_execute(cursor: Any, statement: Any) -> Any: statement = SQL("SELECT ?", (1,), statement_config=sqlite_sync_driver.statement_config) statement.compile() - with pytest.raises(SQLSpecError, match="SQLite database error: boom"): + with pytest.raises(SQLSpecError, match="SQLite operational error: boom"): sqlite_sync_driver._execute_cached_statement(statement) @@ -351,7 +351,7 @@ def test_sync_execute_cache_hit_re_raises_mapped_exception(sqlite_sync_driver: A ), ) - with pytest.raises(SQLSpecError, match="SQLite database error: boom"): + with pytest.raises(SQLSpecError, match="SQLite operational error: boom"): sqlite_sync_driver._execute_cache_hit("INSERT INTO t (id) VALUES (?)", (1,), cached) @@ -837,7 +837,7 @@ async def _fake_dispatch_execute(cursor: Any, statement: Any) -> Any: statement = SQL("SELECT ?", (1,), statement_config=aiosqlite_async_driver.statement_config) statement.compile() - with pytest.raises(SQLSpecError, match="AIOSQLite database error: boom"): + with pytest.raises(SQLSpecError, match="AIOSQLite operational error: boom"): await aiosqlite_async_driver._execute_cached_statement(statement) @@ -864,7 +864,7 @@ def fail_on_worker(*_args: object, **_kwargs: object) -> object: ), ) - with pytest.raises(SQLSpecError, match="AIOSQLite database error: boom"): + with pytest.raises(SQLSpecError, match="AIOSQLite operational error: boom"): await aiosqlite_async_driver._execute_cache_hit("INSERT INTO t (id) VALUES (?)", (1,), cached) @@ -934,3 +934,29 @@ def test_compiled_statement_cache_direct_uses_direct_attribute(sqlite_sync_drive assert compiled.compiled_sql == "SELECT ?" assert prepared == (1,) + + +def test_pooled_statement_cleared_when_attached_to_sql_result(sqlite_sync_driver: Any) -> None: + """build_statement_result clears _pooled so result.statement is not returned to the pool.""" + processed = ProcessedState(compiled_sql="SELECT 1", execution_parameters=[], operation_type="SELECT") + pooled = SQL._create_cached_direct("SELECT 1", sqlite_sync_driver.statement_config, processed) + assert pooled._pooled is True + + result = sqlite_sync_driver.build_statement_result(pooled, sqlite_sync_driver.create_execution_result(None)) + assert result.statement is pooled + assert pooled._pooled is False + + sqlite_sync_driver._release_pooled_statement(pooled) + assert result.statement.raw_sql == "SELECT 1" + + +def test_refresh_statement_cache_state_allows_none_observability(sqlite_sync_driver: Any) -> None: + """_refresh_statement_cache_state enables cache when _observability is None.""" + original_obs = sqlite_sync_driver._observability + try: + sqlite_sync_driver._observability = None + sqlite_sync_driver._refresh_statement_cache_state() + assert sqlite_sync_driver._stmt_cache_enabled is True + finally: + sqlite_sync_driver._observability = original_obs + sqlite_sync_driver._refresh_statement_cache_state() diff --git a/tests/unit/driver/test_select_stream.py b/tests/unit/driver/test_select_stream.py index 5824f3ed7..d149f4ed3 100644 --- a/tests/unit/driver/test_select_stream.py +++ b/tests/unit/driver/test_select_stream.py @@ -217,3 +217,27 @@ async def test_async_select_stream_eager_fallback_applies_schema_type(monkeypatc assert [(row.id, row.name) for row in streamed[:3]] == [(0, "n0"), (1, "n1"), (2, "n2")] finally: await config.close_pool() + + +def test_sync_select_stream_eager_fallback_defers_execution_until_enter(monkeypatch: pytest.MonkeyPatch) -> None: + spec = SQLSpec() + config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"})) + with spec.provide_session(config) as session: + _seed_sync(session) + _disable_native(session, monkeypatch) + executed = False + original_execute = type(session).execute + + def tracked_execute(self: Any, *args: Any, **kwargs: Any) -> Any: + nonlocal executed + executed = True + return original_execute(self, *args, **kwargs) + + monkeypatch.setattr(type(session), "execute", tracked_execute) + stream = session.select_stream(_SELECT, chunk_size=10) + assert executed is False + with stream as active_stream: + assert executed is False + rows = list(active_stream) + assert executed is True + assert len(rows) == 25 diff --git a/tests/unit/driver/test_stack_base.py b/tests/unit/driver/test_stack_base.py index e5433c5ac..c539c3bbd 100644 --- a/tests/unit/driver/test_stack_base.py +++ b/tests/unit/driver/test_stack_base.py @@ -192,3 +192,37 @@ async def test_async_savepoint_round_trip(aiosqlite_async_driver) -> None: rows = await aiosqlite_async_driver.execute("SELECT id FROM sp ORDER BY id") assert [row["id"] for row in rows.get_data()] == [1] + + +@requires_interpreted +def test_sync_execute_stack_preserves_caller_transaction(sqlite_sync_driver) -> None: + """Sync execute_stack should not commit or rollback a caller-managed transaction.""" + sqlite_sync_driver.execute("CREATE TABLE t_caller (id INTEGER)") + sqlite_sync_driver.begin() + sqlite_sync_driver.execute("INSERT INTO t_caller (id) VALUES (1)") + + stack = StatementStack().push_execute("INSERT INTO t_caller (id) VALUES (2)") + sqlite_sync_driver.execute_stack(stack, continue_on_error=True) + + assert sqlite_sync_driver.connection.in_transaction is True + sqlite_sync_driver.rollback() + + rows = sqlite_sync_driver.execute("SELECT id FROM t_caller") + assert rows.get_data() == [] + + +@requires_interpreted +async def test_async_execute_stack_preserves_caller_transaction(aiosqlite_async_driver) -> None: + """Async execute_stack should not commit or rollback a caller-managed transaction.""" + await aiosqlite_async_driver.execute("CREATE TABLE t_caller (id INTEGER)") + await aiosqlite_async_driver.begin() + await aiosqlite_async_driver.execute("INSERT INTO t_caller (id) VALUES (1)") + + stack = StatementStack().push_execute("INSERT INTO t_caller (id) VALUES (2)") + await aiosqlite_async_driver.execute_stack(stack, continue_on_error=True) + + assert aiosqlite_async_driver.connection.in_transaction is True + await aiosqlite_async_driver.rollback() + + rows = await aiosqlite_async_driver.execute("SELECT id FROM t_caller") + assert rows.get_data() == [] diff --git a/tests/unit/exceptions/test_exception_handler.py b/tests/unit/exceptions/test_exception_handler.py index 18d9f27f9..66d6d1294 100644 --- a/tests/unit/exceptions/test_exception_handler.py +++ b/tests/unit/exceptions/test_exception_handler.py @@ -6,9 +6,14 @@ import pytest -from sqlspec.driver import BaseAsyncExceptionHandler, BaseSyncExceptionHandler -from sqlspec.driver._exception_handler import _run_with_async_exception_handler -from sqlspec.exceptions import SerializationConflictError, UniqueViolationError +from sqlspec.driver import ( + AsyncDriverAdapterBase, + BaseAsyncExceptionHandler, + BaseSyncExceptionHandler, + SyncDriverAdapterBase, +) +from sqlspec.driver._exception_handler import _run_with_async_exception_handler, _run_with_sync_exception_handler +from sqlspec.exceptions import ForeignKeyViolationError, SerializationConflictError, UniqueViolationError class _AsyncOnlyExceptionHandler: @@ -444,14 +449,10 @@ async def test_async_handler_none_exc_val_returns_false() -> None: def test_check_pending_exception_raises_when_set() -> None: - """_check_pending_exception should raise the pending exception from None.""" - from sqlspec.exceptions import UniqueViolationError - + """_check_pending_exception should raise the pending exception.""" handler = BaseSyncExceptionHandler() handler.pending_exception = UniqueViolationError("duplicate key") - from sqlspec.driver import SyncDriverAdapterBase - with pytest.raises(UniqueViolationError, match="duplicate key"): SyncDriverAdapterBase._check_pending_exception(handler) @@ -461,21 +462,15 @@ def test_check_pending_exception_noop_when_none() -> None: handler = BaseSyncExceptionHandler() assert handler.pending_exception is None - from sqlspec.driver import SyncDriverAdapterBase - SyncDriverAdapterBase._check_pending_exception(handler) @pytest.mark.anyio async def test_async_check_pending_exception_raises_when_set() -> None: - """Async _check_pending_exception should raise the pending exception from None.""" - from sqlspec.exceptions import ForeignKeyViolationError - + """Async _check_pending_exception should raise the pending exception.""" handler = BaseAsyncExceptionHandler() handler.pending_exception = ForeignKeyViolationError("fk violation") - from sqlspec.driver import AsyncDriverAdapterBase - with pytest.raises(ForeignKeyViolationError, match="fk violation"): AsyncDriverAdapterBase._check_pending_exception(handler) @@ -486,6 +481,45 @@ async def test_async_check_pending_exception_noop_when_none() -> None: handler = BaseAsyncExceptionHandler() assert handler.pending_exception is None - from sqlspec.driver import AsyncDriverAdapterBase - AsyncDriverAdapterBase._check_pending_exception(handler) + + +def test_check_pending_exception_preserves_original_cause() -> None: + """_check_pending_exception and BaseSyncExceptionHandler.__exit__ preserve __cause__.""" + + class _MappingSyncHandler(BaseSyncExceptionHandler): + def _handle_exception(self, exc_type: type[BaseException] | None, exc_val: BaseException) -> bool: + self.pending_exception = UniqueViolationError("mapped") + return True + + handler = _MappingSyncHandler() + original = RuntimeError("raw db driver failure") + assert handler.__exit__(RuntimeError, original, None) is True + assert handler.pending_exception is not None + assert handler.pending_exception.__cause__ is original + + with pytest.raises(UniqueViolationError) as exc_info: + SyncDriverAdapterBase._check_pending_exception(handler) + assert exc_info.value.__cause__ is original + + +def test_run_with_sync_exception_handler_dispatches_fast_and_context_paths() -> None: + """_run_with_sync_exception_handler populates pending_exception with __cause__ even inside an active except block.""" + + class _MappingSyncHandler(BaseSyncExceptionHandler): + def _handle_exception(self, exc_type: type[BaseException] | None, exc_val: BaseException) -> bool: + self.pending_exception = UniqueViolationError("sync mapped") + return True + + def raise_runtime() -> int: + raise RuntimeError("boom") + + handler = _MappingSyncHandler() + try: + raise ValueError("ambient outer exception") + except ValueError: + result = _run_with_sync_exception_handler(handler, raise_runtime) + assert result is None + with pytest.raises(UniqueViolationError, match="sync mapped") as exc_info: + SyncDriverAdapterBase._check_pending_exception(handler) + assert isinstance(exc_info.value.__cause__, RuntimeError) diff --git a/tests/unit/exceptions/test_exceptions.py b/tests/unit/exceptions/test_exceptions.py index db769b689..71782629d 100644 --- a/tests/unit/exceptions/test_exceptions.py +++ b/tests/unit/exceptions/test_exceptions.py @@ -168,3 +168,25 @@ def test_stack_execution_error_preserves_args() -> None: assert len(exc.args) == 1 assert "operation 2" in exc.args[0] assert exc.args[0] == exc.detail + + +def test_root_exports_and_typing_stubs_contracts() -> None: + """Test sqlspec.__all__ includes config and observability and _typing/protocols contracts hold.""" + import sqlspec + from sqlspec._typing import _Metric, convert_stub + from sqlspec.exceptions import SQLFragmentNotFoundError + from sqlspec.protocols import AsyncDataDictionaryProtocol, SyncDataDictionaryProtocol + + assert "config" in sqlspec.__all__ + assert "observability" in sqlspec.__all__ + sentinel = object() + assert convert_stub(sentinel, dict) is sentinel + metric = _Metric("m", "doc", exemplar_fn=lambda: None) + assert metric is not None + assert getattr(SyncDataDictionaryProtocol, "_is_runtime_protocol", False) is True + assert getattr(AsyncDataDictionaryProtocol, "_is_runtime_protocol", False) is True + frag_err = SQLFragmentNotFoundError("frag", "frag", 0) + assert isinstance(frag_err, SQLStatementNotFoundError) + assert isinstance(frag_err, SQLFileNotFoundError) + custom_file_err = SQLFileNotFoundError("q.sql", message="custom file error") + assert str(custom_file_err) == "custom file error" diff --git a/tests/unit/extensions/test_adk/test_migrations.py b/tests/unit/extensions/test_adk/test_migrations.py index b4dd41809..9330bb6e3 100644 --- a/tests/unit/extensions/test_adk/test_migrations.py +++ b/tests/unit/extensions/test_adk/test_migrations.py @@ -193,8 +193,8 @@ async def test_disabled_memory_emits_no_vector_extension() -> None: assert not any("embedding VECTOR(" in sql for sql in statements) -async def test_psqlpy_postgres_memory_migration_emits_no_vector_extension() -> None: - """A PostgreSQL store whose memory DDL has no vector column installs nothing.""" +async def test_psqlpy_postgres_memory_migration_installs_vector_extension() -> None: + """Psqlpy memory DDL with vector column also gets the vector extension prerequisite.""" config = PsqlpyConfig( connection_config=PsqlpyPoolParams(dsn="postgresql://adk@localhost/adk"), extension_config={"adk": {}} ) @@ -202,7 +202,10 @@ async def test_psqlpy_postgres_memory_migration_emits_no_vector_extension() -> N statements = await migration.up(context) - assert CREATE_VECTOR_EXTENSION not in statements + assert statements.count(CREATE_VECTOR_EXTENSION) == 1 + assert statements.index(CREATE_VECTOR_EXTENSION) < next( + index for index, sql in enumerate(statements) if "embedding VECTOR(" in sql + ) assert any("CREATE TABLE IF NOT EXISTS adk_memory " in sql for sql in statements) diff --git a/tests/unit/extensions/test_events/test_listener_hubs.py b/tests/unit/extensions/test_events/test_listener_hubs.py index 068e80fd1..d0f653d77 100644 --- a/tests/unit/extensions/test_events/test_listener_hubs.py +++ b/tests/unit/extensions/test_events/test_listener_hubs.py @@ -189,11 +189,14 @@ class _PsqlpyDriver: def __init__(self, listener: _PsqlpyListener) -> None: self.listener = listener - async def execute_script(self, statement: Any) -> None: + async def execute(self, statement: Any) -> None: channel, payload = statement.parameters if self.listener.listen_called and not self.listener.drop_ready_payloads: await self.listener.emit(channel, payload) + async def execute_script(self, statement: Any) -> None: + await self.execute(statement) + async def commit(self) -> None: return None diff --git a/tests/unit/extensions/test_fastapi/test_providers.py b/tests/unit/extensions/test_fastapi/test_providers.py index 430f8b80a..f6f28d637 100644 --- a/tests/unit/extensions/test_fastapi/test_providers.py +++ b/tests/unit/extensions/test_fastapi/test_providers.py @@ -1,6 +1,6 @@ """Unit tests for FastAPI filter providers.""" -import datetime +import datetime as dt import inspect from typing import Any, Literal, get_args from uuid import UUID, uuid4 @@ -112,7 +112,7 @@ def test_provide_filters_created_at() -> None: assert filters == [] # Before date only - before_dt = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC) + before_dt = dt.datetime(2025, 1, 1, tzinfo=dt.UTC) filters = provider(created_filter=BeforeAfterFilter(field_name="created_at", before=before_dt, after=None)) assert len(filters) == 1 assert isinstance(filters[0], BeforeAfterFilter) @@ -120,7 +120,7 @@ def test_provide_filters_created_at() -> None: assert filters[0].before == before_dt # After date only - after_dt = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) + after_dt = dt.datetime(2024, 1, 1, tzinfo=dt.UTC) filters = provider(created_filter=BeforeAfterFilter(field_name="created_at", before=None, after=after_dt)) assert len(filters) == 1 assert filters[0].after == after_dt # type: ignore[union-attr] @@ -137,7 +137,7 @@ def test_provide_filters_updated_at() -> None: config: FilterConfig = {"updated_at": True} provider = provide_filters(config) - before_dt = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC) + before_dt = dt.datetime(2025, 1, 1, tzinfo=dt.UTC) filters = provider(updated_filter=BeforeAfterFilter(field_name="updated_at", before=before_dt, after=None)) assert len(filters) == 1 assert isinstance(filters[0], BeforeAfterFilter) @@ -396,7 +396,7 @@ def test_provide_filters_combined() -> None: provider = provide_filters(config) test_id = uuid4() - before_dt = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC) + before_dt = dt.datetime(2025, 1, 1, tzinfo=dt.UTC) filters = provider( id_filter=InCollectionFilter(field_name="id", values=[test_id]), diff --git a/tests/unit/extensions/test_litestar/test_providers.py b/tests/unit/extensions/test_litestar/test_providers.py index d43d4516c..60ad2fa7d 100644 --- a/tests/unit/extensions/test_litestar/test_providers.py +++ b/tests/unit/extensions/test_litestar/test_providers.py @@ -1,7 +1,7 @@ # pyright: reportArgumentType=false, reportCallIssue=false """Unit tests for Litestar filter providers (issue #405).""" -import datetime +import datetime as dt import inspect from typing import Any, Literal, NoReturn, cast @@ -103,8 +103,8 @@ def test_not_in_fields_provider_returns_filter_when_values_present() -> None: def test_compiled_annotation_pair_providers_build_and_call() -> None: """Providers with paired annotation locals instantiate and call under compiled builds.""" - before = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC) - after = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC) + before = dt.datetime(2026, 1, 1, tzinfo=dt.UTC) + after = dt.datetime(2025, 1, 1, tzinfo=dt.UTC) deps = _create_statement_filters( FilterConfig( created_at=True, @@ -607,3 +607,29 @@ def test_cursor_secret_cache_preserves_type(secrets: tuple[str | bytes, str | by assert providers[index](cursor=token).limit == 20 with pytest.raises(ValidationException, match="Invalid pagination cursor"): providers[1 - index](cursor=token) + + +def test_single_field_name_type_in_and_not_in_fields_normalized() -> None: + config = FilterConfig( + in_fields=FieldNameType(name="status", type_hint=str), not_in_fields=FieldNameType(name="role", type_hint=str) + ) + deps = _create_statement_filters(config) + assert "status_in_filter" in deps + assert "role_not_in_filter" in deps + agg_params = set(inspect.signature(deps["filters"].dependency).parameters) + assert agg_params == {"status_in_filter", "role_not_in_filter"} + + +def test_aggregate_function_excludes_inactive_before_after_and_id_filters() -> None: + config = FilterConfig(id_filter=int, created_at=True, updated_at=True) + agg_fn = _configured_filter_aggregator(config) + result = agg_fn( + id_filter=InCollectionFilter(field_name="id", values=None), + created_filter=BeforeAfterFilter(field_name="created_at", before=None, after=None), + updated_filter=BeforeAfterFilter( + field_name="updated_at", before=dt.datetime(2026, 1, 1, tzinfo=dt.UTC), after=None + ), + ) + assert len(result) == 1 + assert isinstance(result[0], BeforeAfterFilter) + assert result[0].field_name == "updated_at" diff --git a/tests/unit/extensions/test_litestar/test_serialization_plugin.py b/tests/unit/extensions/test_litestar/test_serialization_plugin.py index c809a5727..e6c747db7 100644 --- a/tests/unit/extensions/test_litestar/test_serialization_plugin.py +++ b/tests/unit/extensions/test_litestar/test_serialization_plugin.py @@ -7,7 +7,7 @@ into sqlspec's serializer is needed. """ -import datetime +import datetime as dt from typing import Any import pytest @@ -40,16 +40,16 @@ def test_plugin_registers_default_encoders_into_app_config() -> None: def test_user_encoder_overrides_sqlspec_default() -> None: """User encoder for an existing default key wins on conflict.""" - def user_dt(_v: datetime.datetime) -> str: + def user_dt(_v: dt.datetime) -> str: return "USER" plugin = _build_plugin() - app_config = AppConfig(type_encoders={datetime.datetime: user_dt}) + app_config = AppConfig(type_encoders={dt.datetime: user_dt}) plugin.on_app_init(app_config) assert app_config.type_encoders is not None - assert app_config.type_encoders[datetime.datetime] is user_dt + assert app_config.type_encoders[dt.datetime] is user_dt def test_user_decoders_take_precedence_in_list_order() -> None: diff --git a/tests/unit/extensions/test_service_reexports.py b/tests/unit/extensions/test_service_reexports.py index 1350de8ec..9b0d3363d 100644 --- a/tests/unit/extensions/test_service_reexports.py +++ b/tests/unit/extensions/test_service_reexports.py @@ -5,10 +5,39 @@ namespace they are already importing from. """ +import asyncio import importlib +import inspect +import threading +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock import pytest +from sqlspec.adapters.aiosqlite import AiosqliteConfig +from sqlspec.adapters.sqlite import SqliteConfig +from sqlspec.core import SQL, CursorFilter, LimitOffsetFilter, OffsetPagination +from sqlspec.exceptions import ImproperConfigurationError +from sqlspec.extensions.adk import __all__ as adk_all +from sqlspec.extensions.adk.artifact.service import _sanitize_path_component +from sqlspec.extensions.adk.memory.converters import memory_entry_to_record +from sqlspec.extensions.adk.memory.service import SQLSpecMemoryService, SQLSpecSyncMemoryService +from sqlspec.extensions.adk.memory.store import BaseAsyncADKMemoryStore, BaseSyncADKMemoryStore +from sqlspec.extensions.adk.service import SQLSpecSessionService +from sqlspec.extensions.adk.store import BaseAsyncADKStore +from sqlspec.extensions.events._channel import AsyncEventChannel, SyncEventChannel +from sqlspec.extensions.events._models import EventMessage +from sqlspec.extensions.events._queue import SyncTableEventQueue +from sqlspec.extensions.events._store import BaseEventQueueStore +from sqlspec.extensions.litestar import __all__ as litestar_all +from sqlspec.extensions.litestar._utils import ( + delete_sqlspec_scope_state, + get_sqlspec_scope_state, + set_sqlspec_scope_state, +) +from sqlspec.extensions.prometheus._observer import PrometheusStatementObserver +from sqlspec.observability import StatementEvent from sqlspec.service import SQLSpecAsyncService, SQLSpecSyncService @@ -33,3 +62,418 @@ def test_async_and_sync_service_reexports(module_name: str) -> None: assert module.SQLSpecSyncService is SQLSpecSyncService assert "SQLSpecAsyncService" in module.__all__ assert "SQLSpecSyncService" in module.__all__ + + +def test_service_exists_bounds_multi_row_queries() -> None: + """Verify sync and async exists() bound queries to 1 row when multiple rows match.""" + sync_config = SqliteConfig() + try: + with sync_config.provide_session() as session: + session.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, category TEXT)") + session.execute_many("INSERT INTO items (id, category) VALUES (?, ?)", [(1, "a"), (2, "a"), (3, "a")]) + service = SQLSpecSyncService(session) + assert service.exists(SQL("SELECT id FROM items WHERE category = :cat", cat="a")) is True + assert service.exists(SQL("SELECT id FROM items WHERE category = :cat", cat="b")) is False + finally: + sync_config.close_pool() + + async def _run_async() -> None: + async_config = AiosqliteConfig() + try: + async with async_config.provide_session() as session: + await session.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, category TEXT)") + await session.execute_many( + "INSERT INTO items (id, category) VALUES (?, ?)", [(1, "a"), (2, "a"), (3, "a")] + ) + service = SQLSpecAsyncService(session) + assert await service.exists(SQL("SELECT id FROM items WHERE category = :cat", cat="a")) is True + assert await service.exists(SQL("SELECT id FROM items WHERE category = :cat", cat="b")) is False + finally: + await async_config.close_pool() + + asyncio.run(_run_async()) + + +def test_session_bound_begin_transaction_nested_in_session_transaction_enforces_owner() -> None: + """Verify session-bound begin_transaction() nested inside session.transaction() tracks owner.""" + sync_config = SqliteConfig() + try: + with sync_config.provide_session() as session: + service = SQLSpecSyncService(session) + with session.transaction(): + with service.begin_transaction(): + assert service._transaction_state is not None + errors: list[Exception] = [] + + def _other_thread() -> None: + try: + with service.begin_transaction(): + pass + except ImproperConfigurationError as exc: + errors.append(exc) + + worker = threading.Thread(target=_other_thread) + worker.start() + worker.join() + assert len(errors) == 1 + assert service._transaction_state is None + finally: + sync_config.close_pool() + + async def _run_async() -> None: + async_config = AiosqliteConfig() + try: + async with async_config.provide_session() as session: + service = SQLSpecAsyncService(session) + async with session.transaction(): + async with service.begin_transaction(): + assert service._transaction_state is not None + + async def _other_task() -> None: + with pytest.raises(ImproperConfigurationError, match="another task or thread"): + async with service.begin_transaction(): + pass + + await asyncio.create_task(_other_task()) + assert service._transaction_state is None + finally: + await async_config.close_pool() + + asyncio.run(_run_async()) + + +def test_paginate_honors_pre_attached_limit_offset_and_cursor_rejects_count_with_window() -> None: + """Verify paginate() reads statement.filters for LimitOffsetFilter and paginate_cursor() rejects count_with_window.""" + sync_config = SqliteConfig() + try: + with sync_config.provide_session() as session: + session.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)") + session.execute_many("INSERT INTO items (id, name) VALUES (?, ?)", [(1, "a"), (2, "b")]) + service = SQLSpecSyncService(session) + + stmt = SQL("SELECT id, name FROM items ORDER BY id", LimitOffsetFilter(limit=10, offset=20)) + page = service.paginate(stmt) + assert isinstance(page, OffsetPagination) + assert page.items == [] + assert page.limit == 10 + assert page.offset == 20 + assert page.total == 2 + + caller_kwargs: dict[str, Any] = {"count_with_window": False} + with pytest.raises(ImproperConfigurationError, match="count_with_window"): + service.paginate_cursor(SQL("SELECT id, name FROM items"), CursorFilter("id", limit=5), **caller_kwargs) + assert caller_kwargs == {"count_with_window": False} + finally: + sync_config.close_pool() + + async def _run_async() -> None: + async_config = AiosqliteConfig() + try: + async with async_config.provide_session() as session: + await session.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)") + await session.execute_many("INSERT INTO items (id, name) VALUES (?, ?)", [(1, "a"), (2, "b")]) + service = SQLSpecAsyncService(session) + + stmt = SQL("SELECT id, name FROM items ORDER BY id", LimitOffsetFilter(limit=10, offset=20)) + page = await service.paginate(stmt) + assert isinstance(page, OffsetPagination) + assert page.items == [] + assert page.limit == 10 + assert page.offset == 20 + assert page.total == 2 + + caller_kwargs: dict[str, Any] = {"count_with_window": False} + with pytest.raises(ImproperConfigurationError, match="count_with_window"): + await service.paginate_cursor( + SQL("SELECT id, name FROM items"), CursorFilter("id", limit=5), **caller_kwargs + ) + assert caller_kwargs == {"count_with_window": False} + finally: + await async_config.close_pool() + + asyncio.run(_run_async()) + + +def test_adk_sanitize_path_component_rejects_slashes_and_empty() -> None: + with pytest.raises(ValueError, match="Unsafe path component"): + _sanitize_path_component("") + with pytest.raises(ValueError, match="Unsafe path component"): + _sanitize_path_component("..") + with pytest.raises(ValueError, match="Unsafe path component"): + _sanitize_path_component("foo/bar") + with pytest.raises(ValueError, match="Unsafe path component"): + _sanitize_path_component("foo\\bar") + assert _sanitize_path_component("report.v2.pdf") == "report.v2.pdf" + + +def test_adk_base_async_store_abstract_methods() -> None: + abstract_methods: frozenset[str] = getattr(BaseAsyncADKStore, "__abstractmethods__", frozenset()) + assert "create_tables" in abstract_methods + assert "create_session" in abstract_methods + + +def test_adk_memory_store_validates_table_name_and_commits_drop() -> None: + class _DummySyncMemoryStore(BaseSyncADKMemoryStore[Any]): + def create_tables(self) -> None: + return None + + def insert_memory_entries(self, entries: Any, owner_id: Any = None) -> int: + return 0 + + def search_entries(self, *args: Any, **kwargs: Any) -> list[Any]: + return [] + + def delete_entries_by_session(self, session_id: str) -> int: + return 0 + + def delete_entries_older_than(self, days: int, app_name: Any = None, scope: Any = None) -> int: + return 0 + + def _memory_table_ddl(self) -> str: + return "CREATE TABLE adk_memory (id TEXT)" + + def _drop_memory_table_sql(self) -> list[str]: + return [f"DROP TABLE IF EXISTS {self._memory_table}"] + + bad_config = MagicMock() + bad_config.extension_config = {"adk": {"memory_table": "bad; DROP TABLE users;--"}} + with pytest.raises(ValueError, match="Invalid table name"): + _DummySyncMemoryStore(bad_config) + + driver = MagicMock() + session_cm = MagicMock() + session_cm.__enter__.return_value = driver + session_cm.__exit__.return_value = False + good_config = MagicMock() + good_config.extension_config = {"adk": {"memory_table": "valid_memory_table"}} + good_config.provide_session.return_value = session_cm + + store = _DummySyncMemoryStore(good_config) + store.drop_tables() + driver.execute.assert_called_once_with("DROP TABLE IF EXISTS valid_memory_table") + driver.commit.assert_called_once() + + +@pytest.mark.anyio +async def test_adk_async_memory_store_drop_tables_commits_and_bridges_sync_session() -> None: + class _DummyAsyncMemoryStore(BaseAsyncADKMemoryStore[Any]): + async def create_tables(self) -> None: + return None + + async def insert_memory_entries(self, entries: Any, owner_id: Any = None) -> int: + return 0 + + async def search_entries(self, *args: Any, **kwargs: Any) -> list[Any]: + return [] + + async def delete_entries_by_session(self, session_id: str) -> int: + return 0 + + async def delete_entries_older_than(self, days: int, app_name: Any = None, scope: Any = None) -> int: + return 0 + + async def _memory_table_ddl(self) -> str: + return "CREATE TABLE adk_memory (id TEXT)" + + def _drop_memory_table_sql(self) -> list[str]: + return [f"DROP TABLE IF EXISTS {self._memory_table}"] + + driver = MagicMock() + session_cm = MagicMock() + session_cm.__enter__.return_value = driver + session_cm.__exit__.return_value = False + del session_cm.__aenter__ + + config = MagicMock() + config.extension_config = {"adk": {"memory_table": "valid_memory"}} + config.provide_session.return_value = session_cm + + store = _DummyAsyncMemoryStore(config) + await store.drop_tables() + driver.execute.assert_called_once_with("DROP TABLE IF EXISTS valid_memory") + driver.commit.assert_called_once() + + +def test_adk_memory_converter_normalizes_naive_timestamp_to_utc() -> None: + entry = MagicMock() + part = MagicMock() + part.text = "remember this" + entry.content.parts = [part] + entry.content.model_dump.return_value = {"parts": [{"text": "remember this"}]} + entry.custom_metadata = None + entry.timestamp = "2026-05-01T12:00:00" + entry.id = "mem-1" + entry.author = "user" + + record = memory_entry_to_record(entry, app_name="app", user_id="u1") + assert record is not None + assert record["timestamp"].tzinfo == UTC + + +def test_adk_memory_service_accepts_embeddings_parameter() -> None: + for cls in (SQLSpecMemoryService, SQLSpecSyncMemoryService): + assert "embeddings" in inspect.signature(cls.add_session_to_memory).parameters + assert "embeddings" in inspect.signature(cls.add_events_to_memory).parameters + assert "embeddings" in inspect.signature(cls.add_memory).parameters + + +@pytest.mark.anyio +async def test_adk_session_service_list_sessions_merges_scoped_state() -> None: + store = AsyncMock() + now = datetime.now(UTC) + store.list_sessions.return_value = [ + { + "id": "s1", + "app_name": "app1", + "user_id": "u1", + "state": {"session_key": "sval"}, + "create_time": now, + "update_time": now, + } + ] + store.get_app_state.return_value = {"app:app_key": "aval"} + store.get_user_state.return_value = {"user:user_key": "uval"} + + service = SQLSpecSessionService(store) + resp = await service.list_sessions(app_name="app1", user_id="u1") + assert len(resp.sessions) == 1 + state = resp.sessions[0].state + assert state["session_key"] == "sval" + assert state["app:app_key"] == "aval" + assert state["user:user_key"] == "uval" + + +def test_events_store_extension_config_options_complete() -> None: + options = BaseEventQueueStore.extension_config_options + assert "cleanup_on_ack" in options + + +def test_events_queue_nack_sql_does_not_double_increment_attempts() -> None: + config = MagicMock() + config.statement_config.dialect = "sqlite" + config.extension_config = {} + queue = SyncTableEventQueue(config) + nack_sql = queue._nack_sql() + assert "attempts = attempts + 1" not in nack_sql + + +def test_events_queue_retention_zero_deletes_immediately_on_ack() -> None: + driver = MagicMock() + driver.execute.return_value = MagicMock(rows_affected=1) + session_cm = MagicMock() + session_cm.__enter__.return_value = driver + session_cm.__exit__.return_value = False + config = MagicMock() + config.statement_config.dialect = "sqlite" + config.extension_config = {"events": {"retention_seconds": 0, "cleanup_on_ack": True}} + config.provide_session.return_value = session_cm + + queue = SyncTableEventQueue(config) + queue.ack("evt-1") + executed_stmt = driver.execute.call_args[0][0] + assert "DELETE FROM" in executed_stmt.sql + + +def test_sync_event_channel_survives_transient_dequeue_error() -> None: + config = MagicMock() + config.is_async = False + config.statement_config.dialect = "sqlite" + config.extension_config = {} + channel = SyncEventChannel(config) + stop_event = threading.Event() + + calls = 0 + + def _flaky_dequeue(*args: Any, **kwargs: Any) -> EventMessage | None: + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("transient connection error") + stop_event.set() + return None + + def _noop_handler(message: EventMessage) -> None: + _ = message + + channel._backend = MagicMock(dequeue=_flaky_dequeue) + channel._run_listener("l1", "notifications", _noop_handler, stop_event, 0.001, True) + assert calls >= 2 + + +@pytest.mark.anyio +async def test_async_event_channel_nacks_on_handler_failure() -> None: + config = MagicMock() + config.is_async = True + config.statement_config.dialect = "sqlite" + config.extension_config = {} + channel = AsyncEventChannel(config) + stop_event = asyncio.Event() + + now = datetime.now(UTC) + message = EventMessage( + event_id="evt-fail", + channel="jobs", + payload={"x": 1}, + metadata=None, + attempts=1, + available_at=now, + lease_expires_at=now, + created_at=now, + ) + nacked: list[str] = [] + + async def _dequeue(*args: Any, **kwargs: Any) -> EventMessage | None: + if not nacked: + return message + stop_event.set() + return None + + async def _nack(event_id: str) -> None: + nacked.append(event_id) + stop_event.set() + + channel._backend = MagicMock(dequeue=_dequeue, nack=_nack) + + def _failing_handler(message: EventMessage) -> None: + _ = message + raise ValueError("boom") + + await channel._run_listener("l1", "jobs", _failing_handler, stop_event, 0.001, True) + assert nacked == ["evt-fail"] + + +def test_litestar_delete_scope_state_is_idempotent() -> None: + scope: Any = {} + set_sqlspec_scope_state(scope, "conn", "value") + assert get_sqlspec_scope_state(scope, "conn") == "value" + delete_sqlspec_scope_state(scope, "conn") + delete_sqlspec_scope_state(scope, "conn") + assert get_sqlspec_scope_state(scope, "conn") is None + + +def test_prometheus_observer_rejects_high_cardinality_labels_and_lazy_payload() -> None: + with pytest.raises(ValueError, match="High-cardinality Prometheus label names"): + PrometheusStatementObserver(label_names=("db_system", "sql")) + + observer = PrometheusStatementObserver.__new__(PrometheusStatementObserver) + observer._label_names = ("db_system", "operation") + event = MagicMock(spec=StatementEvent) + event.db_system = "postgresql" + event.operation = "SELECT" + values = observer._label_values(event) + assert values == ("postgresql", "SELECT") + event.as_dict.assert_not_called() + + +def test_lazy_package_barrels_for_litestar_and_adk() -> None: + import sqlspec.extensions.adk as adk_mod + import sqlspec.extensions.litestar as litestar_mod + + for name in litestar_all: + assert hasattr(litestar_mod, name) + for name in adk_all: + assert hasattr(adk_mod, name) + with pytest.raises(AttributeError): + _ = litestar_mod.NonExistentSymbol # type: ignore[attr-defined] + with pytest.raises(AttributeError): + _ = adk_mod.NonExistentSymbol # type: ignore[attr-defined] diff --git a/tests/unit/loader/test_cache_integration.py b/tests/unit/loader/test_cache_integration.py index 7cbd65d8a..0bf748287 100644 --- a/tests/unit/loader/test_cache_integration.py +++ b/tests/unit/loader/test_cache_integration.py @@ -294,11 +294,14 @@ def test_cache_clear_integration() -> None: mock_cache = Mock() mock_cache_factory.return_value = mock_cache loader.add_named_sql("test_query", "SELECT 1") + loader._files["/tmp/tracked.sql"] = SQLFile("SELECT 1", "/tmp/tracked.sql") + expected_key = loader._file_cache_key("/tmp/tracked.sql") loader.clear_cache() assert len(loader._queries) == 0 assert len(loader._files) == 0 assert len(loader._query_to_file) == 0 - mock_cache.clear.assert_called_once() + mock_cache.delete_file.assert_called_once_with(expected_key) + mock_cache.clear.assert_not_called() def test_file_cache_only_clear() -> None: @@ -314,11 +317,14 @@ def test_file_cache_only_clear() -> None: mock_cache = Mock() mock_cache_factory.return_value = mock_cache loader.add_named_sql("test_query", "SELECT 1") + loader._files["/tmp/tracked.sql"] = SQLFile("SELECT 1", "/tmp/tracked.sql") + expected_key = loader._file_cache_key("/tmp/tracked.sql") loader.clear_file_cache() assert len(loader._queries) == 1 assert len(loader._files) == 0 assert len(loader._query_to_file) == 1 - mock_cache.clear.assert_called_once() + mock_cache.delete_file.assert_called_once_with(expected_key) + mock_cache.clear.assert_not_called() def test_cache_disabled_clear_behavior() -> None: diff --git a/tests/unit/loader/test_slots.py b/tests/unit/loader/test_slots.py index db4ce1cef..6b0453894 100644 --- a/tests/unit/loader/test_slots.py +++ b/tests/unit/loader/test_slots.py @@ -178,3 +178,32 @@ def test_repeated_parameter_with_equal_value_is_not_a_collision(tmp_path: Path, assert equal_values.named_parameters == {"kind": "a"} with pytest.raises(SQLSlotError, match="'kind'"): loader.get_sql("repeated", first=shared, second=SQL("alt_kind = :kind", kind="b")) + + +def test_sql_value_slot_returns_compiled_expression(loader: SQLFileLoader) -> None: + """Parameterized SQL slot values must still return a compiled SQL object with expression populated.""" + stmt = loader.get_sql("list_efforts", predicates=SQL("e.journey = :journey", journey="modernize")) + + assert stmt.expression is not None + assert stmt.named_parameters == {"journey": "modernize"} + + +def test_reload_changed_files_preserves_multiple_namespaces(tmp_path: Path) -> None: + """Reloading a changed SQL file loaded under multiple namespaces must preserve all namespaces.""" + sql_file = tmp_path / "shared_ns.sql" + sql_file.write_text("-- name: fetch_val\nSELECT 1 AS val;\n") + sql_loader = SQLFileLoader() + sql_loader._load_single_file(sql_file, None) + sql_loader._load_single_file(sql_file, "reporting") + + assert sql_loader.has_query("fetch_val") + assert sql_loader.has_query("reporting.fetch_val") + + sql_file.write_text("-- name: fetch_val\nSELECT 2 AS val;\n") + reloaded = sql_loader._reload_changed_files() + + assert str(sql_file) in reloaded + assert sql_loader.has_query("fetch_val") + assert sql_loader.has_query("reporting.fetch_val") + assert "SELECT 2" in sql_loader.get_query_text("fetch_val") + assert "SELECT 2" in sql_loader.get_query_text("reporting.fetch_val") diff --git a/tests/unit/migrations/test_migration_execution.py b/tests/unit/migrations/test_migration_execution.py index cca531b21..80003fc79 100644 --- a/tests/unit/migrations/test_migration_execution.py +++ b/tests/unit/migrations/test_migration_execution.py @@ -1,5 +1,4 @@ # pyright: reportPrivateImportUsage = false, reportPrivateUsage = false -# pyright: reportPrivateImportUsage = false, reportPrivateUsage = false """Unit tests for migration execution. Tests migration execution including: @@ -10,17 +9,42 @@ - Migration file processing """ +import ast from collections.abc import Callable from pathlib import Path from typing import Any, cast -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from sqlspec.builder._ddl import CreateTable from sqlspec.driver import ExecutionResult +from sqlspec.exceptions import MigrationError from sqlspec.migrations import MigrationLoadError -from sqlspec.migrations.base import AppliedMigrationRecord, BaseMigrationTracker, LoadedMigrationMetadata -from sqlspec.migrations.runner import SyncMigrationRunner +from sqlspec.migrations._backup import create_backup, restore_backup +from sqlspec.migrations.base import ( + AppliedMigrationRecord, + BaseMigrationCommands, + BaseMigrationTracker, + LoadedMigrationMetadata, +) +from sqlspec.migrations.commands import AsyncMigrationCommands, SyncMigrationCommands +from sqlspec.migrations.context import MigrationContext +from sqlspec.migrations.fix import MigrationFixer, MigrationRename +from sqlspec.migrations.loaders import PythonFileLoader, SQLFileLoader +from sqlspec.migrations.runner import AsyncMigrationRunner, SyncMigrationRunner +from sqlspec.migrations.schema import SchemaTarget, ensure_schema_async, ensure_schema_sync +from sqlspec.migrations.squash import MigrationSquasher, SquashPlan +from sqlspec.migrations.templates import build_template_settings +from sqlspec.migrations.tracker import AsyncMigrationTracker, SyncMigrationTracker +from sqlspec.migrations.utils import create_migration_file +from sqlspec.migrations.validation import validate_squash_idempotency, validate_squash_range +from sqlspec.migrations.version import ( + MigrationVersion, + extract_migration_version, + generate_conversion_map, + parse_version, +) @pytest.fixture @@ -53,9 +77,10 @@ def get_applied_migrations(self, driver: Any) -> list[AppliedMigrationRecord]: return list(self._applied_migrations.values()) def record_migration( - self, driver: Any, version: str, description: str, execution_time_ms: int, checksum: str + self, driver: Any, version: str, description: str, execution_time_ms: int, checksum: str, *, commit: bool = True ) -> None: """Mock record migration.""" + _ = commit self._applied_migrations[version] = cast( "AppliedMigrationRecord", { @@ -66,8 +91,9 @@ def record_migration( }, ) - def remove_migration(self, driver: Any, version: str) -> None: + def remove_migration(self, driver: Any, version: str, *, commit: bool = True) -> None: """Mock remove migration.""" + _ = commit if version in self._applied_migrations: del self._applied_migrations[version] @@ -659,3 +685,409 @@ def down() -> list[str]: with config.provide_session() as driver: remaining = {record["version_num"] for record in commands.tracker.get_applied_migrations(driver)} assert remaining == {"0001"} + + +def test_squash_update_database_reads_target_file_and_retains_backup_until_commit(tmp_path: Path) -> None: + """Verify squash(update_database=True) does not fail with FileNotFoundError and rolls back on DB error.""" + f1 = tmp_path / "0001_first.sql" + f1.write_text("-- name: migrate-0001-up\nSELECT 1;\n-- name: migrate-0001-down\nSELECT 1;\n", encoding="utf-8") + f2 = tmp_path / "0002_second.sql" + f2.write_text("-- name: migrate-0002-up\nSELECT 2;\n-- name: migrate-0002-down\nSELECT 2;\n", encoding="utf-8") + + config = MagicMock() + config.migration_config = {"script_location": str(tmp_path)} + config.extension_config = {} + config.get_observability_runtime.return_value = None + config.migration_tracker_type = SyncMigrationTracker + + driver = MagicMock() + driver.driver_features = {"autocommit": False} + config.provide_session.return_value.__enter__.return_value = driver + + commands = SyncMigrationCommands(config) + commands.tracker.ensure_tracking_table = MagicMock() + commands.tracker.is_squash_already_applied = MagicMock(return_value=True) + commands.tracker.replace_with_squash = MagicMock() + + commands.squash("0001", "0002", "release", yes=True, update_database=True) + commands.tracker.replace_with_squash.assert_called_once() + assert (tmp_path / "0001_release.sql").exists() + + +def test_stamp_records_all_ancestor_migrations_and_supports_base(tmp_path: Path) -> None: + """Verify stamp(revision) records all ancestor migrations up to revision and supports 'base'.""" + f1 = tmp_path / "0001_first.sql" + f1.write_text("-- name: migrate-0001-up\nSELECT 1;\n", encoding="utf-8") + f2 = tmp_path / "0002_second.sql" + f2.write_text("-- name: migrate-0002-up\nSELECT 2;\n", encoding="utf-8") + f3 = tmp_path / "0003_third.sql" + f3.write_text("-- name: migrate-0003-up\nSELECT 3;\n", encoding="utf-8") + + config = MagicMock() + config.migration_config = {"script_location": str(tmp_path)} + config.extension_config = {} + config.get_observability_runtime.return_value = None + config.migration_tracker_type = SyncMigrationTracker + + driver = MagicMock() + driver.driver_features = {"autocommit": False} + config.provide_session.return_value.__enter__.return_value = driver + + commands = SyncMigrationCommands(config) + commands.tracker.ensure_tracking_table = MagicMock() + commands.tracker.record_migration = MagicMock() + + commands.stamp("0002") + recorded_versions = [call.args[1] for call in commands.tracker.record_migration.call_args_list] + assert recorded_versions == ["0001", "0002"] + + driver.execute.reset_mock() + commands.stamp("base") + assert driver.execute.called + + +def test_calculate_checksum_strips_version_headers_across_fix(tmp_path: Path) -> None: + """Verify calculate_checksum produces identical checksum before and after fix rewrites -- Version:.""" + settings = build_template_settings({}) + mig_file = create_migration_file(tmp_path, "20251011120000", "init_schema", "sql", template_settings=settings) + runner = SyncMigrationRunner(tmp_path) + before_checksum = runner.calculate_checksum(mig_file.read_text(encoding="utf-8")) + + fixer = MigrationFixer(tmp_path) + fixer.update_file_content(mig_file, "20251011120000", "0001") + after_checksum = runner.calculate_checksum(mig_file.read_text(encoding="utf-8")) + assert before_checksum == after_checksum + + +def test_migration_version_equality_and_ordering_antisymmetry() -> None: + """Verify parse_version('1') and parse_version('0001') are equal and preserve antisymmetry.""" + v1 = parse_version("1") + v2 = parse_version("0001") + assert v1 == v2 + assert hash(v1) == hash(v2) + assert not (v1 < v2) + assert not (v1 > v2) + assert v1 <= v2 + assert v1 >= v2 + + +def test_downgrade_aborts_when_migration_lacks_downgrade(tmp_path: Path) -> None: + """Verify downgrade aborts and does not remove tracking record when migration has no down SQL.""" + f1 = tmp_path / "0001_first.sql" + f1.write_text("-- name: migrate-0001-up\nCREATE TABLE t1 (id INT);\n-- name: migrate-0001-down\nDROP TABLE t1;\n") + f2 = tmp_path / "0002_second.sql" + f2.write_text("-- name: migrate-0002-up\nCREATE TABLE t2 (id INT);\n") + + config = MagicMock() + config.migration_config = {"script_location": str(tmp_path)} + config.extension_config = {} + config.get_observability_runtime.return_value = None + config.migration_tracker_type = SyncMigrationTracker + + driver = MagicMock() + driver.driver_features = {"autocommit": False} + config.provide_session.return_value.__enter__.return_value = driver + + commands = SyncMigrationCommands(config) + commands.tracker.ensure_tracking_table = MagicMock() + commands.tracker.get_applied_migrations = MagicMock( + return_value=[{"version_num": "0001", "description": "first"}, {"version_num": "0002", "description": "second"}] + ) + commands.tracker.remove_migration = MagicMock() + + commands.downgrade(revision="base", echo=False) + commands.tracker.remove_migration.assert_not_called() + + +def test_execute_migration_sql_resets_session_schema_before_on_success(tmp_path: Path) -> None: + """Verify _execute_migration_sql resets session schema before invoking on_success.""" + config = MagicMock() + config.supports_migration_schemas = True + runner = SyncMigrationRunner(tmp_path, context=MigrationContext(config=config)) + driver = MagicMock() + driver.has_schema.return_value = True + call_order: list[str] = [] + driver.set_migration_session_schema.side_effect = lambda s: call_order.append(f"set:{s}") + driver.reset_migration_session_schema.side_effect = lambda: call_order.append("reset") + + migration: Any = {"version": "0001", "schema": "custom_schema"} + runner._execute_migration_sql( + driver, + ["CREATE TABLE t (id INT)"], + migration=migration, + use_transaction=True, + on_success=lambda _: call_order.append("on_success"), + start_time=0.0, + ) + assert call_order.index("reset") < call_order.index("on_success") + + +def test_upgrade_failure_logs_failed_summary_and_records_partial_metrics(tmp_path: Path) -> None: + """Verify upgrade logs status='failed' when a migration fails inside _apply_single_migration.""" + f1 = tmp_path / "0001_first.sql" + f1.write_text("-- name: migrate-0001-up\nSELECT 1;\n", encoding="utf-8") + + config = MagicMock() + config.migration_config = {"script_location": str(tmp_path)} + config.extension_config = {} + config.get_observability_runtime.return_value = None + config.migration_tracker_type = SyncMigrationTracker + + driver = MagicMock() + driver.driver_features = {"autocommit": False} + config.provide_session.return_value.__enter__.return_value = driver + + commands = SyncMigrationCommands(config) + commands.tracker.ensure_tracking_table = MagicMock() + commands.tracker.get_applied_migrations = MagicMock(return_value=[]) + commands.runner.execute_upgrade = MagicMock(side_effect=RuntimeError("boom")) + + with patch("sqlspec.migrations.commands._log_command_summary") as mock_summary: + commands.upgrade(echo=False) + assert mock_summary.call_args.kwargs["status"] == "failed" + assert isinstance(mock_summary.call_args.kwargs["error"], RuntimeError) + + +def test_synchronize_version_records_reconciles_with_pending_timestamp_present(tmp_path: Path) -> None: + """Verify _synchronize_version_records reconciles fixed migrations even when another timestamp exists.""" + f1 = tmp_path / "0001_init.sql" + f1.write_text("-- name: migrate-0001-up\nCREATE TABLE t1 (id INT);\n", encoding="utf-8") + f2 = tmp_path / "20251011150000_new.sql" + f2.write_text("-- name: migrate-20251011150000-up\nCREATE TABLE t2 (id INT);\n", encoding="utf-8") + + config = MagicMock() + config.migration_config = {"script_location": str(tmp_path)} + config.extension_config = {} + config.get_observability_runtime.return_value = None + config.migration_tracker_type = SyncMigrationTracker + + commands = SyncMigrationCommands(config) + checksum_0001 = commands.runner.calculate_checksum(f1.read_text(encoding="utf-8")) + commands.tracker.get_applied_migrations = MagicMock( + return_value=[{"version_num": "20251011120000", "checksum": checksum_0001}] + ) + commands.tracker.update_version_record = MagicMock() + + updated = commands._synchronize_version_records(MagicMock(), echo=False) + assert updated == 1 + commands.tracker.update_version_record.assert_called_once() + + +def test_squash_when_target_filename_matches_first_source_file(tmp_path: Path) -> None: + """Verify apply_squash and validate_squash_idempotency succeed when target_path == source_migrations[0].""" + f1 = tmp_path / "0001_initial.sql" + f1.write_text("-- name: migrate-0001-up\nSELECT 1;\n", encoding="utf-8") + f2 = tmp_path / "0002_users.sql" + f2.write_text("-- name: migrate-0002-up\nSELECT 2;\n", encoding="utf-8") + + assert validate_squash_idempotency([f1, f2], f1) == "ready" + + runner = SyncMigrationRunner(tmp_path) + squasher = MigrationSquasher(tmp_path, runner) + plans = squasher.plan_squash("0001", "0002", "initial") + squasher.apply_squash(plans) + + assert f1.exists() + assert not f2.exists() + + +def test_downgrade_to_core_revision_does_not_revert_extension_migrations(tmp_path: Path) -> None: + """Verify _collect_revert_migrations('0001') ignores applied ext_* migrations.""" + config = MagicMock() + config.migration_config = {"script_location": str(tmp_path)} + config.extension_config = {} + config.get_observability_runtime.return_value = None + config.migration_tracker_type = SyncMigrationTracker + + commands = SyncMigrationCommands(config) + applied: list[Any] = [{"version_num": "0001"}, {"version_num": "0002"}, {"version_num": "ext_adk_0001"}] + to_revert = commands._collect_revert_migrations(applied, "0001") + assert [m["version_num"] for m in to_revert] == ["0002"] + + +@pytest.mark.anyio +async def test_python_squash_with_none_downgrade_loads_cleanly(tmp_path: Path) -> None: + """Verify generate_python_squash with down_sql=[] emits valid Python that PythonFileLoader loads.""" + runner = SyncMigrationRunner(tmp_path) + squasher = MigrationSquasher(tmp_path, runner) + target = tmp_path / "0001_squash.py" + plan = SquashPlan( + source_migrations=[("0001", tmp_path / "0001_a.sql")], + target_version="0001", + target_path=target, + description="squash", + source_versions=["0001"], + ) + content = squasher.generate_python_squash(plan, up_sql=["SELECT 1;"], down_sql=[]) + target.write_text(content, encoding="utf-8") + ast.parse(content) + + loader = PythonFileLoader(tmp_path) + assert await loader.get_up_sql(target) == ["SELECT 1;"] + assert await loader.get_down_sql(target) is None + assert loader.has_downgrade(target) is False + + +def test_load_migration_does_not_execute_python_down_function(tmp_path: Path) -> None: + """Verify SyncMigrationRunner.load_migration inspects has_downgrade without invoking down().""" + py_file = tmp_path / "0001_side_effect.py" + py_file.write_text( + "def up():\n return 'SELECT 1'\ndef down():\n raise RuntimeError('down should not be called during load_migration')\n", + encoding="utf-8", + ) + runner = SyncMigrationRunner(tmp_path) + metadata = runner.load_migration(py_file, "0001") + assert metadata["has_downgrade"] is True + + +def test_is_squash_already_applied_raises_on_partial_overlap() -> None: + """Verify SyncMigrationTracker.is_squash_already_applied raises MigrationError on partial overlap.""" + tracker = SyncMigrationTracker() + driver = MagicMock() + res = MagicMock() + res.data = [{"version_num": "0001"}] + res.get_data.return_value = res.data + driver.execute.return_value = res + + with pytest.raises(MigrationError, match="Partial squash state detected"): + tracker.is_squash_already_applied(driver, "0001", ["0001", "0002"]) + + +@pytest.mark.anyio +async def test_ensure_schema_respects_autocommit() -> None: + """Verify ensure_schema_sync and ensure_schema_async skip commit when autocommit=True.""" + ddl = CreateTable("test_tbl").column("id", "INTEGER") + target = SchemaTarget("test_tbl", ddl) + + sync_driver = MagicMock() + sync_driver.driver_features = {"autocommit": True} + sync_driver.data_dictionary.get_tables.return_value = [] + ensure_schema_sync(sync_driver, [target], manage_schema=True, create_schema=True) + sync_driver.commit.assert_not_called() + + async_driver = AsyncMock() + async_driver.driver_features = {"autocommit": True} + async_driver.data_dictionary.get_tables.return_value = [] + await ensure_schema_async(async_driver, [target], manage_schema=True, create_schema=True) + async_driver.commit.assert_not_called() + + +def test_squash_preserves_transactional_and_schema_directives_and_extension_versions(tmp_path: Path) -> None: + """Verify MigrationSquasher groups by transactional/schema directives and supports ext_* versions.""" + f1 = tmp_path / "ext_adk_0001_a.sql" + f1.write_text("-- transactional: false\n-- schema: custom\n-- name: migrate-ext_adk_0001-up\nSELECT 1;\n") + f2 = tmp_path / "ext_adk_0002_b.sql" + f2.write_text("-- transactional: false\n-- schema: custom\n-- name: migrate-ext_adk_0002-up\nSELECT 2;\n") + + migrations = [("ext_adk_0001", f1), ("ext_adk_0002", f2)] + in_range = validate_squash_range(migrations, "ext_adk_0001", "ext_adk_0002") + assert len(in_range) == 2 + + runner = SyncMigrationRunner(tmp_path) + squasher = MigrationSquasher(tmp_path, runner) + plans = squasher.plan_squash("ext_adk_0001", "ext_adk_0002", "release") + assert len(plans) == 1 + content = squasher.generate_squashed_content(plans[0], ["SELECT 1;"], []) + assert "-- transactional: false" in content + assert "-- schema: custom" in content + + +def test_load_all_migrations_joins_multi_statement_python_migrations(tmp_path: Path) -> None: + """Verify load_all_migrations joins multi-statement Python migrations instead of truncating to up_sql[0].""" + py_file = tmp_path / "0001_multi.py" + py_file.write_text("def up():\n return ['SELECT 1', 'SELECT 2']\n", encoding="utf-8") + runner = SyncMigrationRunner(tmp_path) + all_queries = runner.load_all_migrations() + assert "SELECT 2" in all_queries["migrate-0001-up"].sql + + +def test_extract_migration_version_zero_pads_extension_versions() -> None: + """Verify extract_migration_version zero-pads sequential extension versions.""" + assert extract_migration_version("ext_adk_1_init") == "ext_adk_0001" + assert extract_migration_version("1_init") == "0001" + + +@pytest.mark.anyio +async def test_python_loader_accepts_context_when_none_and_handles_keyword_only(tmp_path: Path) -> None: + """Verify PythonFileLoader passes None to required positional context and skips keyword-only defaults.""" + f1 = tmp_path / "0001_ctx.py" + f1.write_text( + "def up(context):\n return 'SELECT 1' if context is None else 'SELECT 2'\n" + "def down(*, dry_run: bool = False):\n return 'DROP TABLE t'\n", + encoding="utf-8", + ) + loader = PythonFileLoader(tmp_path, context=None) + assert await loader.get_up_sql(f1) == ["SELECT 1"] + assert await loader.get_down_sql(f1) == ["DROP TABLE t"] + + +def test_downgrade_records_reverted_metric(tmp_path: Path) -> None: + """Verify downgrade records the 'reverted' metric.""" + f1 = tmp_path / "0001_first.sql" + f1.write_text("-- name: migrate-0001-up\nSELECT 1;\n-- name: migrate-0001-down\nSELECT 1;\n", encoding="utf-8") + + config = MagicMock() + config.migration_config = {"script_location": str(tmp_path)} + config.extension_config = {} + config.get_observability_runtime.return_value = None + config.migration_tracker_type = SyncMigrationTracker + + driver = MagicMock() + driver.driver_features = {"autocommit": False} + config.provide_session.return_value.__enter__.return_value = driver + + commands = SyncMigrationCommands(config) + commands.tracker.ensure_tracking_table = MagicMock() + commands.tracker.get_applied_migrations = MagicMock(return_value=[{"version_num": "0001", "description": "first"}]) + commands.tracker.remove_migration = MagicMock() + commands._record_command_metric = MagicMock() + + commands.downgrade(revision="-1", echo=False) + commands._record_command_metric.assert_called_once_with("reverted", 1.0) + + +def test_load_metadata_cache_hit_respects_explicit_version(tmp_path: Path) -> None: + """Verify BaseMigrationRunner._load_metadata updates version on cache hit when explicit version is passed.""" + f1 = tmp_path / "0001_init.sql" + f1.write_text("-- name: migrate-0001-up\nSELECT 1;\n", encoding="utf-8") + runner = SyncMigrationRunner(tmp_path) + meta1 = runner._load_metadata(f1, None) + assert meta1["version"] == "0001" + meta2 = runner._load_metadata(f1, "ext_adk_0001") + assert meta2["version"] == "ext_adk_0001" + + +def test_fixer_rollback_deletes_backup_and_create_backup_avoids_same_second_collision(tmp_path: Path) -> None: + """Verify MigrationFixer.rollback deletes backup and create_backup handles multiple calls in same second.""" + mig_dir = tmp_path / "migrations" + mig_dir.mkdir() + (mig_dir / "0001_init.sql").write_text("SELECT 1;\n", encoding="utf-8") + + b1 = create_backup(mig_dir) + b2 = create_backup(mig_dir) + assert b1 != b2 + restore_backup(mig_dir, b1, delete_backup=True) + restore_backup(mig_dir, b2, delete_backup=True) + + fixer = MigrationFixer(mig_dir) + backup_path = fixer.create_backup() + assert backup_path.exists() + fixer.rollback() + assert not backup_path.exists() + assert fixer.backup_path is None + + +def test_dataclasses_and_exports_hygiene() -> None: + """Verify MigrationVersion and MigrationRename use slots.""" + assert hasattr(MigrationVersion, "__slots__") + assert hasattr(MigrationRename, "__slots__") + _ = ( + AsyncMigrationCommands, + AsyncMigrationRunner, + AsyncMigrationTracker, + BaseMigrationCommands, + BaseMigrationTracker, + MigrationContext, + SQLFileLoader, + generate_conversion_map, + ) diff --git a/tests/unit/migrations/test_tracker_squash.py b/tests/unit/migrations/test_tracker_squash.py index 476bdda4c..6a75f9321 100644 --- a/tests/unit/migrations/test_tracker_squash.py +++ b/tests/unit/migrations/test_tracker_squash.py @@ -96,7 +96,9 @@ def test_sync_tracker_squash_methods_is_squash_already_applied_returns_true_when tracker = SyncMigrationTracker() driver = Mock() - driver.execute.return_value = _mock_result(data=[{"version_num": "0001"}, {"version_num": "0002"}]) + driver.execute.return_value = _mock_result( + data=[{"version_num": "0001"}, {"version_num": "0002"}, {"version_num": "0003"}] + ) result = tracker.is_squash_already_applied( driver=driver, squashed_version="0001", replaced_versions=["0001", "0002", "0003"] ) @@ -155,7 +157,7 @@ async def test_async_tracker_squash_methods_async_is_squash_already_applied_retu driver = Mock() async def mock_execute(sql: Any) -> Mock: - return _mock_result(data=[{"version_num": "0001"}, {"version_num": "0002"}]) + return _mock_result(data=[{"version_num": "0001"}, {"version_num": "0002"}, {"version_num": "0003"}]) driver.execute = mock_execute result = await tracker.is_squash_already_applied( @@ -197,7 +199,7 @@ def test_sync_is_squash_already_applied_executes_filtered_where_in_query() -> No tracker = SyncMigrationTracker() driver = Mock() - driver.execute.return_value = _mock_result(data=[{"version_num": "0001"}]) + driver.execute.return_value = _mock_result(data=[{"version_num": "0001"}, {"version_num": "0002"}]) result = tracker.is_squash_already_applied( driver=driver, squashed_version="0009", replaced_versions=["0001", "0002"] ) @@ -230,7 +232,7 @@ async def test_async_is_squash_already_applied_executes_filtered_where_in_query( async def execute(query: Any) -> Mock: captured.append(query) - return _mock_result(data=[{"version_num": "0001"}]) + return _mock_result(data=[{"version_num": "0001"}, {"version_num": "0002"}]) driver.execute = execute result = await tracker.is_squash_already_applied( @@ -252,7 +254,7 @@ async def test_async_is_squash_already_applied_returns_true_for_filtered_result( async def execute(query: Any) -> Mock: _ = query - return _mock_result(data=[{"version_num": "0001"}]) + return _mock_result(data=[{"version_num": "0001"}, {"version_num": "0002"}]) driver.execute = execute assert ( @@ -281,3 +283,33 @@ async def execute(query: Any) -> Mock: ) is False ) + + +def test_sync_is_squash_already_applied_raises_on_partial_overlap() -> None: + from sqlspec.exceptions import MigrationError + from sqlspec.migrations.tracker import SyncMigrationTracker + + tracker = SyncMigrationTracker() + driver = Mock() + driver.execute.return_value = _mock_result(data=[{"version_num": "0001"}]) + with pytest.raises(MigrationError, match="Partial squash state detected"): + tracker.is_squash_already_applied(driver=driver, squashed_version="0009", replaced_versions=["0001", "0002"]) + + +@pytest.mark.anyio +async def test_async_is_squash_already_applied_raises_on_partial_overlap() -> None: + from sqlspec.exceptions import MigrationError + from sqlspec.migrations.tracker import AsyncMigrationTracker + + tracker = AsyncMigrationTracker() + driver = Mock() + + async def execute(query: Any) -> Mock: + _ = query + return _mock_result(data=[{"version_num": "0001"}]) + + driver.execute = execute + with pytest.raises(MigrationError, match="Partial squash state detected"): + await tracker.is_squash_already_applied( + driver=driver, squashed_version="0009", replaced_versions=["0001", "0002"] + ) diff --git a/tests/unit/observability/test_observability_core.py b/tests/unit/observability/test_observability_core.py index dabceaab3..baabb45fc 100644 --- a/tests/unit/observability/test_observability_core.py +++ b/tests/unit/observability/test_observability_core.py @@ -3,7 +3,9 @@ import asyncio import builtins import hashlib +import logging import sys +import zlib from collections.abc import Iterable from contextlib import contextmanager from pathlib import Path @@ -19,7 +21,16 @@ from sqlspec.core import SQL, ArrowResult, StatementConfig from sqlspec.data_dictionary import ColumnMetadata, ForeignKeyMetadata, IndexMetadata, TableMetadata from sqlspec.driver import SyncDataDictionaryBase, SyncDriverAdapterBase -from sqlspec.observability import LifecycleDispatcher, compute_sql_hash, get_trace_context, resolve_db_system +from sqlspec.observability import ( + GCPLogFormatter, + LifecycleDispatcher, + SamplingConfig, + compute_sql_hash, + get_trace_context, + resolve_db_system, +) +from sqlspec.observability._diagnostics import collect_diagnostics +from sqlspec.observability._observer import _maybe_truncate_parameters from sqlspec.storage import StorageTelemetry from sqlspec.storage.pipeline import ( record_storage_diagnostic_event, @@ -773,3 +784,245 @@ def get_current_span() -> "FakeSpan": trace_id, span_id = get_trace_context() assert trace_id == "00000000000000000000000000000001" assert span_id == "0000000000000002" + + +def test_parameter_allow_list_preserves_scalar_and_sequence_values() -> None: + """Verify parameter_allow_list preserves allowed scalar and sequence values while masking others.""" + observed: list[dict[str, Any]] = [] + config = ObservabilityConfig( + redaction=RedactionConfig(mask_parameters=True, parameter_allow_list=("tenant_id", "ids")), + statement_observers=(lambda event: observed.append(event.as_dict()),), + ) + runtime = ObservabilityRuntime(config) + runtime.emit_statement_event( + sql="SELECT * FROM items WHERE tenant_id = :tenant_id", + parameters={"tenant_id": "acme", "ids": [1, 2, 3], "secret": "xyz"}, + driver="SqliteDriver", + operation="SELECT", + execution_mode="single", + is_many=False, + is_script=False, + rows_affected=1, + duration_s=0.01, + storage_backend=None, + ) + assert observed[0]["parameters"] == {"tenant_id": "acme", "ids": [1, 2, 3], "secret": "***"} + + +def test_lifecycle_hooks_redact_sql_and_parameters() -> None: + """Verify on_query_start, on_query_complete, and on_error redact SQL literals and parameters.""" + captured: list[dict[str, Any]] = [] + config = ObservabilityConfig( + redaction=RedactionConfig(mask_literals=True, mask_parameters=True, parameter_allow_list=("tenant_id",)), + lifecycle=_lifecycle_config({ + "on_query_start": [captured.append], + "on_query_complete": [captured.append], + "on_error": [captured.append], + }), + ) + runtime = ObservabilityRuntime(config) + runtime.emit_query_start_sync(sql="SELECT 'secret'", parameters={"tenant_id": "acme", "pw": "hunter2"}) + runtime.emit_query_complete_sync(sql="SELECT 'secret'", parameters={"tenant_id": "acme", "pw": "hunter2"}) + runtime.emit_error_sync( + RuntimeError("fail"), sql="SELECT 'secret'", parameters={"tenant_id": "acme", "pw": "hunter2"} + ) + assert len(captured) == 3 + for ctx in captured: + assert ctx["sql"] == "SELECT '***'" + assert ctx["parameters"] == {"tenant_id": "acme", "pw": "***"} + + +def test_sql_literal_redaction_handles_backslash_and_dollar_quotes() -> None: + """Verify _redact_sql masks backslash-escaped quotes and dollar-quoted strings.""" + observed: list[dict[str, Any]] = [] + config = ObservabilityConfig( + redaction=RedactionConfig(mask_literals=True), + statement_observers=(lambda event: observed.append(event.as_dict()),), + ) + runtime = ObservabilityRuntime(config) + runtime.emit_statement_event( + sql=r"SELECT 'admin\'s secret', $$top_secret$$, $tag$tagged_secret$tag$", + parameters=None, + driver="AsyncpgDriver", + operation="SELECT", + execution_mode="single", + is_many=False, + is_script=False, + rows_affected=1, + duration_s=0.01, + storage_backend=None, + ) + redacted_sql = observed[0]["sql"] + assert "secret" not in redacted_sql + assert redacted_sql == "SELECT '***', '***', '***'" + + +def test_precomputed_sql_hash_hashes_sanitized_sql_when_mask_literals_enabled() -> None: + """Verify precomputed_sql_hash is recomputed on redacted SQL when mask_literals is active.""" + observed: list[dict[str, Any]] = [] + config = ObservabilityConfig( + redaction=RedactionConfig(mask_literals=True), + statement_observers=(lambda event: observed.append(event.as_dict()),), + ) + runtime = ObservabilityRuntime(config) + raw_sql = "SELECT * FROM users WHERE ssn = '123-45-6789'" + raw_hash = compute_sql_hash(raw_sql) + expected_redacted_hash = compute_sql_hash("SELECT * FROM users WHERE ssn = '***'") + runtime.emit_statement_event( + sql=raw_sql, + parameters=None, + driver="SqliteDriver", + operation="SELECT", + execution_mode="single", + is_many=False, + is_script=False, + rows_affected=1, + duration_s=0.01, + storage_backend=None, + precomputed_sql_hash=raw_hash, + ) + assert observed[0]["sql_hash"] == expected_redacted_hash + + +def test_deterministic_sampling_uses_crc32_across_processes() -> None: + """Verify deterministic sampling uses zlib.crc32 rather than process-seeded hash().""" + cid = "cross-process-cid-99" + expected_bucket = zlib.crc32(cid.encode("utf-8")) % SamplingConfig.HASH_MODULUS + rate_above = (expected_bucket + 1) / SamplingConfig.HASH_MODULUS + rate_below = expected_bucket / SamplingConfig.HASH_MODULUS + assert SamplingConfig(sample_rate=rate_above, deterministic=True).should_sample(correlation_id=cid) is True + assert SamplingConfig(sample_rate=rate_below, deterministic=True).should_sample(correlation_id=cid) is False + + +def test_statement_observer_exception_is_isolated() -> None: + """Verify exceptions raised by a StatementObserver do not abort subsequent observers or callers.""" + observed: list[str] = [] + + def bad_observer(_event: Any) -> None: + msg = "observer boom" + raise RuntimeError(msg) + + def good_observer(event: Any) -> None: + observed.append(event.sql) + + config = ObservabilityConfig(statement_observers=(bad_observer, good_observer)) + runtime = ObservabilityRuntime(config) + runtime.emit_statement_event( + sql="SELECT 1", + parameters=None, + driver="SqliteDriver", + operation="SELECT", + execution_mode="single", + is_many=False, + is_script=False, + rows_affected=1, + duration_s=0.01, + storage_backend=None, + ) + assert observed == ["SELECT 1"] + + +def test_register_lifecycle_hook_updates_is_idle() -> None: + """Verify register_lifecycle_hook updates is_idle from True to False.""" + runtime = ObservabilityRuntime() + assert runtime.is_idle is True + runtime.register_lifecycle_hook("on_query_start", lambda _ctx: None) + assert runtime.is_idle is False + + +def test_span_manager_attaches_context_and_resolves_db_system(monkeypatch) -> None: + """Verify SpanManager attaches active span context and resolves db.system from driver fallback.""" + assert resolve_db_system("CockroachAsyncpgConfig") == "cockroachdb" + assert resolve_db_system("PymssqlDriver") == "mssql" + assert resolve_db_system("MariaDBConfig") == "mariadb" + + observed: list[dict[str, Any]] = [] + runtime = ObservabilityRuntime( + ObservabilityConfig(statement_observers=(lambda e: observed.append(e.as_dict()),)), config_name="SQLSpecConfig" + ) + runtime.emit_statement_event( + sql="SELECT 1", + parameters=None, + driver="AsyncpgDriver", + operation="SELECT", + execution_mode="single", + is_many=False, + is_script=False, + rows_affected=1, + duration_s=0.01, + storage_backend=None, + ) + assert observed[0]["db_system"] == "postgresql" + + +def test_observability_config_diagnostics_and_logging(caplog) -> None: + """Verify sampling merge, diagnostics prefixing, hook validation, and GCP log formatting.""" + base = ObservabilityConfig(sampling=SamplingConfig(sample_rate=0.5, force_sample_slow_queries_ms=500.0)) + override_partial = ObservabilityConfig(sampling=SamplingConfig(sample_rate=0.2)) + merged_partial = ObservabilityConfig.merge(base, override_partial) + assert merged_partial.sampling is not None + assert merged_partial.sampling.force_sample_slow_queries_ms == 500.0 + + override_disable = ObservabilityConfig(sampling=SamplingConfig(sample_rate=0.2, force_sample_slow_queries_ms=None)) + merged_disable = ObservabilityConfig.merge(base, override_disable) + assert merged_disable.sampling is not None + assert merged_disable.sampling.force_sample_slow_queries_ms is None + + diag = collect_diagnostics([("db1", {"lifecycle.query_start": 2}), ("db2", {"lifecycle.query_start": 3})]) + assert diag["db1.lifecycle.query_start"] == 2.0 + assert diag["db2.lifecycle.query_start"] == 3.0 + + dispatcher = LifecycleDispatcher() + with pytest.raises(ValueError, match="Unsupported lifecycle event"): + dispatcher.register_hook(cast(Any, "on_invalid_event"), lambda _ctx: None) + + caplog.set_level(logging.INFO, logger="sqlspec.sql") + runtime_gcp = ObservabilityRuntime( + ObservabilityConfig(print_sql=True, cloud_formatter=GCPLogFormatter(project_id="my-gcp-proj")) + ) + runtime_gcp.emit_statement_event( + sql="SELECT 1", + parameters=None, + driver="SqliteDriver", + operation="SELECT", + execution_mode="single", + is_many=False, + is_script=False, + rows_affected=1, + duration_s=0.01, + storage_backend=None, + started_at=12.34, + transaction_state="in_transaction", + prepared_statement=True, + ) + sql_records = [r for r in caplog.records if r.name == "sqlspec.sql"] + assert sql_records + last_record = sql_records[-1] + assert last_record.__dict__.get("severity") == "INFO" + assert last_record.__dict__.get("sqlspec.transaction_state") == "in_transaction" + assert last_record.__dict__.get("sqlspec.prepared_statement") is True + assert cast(float, last_record.__dict__.get("started_at")) >= 1_000_000_000.0 + + span_manager = _FakeSpanManager() + runtime_gcp.span_manager = cast(Any, span_manager) + runtime_gcp.emit_pool_destroying_sync(object()) + assert any(s.name == "sqlspec.lifecycle.pool.destroying" for s in span_manager.finished) + + storage_span = runtime_gcp.start_storage_span( + "write", destination="s3://user:secret_pass@bucket/path/file.parquet?X-Amz-Signature=secret" + ) + assert storage_span.attributes["sqlspec.storage.destination"] == "s3://bucket/path/file.parquet" + + orig_telemetry: StorageTelemetry = { + "destination": "s3://user:secret@bucket/key?sig=123", + "backend": "s3", + "bytes_processed": 10, + "rows_processed": 1, + } + annotated = runtime_gcp.annotate_storage_telemetry(orig_telemetry) + assert annotated is not orig_telemetry + assert "config" not in orig_telemetry + + truncated_batch, was_truncated = _maybe_truncate_parameters([[1, 2, 3, 4], [5, 6, 7, 8]], max_items=2) + assert was_truncated is True + assert truncated_batch == [[1, 2], [5, 6]] diff --git a/tests/unit/storage/test_storage_utils.py b/tests/unit/storage/test_storage_utils.py index 4a49ee3f6..12047913b 100644 --- a/tests/unit/storage/test_storage_utils.py +++ b/tests/unit/storage/test_storage_utils.py @@ -1,18 +1,52 @@ """Unit tests for storage utilities.""" -from typing import get_args +import asyncio +import io +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any, get_args +import pyarrow as pa import pytest -from sqlspec.exceptions import MissingDependencyError +import sqlspec.storage as storage_pkg +import sqlspec.storage.backends as storage_backends_pkg +from sqlspec.exceptions import ( + FileNotFoundInStorageError, + MissingDependencyError, + StorageOperationFailedError, + StoragePathTraversalError, +) from sqlspec.storage import StorageFormat as PublicStorageFormat from sqlspec.storage import resolve_storage_path from sqlspec.storage._arrow_payload import StorageFormat as ArrowPayloadStorageFormat +from sqlspec.storage._arrow_payload import decode_arrow_payload, encode_arrow_payload +from sqlspec.storage._paths import parse_file_uri_path, strip_windows_drive_prefix from sqlspec.storage._paths import resolve_storage_path as resolve_compiled_storage_path from sqlspec.storage._utils import import_pyarrow, import_pyarrow_csv, import_pyarrow_parquet +from sqlspec.storage.backends.base import ( + AsyncArrowBatchIterator, + AsyncObStoreStreamIterator, + AsyncThreadedBytesIterator, + ObjectStoreBase, +) +from sqlspec.storage.backends.fsspec import FSSpecBackend +from sqlspec.storage.backends.local import LocalStore +from sqlspec.storage.backends.obstore import ObStoreBackend +from sqlspec.storage.errors import execute_sync_storage_operation, raise_storage_error +from sqlspec.storage.pipeline import ( + AsyncStoragePipeline, + StorageTelemetry, + SyncStoragePipeline, + get_recent_storage_events, + record_storage_diagnostic_event, + reset_storage_bridge_events, +) from sqlspec.storage.pipeline import StorageFormat as PipelineStorageFormat -from sqlspec.typing import PYARROW_INSTALLED +from sqlspec.storage.registry import StorageRegistry +from sqlspec.typing import OBSTORE_INSTALLED, PYARROW_INSTALLED from sqlspec.utils.module_loader import ensure_pyarrow +from sqlspec.utils.sync_tools import set_default_async_executor def test_ensure_pyarrow_succeeds_when_installed() -> None: @@ -86,8 +120,6 @@ def test_resolve_storage_path_no_base_path() -> None: def test_resolve_storage_path_pathlib_input() -> None: """Test path resolution with pathlib.Path input.""" - from pathlib import Path - result = resolve_storage_path(Path("data") / "file.txt", base_path="", protocol="file") assert result == "data/file.txt" @@ -111,3 +143,347 @@ def test_public_storage_format_matches_pipeline_reexport() -> None: def test_arrow_payload_storage_format_only_advertises_encodable_formats() -> None: assert set(get_args(ArrowPayloadStorageFormat)) == {"jsonl", "json", "parquet", "arrow-ipc", "csv"} + + +def test_strip_windows_drive_prefix_handles_any_drive_letter() -> None: + """Verify strip_windows_drive_prefix strips leading slash for any drive letter.""" + assert strip_windows_drive_prefix("/C:/data/file.txt") == "C:/data/file.txt" + assert strip_windows_drive_prefix("/D:/archive/item.parquet") == "D:/archive/item.parquet" + assert strip_windows_drive_prefix("/z:/tmp/a.json") == "z:/tmp/a.json" + assert strip_windows_drive_prefix("/var/data/file.txt") == "/var/data/file.txt" + + +def test_parse_file_uri_path_handles_windows_and_fragments() -> None: + """Verify parse_file_uri_path decodes percent-encoding, fragments, and Windows drives.""" + assert parse_file_uri_path("file:///D:/my%20data/part%231.parquet#frag") == "D:/my data/part#1.parquet#frag" + assert parse_file_uri_path("file://C:/data/file.txt") == "C:/data/file.txt" + assert parse_file_uri_path("file://tmp/payload.jsonl") == "tmp/payload.jsonl" + + +def test_resolve_storage_path_rejects_escape_from_base_path() -> None: + """Verify resolve_storage_path rejects absolute paths escaping base_path.""" + with pytest.raises(StoragePathTraversalError): + resolve_storage_path("/etc/passwd", base_path="/var/data", protocol="file") + with pytest.raises(StoragePathTraversalError): + resolve_storage_path("D:/secret/file.txt", base_path="C:/data", protocol="file") + assert resolve_storage_path("C:/data/sub/file.txt", base_path="C:/data", protocol="file") == "C:/data/sub/file.txt" + + +def test_local_store_glob_and_list_reject_escape_outside_base_path(tmp_path: Path) -> None: + """Verify LocalStore.glob_sync and list_objects_sync enforce base_path containment.""" + root = tmp_path / "root" + outside = tmp_path / "outside" + root.mkdir() + outside.mkdir() + (root / "inside.txt").write_text("ok", encoding="utf-8") + secret = outside / "secret.txt" + secret.write_text("secret", encoding="utf-8") + (root / "escape_link.txt").symlink_to(secret) + + store = LocalStore(str(root)) + with pytest.raises(StoragePathTraversalError): + store.glob_sync(f"{outside}/*.txt") + with pytest.raises(StoragePathTraversalError): + store.glob_sync("../*.txt") + with pytest.raises(StoragePathTraversalError): + store.list_objects_sync(prefix="../outside") + + assert store.glob_sync("*.txt") == ["inside.txt"] + assert store.list_objects_sync() == ["inside.txt"] + + +def test_raise_storage_error_preserves_file_not_found_in_storage_error() -> None: + """Verify raise_storage_error does not wrap FileNotFoundInStorageError into StorageOperationFailedError.""" + original = FileNotFoundInStorageError("missing.parquet") + with pytest.raises(FileNotFoundInStorageError) as exc_info: + execute_sync_storage_operation( + lambda: (_ for _ in ()).throw(original), backend="local", operation="read_bytes", path="missing.parquet" + ) + assert exc_info.value is original + + already_wrapped = StorageOperationFailedError("write failed for a.bin") + with pytest.raises(StorageOperationFailedError) as op_exc_info: + raise_storage_error(already_wrapped, backend="local", operation="outer", path="a.bin") + assert op_exc_info.value is already_wrapped + + +@pytest.mark.skipif(not PYARROW_INSTALLED, reason="PyArrow not installed") +def test_arrow_ipc_compression_and_empty_json_decode() -> None: + """Verify arrow-ipc honors compression and decode_arrow_payload handles empty JSON.""" + table = pa.table({"id": [1, 2, 3], "name": ["a", "b", "c"]}) + compressed = encode_arrow_payload(table, "arrow-ipc", compression="zstd") + decoded = decode_arrow_payload(compressed, "arrow-ipc") + assert decoded.to_pydict() == {"id": [1, 2, 3], "name": ["a", "b", "c"]} + + empty_json_table = decode_arrow_payload(b" ", "json") + assert empty_json_table.num_rows == 0 + + +def test_async_iterators_use_default_async_executor_and_convert_obstore_chunks() -> None: + """Verify AsyncArrowBatchIterator, AsyncThreadedBytesIterator, and AsyncObStoreStreamIterator contracts.""" + + async def _run() -> None: + executor_calls = 0 + + class TrackingExecutor(ThreadPoolExecutor): + def submit(self, fn: Any, /, *args: Any, **kwargs: Any) -> Any: + nonlocal executor_calls + executor_calls += 1 + return super().submit(fn, *args, **kwargs) + + tracking_executor = TrackingExecutor(max_workers=2) + set_default_async_executor(tracking_executor) + try: + bio = io.BytesIO(b"abcdefghijk") + byte_iter = AsyncThreadedBytesIterator(bio, chunk_size=5) + chunks = [chunk async for chunk in byte_iter] + await byte_iter.aclose() + assert chunks == [b"abcde", b"fghij", b"k"] + assert executor_calls >= 4 + + if PYARROW_INSTALLED: + batch = pa.record_batch({"x": [1, 2]}) + arrow_iter = AsyncArrowBatchIterator(iter([batch])) + batches = [item async for item in arrow_iter] + assert len(batches) == 1 + finally: + set_default_async_executor(None) + tracking_executor.shutdown(wait=True) + + closed_flag = False + + class FakeObstoreStream: + def __init__(self) -> None: + self._items = iter([bytearray(b"abc"), memoryview(b"def")]) + + async def __anext__(self) -> Any: + try: + return next(self._items) + except StopIteration as exc: + raise StopAsyncIteration from exc + + async def aclose(self) -> None: + nonlocal closed_flag + closed_flag = True + + async with AsyncObStoreStreamIterator(FakeObstoreStream(), chunk_size=3) as ob_iter: + first = await anext(ob_iter) + second = await anext(ob_iter) + assert type(first) is bytes + assert type(second) is bytes + assert first + second == b"abcdef" + assert closed_flag is True + + asyncio.run(_run()) + + +def test_pipeline_direct_cloud_uri_strips_bucket_from_object_key() -> None: + """Verify SyncStoragePipeline and AsyncStoragePipeline do not duplicate bucket name in object key.""" + written: list[tuple[str, str, bytes]] = [] + + class FakeCloudStore(ObjectStoreBase): + protocol = "s3" + backend_type = "obstore" + + def __init__(self, uri: str, **kwargs: Any) -> None: + self.store_uri = uri + self.options = kwargs + + @property + def supports_signing(self) -> bool: + return True + + def resolve_uri(self, path: str | Path) -> str: + return f"{self.store_uri}/{str(path).lstrip('/')}" + + def read_bytes_sync(self, path: str | Path, **kwargs: Any) -> bytes: + del path, kwargs + return b'{"id": 1}\n' + + def write_bytes_sync(self, path: str | Path, data: bytes, **kwargs: Any) -> None: + del kwargs + written.append((self.store_uri, str(path), data)) + + def stream_read_sync(self, path: str | Path, chunk_size: int | None = None, **kwargs: Any) -> Any: + del path, chunk_size, kwargs + return iter([b"data"]) + + def read_text_sync(self, path: str | Path, encoding: str = "utf-8", **kwargs: Any) -> str: + del path, encoding, kwargs + return "" + + def write_text_sync(self, path: str | Path, data: str, encoding: str = "utf-8", **kwargs: Any) -> None: + del path, data, encoding, kwargs + + def list_objects_sync(self, prefix: str = "", recursive: bool = True, **kwargs: Any) -> list[str]: + del prefix, recursive, kwargs + return [] + + def exists_sync(self, path: str | Path, **kwargs: Any) -> bool: + del path, kwargs + return True + + def delete_sync(self, path: str | Path, **kwargs: Any) -> None: + del path, kwargs + + def copy_sync(self, source: str | Path, destination: str | Path, **kwargs: Any) -> None: + del source, destination, kwargs + + def move_sync(self, source: str | Path, destination: str | Path, **kwargs: Any) -> None: + del source, destination, kwargs + + def glob_sync(self, pattern: str, **kwargs: Any) -> list[str]: + del pattern, kwargs + return [] + + def get_metadata_sync(self, path: str | Path, **kwargs: Any) -> dict[str, object]: + del path, kwargs + return {} + + def is_object_sync(self, path: str | Path) -> bool: + del path + return True + + def is_path_sync(self, path: str | Path) -> bool: + del path + return False + + def read_arrow_sync(self, path: str | Path, **kwargs: Any) -> Any: + raise NotImplementedError + + def write_arrow_sync(self, path: str | Path, table: Any, **kwargs: Any) -> None: + raise NotImplementedError + + def stream_arrow_sync( + self, pattern: str, *, file_format: Any = "parquet", batch_size: int = 65536, **kwargs: Any + ) -> Any: + raise NotImplementedError + + def sign_sync(self, paths: Any, expires_in: int = 3600, for_upload: bool = False) -> Any: + del paths, expires_in, for_upload + return "" + + async def read_bytes_async(self, path: str | Path, **kwargs: Any) -> bytes: + del path, kwargs + return b'{"id": 1}\n' + + async def write_bytes_async(self, path: str | Path, data: bytes, **kwargs: Any) -> None: + del kwargs + written.append((self.store_uri, str(path), data)) + + async def read_text_async(self, path: str | Path, encoding: str = "utf-8", **kwargs: Any) -> str: + del path, encoding, kwargs + return "" + + async def write_text_async(self, path: str | Path, data: str, encoding: str = "utf-8", **kwargs: Any) -> None: + del path, data, encoding, kwargs + + async def stream_read_async(self, path: str | Path, chunk_size: int | None = None, **kwargs: Any) -> Any: + del path, chunk_size, kwargs + return AsyncObStoreStreamIterator(iter([])) + + async def list_objects_async(self, prefix: str = "", recursive: bool = True, **kwargs: Any) -> list[str]: + del prefix, recursive, kwargs + return [] + + async def exists_async(self, path: str | Path, **kwargs: Any) -> bool: + del path, kwargs + return True + + async def delete_async(self, path: str | Path, **kwargs: Any) -> None: + del path, kwargs + + async def copy_async(self, source: str | Path, destination: str | Path, **kwargs: Any) -> None: + del source, destination, kwargs + + async def move_async(self, source: str | Path, destination: str | Path, **kwargs: Any) -> None: + del source, destination, kwargs + + async def get_metadata_async(self, path: str | Path, **kwargs: Any) -> dict[str, object]: + del path, kwargs + return {} + + async def read_arrow_async(self, path: str | Path, **kwargs: Any) -> Any: + raise NotImplementedError + + async def write_arrow_async(self, path: str | Path, table: Any, **kwargs: Any) -> None: + raise NotImplementedError + + def stream_arrow_async( + self, pattern: str, *, file_format: Any = "parquet", batch_size: int = 65536, **kwargs: Any + ) -> Any: + raise NotImplementedError + + async def sign_async(self, paths: Any, expires_in: int = 3600, for_upload: bool = False) -> Any: + del paths, expires_in, for_upload + return "" + + class CustomRegistry(StorageRegistry): + def _backend_from_uri(self, uri: str, **kwargs: Any) -> Any: + return FakeCloudStore(uri, **kwargs) + + registry = CustomRegistry() + + sync_pipeline = SyncStoragePipeline(registry=registry) + telemetry = sync_pipeline.write_rows([{"id": 1}], "s3://my-bucket/folder/items.jsonl") + assert telemetry["destination"] == "folder/items.jsonl" + assert written[-1][0] == "s3://my-bucket" + assert written[-1][1] == "folder/items.jsonl" + + async def _run_async() -> None: + async_pipeline = AsyncStoragePipeline(registry=registry) + async_telemetry = await async_pipeline.write_rows([{"id": 2}], "s3://my-bucket/folder/async_items.jsonl") + assert async_telemetry["destination"] == "folder/async_items.jsonl" + assert written[-1][0] == "s3://my-bucket" + assert written[-1][1] == "folder/async_items.jsonl" + + asyncio.run(_run_async()) + + +def test_recent_storage_events_most_recent_first_and_registry_hashable() -> None: + """Verify get_recent_storage_events order and StorageRegistry._make_hashable resilience.""" + reset_storage_bridge_events() + first: StorageTelemetry = {"destination": "first.parquet", "bytes_processed": 10, "rows_processed": 1} + second: StorageTelemetry = {"destination": "second.parquet", "bytes_processed": 20, "rows_processed": 2} + record_storage_diagnostic_event(first) + record_storage_diagnostic_event(second) + events = get_recent_storage_events() + assert [e["destination"] for e in events[:2]] == ["second.parquet", "first.parquet"] + + registry = StorageRegistry() + hashed = registry._make_hashable({"mixed_set": {1, "two"}, "path": Path("/tmp/test")}) + assert isinstance(hashed, tuple) + + +@pytest.mark.skipif(not OBSTORE_INSTALLED or not PYARROW_INSTALLED, reason="obstore or PyArrow missing") +def test_obstore_write_arrow_async_casts_decimal64() -> None: + """Verify ObStoreBackend.write_arrow_async casts decimal64 columns to decimal128.""" + + async def _run() -> None: + store = ObStoreBackend("memory://") + dec64_type = getattr(pa, "decimal64", None) + if dec64_type is None: + pytest.skip("pyarrow.decimal64 not available") + arr = pa.array([123, 456], type=dec64_type(10, 2)) + table = pa.table({"amount": arr}) + await store.write_arrow_async("dec64.parquet", table) + loaded = await store.read_arrow_async("dec64.parquet") + assert loaded.num_rows == 2 + + asyncio.run(_run()) + + +def test_storage_and_backends_pep562_lazy_exports() -> None: + """Verify PEP 562 __getattr__ and __dir__ on sqlspec.storage and sqlspec.storage.backends.""" + assert "SyncStoragePipeline" in dir(storage_pkg) + assert storage_pkg.__getattr__("SyncStoragePipeline") is SyncStoragePipeline + with pytest.raises(AttributeError): + storage_pkg.__getattr__("NonExistentExport") + + assert "LocalStore" in dir(storage_backends_pkg) + assert storage_backends_pkg.__getattr__("LocalStore") is LocalStore + assert storage_backends_pkg.__getattr__("FSSpecBackend") is FSSpecBackend + assert storage_backends_pkg.__getattr__("ObStoreBackend") is ObStoreBackend + assert storage_backends_pkg.__getattr__("ObjectStoreBase") is ObjectStoreBase + with pytest.raises(AttributeError): + storage_backends_pkg.__getattr__("NonExistentBackend") diff --git a/tests/unit/utils/serializers/test_default_type_encoders.py b/tests/unit/utils/serializers/test_default_type_encoders.py index 40fa6c1a4..53b0ce698 100644 --- a/tests/unit/utils/serializers/test_default_type_encoders.py +++ b/tests/unit/utils/serializers/test_default_type_encoders.py @@ -5,7 +5,7 @@ - Unsupported objects raise ``TypeError`` instead of falling back to ``str``. """ -import datetime +import datetime as dt import enum import ipaddress import json @@ -194,10 +194,10 @@ def import_optional_type(_module_name: str, attr_name: str) -> type: def test_registry_covers_required_types() -> None: """Registry covers every type listed in the AC.""" required = { - datetime.datetime, - datetime.date, - datetime.time, - datetime.timedelta, + dt.datetime, + dt.date, + dt.time, + dt.timedelta, Decimal, uuid.UUID, bytes, @@ -316,9 +316,9 @@ class Currency(enum.Enum): def test_enc_hook_user_override_wins() -> None: """A user-supplied registry entry takes precedence over the default.""" - overrides = {**DEFAULT_TYPE_ENCODERS, datetime.datetime: lambda _: "OVERRIDE"} + overrides = {**DEFAULT_TYPE_ENCODERS, dt.datetime: lambda _: "OVERRIDE"} enc_hook = _enc_hook(overrides) - assert enc_hook(datetime.datetime(2026, 1, 1)) == "OVERRIDE" + assert enc_hook(dt.datetime(2026, 1, 1)) == "OVERRIDE" def test_enc_hook_raises_on_unknown_type() -> None: @@ -387,10 +387,10 @@ def test_behavioural_equivalence_battery() -> None: """ serializer = StandardLibSerializer() payload = { - "ts": datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC), - "d": datetime.date(2026, 1, 1), - "t": datetime.time(12, 30, 0), - "td": datetime.timedelta(seconds=90), + "ts": dt.datetime(2026, 1, 1, tzinfo=dt.UTC), + "d": dt.date(2026, 1, 1), + "t": dt.time(12, 30, 0), + "td": dt.timedelta(seconds=90), "dec": Decimal("1.5"), "uid": uuid.UUID("12345678-1234-5678-1234-567812345678"), "raw": b"abc", diff --git a/tests/unit/utils/test_arrow_helpers.py b/tests/unit/utils/test_arrow_helpers.py index 4c3c948f0..cfd2cdacd 100644 --- a/tests/unit/utils/test_arrow_helpers.py +++ b/tests/unit/utils/test_arrow_helpers.py @@ -299,3 +299,30 @@ def test_arrow_reader_to_return_format_rejects_non_reader() -> None: with pytest.raises(TypeError): arrow_reader_to_return_format(object(), return_format="reader") + + +def test_convert_empty_data_to_batches_returns_empty_list() -> None: + """convert_dict_to_arrow([], return_format='batches') returns an empty list rather than a single RecordBatch.""" + result = convert_dict_to_arrow([], return_format="batches") + assert result == [] + + +def test_deferred_close_batch_iterator_closes_on_explicit_close_or_gc() -> None: + """_DeferredCloseBatchIterator invokes close_callback when closed early before full iteration.""" + import pyarrow as pa + + from sqlspec.utils.arrow_helpers import _DeferredCloseBatchIterator + + batches = [pa.record_batch({"id": pa.array([1, 2])}), pa.record_batch({"id": pa.array([3, 4])})] + reader = pa.RecordBatchReader.from_batches(batches[0].schema, batches) + closed: list[bool] = [] + + iterator = _DeferredCloseBatchIterator(reader, lambda: closed.append(True)) + first = next(iterator) + assert first.num_rows == 2 + assert closed == [] + + iterator.close() + assert closed == [True] + iterator.close() + assert closed == [True] diff --git a/tests/unit/utils/test_fixtures.py b/tests/unit/utils/test_fixtures.py index a71a9847f..10325bf2c 100644 --- a/tests/unit/utils/test_fixtures.py +++ b/tests/unit/utils/test_fixtures.py @@ -1387,8 +1387,8 @@ async def test_export_decodes_json_and_jsonb_string_cells_with_fallback_async(tm await export_table_fixtures_async(driver, tmp_path, ["events"], compress=False) query = driver.select.call_args.args[0].build().sql - assert 'CAST("events"."payload" AS TEXT) AS "payload"' in query - assert 'CAST("events"."raw_json" AS TEXT) AS "raw_json"' in query + assert 'CAST("payload" AS TEXT) AS "payload"' in query + assert 'CAST("raw_json" AS TEXT) AS "raw_json"' in query exported = json.loads((tmp_path / "events.json").read_text()) assert exported == [ {"id": 1, "payload": {"a": 1}, "raw_json": "scalar"}, @@ -1627,3 +1627,72 @@ def test_ignore_unknown_fixture_columns_uses_exact_quoted_names(tmp_path: Path) assert load_table_fixtures_sync(driver, tmp_path, ignore_unknown_columns=True) == {"users": 1} assert driver.execute_many.call_args.args[1] == [{"id": 1}] + + +@pytest.mark.parametrize("bad_name", ["../secret", "sub/table", "..\\secret", "", ".", ".."]) +def test_fixture_name_rejects_path_traversal(tmp_path: Path, bad_name: str) -> None: + """open_fixture_sync and write_fixture_sync reject fixture names with path separators or traversal.""" + with pytest.raises(ValueError, match="Invalid fixture name"): + open_fixture_sync(tmp_path, bad_name) + with pytest.raises(ValueError, match="Invalid fixture name"): + write_fixture_sync(str(tmp_path), bad_name, [{"id": 1}]) + + +def test_read_compressed_file_rejects_oversized_zip_member(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """_read_compressed_file rejects ZIP members larger than _MAX_ZIP_FIXTURE_BYTES.""" + zip_file = tmp_path / "bomb.json.zip" + with zipfile.ZipFile(zip_file, "w") as zf: + zf.writestr("bomb.json", json.dumps([{"id": 1, "payload": "x" * 200}])) + + monkeypatch.setattr(fixture_module, "_MAX_ZIP_FIXTURE_BYTES", 50) + with pytest.raises(ValueError, match="exceeds maximum allowed size"): + _read_compressed_file(zip_file) + + +def test_postgres_table_columns_falls_back_to_current_schema() -> None: + """Unqualified PostgreSQL table column lookup falls back to SELECT current_schema() when initial lookup is empty.""" + driver = MagicMock() + driver.statement_config.dialect = "postgres" + driver.select_value_or_none.return_value = "tenant_a" + driver.data_dictionary.get_columns.side_effect = [ + [], + [{"column_name": "id", "data_type": "integer", "is_primary": True}], + ] + + cols = fixture_module._table_columns_sync(driver, "postgres", "users") + assert len(cols) == 1 + assert cols[0].name == "id" + assert driver.data_dictionary.get_columns.call_count == 2 + assert driver.data_dictionary.get_columns.call_args_list[1].kwargs["schema"] == '"tenant_a"' + + +def test_table_insert_statement_overriding_system_value_does_not_corrupt_conflict_clause() -> None: + """OVERRIDING SYSTEM VALUE insertion targets only the top-level column list boundary.""" + cols = [ + fixture_module._TableColumn( + name="id", data_type="integer", is_primary=True, identity_kind="a", sequence_name=None, is_generated=False + ), + fixture_module._TableColumn( + name="note", data_type="text", is_primary=False, identity_kind="", sequence_name=None, is_generated=False + ), + ] + stmt = fixture_module._table_insert_statement( + dialect="postgres", + family="postgres", + table="users", + columns=["id", "note"], + conflict_keys=("id",), + table_columns=cols, + ) + assert isinstance(stmt, str) + assert stmt.count("OVERRIDING SYSTEM VALUE") == 1 + assert ") OVERRIDING SYSTEM VALUE VALUES (" in stmt + + +def test_sequence_resync_disabled_for_cockroach_dialects() -> None: + """CockroachDB dialects are treated as postgres family for inserts/upserts but skip pg_get_serial_sequence resync.""" + assert fixture_module._dialect_family("cockroach") == "postgres" + assert fixture_module._dialect_family("cockroachdb") == "postgres" + assert fixture_module._sequence_resync_enabled(True, "postgres", "postgres") is True + assert fixture_module._sequence_resync_enabled(True, "cockroach", "postgres") is False + assert fixture_module._sequence_resync_enabled(True, "cockroachdb", "postgres") is False diff --git a/tests/unit/utils/test_portal.py b/tests/unit/utils/test_portal.py index 6ea361f0c..50acf26b3 100644 --- a/tests/unit/utils/test_portal.py +++ b/tests/unit/utils/test_portal.py @@ -425,16 +425,53 @@ def test_portal_manager_atexit_cleanup_noop_when_stopped() -> None: def test_portal_call_timeout() -> None: - """PortalProvider.call raises error on timeout.""" + """PortalProvider.call raises error on timeout and cancels the in-flight task.""" + cancelled = False async def slow_function() -> int: - await asyncio.sleep(10) + nonlocal cancelled + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled = True + raise return 42 provider = PortalProvider() provider.start() with pytest.raises(ImproperConfigurationError, match="timed out after"): - provider.call(slow_function, timeout=0.1) + provider.call(slow_function, timeout=0.05) provider.stop() + assert cancelled is True + + +def test_portal_call_rejects_same_thread_reentrancy() -> None: + """PortalProvider.call raises ImproperConfigurationError when called from its own event-loop thread.""" + provider = PortalProvider() + provider.start() + + async def reentrant() -> int: + return provider.call(reentrant) + + try: + with pytest.raises(ImproperConfigurationError, match="event loop thread"): + provider.call(reentrant) + finally: + provider.stop() + + +def test_portal_call_propagates_cancelled_error() -> None: + """PortalProvider.call propagates asyncio.CancelledError instead of hanging.""" + provider = PortalProvider() + provider.start() + + async def cancel_me() -> int: + raise asyncio.CancelledError("task cancelled") + + try: + with pytest.raises(asyncio.CancelledError): + provider.call(cancel_me) + finally: + provider.stop() diff --git a/tests/unit/utils/test_sync_tools.py b/tests/unit/utils/test_sync_tools.py index e5d445567..717f39598 100644 --- a/tests/unit/utils/test_sync_tools.py +++ b/tests/unit/utils/test_sync_tools.py @@ -14,6 +14,7 @@ import time from collections.abc import Coroutine from typing import Any, Self, assert_type, cast +from unittest.mock import MagicMock, patch import pytest @@ -807,10 +808,9 @@ async def async_explode() -> int: @pytest.mark.skipif(_SYNC_TOOLS_COMPILED, reason="patch.object cannot intercept mypyc-compiled methods") -def test_await_run_coroutine_threadsafe_when_no_current_task() -> None: - """When the loop is running but current_task is None (worker thread context), - await_ should use asyncio.run_coroutine_threadsafe.""" - from unittest.mock import MagicMock, patch +def test_await_delegates_to_portal_when_no_current_task() -> None: + """When the loop is running on the current thread and current_task is None (e.g. loop callback), + await_ must delegate to the background portal instead of deadlocking via run_coroutine_threadsafe.""" async def async_add(a: int, b: int) -> int: return a + b @@ -818,21 +818,19 @@ async def async_add(a: int, b: int) -> int: mock_loop = MagicMock() mock_loop.is_running.return_value = True - mock_future = MagicMock() - mock_future.result.return_value = 7 - - def _capture_and_close_coro(coro: "Any", loop: "Any") -> MagicMock: - """Close the coroutine to avoid 'was never awaited' warning.""" - coro.close() - return mock_future + mock_portal = MagicMock() + mock_portal.call.return_value = 7 with ( patch("asyncio.get_running_loop", return_value=mock_loop), patch("asyncio.current_task", return_value=None), - patch("asyncio.run_coroutine_threadsafe", side_effect=_capture_and_close_coro) as mock_rcts, + patch("sqlspec.utils.sync_tools.get_global_portal", return_value=mock_portal), ): sync_add = await_(async_add, raise_sync_error=False) result = sync_add(3, 4) assert result == 7 - mock_rcts.assert_called_once() + mock_portal.call.assert_called_once() + called_arg = mock_portal.call.call_args.args[0] + assert called_arg.func is async_add + assert called_arg.args == (3, 4) diff --git a/tests/unit/utils/test_to_value_type.py b/tests/unit/utils/test_to_value_type.py index a65727911..740d52c74 100644 --- a/tests/unit/utils/test_to_value_type.py +++ b/tests/unit/utils/test_to_value_type.py @@ -1,6 +1,7 @@ """Tests for to_value_type() value conversion utility.""" -import datetime +import datetime as dt +import enum from dataclasses import dataclass from decimal import Decimal from pathlib import Path, PurePosixPath @@ -14,21 +15,23 @@ from pydantic import BaseModel import sqlspec.utils.schema as schema_utils -from sqlspec.utils.schema import to_value_type +from sqlspec.data_dictionary import ForeignKeyMetadata +from sqlspec.utils.schema import ( + _convert_foreign_key_metadata, + _default_msgspec_deserializer, + _get_schema_converter, + to_schema, + to_value_type, +) def test_foreign_key_metadata_recognized_via_issubclass() -> None: """Real ForeignKeyMetadata resolves to a schema converter.""" - from sqlspec.data_dictionary import ForeignKeyMetadata - from sqlspec.utils.schema import _get_schema_converter - assert _get_schema_converter(ForeignKeyMetadata) is not None def test_foreign_key_metadata_conversion_roundtrip() -> None: """Dict with FK column data converts to ForeignKeyMetadata.""" - from sqlspec.data_dictionary import ForeignKeyMetadata - result = to_value_type( { "table_name": "orders", @@ -49,19 +52,15 @@ def test_foreign_key_metadata_conversion_roundtrip() -> None: def test_unrelated_class_named_foreign_key_metadata_not_matched() -> None: """A same-named third-party class must not use the FK metadata converter.""" - from sqlspec.utils.schema import _get_schema_converter - class ForeignKeyMetadata: + class LocalForeignKeyMetadata: __slots__ = ("column_name", "referenced_column", "referenced_table", "table_name") - assert _get_schema_converter(ForeignKeyMetadata) is None + assert _get_schema_converter(LocalForeignKeyMetadata) is None def test_foreign_key_metadata_list_conversion() -> None: """A list of FK dictionaries converts to FK metadata instances.""" - from sqlspec.data_dictionary import ForeignKeyMetadata - from sqlspec.utils.schema import _convert_foreign_key_metadata - result = _convert_foreign_key_metadata( [ {"table_name": "orders", "column_name": "user_id", "referenced_table": "users", "referenced_column": "id"}, @@ -83,9 +82,9 @@ def test_foreign_key_metadata_list_conversion() -> None: pytest.param(3.14, float, id="float"), pytest.param("hello", str, id="str"), pytest.param(True, bool, id="bool"), - pytest.param(datetime.datetime(2024, 1, 15, 12, 30, 45), datetime.datetime, id="datetime"), - pytest.param(datetime.date(2024, 1, 15), datetime.date, id="date"), - pytest.param(datetime.time(12, 30, 45), datetime.time, id="time"), + pytest.param(dt.datetime(2024, 1, 15, 12, 30, 45), dt.datetime, id="datetime"), + pytest.param(dt.date(2024, 1, 15), dt.date, id="date"), + pytest.param(dt.time(12, 30, 45), dt.time, id="time"), pytest.param(Decimal("123.45"), Decimal, id="decimal"), pytest.param(UUID("550e8400-e29b-41d4-a716-446655440000"), UUID, id="uuid"), pytest.param(Path("/tmp/test.txt"), Path, id="path"), @@ -115,22 +114,22 @@ def test_subclass_bug_fixes_bool_to_int(val: bool, expected: int) -> None: @pytest.mark.parametrize( ("target_type", "expected_type", "expected_value"), [ - pytest.param(datetime.date, datetime.date, datetime.date(2024, 1, 15), id="datetime_to_date"), - pytest.param(datetime.time, datetime.time, datetime.time(12, 30, 45), id="datetime_to_time"), + pytest.param(dt.date, dt.date, dt.date(2024, 1, 15), id="datetime_to_date"), + pytest.param(dt.time, dt.time, dt.time(12, 30, 45), id="datetime_to_time"), ], ) def test_subclass_bug_fixes_datetime_subtypes( - target_type: type[datetime.date] | type[datetime.time], - expected_type: type[datetime.date] | type[datetime.time], - expected_value: datetime.date | datetime.time, + target_type: type[dt.date] | type[dt.time], + expected_type: type[dt.date] | type[dt.time], + expected_value: dt.date | dt.time, ) -> None: """Datetime instances should convert to strict date or time instances.""" - dt = datetime.datetime(2024, 1, 15, 12, 30, 45) - result = to_value_type(dt, target_type) + sample_dt = dt.datetime(2024, 1, 15, 12, 30, 45) + result = to_value_type(sample_dt, target_type) assert result == expected_value assert type(result) is expected_type - if target_type is datetime.date: - assert not isinstance(result, datetime.datetime) + if target_type is dt.date: + assert not isinstance(result, dt.datetime) def test_convert_numpy_recursive_preserves_tuple_shape() -> None: @@ -182,13 +181,14 @@ class Measurement(msgspec.Struct): pytest.param(-3.7, -3, id="float_negative"), pytest.param("42", 42, id="str_positive"), pytest.param("-123", -123, id="str_negative"), - pytest.param("42.7", 42, id="str_float_positive"), - pytest.param("-3.9", -3, id="str_float_negative"), + pytest.param("42.0", 42, id="str_integral_float_positive"), + pytest.param("-3.0", -3, id="str_integral_float_negative"), + pytest.param("9007199254740993", 9007199254740993, id="str_large_64bit_int"), pytest.param(Decimal("42.7"), 42, id="decimal"), ], ) def test_int_conversions(value: Any, expected: int) -> None: - """Values convert to integer with truncation where applicable.""" + """Values convert to integer with exact precision for integral strings.""" assert to_value_type(value, int) == expected @@ -245,9 +245,7 @@ def test_bool_conversion_str_true_values(val: str) -> None: assert to_value_type(val, bool) is True -@pytest.mark.parametrize( - "val", ["false", "False", "FALSE", "0", "no", "No", "n", "N", "f", "F", "off", "OFF", "", "anything"] -) +@pytest.mark.parametrize("val", ["false", "False", "FALSE", "0", "no", "No", "n", "N", "f", "F", "off", "OFF", ""]) def test_bool_conversion_str_false_values(val: str) -> None: """String representations of falsity or empty strings convert to False.""" assert to_value_type(val, bool) is False @@ -256,18 +254,18 @@ def test_bool_conversion_str_false_values(val: str) -> None: @pytest.mark.parametrize( ("value", "expected"), [ - pytest.param("2024-01-15T12:30:45", datetime.datetime(2024, 1, 15, 12, 30, 45), id="iso_str"), - pytest.param(datetime.date(2024, 1, 15), datetime.datetime(2024, 1, 15, 0, 0, 0), id="date_to_datetime"), + pytest.param("2024-01-15T12:30:45", dt.datetime(2024, 1, 15, 12, 30, 45), id="iso_str"), + pytest.param(dt.date(2024, 1, 15), dt.datetime(2024, 1, 15, 0, 0, 0), id="date_to_datetime"), ], ) -def test_datetime_conversions(value: Any, expected: datetime.datetime) -> None: +def test_datetime_conversions(value: Any, expected: dt.datetime) -> None: """Values convert to datetime instances.""" - assert to_value_type(value, datetime.datetime) == expected + assert to_value_type(value, dt.datetime) == expected def test_datetime_conversion_str_iso_with_tz_to_datetime() -> None: """ISO format string with timezone converts to datetime.""" - result = to_value_type("2024-01-15T12:30:45+00:00", datetime.datetime) + result = to_value_type("2024-01-15T12:30:45+00:00", dt.datetime) assert result.year == 2024 assert result.month == 1 assert result.day == 15 @@ -276,26 +274,31 @@ def test_datetime_conversion_str_iso_with_tz_to_datetime() -> None: @pytest.mark.parametrize( ("value", "expected"), [ - pytest.param("2024-01-15", datetime.date(2024, 1, 15), id="iso_date_str"), - pytest.param("2024-01-15T12:30:45", datetime.date(2024, 1, 15), id="iso_datetime_str"), - pytest.param(datetime.datetime(2024, 1, 15, 12, 30, 45), datetime.date(2024, 1, 15), id="datetime_instance"), + pytest.param("2024-01-15", dt.date(2024, 1, 15), id="iso_date_str"), + pytest.param("2024-01-15T12:30:45", dt.date(2024, 1, 15), id="iso_datetime_str"), + pytest.param(dt.datetime(2024, 1, 15, 12, 30, 45), dt.date(2024, 1, 15), id="datetime_instance"), ], ) -def test_date_conversions(value: Any, expected: datetime.date) -> None: +def test_date_conversions(value: Any, expected: dt.date) -> None: """Values convert to date instances.""" - assert to_value_type(value, datetime.date) == expected + assert to_value_type(value, dt.date) == expected @pytest.mark.parametrize( ("value", "expected"), [ - pytest.param("12:30:45", datetime.time(12, 30, 45), id="iso_time_str"), - pytest.param(datetime.datetime(2024, 1, 15, 12, 30, 45), datetime.time(12, 30, 45), id="datetime_instance"), + pytest.param("12:30:45", dt.time(12, 30, 45), id="iso_time_str"), + pytest.param(dt.datetime(2024, 1, 15, 12, 30, 45), dt.time(12, 30, 45), id="datetime_instance"), + pytest.param( + dt.datetime(2024, 1, 15, 12, 30, 45, tzinfo=dt.UTC), + dt.time(12, 30, 45, tzinfo=dt.UTC), + id="tz_aware_datetime_instance", + ), ], ) -def test_time_conversions(value: Any, expected: datetime.time) -> None: - """Values convert to time instances.""" - assert to_value_type(value, datetime.time) == expected +def test_time_conversions(value: Any, expected: dt.time) -> None: + """Values convert to time instances preserving tzinfo.""" + assert to_value_type(value, dt.time) == expected @pytest.mark.parametrize( @@ -372,10 +375,14 @@ def test_set_to_list_conversions(value: Any) -> None: ("value", "target_type", "match"), [ pytest.param("not a number", int, "Cannot convert str to int", id="str_to_int"), + pytest.param("42.7", int, "Cannot convert str to int", id="fractional_str_to_int"), + pytest.param("-3.9", int, "Cannot convert str to int", id="negative_fractional_str_to_int"), + pytest.param("inf", int, "Cannot convert str to int", id="inf_str_to_int"), + pytest.param("anything", bool, "Cannot convert str to bool", id="unrecognized_str_to_bool"), pytest.param("not a number", float, "Cannot convert str to float", id="str_to_float"), - pytest.param("not a date", datetime.datetime, "Cannot convert str to datetime", id="str_to_datetime"), - pytest.param("not a date", datetime.date, "Cannot convert str to date", id="str_to_date"), - pytest.param("not a time", datetime.time, "Cannot convert str to time", id="str_to_time"), + pytest.param("not a date", dt.datetime, "Cannot convert str to datetime", id="str_to_datetime"), + pytest.param("not a date", dt.date, "Cannot convert str to date", id="str_to_date"), + pytest.param("not a time", dt.time, "Cannot convert str to time", id="str_to_time"), pytest.param("not a number", Decimal, "Cannot convert str to Decimal", id="str_to_decimal"), pytest.param("not-a-uuid", UUID, "Cannot convert str to UUID", id="str_to_uuid"), pytest.param(123, Path, "Cannot convert int to Path", id="int_to_path"), @@ -547,3 +554,35 @@ def test_schema_type_edge_cases_already_parsed_dict_works() -> None: result = to_value_type(data, UserMsgspec) assert isinstance(result, UserMsgspec) assert result.name == "Charlie" + + +def test_default_msgspec_deserializer_coerces_uuid_date_and_enum() -> None: + """_default_msgspec_deserializer converts UUID, ISO date/datetime/time, and Enum subclasses.""" + + class Status(enum.Enum): + ACTIVE = "active" + + uid_str = "550e8400-e29b-41d4-a716-446655440000" + assert _default_msgspec_deserializer(UUID, uid_str) == UUID(uid_str) + assert _default_msgspec_deserializer(UUID, UUID(uid_str).bytes) == UUID(uid_str) + assert _default_msgspec_deserializer(dt.date, "2024-01-15") == dt.date(2024, 1, 15) + assert _default_msgspec_deserializer(dt.datetime, "2024-01-15T12:30:00Z") == dt.datetime( + 2024, 1, 15, 12, 30, 0, tzinfo=dt.UTC + ) + assert _default_msgspec_deserializer(dt.time, "12:30:00Z") == dt.time(12, 30, 0, tzinfo=dt.UTC) + assert _default_msgspec_deserializer(Status, "active") is Status.ACTIVE + + +@pytest.mark.parametrize("model_cls", [UserPydantic, UserDataclass, UserMsgspec, UserAttrs, UserTypedDict]) +def test_to_schema_supports_tuple_sequences(model_cls: Any) -> None: + """to_schema converts tuple sequences of mappings into a list of schema instances.""" + rows = ({"name": "Alice", "email": "a@example.com"}, {"name": "Bob", "email": "b@example.com"}) + result = to_schema(rows, schema_type=model_cls) + assert isinstance(result, list) + assert len(result) == 2 + + +def test_attrs_converter_rejects_non_mapping_sequence_items() -> None: + """_convert_attrs raises TypeError instead of crashing when given a sequence of non-mappings.""" + with pytest.raises(TypeError, match="Cannot convert"): + to_schema(["not-a-mapping"], schema_type=UserAttrs) diff --git a/tests/unit/utils/test_type_guards.py b/tests/unit/utils/test_type_guards.py index d3b484560..5d358861d 100644 --- a/tests/unit/utils/test_type_guards.py +++ b/tests/unit/utils/test_type_guards.py @@ -4,18 +4,23 @@ Uses function-based pytest approach as per AGENTS.md requirements. """ +import logging import typing from collections import UserDict from collections.abc import Mapping from dataclasses import dataclass from typing import Any, cast +from uuid import UUID import msgspec import pytest from sqlglot import exp from typing_extensions import TypedDict -from sqlspec.typing import PYARROW_INSTALLED +from sqlspec.typing import PYARROW_INSTALLED, SupportedSchemaModel +from sqlspec.utils.config_tools import parse_mysql_dsn +from sqlspec.utils.correlation import CorrelationContext, get_correlation_adapter +from sqlspec.utils.deprecation import deprecated from sqlspec.utils.serializers import ( get_collection_serializer, get_serializer_metrics, @@ -23,6 +28,8 @@ schema_dump, serialize_collection, ) +from sqlspec.utils.serializers._json import StandardLibSerializer +from sqlspec.utils.text import normalize_identifier from sqlspec.utils.type_guards import ( dataclass_to_dict, expression_has_limit, @@ -74,6 +81,7 @@ resolve_row_format, supports_arrow_results, ) +from sqlspec.utils.uuids import uuid_from_string from tests.conftest import is_compiled _UNSET = object() @@ -391,7 +399,7 @@ def test_is_schema_or_dict(value: Any, expected: bool) -> None: @pytest.mark.parametrize( ("field_name", "expected_with", "expected_without"), - [pytest.param("name", False, True, id="existing_name"), pytest.param("nonexistent", False, True, id="nonexistent")], + [pytest.param("name", True, False, id="existing_name"), pytest.param("nonexistent", False, True, id="nonexistent")], ) def test_is_schema_field_guards_with_dataclass(field_name: str, expected_with: bool, expected_without: bool) -> None: """Validate schema field guards on dataclass instances.""" @@ -403,7 +411,7 @@ def test_is_schema_field_guards_with_dataclass(field_name: str, expected_with: b @pytest.mark.parametrize( ("target", "field_name", "expected_with", "expected_without"), [ - pytest.param(SampleDataclass(name="test", age=25), "name", False, True, id="dataclass_existing_field"), + pytest.param(SampleDataclass(name="test", age=25), "name", True, False, id="dataclass_existing_field"), pytest.param({"name": "test", "age": 25}, "name", True, False, id="dict_existing_field"), pytest.param(SampleDataclass(name="test", age=25), "nonexistent", False, True, id="dataclass_missing_field"), pytest.param({"name": "test", "age": 25}, "nonexistent", False, True, id="dict_missing_field"), @@ -1013,7 +1021,92 @@ def test_supports_arrow_results(target: Any, expected: bool) -> None: def test_typing_module_supported_schema_model_includes_mapping() -> None: """SupportedSchemaModel should include Mapping[str, Any].""" - from sqlspec.typing import SupportedSchemaModel - args = typing.get_args(SupportedSchemaModel) assert any(getattr(arg, "__origin__", None) is Mapping for arg in args) + + +def test_schema_dump_recursive_msgspec_struct_and_unset_filtering() -> None: + """schema_dump recursively dumps nested msgspec.Struct fields and filters UNSET.""" + + class Child(msgspec.Struct): + label: str + note: str | msgspec.UnsetType = msgspec.UNSET + + class Parent(msgspec.Struct): + name: str + child: Child + children: list[Child] + tag: str | msgspec.UnsetType = msgspec.UNSET + + payload = Parent(name="root", child=Child(label="one"), children=[Child(label="two", note="present")]) + dumped = schema_dump(payload, exclude_unset=True) + assert dumped == {"name": "root", "child": {"label": "one"}, "children": [{"label": "two", "note": "present"}]} + + encoded = StandardLibSerializer().encode(Child(label="std")) + assert encoded == '{"label": "std"}' + + +def test_uuid_from_string_accepts_braced_and_urn_formats() -> None: + """uuid_from_string accepts stdlib braced and URN UUID strings even when uuid_utils is present.""" + expected = UUID("550e8400-e29b-41d4-a716-446655440000") + assert uuid_from_string("{550e8400-e29b-41d4-a716-446655440000}") == expected + assert uuid_from_string("urn:uuid:550e8400-e29b-41d4-a716-446655440000") == expected + + +def test_parse_mysql_dsn_coerces_semicolon_values_and_preserves_non_ascii_digits() -> None: + """parse_mysql_dsn coerces booleans/ints in semicolon DSNs and does not crash on non-ASCII superscript digits.""" + semi = parse_mysql_dsn("host=localhost;port=3306;ssl=true;autocommit=false;label=²") + assert semi == {"host": "localhost", "port": 3306, "ssl": True, "autocommit": False, "label": "²"} + + url = parse_mysql_dsn("mysql://user:pass@localhost:3306/db?connect_timeout=10&ssl=true&tag=%C2%B2") + assert url["connect_timeout"] == 10 + assert url["ssl"] is True + assert url["tag"] == "²" + + +def test_normalize_identifier_strips_bracket_quotes() -> None: + """normalize_identifier strips T-SQL [...] bracket quotes and unescapes ]].""" + assert normalize_identifier("[Users]", "tsql") == "Users" + assert normalize_identifier("[My]]Table]", "mssql") == "My]Table" + + +def test_correlation_context_token_reset_and_adapter_extra_copy() -> None: + """CorrelationContext.context resets via token and _CorrelationAdapter does not mutate caller extra dict.""" + CorrelationContext.clear() + with CorrelationContext.context("outer-id"): + assert CorrelationContext.get() == "outer-id" + with CorrelationContext.context("inner-id"): + assert CorrelationContext.get() == "inner-id" + assert CorrelationContext.get() == "outer-id" + assert CorrelationContext.get() is None + + adapter = get_correlation_adapter(logging.getLogger("sqlspec.test")) + caller_extra: dict[str, Any] = {"request_id": "req-1"} + with CorrelationContext.context("cid-123"): + _, kwargs = adapter.process("hello", {"extra": caller_extra}) + assert kwargs["extra"] == {"request_id": "req-1", "correlation_id": "cid-123"} + assert "correlation_id" not in caller_extra + + +def test_deprecated_supports_property_and_classmethod() -> None: + """@deprecated supports @property and @classmethod descriptors without raising TypeError.""" + decorator_prop: Any = deprecated("0.1.0", alternative="new_prop") + decorator_cls: Any = deprecated("0.1.0", alternative="new_cls_method") + + class LegacyService: + @property + def _old_prop(self) -> str: + return "prop-val" + + @classmethod + def _old_cls_method(cls) -> str: + return "cls-val" + + old_prop = decorator_prop(_old_prop) + old_cls_method = decorator_cls(_old_cls_method) + + svc = LegacyService() + with pytest.deprecated_call(match="old_prop"): + assert svc.old_prop == "prop-val" + with pytest.deprecated_call(match="old_cls_method"): + assert LegacyService.old_cls_method() == "cls-val"