From 94007c48f31e05b6c147da94ba440f35d0cb3dea Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 13:47:36 -0400 Subject: [PATCH 01/14] feat: rewrote dialect as a subclass of sqlalchemy-cloudflare-d1 Moved to SQLAlchemy 2.0, dropped dbapi-d1 and lifted the python cap. The dialect keeps the d1 name and adds date and boolean type names, date and boolean reflection, autoincrement, schema and view listing and _cf_ filtering on top of upstream. --- poetry.lock | 585 +++++++++++++---------------- pyproject.toml | 61 ++- src/sqlalchemy_d1/__init__.py | 8 +- src/sqlalchemy_d1/compiler.py | 5 - src/sqlalchemy_d1/dialect.py | 312 +++------------ src/sqlalchemy_d1/type_compiler.py | 26 +- 6 files changed, 375 insertions(+), 622 deletions(-) delete mode 100644 src/sqlalchemy_d1/compiler.py diff --git a/poetry.lock b/poetry.lock index c23c672..44718b0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,50 +1,23 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] -name = "black" -version = "25.9.0" -description = "The uncompromising code formatter." +name = "anyio" +version = "4.14.2" +description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false -python-versions = ">=3.9" -groups = ["dev"] +python-versions = ">=3.10" +groups = ["main"] files = [ - {file = "black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7"}, - {file = "black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92"}, - {file = "black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713"}, - {file = "black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1"}, - {file = "black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa"}, - {file = "black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d"}, - {file = "black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608"}, - {file = "black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f"}, - {file = "black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0"}, - {file = "black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4"}, - {file = "black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e"}, - {file = "black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a"}, - {file = "black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175"}, - {file = "black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f"}, - {file = "black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831"}, - {file = "black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357"}, - {file = "black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47"}, - {file = "black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823"}, - {file = "black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140"}, - {file = "black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933"}, - {file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"}, - {file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"}, + {file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"}, + {file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"}, ] [package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -pytokens = ">=0.1.10" +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] +trio = ["trio (>=0.32.0)"] [[package]] name = "certifi" @@ -58,172 +31,19 @@ files = [ {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, ] -[[package]] -name = "charset-normalizer" -version = "3.4.4" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, -] - -[[package]] -name = "click" -version = "8.3.0" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, - {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -optional = false +optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" +groups = ["main"] +markers = "extra == \"dev\" and sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -[[package]] -name = "dbapi-d1" -version = "0.1.0" -description = "" -optional = false -python-versions = "<3.12,>=3.11" -groups = ["main"] -files = [ - {file = "dbapi_d1-0.1.0-py3-none-any.whl", hash = "sha256:625093e0877bae62a14646fcb5a1a73e6ca00659b21dce4330c95a2ff4de1470"}, - {file = "dbapi_d1-0.1.0.tar.gz", hash = "sha256:864837e89f30bd53874090996e761ae359258f182dc21c6d88b21be943f96787"}, -] - -[package.dependencies] -requests = ">=2.32.5,<3.0.0" - [[package]] name = "greenlet" version = "3.2.4" @@ -241,6 +61,8 @@ files = [ {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"}, {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, @@ -250,6 +72,8 @@ files = [ {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"}, {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, @@ -259,6 +83,8 @@ files = [ {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"}, {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, @@ -268,6 +94,8 @@ files = [ {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"}, {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, @@ -275,6 +103,8 @@ files = [ {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, + {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"}, + {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"}, {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, @@ -284,6 +114,8 @@ files = [ {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"}, {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, @@ -293,6 +125,65 @@ files = [ docs = ["Sphinx", "furo"] test = ["objgraph", "psutil", "setuptools"] +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.11" @@ -312,9 +203,10 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 name = "iniconfig" version = "2.3.0" description = "brain-dead simple config-ini parsing" -optional = false +optional = true python-versions = ">=3.10" -groups = ["dev"] +groups = ["main"] +markers = "extra == \"dev\"" files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, @@ -324,9 +216,10 @@ files = [ name = "mypy" version = "1.18.2" description = "Optional static typing for Python" -optional = false +optional = true python-versions = ">=3.9" -groups = ["dev"] +groups = ["main"] +markers = "extra == \"dev\"" files = [ {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, @@ -384,9 +277,10 @@ reports = ["lxml"] name = "mypy-extensions" version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." -optional = false +optional = true python-versions = ">=3.8" -groups = ["dev"] +groups = ["main"] +markers = "extra == \"dev\"" files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -396,9 +290,10 @@ files = [ name = "packaging" version = "25.0" description = "Core utilities for Python packages" -optional = false +optional = true python-versions = ">=3.8" -groups = ["dev"] +groups = ["main"] +markers = "extra == \"dev\"" files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -408,38 +303,23 @@ files = [ name = "pathspec" version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." -optional = false +optional = true python-versions = ">=3.8" -groups = ["dev"] +groups = ["main"] +markers = "extra == \"dev\"" files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] -[[package]] -name = "platformdirs" -version = "4.5.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, - {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, -] - -[package.extras] -docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] -type = ["mypy (>=1.18.2)"] - [[package]] name = "pluggy" version = "1.6.0" description = "plugin and hook calling mechanisms for python" -optional = false +optional = true python-versions = ">=3.9" -groups = ["dev"] +groups = ["main"] +markers = "extra == \"dev\"" files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -453,9 +333,10 @@ testing = ["coverage", "pytest", "pytest-benchmark"] name = "pygments" version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." -optional = false +optional = true python-versions = ">=3.8" -groups = ["dev"] +groups = ["main"] +markers = "extra == \"dev\"" files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -468,9 +349,10 @@ windows-terminal = ["colorama (>=0.4.6)"] name = "pytest" version = "8.4.2" description = "pytest: simple powerful testing with Python" -optional = false +optional = true python-versions = ">=3.9" -groups = ["dev"] +groups = ["main"] +markers = "extra == \"dev\"" files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -487,151 +369,186 @@ pygments = ">=2.7.2" dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] -name = "pytokens" -version = "0.2.0" -description = "A Fast, spec compliant Python 3.13+ tokenizer that runs on older Pythons." -optional = false -python-versions = ">=3.8" -groups = ["dev"] +name = "pytest-socket" +version = "0.8.1" +description = "Pytest Plugin to disable socket calls during tests" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" files = [ - {file = "pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8"}, - {file = "pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43"}, + {file = "pytest_socket-0.8.1-py3-none-any.whl", hash = "sha256:f9846bed1dcd96eed459e5e14795bbaf96715cf4e827891fe70773817ecb8ed4"}, + {file = "pytest_socket-0.8.1.tar.gz", hash = "sha256:2f57787914ad2e1308d09ce141b95c3e55741fbb4fb7b7556593a6b063e0c9c7"}, ] -[package.extras] -dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] +[package.dependencies] +pytest = ">=7.0.0" [[package]] -name = "requests" -version = "2.32.5" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.9" +name = "ruff" +version = "0.16.8" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = true +python-versions = ">=3.7" groups = ["main"] +markers = "extra == \"dev\"" files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, + {file = "ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38"}, + {file = "ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e"}, + {file = "ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df"}, + {file = "ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9"}, + {file = "ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773"}, + {file = "ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f"}, + {file = "ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa"}, + {file = "ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a"}, + {file = "ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b"}, ] -[package.dependencies] -certifi = ">=2017.4.17" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - [[package]] name = "sqlalchemy" -version = "1.4.54" +version = "2.0.54" description = "Database Abstraction Library" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "SQLAlchemy-1.4.54-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:af00236fe21c4d4f4c227b6ccc19b44c594160cc3ff28d104cdce85855369277"}, - {file = "SQLAlchemy-1.4.54-cp310-cp310-manylinux1_x86_64.manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_5_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1183599e25fa38a1a322294b949da02b4f0da13dbc2688ef9dbe746df573f8a6"}, - {file = "SQLAlchemy-1.4.54-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1990d5a6a5dc358a0894c8ca02043fb9a5ad9538422001fb2826e91c50f1d539"}, - {file = "SQLAlchemy-1.4.54-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:14b3f4783275339170984cadda66e3ec011cce87b405968dc8d51cf0f9997b0d"}, - {file = "SQLAlchemy-1.4.54-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b24364150738ce488333b3fb48bfa14c189a66de41cd632796fbcacb26b4585"}, - {file = "SQLAlchemy-1.4.54-cp310-cp310-win32.whl", hash = "sha256:a8a72259a1652f192c68377be7011eac3c463e9892ef2948828c7d58e4829988"}, - {file = "SQLAlchemy-1.4.54-cp310-cp310-win_amd64.whl", hash = "sha256:b67589f7955924865344e6eacfdcf70675e64f36800a576aa5e961f0008cde2a"}, - {file = "SQLAlchemy-1.4.54-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b05e0626ec1c391432eabb47a8abd3bf199fb74bfde7cc44a26d2b1b352c2c6e"}, - {file = "SQLAlchemy-1.4.54-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13e91d6892b5fcb94a36ba061fb7a1f03d0185ed9d8a77c84ba389e5bb05e936"}, - {file = "SQLAlchemy-1.4.54-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb59a11689ff3c58e7652260127f9e34f7f45478a2f3ef831ab6db7bcd72108f"}, - {file = "SQLAlchemy-1.4.54-cp311-cp311-win32.whl", hash = "sha256:1390ca2d301a2708fd4425c6d75528d22f26b8f5cbc9faba1ddca136671432bc"}, - {file = "SQLAlchemy-1.4.54-cp311-cp311-win_amd64.whl", hash = "sha256:2b37931eac4b837c45e2522066bda221ac6d80e78922fb77c75eb12e4dbcdee5"}, - {file = "SQLAlchemy-1.4.54-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3f01c2629a7d6b30d8afe0326b8c649b74825a0e1ebdcb01e8ffd1c920deb07d"}, - {file = "SQLAlchemy-1.4.54-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c24dd161c06992ed16c5e528a75878edbaeced5660c3db88c820f1f0d3fe1f4"}, - {file = "SQLAlchemy-1.4.54-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5e0d47d619c739bdc636bbe007da4519fc953393304a5943e0b5aec96c9877c"}, - {file = "SQLAlchemy-1.4.54-cp312-cp312-win32.whl", hash = "sha256:12bc0141b245918b80d9d17eca94663dbd3f5266ac77a0be60750f36102bbb0f"}, - {file = "SQLAlchemy-1.4.54-cp312-cp312-win_amd64.whl", hash = "sha256:f941aaf15f47f316123e1933f9ea91a6efda73a161a6ab6046d1cde37be62c88"}, - {file = "SQLAlchemy-1.4.54-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:a41611835010ed4ea4c7aed1da5b58aac78ee7e70932a91ed2705a7b38e40f52"}, - {file = "SQLAlchemy-1.4.54-cp36-cp36m-manylinux1_x86_64.manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_5_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8c1b9ecaf9f2590337d5622189aeb2f0dbc54ba0232fa0856cf390957584a9"}, - {file = "SQLAlchemy-1.4.54-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0de620f978ca273ce027769dc8db7e6ee72631796187adc8471b3c76091b809e"}, - {file = "SQLAlchemy-1.4.54-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c5a2530400a6e7e68fd1552a55515de6a4559122e495f73554a51cedafc11669"}, - {file = "SQLAlchemy-1.4.54-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0cf7076c8578b3de4e43a046cc7a1af8466e1c3f5e64167189fe8958a4f9c02"}, - {file = "SQLAlchemy-1.4.54-cp37-cp37m-macosx_11_0_x86_64.whl", hash = "sha256:f1e1b92ee4ee9ffc68624ace218b89ca5ca667607ccee4541a90cc44999b9aea"}, - {file = "SQLAlchemy-1.4.54-cp37-cp37m-manylinux1_x86_64.manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_5_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41cffc63c7c83dfc30c4cab5b4308ba74440a9633c4509c51a0c52431fb0f8ab"}, - {file = "SQLAlchemy-1.4.54-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5933c45d11cbd9694b1540aa9076816cc7406964c7b16a380fd84d3a5fe3241"}, - {file = "SQLAlchemy-1.4.54-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cafe0ba3a96d0845121433cffa2b9232844a2609fce694fcc02f3f31214ece28"}, - {file = "SQLAlchemy-1.4.54-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a19f816f4702d7b1951d7576026c7124b9bfb64a9543e571774cf517b7a50b29"}, - {file = "SQLAlchemy-1.4.54-cp37-cp37m-win32.whl", hash = "sha256:76c2ba7b5a09863d0a8166fbc753af96d561818c572dbaf697c52095938e7be4"}, - {file = "SQLAlchemy-1.4.54-cp37-cp37m-win_amd64.whl", hash = "sha256:a86b0e4be775902a5496af4fb1b60d8a2a457d78f531458d294360b8637bb014"}, - {file = "SQLAlchemy-1.4.54-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:a49730afb716f3f675755afec109895cab95bc9875db7ffe2e42c1b1c6279482"}, - {file = "SQLAlchemy-1.4.54-cp38-cp38-manylinux1_x86_64.manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_5_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26e78444bc77d089e62874dc74df05a5c71f01ac598010a327881a48408d0064"}, - {file = "SQLAlchemy-1.4.54-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02d2ecb9508f16ab9c5af466dfe5a88e26adf2e1a8d1c56eb616396ccae2c186"}, - {file = "SQLAlchemy-1.4.54-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:394b0135900b62dbf63e4809cdc8ac923182af2816d06ea61cd6763943c2cc05"}, - {file = "SQLAlchemy-1.4.54-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ed3576675c187e3baa80b02c4c9d0edfab78eff4e89dd9da736b921333a2432"}, - {file = "SQLAlchemy-1.4.54-cp38-cp38-win32.whl", hash = "sha256:fc9ffd9a38e21fad3e8c5a88926d57f94a32546e937e0be46142b2702003eba7"}, - {file = "SQLAlchemy-1.4.54-cp38-cp38-win_amd64.whl", hash = "sha256:a01bc25eb7a5688656c8770f931d5cb4a44c7de1b3cec69b84cc9745d1e4cc10"}, - {file = "SQLAlchemy-1.4.54-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:0b76bbb1cbae618d10679be8966f6d66c94f301cfc15cb49e2f2382563fb6efb"}, - {file = "SQLAlchemy-1.4.54-cp39-cp39-manylinux1_x86_64.manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_5_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdb2886c0be2c6c54d0651d5a61c29ef347e8eec81fd83afebbf7b59b80b7393"}, - {file = "SQLAlchemy-1.4.54-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:954816850777ac234a4e32b8c88ac1f7847088a6e90cfb8f0e127a1bf3feddff"}, - {file = "SQLAlchemy-1.4.54-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1d83cd1cc03c22d922ec94d0d5f7b7c96b1332f5e122e81b1a61fb22da77879a"}, - {file = "SQLAlchemy-1.4.54-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1576fba3616f79496e2f067262200dbf4aab1bb727cd7e4e006076686413c80c"}, - {file = "SQLAlchemy-1.4.54-cp39-cp39-win32.whl", hash = "sha256:3112de9e11ff1957148c6de1df2bc5cc1440ee36783412e5eedc6f53638a577d"}, - {file = "SQLAlchemy-1.4.54-cp39-cp39-win_amd64.whl", hash = "sha256:6da60fb24577f989535b8fc8b2ddc4212204aaf02e53c4c7ac94ac364150ed08"}, - {file = "sqlalchemy-1.4.54.tar.gz", hash = "sha256:4470fbed088c35dc20b78a39aaf4ae54fe81790c783b3264872a0224f437c31a"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:24ae093dec196ba37fc2beb0316de53e7871d3d246a50faecbbb53034e41ded2"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cc6532f930c27974e9239e5ce5abebe7600ba9807cea4fcf42f1b6cab18fe7"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e7a76d5dce712ce50435d0f97181eb955ec27d138c004176f01282e063bac52"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f5c09090b1a7c4d389d1431f820931e8df318f82caafc53f9a72c872fef467c5"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:762cfe4d340c56368256d936a98b620a9a5650e49c1c84eba51d6edd17ffefb2"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-win32.whl", hash = "sha256:6b6d4e601c4f6d85e99bb3416107cc9418c5603ca73d4ee0f5f8d79c2a1ed9e8"}, + {file = "sqlalchemy-2.0.54-cp310-cp310-win_amd64.whl", hash = "sha256:03cbf8d9a67da618bd65500a5eb3ddac89caf4c61e99b2f03fa4a1952a0725a9"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d03084f3352dd92048cb19c71d90f116d076c9c7937e0ebc7752c4685de6d38"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92622fbbda1b1fe1632f3402a6e516a93c0e41d9158839c6b3dfb12117f26b72"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5800ddea045c2c860ef1d359a07a3066c7c0c426f45e3abc3874e116cb3c6937"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1019abef05a4b5eafc8eae6fb483167fa28a4dbe5f518d577b744f31a5276a37"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b67749f7da3985a529cefbb1474783cb91ef44371cb9713630bade3de908760d"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-win32.whl", hash = "sha256:2f61a70b3b82e2ec7ad6a4f2301422b9ca93ff06917983e41317bcae878bddf6"}, + {file = "sqlalchemy-2.0.54-cp311-cp311-win_amd64.whl", hash = "sha256:1d887fbd5d248e250807bd801e697fc73e3b44866ce5f093dbc90512e75bde25"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffba7eb2d67c7505e82a0902aa854d8824b74c28a183820d6a8bd3cfd0f812c2"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63cae7210fea9899e0bf35c1f1ae55d3ddd9c6d47cae8b6b43d945afa79dd65b"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68d994e9b0d0423a02a20039631fa6fcbb7fa829a992f7605025774940305d19"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3de32cc6721eb42c3aad35bcfb244bb7a18f66c00f3582aae6281d6287a339b5"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d31a2bc06a854ee52dd86b455be4df7c750b28817e2d1b884e31fff126c4fd7b"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-win32.whl", hash = "sha256:32de6deded25e8b9b11d07428d496ff24dfbc882b8e990c177266948cb5f3d9e"}, + {file = "sqlalchemy-2.0.54-cp312-cp312-win_amd64.whl", hash = "sha256:d65f8ca742ef1e1e14bc417ef59dc2ddf207a7b66b30cfdc6152447314e030cf"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b374e3bc91e246a942592a98ba6a23be76fff21358b00546ac8c0ebc0fd0e00b"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d5458672a6f72db2c087f4a5098b3c8503ea0254186ff29205d63afa9401a4"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cad78d04254967bdbcccbed5e631d88fe4868530946ab0929aa45e9032849518"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:48611087a75d26d798003645c688c7d3cfc26b89dbe4a2c568d6b378d330deae"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d6adf80277372a89910a0f3ccfe960b846d279dc55b366dd5c5ec07f41c84758"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-win32.whl", hash = "sha256:264460333ed0b177cbb1956355d0ee4e0cab83fb415c934ce12a25db2e7be39c"}, + {file = "sqlalchemy-2.0.54-cp313-cp313-win_amd64.whl", hash = "sha256:cf89e92bf0d4204a6afcc17af27b9271ed9c7e34e17d6f80c085d431ea4a1747"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abd6b21bc58e91c1932eb5d6d7f1bd44a551dfec7b6a7f517c3638ccd67233a0"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5417322b3c025dd82918725d3bf09ec105fac95efc195722b8b06e1d9c381139"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f84099e4b04a5c2d44500a2a8302eee5af4bc6fee63e8c6e9cf6786e747280e"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0956dc754d3884da7fe60097110ec7a8a105d26afa2f0844468f4b1598c6912"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:87ba8834318b0d8dc94fc6f405d071b5c08be32a6c3fd68107fd6952ee949615"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-win32.whl", hash = "sha256:842540e4382472f23c79589995752648d14696a8200d0807ed8c5c59c92ade44"}, + {file = "sqlalchemy-2.0.54-cp314-cp314-win_amd64.whl", hash = "sha256:f4e8f955d13af83fb4e35c3472e5377ee22d3445eada1e5e48199588edb69835"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca05f4e7852cf48083b0cf157e4f9504b7068780422a50fa82f45353b8c5e14a"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a8b6417cbb7b735cf91c2b59453c2a554cefa0a8d7bd15aa35740739410d77"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e55a0b96a1577a1e108c91ccdeeb9cd92768f28ce206597311c3bf6d6423abd"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:69cab115c40fd02c5a22c68e4ee630fa6ef9a1650f1de944419aab1f7096fc4f"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e08397c6c42f53b2488acde9108b8bfefd52d7afd1bf2f03d2ffcab7a204aceb"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-win32.whl", hash = "sha256:b9086b8ad48280ef6a7ba68262d5e44f7db1c4cb1973e8cdae8a9f467ae66f51"}, + {file = "sqlalchemy-2.0.54-cp314-cp314t-win_amd64.whl", hash = "sha256:b67c1744e453af833667fc1b84de07adb4a64f3536ef52a8ec5ac2b941d43970"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:330d35f9ce815d35cb1daab038d4d7ec0e907f4d7ed0fc8bcb2411d1f23d0b50"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1f455db400289f77ba2f7b62fffafe8875153812d0e3777aa4ff2b34a0fc1f7"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8a4afcc7d714cc3c8a57facdff4c3529f5f93d71e54b7da1e03e022c9089c9"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:34e10af7d274a5c4b7cd0fced5e7361008c5e07d97dd48a93852d5b2f1142a1c"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:7108f410f596c5ac22fe43ba467e864d27c4e1477ae89e90c6c87120b2c1be23"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-win32.whl", hash = "sha256:c1a3455a88f66e4851792bedb098ed942912253d31caed1dbc58afbfa9e875cd"}, + {file = "sqlalchemy-2.0.54-cp38-cp38-win_amd64.whl", hash = "sha256:f3ea33bcf0aa599c1511fe5c9fb126f45aa450419084c4823f786155fe4c79f1"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b6c419c83a87fd901f0b1b5338ffcb82471c3ac32a86bb8883688c18f8eb85d3"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:415239eb2ddbbc508ba4cac97affb91c0f210548fd1731edda6e529b0bb93015"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:279bde5bfedb0f3e0f1bdbcffa2daa39c6c54d90f9408ef3b1802001597199f0"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7b973e4facc2f80e42f5a27b841feb7e202661881a6320580abbe597a28a007f"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:deeab253fe01a770f634c7007c73702df2324c868a79ae756507a9a1a76294fe"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-win32.whl", hash = "sha256:d566099d60cded87d175d4171dc899b9613d2e3b663573364565ca1b27ccd241"}, + {file = "sqlalchemy-2.0.54-cp39-cp39-win_amd64.whl", hash = "sha256:744fb219a390561a57dbbd59cd69a22b5b5b2facfde794c1f79236dd847fa67a"}, + {file = "sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2"}, + {file = "sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b"}, ] [package.dependencies] -greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} +greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +typing-extensions = ">=4.6.0" [package.extras] -aiomysql = ["aiomysql (>=0.2.0) ; python_version >= \"3\"", "greenlet (!=0.4.17) ; python_version >= \"3\""] -aiosqlite = ["aiosqlite ; python_version >= \"3\"", "greenlet (!=0.4.17) ; python_version >= \"3\"", "typing_extensions (!=3.10.0.1)"] -asyncio = ["greenlet (!=0.4.17) ; python_version >= \"3\""] -asyncmy = ["asyncmy (>=0.2.3,!=0.2.4) ; python_version >= \"3\"", "greenlet (!=0.4.17) ; python_version >= \"3\""] -mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2) ; python_version >= \"3\"", "mariadb (>=1.0.1,!=1.1.2) ; python_version >= \"3\""] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.12)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] mssql = ["pyodbc"] -mssql-pymssql = ["pymssql", "pymssql"] -mssql-pyodbc = ["pyodbc", "pyodbc"] -mypy = ["mypy (>=0.910) ; python_version >= \"3\"", "sqlalchemy2-stubs"] -mysql = ["mysqlclient (>=1.4.0) ; python_version >= \"3\"", "mysqlclient (>=1.4.0,<2) ; python_version < \"3\""] -mysql-connector = ["mysql-connector-python", "mysql-connector-python"] -oracle = ["cx_oracle (>=7) ; python_version >= \"3\"", "cx_oracle (>=7,<8) ; python_version < \"3\""] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] postgresql = ["psycopg2 (>=2.7)"] -postgresql-asyncpg = ["asyncpg ; python_version >= \"3\"", "asyncpg ; python_version >= \"3\"", "greenlet (!=0.4.17) ; python_version >= \"3\"", "greenlet (!=0.4.17) ; python_version >= \"3\""] -postgresql-pg8000 = ["pg8000 (>=1.16.6,!=1.29.0) ; python_version >= \"3\"", "pg8000 (>=1.16.6,!=1.29.0) ; python_version >= \"3\""] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] postgresql-psycopg2binary = ["psycopg2-binary"] postgresql-psycopg2cffi = ["psycopg2cffi"] -pymysql = ["pymysql (<1) ; python_version < \"3\"", "pymysql ; python_version >= \"3\""] -sqlcipher = ["sqlcipher3_binary ; python_version >= \"3\""] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] [[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" +name = "sqlalchemy-cloudflare-d1" +version = "0.4.0" +description = "A SQLAlchemy dialect for Cloudflare's D1 Serverless SQLite Database" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main"] files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, + {file = "sqlalchemy_cloudflare_d1-0.4.0-py3-none-any.whl", hash = "sha256:c6d0808aea73ed32f295c656bf57036fdba8d5ca1ef3ca7730a05d836ea8f0fa"}, + {file = "sqlalchemy_cloudflare_d1-0.4.0.tar.gz", hash = "sha256:530591c3833d775e7ac0fb5fcd5732d7579d9fcc133d8861af436e341a54e53d"}, ] +[package.dependencies] +httpx = ">=0.24.0" +sqlalchemy = ">=2.0.0" +typing-extensions = ">=4.0.0" + +[package.extras] +async = ["greenlet (>=3.2.3)"] +dev = ["black (>=23.0.0)", "isort (>=5.12.0)", "mypy (>=1.0.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.21.0)", "ruff (==0.12.4)"] +hyperdrive = ["pg8000 (>=1.30.0)"] +hyperdrive-mysql = ["pymysql (>=1.1.0)"] + [[package]] -name = "urllib3" -version = "2.5.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -[package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +[extras] +dev = ["mypy", "pytest", "pytest-socket", "ruff"] [metadata] lock-version = "2.1" -python-versions = ">=3.11,<3.12" -content-hash = "7f40baf0a84ed38729dc13662d2a78feb1151e610b113a49000d70248c3d350c" +python-versions = ">=3.11" +content-hash = "f4a4164792ade23b597bbbcf0b750bca2dce994f03f41938fae87af4fce8ac3b" diff --git a/pyproject.toml b/pyproject.toml index ab952ce..d1d91c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "sqlalchemy-d1" -version = "0.1.0" -description = "" +version = "0.2.0" +description = "SQLAlchemy dialect for Cloudflare D1" authors = [ {name = "Chad Rossouw",email = "chadrossouw7247@gmail.com"}, {name = "Murphy Lee",email = "murphylee2004@gmail.com"}, @@ -9,29 +9,54 @@ authors = [ {name = "Alan Zhang",email = "zhangalan54@gmail.com"}, {name = "Daniel Alyoshin",email = "daniel.alyoshin@gmail.com"} ] +maintainers = [ + {name = "Daniel Alyoshin",email = "daniel.alyoshin@gmail.com"} +] readme = "README.md" -requires-python = ">=3.11,<3.12" -dynamic = ["dependencies"] +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Database", +] +dependencies = [ + "sqlalchemy>=2.0,<2.1", + "sqlalchemy-cloudflare-d1>=0.3.11,<0.5", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.4.2,<9.0.0", + "pytest-socket>=0.7.0", + "ruff>=0.16.8,<0.17.0", + "mypy>=1.18.2,<2.0.0", +] + +[project.urls] +Repository = "https://github.com/sqlalchemy-cf-d1/sqlalchemy-d1" + +[project.entry-points."sqlalchemy.dialects"] +d1 = "sqlalchemy_d1.dialect:D1Dialect" [tool.poetry] packages = [{include = "sqlalchemy_d1", from = "src"}] -[tool.poetry.dependencies] -python = ">=3.11,<3.12" -sqlalchemy = ">=1.4,<2" -dbapi-d1 = ">=0.1.0" # published version +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "integration: runs against a real D1 database", +] + +[tool.ruff] +line-length = 79 + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] -[tool.poetry.group.dev.dependencies] -pytest = ">=8.4.2,<9.0.0" -black = ">=25.9.0,<26.0.0" -mypy = ">=1.18.2,<2.0.0" +[tool.ruff.lint.isort] +combine-as-imports = true [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" - -[project.entry-points."sqlalchemy.dialects"] -d1 = "sqlalchemy_d1.dialect:D1Dialect" - -# [tool.poetry.plugins."sqlalchemy.dialects"] -# d1 = "sqlalchemy_d1.dialect:D1Dialect" diff --git a/src/sqlalchemy_d1/__init__.py b/src/sqlalchemy_d1/__init__.py index 28c7bfc..610d819 100644 --- a/src/sqlalchemy_d1/__init__.py +++ b/src/sqlalchemy_d1/__init__.py @@ -1,6 +1,12 @@ """SQLAlchemy dialect for Cloudflare D1""" -from .dialect import D1Dialect +from importlib.metadata import version + from sqlalchemy.dialects import registry +from .dialect import D1Dialect + +__version__ = version("sqlalchemy-d1") +__all__ = ["D1Dialect", "__version__"] + registry.register("d1", "sqlalchemy_d1.dialect", "D1Dialect") diff --git a/src/sqlalchemy_d1/compiler.py b/src/sqlalchemy_d1/compiler.py deleted file mode 100644 index 1c6b4b1..0000000 --- a/src/sqlalchemy_d1/compiler.py +++ /dev/null @@ -1,5 +0,0 @@ -from sqlalchemy.sql.compiler import SQLCompiler - - -class D1Compiler(SQLCompiler): - pass diff --git a/src/sqlalchemy_d1/dialect.py b/src/sqlalchemy_d1/dialect.py index 07bb60f..44b5101 100644 --- a/src/sqlalchemy_d1/dialect.py +++ b/src/sqlalchemy_d1/dialect.py @@ -1,57 +1,32 @@ # sqlalchemy_d1/dialect.py -import dbapi_d1 -from sqlalchemy.engine.default import DefaultDialect from sqlalchemy import text, types as sqltypes from sqlalchemy.engine import reflection +from sqlalchemy_cloudflare_d1.dialect import ( + CloudflareD1Dialect, + D1Boolean, + D1Date, + D1DateTime, + D1Time, +) +from .type_compiler import D1TypeCompiler -class D1Dialect(DefaultDialect): - name = "d1" - driver = "dbapi-d1" - supports_alter = False - supports_sane_rowcount = True - supports_sane_multi_rowcount = True - supports_statement_cache = True - paramstyle = "qmark" - - def create_connect_args(self, url): - # URL format: d1://:@ - account_id = url.username - api_token = url.password - database_id = url.host - return ( - (), - { - "account_id": account_id, - "api_token": api_token, - "database_id": database_id, - }, - ) +INTERNAL_PREFIX = "_cf_" - def do_ping(self, dbapi_connection) -> bool: - """ - Return if the database can be reached. - """ - try: - dbapi_connection.execute(text("SELECT 1")) - except Exception as ex: - return False +# Checked in order, first match wins +TYPE_OVERRIDES = ( + ("DATETIME", D1DateTime), + ("TIMESTAMP", D1DateTime), + ("DATE", D1Date), + ("TIME", D1Time), + ("BOOL", D1Boolean), +) - return True - def do_execute(self, cursor, statement, parameters, context=None): - cursor.execute(statement, parameters) - - def do_on_first_connect(self, conn, branch): - # This is called by SQLAlchemy on the first raw connection - # Reset the flag so future rollbacks raise error - conn._first_connect = False - - def import_dbapi(): - return dbapi_d1 - - def dbapi(): - return dbapi_d1 +class D1Dialect(CloudflareD1Dialect): + name = "d1" + type_compiler = D1TypeCompiler + supports_statement_cache = True @reflection.cache def get_schema_names(self, connection, **kwargs): @@ -63,17 +38,12 @@ def get_table_names(self, connection, schema=None, **kw): """ Return list of table names in the D1 database. """ - try: - result = connection.execute( - text("SELECT name FROM sqlite_master WHERE type='table';") - ) - - all_tables = [row[0] for row in result.fetchall()] - # Filter out cloudflare tables - visible_tables = [t for t in all_tables if not t.startswith("_cf")] - return visible_tables - except Exception as e: - raise RuntimeError(f"Failed to fetch table names: {e}") + all_tables = super().get_table_names(connection, schema, **kw) + # Filter out cloudflare tables + visible_tables = [ + t for t in all_tables if not t.startswith(INTERNAL_PREFIX) + ] + return visible_tables @reflection.cache def get_view_names(self, connection, schema=None, **kw): @@ -82,10 +52,15 @@ def get_view_names(self, connection, schema=None, **kw): """ try: result = connection.execute( - text("SELECT name FROM sqlite_master WHERE type='view';") + text( + "SELECT name FROM sqlite_master " + "WHERE type='view' ORDER BY name" + ) ) - all_views = [row[0] for row in result.fetchall()] - visible_views = [v for v in all_views if not v.startswith("_cf")] + all_views = [row[0] for row in result] + visible_views = [ + v for v in all_views if not v.startswith(INTERNAL_PREFIX) + ] return visible_views except Exception as e: raise RuntimeError(f"Failed to fetch view names: {e}") @@ -95,205 +70,20 @@ def get_columns(self, connection, table_name, schema=None, **kw): """ Return column info for a given table in D1. """ - try: - query = text(f"PRAGMA table_info({table_name});") - result = connection.execute(query).mappings() - columns = [] - for row in result.fetchall(): - columns.append( - { - "name": row["name"], - "type": self._resolve_type(row["type"]), - "nullable": not row["notnull"], - "default": row["dflt_value"], - "autoincrement": row["pk"] == 1, - } - ) - return columns - except Exception as e: - raise RuntimeError( - f"Failed to fetch columns for table '{table_name}': {e}" - ) - - @reflection.cache - def get_primary_keys(self, connection, table_name, schema=None, **kw): - try: - query = text(f"PRAGMA table_info({table_name});") - result = connection.execute(query).mappings() - pks = [row["name"] for row in result.fetchall() if row["pk"] == 1] - return pks - except Exception as e: - raise RuntimeError( - f"Failed to fetch primary keys for table '{table_name}': {e}" - ) - - @reflection.cache - def get_pk_constraint(self, connection, table_name, schema=None, **kw): - """ - Return the primary key for the given table as a dict with: - - constrained_columns: list of columns in the PK - - name: name of the PK constraint (SQLite doesn't store names, so None) - """ - try: - result = connection.execute( - text(f"PRAGMA table_info({table_name});") - ).mappings() - pk_columns = [ - row["name"] for row in result.fetchall() if row["pk"] != 0 - ] - return {"constrained_columns": pk_columns, "name": None} - except Exception as e: - raise RuntimeError( - f"Failed to fetch primary key for '{table_name}': {e}" - ) - - @reflection.cache - def get_foreign_keys(self, connection, table_name, schema=None, **kw): - """ - Return list of foreign keys for the given table. - Each foreign key is a dict with keys: name, constrained_columns, referred_schema, - referred_table, referred_columns - """ - try: - result = connection.execute( - text(f"PRAGMA foreign_key_list({table_name});") - ).mappings() - fks = [] - for row in result.fetchall(): - fks.append( - { - "name": row["id"], # SQLite assigns an integer id - "constrained_columns": [row["from"]], - "referred_schema": None, - "referred_table": row["table"], - "referred_columns": [row["to"]], - "options": { - "onupdate": row["on_update"], - "ondelete": row["on_delete"], - }, - } - ) - return fks - except Exception as e: - raise RuntimeError( - f"Failed to fetch foreign keys for '{table_name}': {e}" + columns = super().get_columns(connection, table_name, schema, **kw) + pk_columns = [c for c in columns if c.get("primary_key")] + for column in columns: + column.setdefault( + "autoincrement", + len(pk_columns) == 1 + and column is pk_columns[0] + and isinstance(column["type"], sqltypes.Integer), ) - - @reflection.cache - def get_indexes(self, connection, table_name, schema=None, **kw): - """ - Return list of indexes for the given table. - Each index is a dict with keys: name, column_names, unique, primary_key - """ - try: - result = connection.execute( - text( - f"SELECT name, sql FROM sqlite_schema WHERE type='index' AND tbl_name='{table_name}';" - ) - ).mappings() - indexes = [] - for row in result.fetchall(): - sql = row["sql"] or "" - indexes.append( - { - "name": row["name"], - "column_names": self._parse_index_columns(sql), - "unique": "UNIQUE" in sql.upper(), - "primary_key": False, # primary keys handled separately - } - ) - return indexes - except Exception as e: - raise RuntimeError( - f"Failed to fetch indexes for '{table_name}': {e}" - ) - - @reflection.cache - def get_unique_constraints( - self, connection, table_name, schema=None, **kw - ): - """ - Return list of unique constraints for the table. - SQLite stores unique constraints as unique indexes. - """ - unique_constraints = [] - indexes = self.get_indexes(connection, table_name, schema=schema) - for idx in indexes: - if idx["unique"]: - unique_constraints.append( - { - "name": idx["name"], - "column_names": idx["column_names"], - } - ) - return unique_constraints - - # Helper to parse index columns - def _parse_index_columns(self, sql): - """ - Extract column names from CREATE INDEX SQL statement. - e.g., "CREATE UNIQUE INDEX idx_name ON mytable(col1, col2)" - """ - import re - - m = re.search(r"\((.*?)\)", sql) - if m: - return [c.strip().strip('"') for c in m.group(1).split(",")] - return [] - - @reflection.cache - def has_table(self, connection, table_name, schema=None, **kw): - """ - Return True if the table exists in the database. - """ - try: - result = connection.execute( - text( - "SELECT 1 FROM sqlite_master " - "WHERE type='table' AND name=:table_name;" - ), - {"table_name": table_name}, - ) - return result.scalar() is not None - except Exception as e: - raise RuntimeError( - f"Failed to check existence of table '{table_name}': {e}" - ) - - def get_check_constraints( - self, connection, table_name, schema=None, **kwargs - ): - return [] - - def get_table_comment(self, connection, table_name, schema=None, **kwargs): - return {"text": ""} - - def get_view_definition( - self, connection, view_name, schema=None, **kwargs - ): - pass - - def do_rollback(self, dbapi_connection): - pass - - def _resolve_type(self, d1_type: str): - """ - Map D1/SQLite type string to SQLAlchemy type. - """ - if d1_type is None: - return sqltypes.NullType() - t = d1_type.upper() - if "INT" in t: - return sqltypes.Integer() - elif "CHAR" in t or "CLOB" in t or "TEXT" in t: - return sqltypes.String() - elif "BLOB" in t: - return sqltypes.LargeBinary() - elif "REAL" in t or "FLOA" in t or "DOUB" in t: - return sqltypes.Float() - elif "NUMERIC" in t or "DECIMAL" in t: - return sqltypes.Numeric() - elif "BOOL" in t: - return sqltypes.Boolean() - else: - return sqltypes.String() # fallback + return columns + + def _get_column_type(self, type_string): + declared = (type_string or "").upper() + for needle, sqla_type in TYPE_OVERRIDES: + if needle in declared: + return sqla_type() + return super()._get_column_type(declared) diff --git a/src/sqlalchemy_d1/type_compiler.py b/src/sqlalchemy_d1/type_compiler.py index 2ea30d3..15b5a59 100644 --- a/src/sqlalchemy_d1/type_compiler.py +++ b/src/sqlalchemy_d1/type_compiler.py @@ -1,5 +1,25 @@ -from sqlalchemy.sql.type_api import TypeEngine +# sqlalchemy_d1/type_compiler.py +from sqlalchemy_cloudflare_d1.compiler import CloudflareD1TypeCompiler -class D1TypeCompiler(TypeEngine): - pass +class D1TypeCompiler(CloudflareD1TypeCompiler): + def _type_name(self, name, parent, type_, kw): + # Tables are created with the same types as upstream + if kw.get("type_expression") is not None: + return parent(type_, **kw) + return name + + def visit_DATETIME(self, type_, **kw): + return self._type_name("DATETIME", super().visit_DATETIME, type_, kw) + + def visit_TIMESTAMP(self, type_, **kw): + return self._type_name("TIMESTAMP", super().visit_TIMESTAMP, type_, kw) + + def visit_DATE(self, type_, **kw): + return self._type_name("DATE", super().visit_DATE, type_, kw) + + def visit_TIME(self, type_, **kw): + return self._type_name("TIME", super().visit_TIME, type_, kw) + + def visit_BOOLEAN(self, type_, **kw): + return self._type_name("BOOLEAN", super().visit_BOOLEAN, type_, kw) From a108abf5138f85915f18a2fc3a2b2681ac39d9d2 Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 13:47:36 -0400 Subject: [PATCH 02/14] test: added unit tests for the new dialect and type compiler --- tests/test_d1_dialect.py | 540 +++++++++++++++++---------------- tests/test_d1_type_compiler.py | 131 ++++++++ 2 files changed, 406 insertions(+), 265 deletions(-) create mode 100644 tests/test_d1_type_compiler.py diff --git a/tests/test_d1_dialect.py b/tests/test_d1_dialect.py index fc03315..344edcd 100644 --- a/tests/test_d1_dialect.py +++ b/tests/test_d1_dialect.py @@ -1,265 +1,275 @@ -import unittest - -from sqlalchemy import types as sqltypes -from sqlalchemy.engine import make_url - -import dbapi_d1 -from sqlalchemy_d1.dialect import D1Dialect - -class DummyResult: - def __init__(self, rows): - self._rows = rows - - def fetchall(self): - return self._rows - - def scalar(self): - if not self._rows: - return None - first = self._rows[0] - return first[0] if isinstance(first, tuple) else first - - -class DummyConnection: - def __init__(self, execute_impl): - self._execute_impl = execute_impl - self.last_execute_args = None - self.last_execute_kwargs = None - - def execute(self, query, *args, **kwargs): - self.last_execute_args = (query, args) - self.last_execute_kwargs = kwargs - return self._execute_impl(query, *args, **kwargs) - -class DummyMappingResult: - def __init__(self, rows): - self._rows = rows - - def mappings(self): - return self - - def fetchall(self): - return self._rows - -class D1DialectTestSuite(unittest.TestCase): - dialect = D1Dialect() - - def test_dbapi_returns_dbapi_d1_module(self): - self.assertIs(D1Dialect.dbapi(), dbapi_d1) - - def test_create_connect_args_parses_d1_url(self): - url = make_url("d1://acct123:secrettoken@mydatabase") - - pos, kw = self.dialect.create_connect_args(url) - - self.assertEqual(pos, ()) - self.assertEqual( - kw, - { - "account_id": "acct123", - "api_token": "secrettoken", - "database_id": "mydatabase", - }, - ) - - def test_get_table_names_filters_cf_internal_tables(self): - def execute_impl(query, *args, **kwargs): - rows = [ - ("test_table",), - ("_cf_internal_table",), - ("another_table",), - ] - return DummyResult(rows) - - conn = DummyConnection(execute_impl) - - tables = self.dialect.get_table_names(conn) - self.assertEqual(sorted(tables), ["another_table", "test_table"]) - - def test_get_view_names_filters_cf_internal_views(self): - def execute_impl(query, *args, **kwargs): - rows = [ - ("my_view",), - ("_cf_view_internal",), - ] - return DummyResult(rows) - - conn = DummyConnection(execute_impl) - - views = self.dialect.get_view_names(conn) - self.assertEqual(views, ["my_view"]) - - def test_get_columns_maps_basic_sqlite_types(self): - def execute_impl(query, *args, **kwargs): - rows = [ - { - "name": "id", - "type": "INTEGER", - "notnull": 1, - "dflt_value": None, - "pk": 1, - }, - { - "name": "name", - "type": "TEXT", - "notnull": 0, - "dflt_value": "'anonymous'", - "pk": 0, - }, - { - "name": "is_active", - "type": "BOOLEAN", - "notnull": 1, - "dflt_value": "1", - "pk": 0, - }, - ] - return DummyMappingResult(rows) - - conn = DummyConnection(execute_impl) - - cols = self.dialect.get_columns(conn, "mytable") - - self.assertEqual(cols[0]["name"], "id") - self.assertIsInstance(cols[0]["type"], sqltypes.Integer) - self.assertFalse(cols[0]["nullable"]) - self.assertIsNone(cols[0]["default"]) - self.assertTrue(cols[0]["autoincrement"]) - - self.assertEqual(cols[1]["name"], "name") - self.assertIsInstance(cols[1]["type"], sqltypes.String) - self.assertTrue(cols[1]["nullable"]) - self.assertEqual(cols[1]["default"], "'anonymous'") - self.assertFalse(cols[1]["autoincrement"]) - - self.assertEqual(cols[2]["name"], "is_active") - self.assertIsInstance(cols[2]["type"], sqltypes.Boolean) - self.assertFalse(cols[2]["nullable"]) - self.assertEqual(cols[2]["default"], "1") - self.assertFalse(cols[2]["autoincrement"]) - - def test_resolve_type_fallbacks(self): - self.assertIsInstance(self.dialect._resolve_type("INTEGER"), sqltypes.Integer) - self.assertIsInstance(self.dialect._resolve_type("TEXT"), sqltypes.String) - self.assertIsInstance( - self.dialect._resolve_type("BLOB"), sqltypes.LargeBinary - ) - self.assertIsInstance(self.dialect._resolve_type("REAL"), sqltypes.Float) - self.assertIsInstance(self.dialect._resolve_type("NUMERIC"), sqltypes.Numeric) - self.assertIsInstance(self.dialect._resolve_type("BOOLEAN"), sqltypes.Boolean) - self.assertIsInstance( - self.dialect._resolve_type("FOO_CUSTOM"), sqltypes.String - ) - self.assertIsInstance(self.dialect._resolve_type(None), sqltypes.NullType) - - def test_get_pk_constraint_builds_constrained_columns(self): - def execute_impl(query, *args, **kwargs): - rows = [ - {"name": "id", "type": "INTEGER", "notnull": 1, "dflt_value": None, "pk": 1}, - {"name": "email", "type": "TEXT", "notnull": 1, "dflt_value": None, "pk": 0}, - ] - return DummyMappingResult(rows) - - conn = DummyConnection(execute_impl) - - pk = self.dialect.get_pk_constraint(conn, "users") - - self.assertEqual(pk["constrained_columns"], ["id"]) - self.assertIsNone(pk["name"]) - - def test_get_foreign_keys_parses_foreign_key_list(self): - def execute_impl(query, *args, **kwargs): - rows = [ - { - "id": 0, - "seq": 0, - "table": "parent_table", - "from": "parent_id", - "to": "id", - "on_update": "CASCADE", - "on_delete": "SET NULL", - "match": "NONE", - } - ] - return DummyMappingResult(rows) - - conn = DummyConnection(execute_impl) - - fks = self.dialect.get_foreign_keys(conn, "child_table") - - self.assertEqual(len(fks), 1) - fk = fks[0] - self.assertEqual(fk["name"], 0) - self.assertEqual(fk["constrained_columns"], ["parent_id"]) - self.assertEqual(fk["referred_table"], "parent_table") - self.assertEqual(fk["referred_columns"], ["id"]) - self.assertEqual( - fk["options"], - {"onupdate": "CASCADE", "ondelete": "SET NULL"}, - ) - - def test_get_indexes_and_unique_constraints(self): - def execute_impl(query, *args, **kwargs): - rows = [ - { - "name": "idx_users_email", - "sql": "CREATE UNIQUE INDEX idx_users_email ON users(email)", - }, - { - "name": "idx_users_created_at", - "sql": "CREATE INDEX idx_users_created_at ON users(created_at)", - }, - { - "name": "idx_no_sql", - "sql": None, - }, - ] - return DummyMappingResult(rows) - - conn = DummyConnection(execute_impl) - - indexes = self.dialect.get_indexes(conn, "users") - self.assertEqual(len(indexes), 3) - - idx0 = indexes[0] - self.assertEqual(idx0["name"], "idx_users_email") - self.assertEqual(idx0["column_names"], ["email"]) - self.assertTrue(idx0["unique"]) - self.assertFalse(idx0["primary_key"]) - - idx1 = indexes[1] - self.assertEqual(idx1["name"], "idx_users_created_at") - self.assertEqual(idx1["column_names"], ["created_at"]) - self.assertFalse(idx1["unique"]) - - idx2 = indexes[2] - self.assertEqual(idx2["name"], "idx_no_sql") - self.assertEqual(idx2["column_names"], []) - self.assertFalse(idx2["unique"]) - - uniques = self.dialect.get_unique_constraints(conn, "users") - self.assertEqual(len(uniques), 1) - uc = uniques[0] - self.assertEqual(uc["name"], "idx_users_email") - self.assertEqual(uc["column_names"], ["email"]) - - def test_has_table_true_when_scalar_not_none(self): - def execute_impl(query, *args, **kwargs): - return DummyResult([(1,)]) - - conn = DummyConnection(execute_impl) - - exists = self.dialect.has_table(conn, "users") - self.assertTrue(exists) - - def test_has_table_false_when_scalar_is_none(self): - def execute_impl(query, *args, **kwargs): - return DummyResult([]) - - conn = DummyConnection(execute_impl) - - exists = self.dialect.has_table(conn, "missing_table") - self.assertFalse(exists) - -if __name__ == "__main__": - unittest.main() \ No newline at end of file +import inspect +import unittest +from datetime import date, datetime +from importlib.metadata import entry_points +from unittest import mock + +from sqlalchemy import create_engine, types as sqltypes +from sqlalchemy.dialects import registry +from sqlalchemy.engine import make_url +from sqlalchemy_cloudflare_d1.compiler import CloudflareD1TypeCompiler +from sqlalchemy_cloudflare_d1.dialect import ( + CloudflareD1Dialect, + D1Boolean, + D1Date, + D1DateTime, + D1Time, +) + +import sqlalchemy_d1 +from sqlalchemy_d1.dialect import D1Dialect + + +class DummyResult: + def __init__(self, rows): + self._rows = rows + + def __iter__(self): + return iter(self._rows) + + def fetchall(self): + return self._rows + + +class DummyConnection: + def __init__(self, execute_impl): + self._execute_impl = execute_impl + self.last_execute_args = None + self.last_execute_kwargs = None + + def execute(self, query, *args, **kwargs): + self.last_execute_args = (query, args) + self.last_execute_kwargs = kwargs + return self._execute_impl(query, *args, **kwargs) + + +class D1DialectTestSuite(unittest.TestCase): + dialect = D1Dialect() + + def test_registry_loads_d1_dialect(self): + self.assertIs(registry.load("d1"), D1Dialect) + + def test_entry_point_points_at_d1_dialect(self): + eps = entry_points(group="sqlalchemy.dialects") + self.assertEqual(eps["d1"].value, "sqlalchemy_d1.dialect:D1Dialect") + + def test_package_exports_dialect_and_version(self): + self.assertIs(sqlalchemy_d1.D1Dialect, D1Dialect) + self.assertEqual(sqlalchemy_d1.__version__, "0.2.0") + + def test_create_engine_builds_d1_dialect(self): + engine = create_engine("d1://acct:tok@dbid") + + self.assertIsInstance(engine.dialect, D1Dialect) + self.assertIsInstance(engine.dialect, CloudflareD1Dialect) + self.assertEqual(engine.dialect.name, "d1") + + def test_statement_cache_is_enabled(self): + self.assertTrue(vars(D1Dialect)["supports_statement_cache"]) + + def test_create_connect_args_parses_d1_url(self): + url = make_url("d1://acct:tok@dbid") + + pos, kw = self.dialect.create_connect_args(url) + + self.assertEqual(pos, ()) + self.assertEqual( + kw, + { + "account_id": "acct", + "api_token": "tok", + "database_id": "dbid", + }, + ) + + def test_get_schema_names_returns_main(self): + conn = DummyConnection(lambda query, *args, **kwargs: DummyResult([])) + + self.assertEqual(self.dialect.get_schema_names(conn), ["main"]) + + def test_get_table_names_filters_cf_internal_tables(self): + conn = DummyConnection(lambda query, *args, **kwargs: DummyResult([])) + + with mock.patch.object( + CloudflareD1Dialect, + "get_table_names", + return_value=["_cf_KV", "another_table", "test_table"], + ): + tables = self.dialect.get_table_names(conn) + + self.assertEqual(tables, ["another_table", "test_table"]) + + def test_get_view_names_filters_cf_internal_views(self): + def execute_impl(query, *args, **kwargs): + rows = [ + ("_cf_view_internal",), + ("my_view",), + ] + return DummyResult(rows) + + conn = DummyConnection(execute_impl) + + views = self.dialect.get_view_names(conn) + self.assertEqual(views, ["my_view"]) + + def test_get_view_names_returns_empty_list_without_views(self): + conn = DummyConnection(lambda query, *args, **kwargs: DummyResult([])) + + self.assertEqual(self.dialect.get_view_names(conn), []) + + def test_get_view_names_wraps_errors(self): + def execute_impl(query, *args, **kwargs): + raise ValueError("boom") + + conn = DummyConnection(execute_impl) + + with self.assertRaises(RuntimeError): + self.dialect.get_view_names(conn) + + def test_get_column_type_maps_date_and_boolean_types(self): + cases = [ + ("DATETIME", D1DateTime), + ("datetime", D1DateTime), + ("TIMESTAMP", D1DateTime), + ("DATE", D1Date), + ("TIME", D1Time), + ("BOOLEAN", D1Boolean), + ("BOOL", D1Boolean), + ] + for declared, expected in cases: + self.assertIsInstance( + self.dialect._get_column_type(declared), expected + ) + + def test_get_column_type_leaves_other_types_to_upstream(self): + upstream = CloudflareD1Dialect() + for declared in ["VARCHAR(50)", "INTEGER", "REAL", "BLOB", "FOO", ""]: + self.assertIs( + type(self.dialect._get_column_type(declared)), + type(upstream._get_column_type(declared)), + ) + self.assertIsInstance( + self.dialect._get_column_type("VARCHAR(50)"), sqltypes.TEXT + ) + self.assertIsInstance( + self.dialect._get_column_type("INTEGER"), sqltypes.INTEGER + ) + self.assertIsInstance( + self.dialect._get_column_type("REAL"), sqltypes.REAL + ) + + def test_get_column_type_handles_missing_type(self): + self.assertIsInstance( + self.dialect._get_column_type(None), sqltypes.TEXT + ) + + def test_get_columns_reflects_types_and_autoincrement(self): + def execute_impl(query, *args, **kwargs): + rows = [ + (0, "id", "INTEGER", 1, None, 1), + (1, "name", "TEXT", 0, "'anonymous'", 0), + (2, "created_at", "DATETIME", 0, None, 0), + (3, "day", "DATE", 0, None, 0), + (4, "is_active", "BOOLEAN", 1, "1", 0), + ] + return DummyResult(rows) + + conn = DummyConnection(execute_impl) + + cols = self.dialect.get_columns(conn, "mytable") + + self.assertEqual( + [c["name"] for c in cols], + ["id", "name", "created_at", "day", "is_active"], + ) + self.assertIsInstance(cols[0]["type"], sqltypes.INTEGER) + self.assertIsInstance(cols[1]["type"], sqltypes.TEXT) + self.assertIsInstance(cols[2]["type"], D1DateTime) + self.assertIsInstance(cols[3]["type"], D1Date) + self.assertIsInstance(cols[4]["type"], D1Boolean) + self.assertFalse(cols[0]["nullable"]) + self.assertEqual(cols[1]["default"], "'anonymous'") + self.assertTrue(cols[0]["primary_key"]) + self.assertEqual( + [c["autoincrement"] for c in cols], + [True, False, False, False, False], + ) + + def test_autoincrement_false_for_composite_primary_key(self): + def execute_impl(query, *args, **kwargs): + rows = [ + (0, "a", "INTEGER", 1, None, 1), + (1, "b", "INTEGER", 1, None, 2), + (2, "c", "TEXT", 0, None, 0), + ] + return DummyResult(rows) + + conn = DummyConnection(execute_impl) + + cols = self.dialect.get_columns(conn, "mytable") + self.assertEqual( + [c["autoincrement"] for c in cols], [False, False, False] + ) + + def test_autoincrement_false_for_text_primary_key(self): + def execute_impl(query, *args, **kwargs): + rows = [ + (0, "code", "TEXT", 1, None, 1), + (1, "amount", "INTEGER", 0, None, 0), + ] + return DummyResult(rows) + + conn = DummyConnection(execute_impl) + + cols = self.dialect.get_columns(conn, "mytable") + self.assertEqual([c["autoincrement"] for c in cols], [False, False]) + + +class UpstreamGuardTestSuite(unittest.TestCase): + def test_get_column_type_takes_one_argument(self): + params = inspect.signature( + CloudflareD1Dialect._get_column_type + ).parameters + self.assertEqual(len(params), 2) + + def test_upstream_has_no_get_view_names(self): + self.assertNotIn("get_view_names", vars(CloudflareD1Dialect)) + + def test_type_compiler_has_visit_methods(self): + for name in [ + "visit_DATETIME", + "visit_TIMESTAMP", + "visit_DATE", + "visit_TIME", + "visit_BOOLEAN", + ]: + self.assertTrue(callable(getattr(CloudflareD1TypeCompiler, name))) + + +class D1ResultProcessorTestSuite(unittest.TestCase): + dialect = D1Dialect() + messy_values = [1758448800, "", "09/21/2026", None] + + def test_datetime_parses_iso_string(self): + process = D1DateTime().result_processor(self.dialect, None) + self.assertEqual( + process("2026-09-21 10:00:00"), datetime(2026, 9, 21, 10, 0) + ) + + def test_datetime_returns_messy_values_unchanged(self): + process = D1DateTime().result_processor(self.dialect, None) + for value in self.messy_values: + self.assertEqual(process(value), value) + + def test_date_parses_iso_string(self): + process = D1Date().result_processor(self.dialect, None) + self.assertEqual(process("2026-09-21"), date(2026, 9, 21)) + + def test_date_returns_messy_values_unchanged(self): + process = D1Date().result_processor(self.dialect, None) + for value in self.messy_values: + self.assertEqual(process(value), value) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_d1_type_compiler.py b/tests/test_d1_type_compiler.py new file mode 100644 index 0000000..633a641 --- /dev/null +++ b/tests/test_d1_type_compiler.py @@ -0,0 +1,131 @@ +import re +import unittest + +from sqlalchemy import ( + TIMESTAMP, + Boolean, + Column, + Date, + DateTime, + Integer, + MetaData, + String, + Table, + Time, + cast, + literal_column, + select, +) +from sqlalchemy.schema import CreateTable +from sqlalchemy_cloudflare_d1.dialect import ( + CloudflareD1Dialect, + D1Boolean, + D1Date, + D1DateTime, + D1Time, +) + +from sqlalchemy_d1.dialect import D1Dialect + +SUPERSET_TEMPORAL = [ + re.compile(r"^timestamp", re.IGNORECASE), + re.compile(r"^datetime", re.IGNORECASE), + re.compile(r"^date", re.IGNORECASE), + re.compile(r"^time", re.IGNORECASE), +] +SUPERSET_BOOLEAN = [re.compile(r"^bool(ean)?", re.IGNORECASE)] +SUPERSET_STRING = [re.compile(r"^(tiny|medium|long)?text", re.IGNORECASE)] + + +class DummySupersetColumnSpec: + def __init__(self, dialect): + self._dialect = dialect + + def type_string(self, sqla_type): + return sqla_type.compile(dialect=self._dialect).upper() + + def matches(self, sqla_type, patterns): + name = self.type_string(sqla_type) + return any(p.match(name) for p in patterns) + + +class D1TypeCompilerTestSuite(unittest.TestCase): + dialect = D1Dialect() + upstream = CloudflareD1Dialect() + + def build_table(self, **kw): + return Table( + "events", + MetaData(), + Column("id", Integer, primary_key=True), + Column("name", String(50)), + Column("created_at", DateTime), + Column("updated_at", TIMESTAMP), + Column("day", Date), + Column("at", Time), + Column("active", Boolean), + **kw, + ) + + def test_bare_types_compile_to_real_names(self): + cases = [ + (D1DateTime(), "DATETIME"), + (D1Date(), "DATE"), + (D1Time(), "TIME"), + (D1Boolean(), "BOOLEAN"), + (TIMESTAMP(), "TIMESTAMP"), + (DateTime(), "DATETIME"), + (Date(), "DATE"), + (Time(), "TIME"), + (Boolean(), "BOOLEAN"), + ] + for sqla_type, expected in cases: + self.assertEqual(sqla_type.compile(dialect=self.dialect), expected) + + def test_other_types_compile_like_upstream(self): + for sqla_type in [Integer(), String(), String(50)]: + self.assertEqual( + sqla_type.compile(dialect=self.dialect), + sqla_type.compile(dialect=self.upstream), + ) + + def test_superset_sees_dates_and_booleans(self): + spec = DummySupersetColumnSpec(self.dialect) + for sqla_type in [D1DateTime(), D1Date(), D1Time(), TIMESTAMP()]: + self.assertTrue(spec.matches(sqla_type, SUPERSET_TEMPORAL)) + self.assertFalse(spec.matches(sqla_type, SUPERSET_STRING)) + self.assertTrue(spec.matches(D1Boolean(), SUPERSET_BOOLEAN)) + + def test_create_table_matches_upstream(self): + table = self.build_table() + + ours = str(CreateTable(table).compile(dialect=self.dialect)) + theirs = str(CreateTable(table).compile(dialect=self.upstream)) + + self.assertEqual(ours, theirs) + self.assertNotIn("DATETIME", ours) + self.assertNotIn("TIMESTAMP", ours) + self.assertNotIn("BOOLEAN", ours) + + def test_create_strict_table_matches_upstream(self): + table = self.build_table(sqlite_strict=True) + + ours = str(CreateTable(table).compile(dialect=self.dialect)) + theirs = str(CreateTable(table).compile(dialect=self.upstream)) + + self.assertEqual(ours, theirs) + self.assertIn("STRICT", ours) + self.assertNotIn("DATETIME", ours) + self.assertNotIn("BOOLEAN", ours) + + def test_cast_matches_upstream(self): + for sqla_type in [DateTime, Date, Boolean, Integer, String]: + stmt = select(cast(literal_column("x"), sqla_type)) + self.assertEqual( + str(stmt.compile(dialect=self.dialect)), + str(stmt.compile(dialect=self.upstream)), + ) + + +if __name__ == "__main__": + unittest.main() From 0691a8791bcdbda70680badc818cafcbe0f63013 Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 13:47:36 -0400 Subject: [PATCH 03/14] test: added integration tests against a real D1 database --- tests/test_d1_integration.py | 218 +++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 tests/test_d1_integration.py diff --git a/tests/test_d1_integration.py b/tests/test_d1_integration.py new file mode 100644 index 0000000..11ee511 --- /dev/null +++ b/tests/test_d1_integration.py @@ -0,0 +1,218 @@ +import os +import unittest +import uuid +from datetime import date, datetime + +import pytest +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Integer, + MetaData, + Table, + create_engine, + inspect, + select, + text, + types as sqltypes, +) +from sqlalchemy.engine import URL +from sqlalchemy.exc import DBAPIError +from sqlalchemy_cloudflare_d1.dialect import D1Boolean, D1Date, D1DateTime + +COLUMN_NAMES = ["id", "name", "created_at", "day", "active"] + + +@pytest.mark.integration +@unittest.skipUnless( + os.environ.get("CF_D1_DATABASE_ID"), "CF_D1_DATABASE_ID is not set" +) +class D1IntegrationTestSuite(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.suffix = uuid.uuid4().hex[:8] + cls.table_name = f"sqlalchemy_d1_test_{cls.suffix}" + cls.view_name = f"sqlalchemy_d1_test_view_{cls.suffix}" + cls.engine = create_engine( + URL.create( + "d1", + username=os.environ["CF_ACCOUNT_ID"], + password=os.environ["CF_API_TOKEN"], + host=os.environ["CF_D1_DATABASE_ID"], + ) + ) + cls.addClassCleanup(cls.drop_all) + with cls.engine.begin() as conn: + conn.execute( + text( + f"CREATE TABLE {cls.table_name} (" + "id INTEGER PRIMARY KEY, name TEXT, " + "created_at DATETIME, day DATE, active BOOLEAN)" + ) + ) + conn.execute( + text( + f"INSERT INTO {cls.table_name} " + "(id, name, created_at, day, active) VALUES " + "(:id, :name, :created_at, :day, :active)" + ), + [ + { + "id": 1, + "name": "first", + "created_at": "2026-09-21 10:00:00", + "day": "2026-09-21", + "active": 1, + }, + { + "id": 2, + "name": "second", + "created_at": "2026-09-22 11:30:00", + "day": "2026-09-22", + "active": 0, + }, + ], + ) + conn.execute( + text( + f"CREATE VIEW {cls.view_name} AS " + f"SELECT id, name, created_at FROM {cls.table_name}" + ) + ) + + @classmethod + def drop_all(cls): + with cls.engine.begin() as conn: + conn.execute(text(f"DROP VIEW IF EXISTS {cls.view_name}")) + conn.execute(text(f"DROP TABLE IF EXISTS {cls.table_name}")) + cls.engine.dispose() + + def test_get_columns_reflects_declared_types(self): + cols = inspect(self.engine).get_columns(self.table_name) + + self.assertEqual([c["name"] for c in cols], COLUMN_NAMES) + self.assertIsInstance(cols[0]["type"], sqltypes.INTEGER) + self.assertIsInstance(cols[1]["type"], sqltypes.TEXT) + self.assertIsInstance(cols[2]["type"], D1DateTime) + self.assertIsInstance(cols[3]["type"], D1Date) + self.assertIsInstance(cols[4]["type"], D1Boolean) + self.assertEqual( + [c["autoincrement"] for c in cols], + [True, False, False, False, False], + ) + + def test_zero_row_select_keeps_column_names(self): + with self.engine.connect() as conn: + result = conn.execute( + text(f"SELECT * FROM {self.table_name} WHERE 1=0") + ) + self.assertEqual(list(result.keys()), COLUMN_NAMES) + self.assertEqual(result.all(), []) + + def test_reflected_columns_return_python_values(self): + table = Table(self.table_name, MetaData(), autoload_with=self.engine) + + with self.engine.connect() as conn: + rows = conn.execute(select(table).order_by(table.c.id)).all() + + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0].created_at, datetime(2026, 9, 21, 10, 0)) + self.assertEqual(rows[0].day, date(2026, 9, 21)) + self.assertIs(rows[0].active, True) + self.assertEqual(rows[1].created_at, datetime(2026, 9, 22, 11, 30)) + self.assertIs(rows[1].active, False) + + def test_messy_date_values_do_not_raise(self): + name = f"sqlalchemy_d1_test_messy_{self.suffix}" + try: + with self.engine.begin() as conn: + conn.execute( + text( + f"CREATE TABLE {name} " + "(id INTEGER PRIMARY KEY, created_at DATETIME)" + ) + ) + conn.execute( + text( + f"INSERT INTO {name} (id, created_at) VALUES " + "(1, 1758448800), (2, ''), " + "(3, '09/21/2026'), (4, NULL)" + ) + ) + table = Table(name, MetaData(), autoload_with=self.engine) + with self.engine.connect() as conn: + rows = conn.execute(select(table).order_by(table.c.id)).all() + + self.assertIsInstance(table.c.created_at.type, D1DateTime) + self.assertEqual( + [r.created_at for r in rows], + [1758448800, "", "09/21/2026", None], + ) + finally: + with self.engine.begin() as conn: + conn.execute(text(f"DROP TABLE IF EXISTS {name}")) + + def test_cf_internal_tables_are_hidden(self): + tables = inspect(self.engine).get_table_names() + + self.assertIn(self.table_name, tables) + self.assertEqual([t for t in tables if t.startswith("_cf_")], []) + + def test_views_are_listed_and_reflected(self): + inspector = inspect(self.engine) + + self.assertIn(self.view_name, inspector.get_view_names()) + self.assertNotIn(self.view_name, inspector.get_table_names()) + + cols = inspector.get_columns(self.view_name) + self.assertEqual( + [c["name"] for c in cols], ["id", "name", "created_at"] + ) + self.assertIsInstance(cols[2]["type"], D1DateTime) + + def test_cf_prefixed_view_is_refused_or_hidden(self): + name = f"_cf_sqlalchemy_d1_test_{self.suffix}" + with self.engine.connect() as conn: + try: + conn.execute(text(f"CREATE VIEW {name} AS SELECT 1 AS x")) + except DBAPIError: + return + try: + self.assertNotIn(name, inspect(self.engine).get_view_names()) + finally: + conn.execute(text(f"DROP VIEW IF EXISTS {name}")) + + def test_create_all_round_trips_and_reflects_as_text(self): + name = f"sqlalchemy_d1_test_created_{self.suffix}" + metadata = MetaData() + table = Table( + name, + metadata, + Column("id", Integer, primary_key=True), + Column("created_at", DateTime), + Column("active", Boolean), + ) + value = datetime(2026, 9, 21, 10, 30, 15) + try: + metadata.create_all(self.engine) + with self.engine.begin() as conn: + conn.execute( + table.insert(), + {"id": 1, "created_at": value, "active": True}, + ) + with self.engine.connect() as conn: + row = conn.execute(select(table)).one() + + self.assertEqual(row.created_at, value) + self.assertIs(row.active, True) + + cols = inspect(self.engine).get_columns(name) + self.assertIsInstance(cols[1]["type"], sqltypes.TEXT) + self.assertIsInstance(cols[2]["type"], sqltypes.INTEGER) + finally: + metadata.drop_all(self.engine) + + +if __name__ == "__main__": + unittest.main() From dd4e4acdd87a6d61bcce3e3f3762495ef17aa963 Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 13:47:36 -0400 Subject: [PATCH 04/14] chore: added Apache-2.0 license file and metadata --- LICENSE | 202 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 + 2 files changed, 204 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/pyproject.toml b/pyproject.toml index d1d91c8..6884047 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ maintainers = [ {name = "Daniel Alyoshin",email = "daniel.alyoshin@gmail.com"} ] readme = "README.md" +license = "Apache-2.0" +license-files = ["LICENSE"] requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3", From f02aec1bb4443c9900f6b4a5a1ce6fb973cbb2e0 Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 13:47:36 -0400 Subject: [PATCH 05/14] ci: added lint, type check and test workflow --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea43b8b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: pip install -e ".[dev]" + - name: Lint + run: | + ruff check . + ruff format --check . + - name: Type check + run: mypy src + - name: Unit tests + run: pytest -m "not integration" --disable-socket + + integration: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install + run: pip install -e ".[dev]" + - name: Integration tests + env: + CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + CF_D1_DATABASE_ID: ${{ secrets.CF_D1_DATABASE_ID }} + run: pytest -m integration From 02e7ad9216a4a704f608ba25fbe8adbc51e48499 Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 13:47:36 -0400 Subject: [PATCH 06/14] docs: added README and CHANGELOG --- CHANGELOG.md | 34 ++++++++++++++++++ README.md | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a793e60 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - Unreleased + +### Changed + +* Rewrote `D1Dialect` as a subclass of `CloudflareD1Dialect` from `sqlalchemy-cloudflare-d1`. The `d1://` connection string and the `d1` entry point are unchanged. +* Requires **SQLAlchemy 2.0** (`>=2.0,<2.1`). Version 0.1.0 stays available for SQLAlchemy 1.4. +* Lifted the Python cap. Python 3.11 or newer is supported. + +### Added + +* Real type names for bare `DATETIME`, `TIMESTAMP`, `DATE`, `TIME` and `BOOLEAN` types, so Apache Superset marks those columns as dates and booleans. Tables created through SQLAlchemy are still declared as `TEXT` and `INTEGER`, the same as upstream. +* Reflection of columns declared as `DATETIME`, `TIMESTAMP`, `DATE`, `TIME` or `BOOLEAN`. +* `autoincrement` on reflected columns. +* `get_view_names`, and `get_schema_names` returning `main`. Upstream has neither. +* Tables and views that start with `_cf_` are hidden from the table and view lists. +* `LICENSE` file and license metadata. The package is licensed under **Apache License 2.0**. +* Unit tests that run offline, integration tests against a real D1 database, and a CI workflow. + +### Removed + +* The dependency on `dbapi-d1`. The driver built into `sqlalchemy-cloudflare-d1` replaces it. +* The original reflection code and the empty `compiler.py` module. + +## [0.1.0] - 2025-11-30 + +### Added + +* First release. `D1Dialect` built on `DefaultDialect` with the `dbapi-d1` driver, for SQLAlchemy 1.4. diff --git a/README.md b/README.md index e69de29..cbf22e7 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,99 @@ +# sqlalchemy-d1 + +A **SQLAlchemy dialect** for **Cloudflare D1** that keeps the `d1://` connection string working in **Apache Superset**. + +Since version 0.2.0 this package is a thin layer over [sqlalchemy-cloudflare-d1](https://github.com/CollierKing/sqlalchemy-cloudflare-d1). That project is the real dialect and the real driver. This package registers it under the `d1` name and adds the few things Superset needs. + +--- + +## Why this package exists + +Superset ships a built-in **Cloudflare D1** engine spec that expects `d1://` connection strings. See the [Superset D1 docs page](https://superset.apache.org/user-docs/databases/supported/cloudflare-d1/). + +Version 0.1.0 of this package only worked with SQLAlchemy 1.4. Superset has moved to SQLAlchemy 2.0, and its [UPDATING.md](https://github.com/apache/superset/blob/master/UPDATING.md) lists `d1` among the connectors that were held back because of that. Version 0.2.0 removes the block. + +If you do not use Superset, install `sqlalchemy-cloudflare-d1` directly and use its `cloudflare_d1://` connection string. + +--- + +## Installation + +```bash +pip install sqlalchemy-d1 +``` + +This also installs `sqlalchemy-cloudflare-d1` and SQLAlchemy 2.0. Python 3.11 or newer is required. + +--- + +## Usage + +```python +from sqlalchemy import create_engine, text + +engine = create_engine("d1://:@") + +with engine.connect() as conn: + print(conn.execute(text("SELECT 1")).scalar()) +``` + +In Superset, use the same URL as the SQLAlchemy URI of the database connection. + +--- + +## What it adds + +| Feature | Description | +|---------|-------------| +| `d1://` name | Registers the upstream dialect under the `d1` name that Superset uses. | +| Date and boolean type names | A bare `DATETIME`, `TIMESTAMP`, `DATE`, `TIME` or `BOOLEAN` type prints its real name. Superset reads that name to decide if a column is a date. | +| Date and boolean reflection | Columns declared as `DATETIME`, `TIMESTAMP`, `DATE`, `TIME` or `BOOLEAN` are reflected as those types. Upstream reflects them as `TEXT`. | +| `autoincrement` | Reflected columns report `autoincrement`. It is true for a single `INTEGER PRIMARY KEY` column. | +| Schema and view listing | `get_schema_names` returns `main` and `get_view_names` lists views. Upstream has neither. | +| Internal tables hidden | Tables and views that start with `_cf_`, such as `_cf_KV`, are left out of the lists. | + +Everything else comes from `sqlalchemy-cloudflare-d1` unchanged. That includes the connection, the cursor, the SQL compiler and the rest of reflection. + +--- + +## Known limit + +Tables **created through SQLAlchemy** are identical to the ones upstream creates. A `DateTime` column is declared as `TEXT` and a `Boolean` column as `INTEGER`, so they are reflected as text and integer afterwards. Values still round trip correctly when you use the same `Table` object. + +Tables created with plain SQL and a `DATETIME` column, which is the normal case for D1, are reflected as dates. + +--- + +## Development + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` + +With **Poetry**, run `poetry install --all-extras` instead. + +Run the checks: + +```bash +ruff check . && ruff format --check . && mypy src +pytest -m "not integration" --disable-socket +``` + +The integration tests need a real D1 database. Use a throwaway one. They create and drop their own tables. + +```bash +export CF_ACCOUNT_ID= +export CF_API_TOKEN= +export CF_D1_DATABASE_ID= +pytest -m integration +``` + +--- + +## License + +This package is licensed under the **Apache License 2.0**. See [LICENSE](LICENSE). + +`sqlalchemy-cloudflare-d1` is a separate project under the MIT license. It is a dependency and none of its code is copied here. From 813fa41b21349139af089bf5bc9f6be808fa9912 Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 14:37:54 -0400 Subject: [PATCH 07/14] docs: added install note for SQLAlchemy 1.4 users --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index cbf22e7..d995031 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ pip install sqlalchemy-d1 This also installs `sqlalchemy-cloudflare-d1` and SQLAlchemy 2.0. Python 3.11 or newer is required. +If your Superset still uses **SQLAlchemy 1.4** (Superset 6.1.0, for example), stay on version 0.1.0. Newer versions would upgrade SQLAlchemy and break it. + +```bash +pip install "sqlalchemy-d1==0.1.0" +``` + --- ## Usage From 897a0c35c132f144c8e7d5dc713bd31c1e0581d1 Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 16:05:20 -0400 Subject: [PATCH 08/14] ci: added release workflow for PyPI trusted publishing --- .github/workflows/release.yml | 52 +++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8eff091 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Check tag matches version + run: | + version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + if [ "v$version" != "$GITHUB_REF_NAME" ]; then + echo "Tag $GITHUB_REF_NAME does not match version $version" + exit 1 + fi + - name: Install + run: pip install -e ".[dev]" build twine + - name: Unit tests + run: pytest -m "not integration" --disable-socket + - name: Build + run: python -m build + - name: Check package + run: twine check dist/* + - uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/sqlalchemy-d1/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 From 534d4b6e7f07fefb975ac8871a18050926535654 Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 16:29:08 -0400 Subject: [PATCH 09/14] fix: corrected reflection, type names and connect args after code review - Reflected DECIMAL as numeric and matched declared types on the first word - Reported autoincrement only for a single INTEGER primary key column in a table that is not WITHOUT ROWID - Reflected composite primary keys in key order - Ignored URL query parameters so base_url cannot reach the driver - Let get_view_names errors propagate instead of raising RuntimeError - Added get_view_definition, get_table_comment and get_check_constraints - Made has_table true for views - Replaced the visit method overrides with reflection-only types in sqlalchemy_d1.types, so generic types compile the same as upstream - Fell back to a placeholder version when package metadata is missing - Moved the get_columns tests to an in-memory SQLite suite --- src/sqlalchemy_d1/__init__.py | 9 +- src/sqlalchemy_d1/dialect.py | 193 +++++++++++++++++++------- src/sqlalchemy_d1/type_compiler.py | 23 +--- src/sqlalchemy_d1/types.py | 37 +++++ tests/test_d1_dialect.py | 166 +++++++++++------------ tests/test_d1_integration.py | 5 + tests/test_d1_reflection.py | 211 +++++++++++++++++++++++++++++ tests/test_d1_type_compiler.py | 68 +++++++--- 8 files changed, 540 insertions(+), 172 deletions(-) create mode 100644 src/sqlalchemy_d1/types.py create mode 100644 tests/test_d1_reflection.py diff --git a/src/sqlalchemy_d1/__init__.py b/src/sqlalchemy_d1/__init__.py index 610d819..606bd49 100644 --- a/src/sqlalchemy_d1/__init__.py +++ b/src/sqlalchemy_d1/__init__.py @@ -1,12 +1,17 @@ """SQLAlchemy dialect for Cloudflare D1""" -from importlib.metadata import version +from importlib.metadata import PackageNotFoundError, version from sqlalchemy.dialects import registry from .dialect import D1Dialect -__version__ = version("sqlalchemy-d1") +try: + __version__ = version("sqlalchemy-d1") +except PackageNotFoundError: + # Running from a source tree or a vendored copy + __version__ = "0+unknown" + __all__ = ["D1Dialect", "__version__"] registry.register("d1", "sqlalchemy_d1.dialect", "D1Dialect") diff --git a/src/sqlalchemy_d1/dialect.py b/src/sqlalchemy_d1/dialect.py index 44b5101..1fefe9d 100644 --- a/src/sqlalchemy_d1/dialect.py +++ b/src/sqlalchemy_d1/dialect.py @@ -1,26 +1,33 @@ # sqlalchemy_d1/dialect.py -from sqlalchemy import text, types as sqltypes +import re + +from sqlalchemy import exc, text, types as sqltypes from sqlalchemy.engine import reflection -from sqlalchemy_cloudflare_d1.dialect import ( - CloudflareD1Dialect, - D1Boolean, - D1Date, - D1DateTime, - D1Time, -) +from sqlalchemy_cloudflare_d1.dialect import CloudflareD1Dialect +from . import types as d1types from .type_compiler import D1TypeCompiler INTERNAL_PREFIX = "_cf_" -# Checked in order, first match wins -TYPE_OVERRIDES = ( - ("DATETIME", D1DateTime), - ("TIMESTAMP", D1DateTime), - ("DATE", D1Date), - ("TIME", D1Time), - ("BOOL", D1Boolean), -) +# Keyed by the first word of the declared type +DECLARED_TYPES = { + "DATETIME": d1types.DATETIME, + "TIMESTAMP": d1types.TIMESTAMP, + "DATE": d1types.DATE, + "TIME": d1types.TIME, + "BOOLEAN": d1types.BOOLEAN, + "BOOL": d1types.BOOLEAN, + "DECIMAL": sqltypes.DECIMAL, +} + +# Table options come after the last closing bracket of CREATE TABLE +WITHOUT_ROWID = re.compile(r"\bWITHOUT\s+ROWID\b[^)]*$", re.IGNORECASE) + + +def _visible(names): + # Cloudflare keeps its own tables, such as _cf_KV, in the same database + return [n for n in names if not n.startswith(INTERNAL_PREFIX)] class D1Dialect(CloudflareD1Dialect): @@ -28,6 +35,19 @@ class D1Dialect(CloudflareD1Dialect): type_compiler = D1TypeCompiler supports_statement_cache = True + def create_connect_args(self, url): + # URL format: d1://:@ + # Query parameters are ignored. Upstream passes them to the driver, + # where base_url would send the API token to any host. + return ( + (), + { + "account_id": url.username, + "api_token": url.password, + "database_id": url.host, + }, + ) + @reflection.cache def get_schema_names(self, connection, **kwargs): # D1 is built on SQLite, which only uses one schema @@ -38,52 +58,129 @@ def get_table_names(self, connection, schema=None, **kw): """ Return list of table names in the D1 database. """ - all_tables = super().get_table_names(connection, schema, **kw) - # Filter out cloudflare tables - visible_tables = [ - t for t in all_tables if not t.startswith(INTERNAL_PREFIX) - ] - return visible_tables + return _visible(super().get_table_names(connection, schema, **kw)) @reflection.cache def get_view_names(self, connection, schema=None, **kw): """ Return list of view names in the D1 database. """ - try: - result = connection.execute( - text( - "SELECT name FROM sqlite_master " - "WHERE type='view' ORDER BY name" - ) + result = connection.execute( + text( + "SELECT name FROM sqlite_master " + "WHERE type='view' ORDER BY name" ) - all_views = [row[0] for row in result] - visible_views = [ - v for v in all_views if not v.startswith(INTERNAL_PREFIX) - ] - return visible_views - except Exception as e: - raise RuntimeError(f"Failed to fetch view names: {e}") + ) + return _visible([row[0] for row in result]) + + @reflection.cache + def get_view_definition(self, connection, view_name, schema=None, **kw): + """ + Return the CREATE VIEW statement of a view. + """ + result = connection.execute( + text( + "SELECT sql FROM sqlite_master " + "WHERE type='view' AND name=:name" + ), + {"name": view_name}, + ) + row = result.first() + if row is None: + raise exc.NoSuchTableError(view_name) + return row[0] + + @reflection.cache + def has_table(self, connection, table_name, schema=None, **kw): + """ + Return True if a table or a view with this name exists. + """ + # Upstream only looks for tables. SQLAlchemy 2.0 expects views too + result = connection.execute( + text( + "SELECT 1 FROM sqlite_master " + "WHERE type IN ('table', 'view') AND name=:name " + "AND name NOT LIKE 'sqlite_%'" + ), + {"name": table_name}, + ) + return result.first() is not None + + @reflection.cache + def _get_table_info(self, connection, table_name, schema=None, **kw): + # Rows are: cid, name, type, notnull, dflt_value, pk + quoted = self.identifier_preparer.quote_identifier(table_name) + result = connection.execute(text(f"PRAGMA table_info({quoted})")) + return [tuple(row) for row in result] @reflection.cache def get_columns(self, connection, table_name, schema=None, **kw): """ Return column info for a given table in D1. """ - columns = super().get_columns(connection, table_name, schema, **kw) - pk_columns = [c for c in columns if c.get("primary_key")] - for column in columns: - column.setdefault( - "autoincrement", - len(pk_columns) == 1 - and column is pk_columns[0] - and isinstance(column["type"], sqltypes.Integer), - ) - return columns + rows = self._get_table_info(connection, table_name, schema, **kw) + rowid_alias = self._get_rowid_alias(connection, table_name, rows) + return [ + { + "name": name, + "type": self._get_column_type(declared), + "nullable": not notnull, + "default": default, + "primary_key": bool(pk), + "autoincrement": name == rowid_alias, + } + for _cid, name, declared, notnull, default, pk in rows + ] + + @reflection.cache + def get_pk_constraint(self, connection, table_name, schema=None, **kw): + """ + Return the primary key columns in the order of the key. + """ + rows = self._get_table_info(connection, table_name, schema, **kw) + # pk is the position of the column inside the key, or 0 + pk_rows = sorted((r for r in rows if r[5]), key=lambda r: r[5]) + return { + "constrained_columns": [r[1] for r in pk_rows], + "name": None, + } + + def get_check_constraints(self, connection, table_name, schema=None, **kw): + # CHECK constraints are not reflected + return [] + + def get_table_comment(self, connection, table_name, schema=None, **kw): + # SQLite has no table comments + return {"text": None} + + def _get_rowid_alias(self, connection, table_name, rows): + """ + Return the name of the column that SQLite fills in by itself. + + That is a single primary key column declared as exactly INTEGER, + in a table that is not WITHOUT ROWID. + """ + pk_rows = [r for r in rows if r[5]] + if len(pk_rows) != 1: + return None + name, declared = pk_rows[0][1], pk_rows[0][2] + if (declared or "").strip().upper() != "INTEGER": + return None + table_sql = connection.execute( + text( + "SELECT sql FROM sqlite_master " + "WHERE type='table' AND name=:name" + ), + {"name": table_name}, + ).scalar() + if WITHOUT_ROWID.search(table_sql or ""): + return None + return name def _get_column_type(self, type_string): declared = (type_string or "").upper() - for needle, sqla_type in TYPE_OVERRIDES: - if needle in declared: - return sqla_type() + # Only the first word counts, so UPDATED_INT stays an integer + first_word = re.match(r"\s*(\w*)", declared).group(1) + if first_word in DECLARED_TYPES: + return DECLARED_TYPES[first_word]() return super()._get_column_type(declared) diff --git a/src/sqlalchemy_d1/type_compiler.py b/src/sqlalchemy_d1/type_compiler.py index 15b5a59..e9a9c4d 100644 --- a/src/sqlalchemy_d1/type_compiler.py +++ b/src/sqlalchemy_d1/type_compiler.py @@ -3,23 +3,6 @@ class D1TypeCompiler(CloudflareD1TypeCompiler): - def _type_name(self, name, parent, type_, kw): - # Tables are created with the same types as upstream - if kw.get("type_expression") is not None: - return parent(type_, **kw) - return name - - def visit_DATETIME(self, type_, **kw): - return self._type_name("DATETIME", super().visit_DATETIME, type_, kw) - - def visit_TIMESTAMP(self, type_, **kw): - return self._type_name("TIMESTAMP", super().visit_TIMESTAMP, type_, kw) - - def visit_DATE(self, type_, **kw): - return self._type_name("DATE", super().visit_DATE, type_, kw) - - def visit_TIME(self, type_, **kw): - return self._type_name("TIME", super().visit_TIME, type_, kw) - - def visit_BOOLEAN(self, type_, **kw): - return self._type_name("BOOLEAN", super().visit_BOOLEAN, type_, kw) + def visit_d1_declared(self, type_, **kw): + # Reflected types keep the name the column was declared with + return type(type_).__name__ diff --git a/src/sqlalchemy_d1/types.py b/src/sqlalchemy_d1/types.py new file mode 100644 index 0000000..84df25d --- /dev/null +++ b/src/sqlalchemy_d1/types.py @@ -0,0 +1,37 @@ +# sqlalchemy_d1/types.py +""" +Types that reflection returns for columns declared with a date or boolean +type name. + +Each one behaves like the upstream D1 type it extends, and compiles to its +class name, which is the name the column was declared with. Generic types +such as DateTime are left to upstream. +""" + +from sqlalchemy_cloudflare_d1.dialect import ( + D1Boolean, + D1Date, + D1DateTime, + D1Time, +) + + +# SQLAlchemy only picks up a __visit_name__ set in the class body itself +class DATETIME(D1DateTime): + __visit_name__ = "d1_declared" + + +class TIMESTAMP(D1DateTime): + __visit_name__ = "d1_declared" + + +class DATE(D1Date): + __visit_name__ = "d1_declared" + + +class TIME(D1Time): + __visit_name__ = "d1_declared" + + +class BOOLEAN(D1Boolean): + __visit_name__ = "d1_declared" diff --git a/tests/test_d1_dialect.py b/tests/test_d1_dialect.py index 344edcd..7f1a29d 100644 --- a/tests/test_d1_dialect.py +++ b/tests/test_d1_dialect.py @@ -1,13 +1,13 @@ +import importlib import inspect import unittest from datetime import date, datetime -from importlib.metadata import entry_points +from importlib.metadata import PackageNotFoundError, entry_points, version from unittest import mock from sqlalchemy import create_engine, types as sqltypes from sqlalchemy.dialects import registry from sqlalchemy.engine import make_url -from sqlalchemy_cloudflare_d1.compiler import CloudflareD1TypeCompiler from sqlalchemy_cloudflare_d1.dialect import ( CloudflareD1Dialect, D1Boolean, @@ -17,6 +17,7 @@ ) import sqlalchemy_d1 +from sqlalchemy_d1 import types as d1types from sqlalchemy_d1.dialect import D1Dialect @@ -55,7 +56,18 @@ def test_entry_point_points_at_d1_dialect(self): def test_package_exports_dialect_and_version(self): self.assertIs(sqlalchemy_d1.D1Dialect, D1Dialect) - self.assertEqual(sqlalchemy_d1.__version__, "0.2.0") + self.assertEqual(sqlalchemy_d1.__version__, version("sqlalchemy-d1")) + + def test_package_imports_without_installed_metadata(self): + self.addCleanup(importlib.reload, sqlalchemy_d1) + + with mock.patch( + "importlib.metadata.version", side_effect=PackageNotFoundError + ): + importlib.reload(sqlalchemy_d1) + + self.assertEqual(sqlalchemy_d1.__version__, "0+unknown") + self.assertIs(registry.load("d1"), D1Dialect) def test_create_engine_builds_d1_dialect(self): engine = create_engine("d1://acct:tok@dbid") @@ -82,6 +94,23 @@ def test_create_connect_args_parses_d1_url(self): }, ) + def test_create_connect_args_ignores_query_parameters(self): + url = make_url( + "d1://acct:tok@dbid?base_url=http://169.254.169.254/x&timeout=1" + ) + + pos, kw = self.dialect.create_connect_args(url) + + self.assertEqual(pos, ()) + self.assertEqual( + kw, + { + "account_id": "acct", + "api_token": "tok", + "database_id": "dbid", + }, + ) + def test_get_schema_names_returns_main(self): conn = DummyConnection(lambda query, *args, **kwargs: DummyResult([])) @@ -117,28 +146,49 @@ def test_get_view_names_returns_empty_list_without_views(self): self.assertEqual(self.dialect.get_view_names(conn), []) - def test_get_view_names_wraps_errors(self): + def test_get_view_names_lets_errors_propagate(self): def execute_impl(query, *args, **kwargs): raise ValueError("boom") conn = DummyConnection(execute_impl) - with self.assertRaises(RuntimeError): + with self.assertRaises(ValueError): self.dialect.get_view_names(conn) def test_get_column_type_maps_date_and_boolean_types(self): cases = [ - ("DATETIME", D1DateTime), - ("datetime", D1DateTime), - ("TIMESTAMP", D1DateTime), - ("DATE", D1Date), - ("TIME", D1Time), - ("BOOLEAN", D1Boolean), - ("BOOL", D1Boolean), + ("DATETIME", d1types.DATETIME, D1DateTime), + ("datetime", d1types.DATETIME, D1DateTime), + ("DATETIME(6)", d1types.DATETIME, D1DateTime), + ("TIMESTAMP", d1types.TIMESTAMP, D1DateTime), + ("TIMESTAMP WITH TIME ZONE", d1types.TIMESTAMP, D1DateTime), + ("DATE", d1types.DATE, D1Date), + ("TIME", d1types.TIME, D1Time), + ("BOOLEAN", d1types.BOOLEAN, D1Boolean), + ("BOOL", d1types.BOOLEAN, D1Boolean), ] - for declared, expected in cases: + for declared, expected, upstream_type in cases: + sqla_type = self.dialect._get_column_type(declared) + self.assertIs(type(sqla_type), expected) + self.assertIsInstance(sqla_type, upstream_type) + + def test_get_column_type_maps_decimal_to_numeric(self): + for declared in ["DECIMAL", "DECIMAL(10,2)", "decimal(10, 2)"]: self.assertIsInstance( - self.dialect._get_column_type(declared), expected + self.dialect._get_column_type(declared), sqltypes.Numeric + ) + + def test_get_column_type_only_matches_the_first_word(self): + cases = [ + ("UPDATED_INT", sqltypes.INTEGER), + ("VALIDATED INT", sqltypes.INTEGER), + ("DATETEXT", sqltypes.TEXT), + ("BOOLEAN_TEXT", sqltypes.TEXT), + ("MYDATE", sqltypes.TEXT), + ] + for declared, expected in cases: + self.assertIs( + type(self.dialect._get_column_type(declared)), expected ) def test_get_column_type_leaves_other_types_to_upstream(self): @@ -163,67 +213,6 @@ def test_get_column_type_handles_missing_type(self): self.dialect._get_column_type(None), sqltypes.TEXT ) - def test_get_columns_reflects_types_and_autoincrement(self): - def execute_impl(query, *args, **kwargs): - rows = [ - (0, "id", "INTEGER", 1, None, 1), - (1, "name", "TEXT", 0, "'anonymous'", 0), - (2, "created_at", "DATETIME", 0, None, 0), - (3, "day", "DATE", 0, None, 0), - (4, "is_active", "BOOLEAN", 1, "1", 0), - ] - return DummyResult(rows) - - conn = DummyConnection(execute_impl) - - cols = self.dialect.get_columns(conn, "mytable") - - self.assertEqual( - [c["name"] for c in cols], - ["id", "name", "created_at", "day", "is_active"], - ) - self.assertIsInstance(cols[0]["type"], sqltypes.INTEGER) - self.assertIsInstance(cols[1]["type"], sqltypes.TEXT) - self.assertIsInstance(cols[2]["type"], D1DateTime) - self.assertIsInstance(cols[3]["type"], D1Date) - self.assertIsInstance(cols[4]["type"], D1Boolean) - self.assertFalse(cols[0]["nullable"]) - self.assertEqual(cols[1]["default"], "'anonymous'") - self.assertTrue(cols[0]["primary_key"]) - self.assertEqual( - [c["autoincrement"] for c in cols], - [True, False, False, False, False], - ) - - def test_autoincrement_false_for_composite_primary_key(self): - def execute_impl(query, *args, **kwargs): - rows = [ - (0, "a", "INTEGER", 1, None, 1), - (1, "b", "INTEGER", 1, None, 2), - (2, "c", "TEXT", 0, None, 0), - ] - return DummyResult(rows) - - conn = DummyConnection(execute_impl) - - cols = self.dialect.get_columns(conn, "mytable") - self.assertEqual( - [c["autoincrement"] for c in cols], [False, False, False] - ) - - def test_autoincrement_false_for_text_primary_key(self): - def execute_impl(query, *args, **kwargs): - rows = [ - (0, "code", "TEXT", 1, None, 1), - (1, "amount", "INTEGER", 0, None, 0), - ] - return DummyResult(rows) - - conn = DummyConnection(execute_impl) - - cols = self.dialect.get_columns(conn, "mytable") - self.assertEqual([c["autoincrement"] for c in cols], [False, False]) - class UpstreamGuardTestSuite(unittest.TestCase): def test_get_column_type_takes_one_argument(self): @@ -232,18 +221,9 @@ def test_get_column_type_takes_one_argument(self): ).parameters self.assertEqual(len(params), 2) - def test_upstream_has_no_get_view_names(self): + def test_upstream_has_no_view_reflection(self): self.assertNotIn("get_view_names", vars(CloudflareD1Dialect)) - - def test_type_compiler_has_visit_methods(self): - for name in [ - "visit_DATETIME", - "visit_TIMESTAMP", - "visit_DATE", - "visit_TIME", - "visit_BOOLEAN", - ]: - self.assertTrue(callable(getattr(CloudflareD1TypeCompiler, name))) + self.assertNotIn("get_view_definition", vars(CloudflareD1Dialect)) class D1ResultProcessorTestSuite(unittest.TestCase): @@ -270,6 +250,20 @@ def test_date_returns_messy_values_unchanged(self): for value in self.messy_values: self.assertEqual(process(value), value) + def test_reflected_types_use_upstream_processors(self): + process = d1types.TIMESTAMP().result_processor(self.dialect, None) + self.assertEqual( + process("2026-09-21 10:00:00"), datetime(2026, 9, 21, 10, 0) + ) + + process = d1types.BOOLEAN().bind_processor(self.dialect) + self.assertEqual(process(True), 1) + + # The dialect must not swap a reflected type for the generic one + for reflected in [d1types.DATETIME(), d1types.BOOLEAN()]: + impl = reflected.dialect_impl(self.dialect) + self.assertIs(type(impl), type(reflected)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_d1_integration.py b/tests/test_d1_integration.py index 11ee511..b69170c 100644 --- a/tests/test_d1_integration.py +++ b/tests/test_d1_integration.py @@ -164,6 +164,11 @@ def test_views_are_listed_and_reflected(self): self.assertIn(self.view_name, inspector.get_view_names()) self.assertNotIn(self.view_name, inspector.get_table_names()) + self.assertTrue(inspector.has_table(self.view_name)) + self.assertIn( + f"CREATE VIEW {self.view_name}", + inspector.get_view_definition(self.view_name), + ) cols = inspector.get_columns(self.view_name) self.assertEqual( diff --git a/tests/test_d1_reflection.py b/tests/test_d1_reflection.py new file mode 100644 index 0000000..e0ecc3b --- /dev/null +++ b/tests/test_d1_reflection.py @@ -0,0 +1,211 @@ +import sqlite3 +import unittest + +from sqlalchemy import ( + Column, + Integer, + MetaData, + Table, + create_engine, + inspect, + text, + types as sqltypes, +) +from sqlalchemy.exc import NoSuchTableError +from sqlalchemy.pool import StaticPool +from sqlalchemy.schema import CreateTable + +from sqlalchemy_d1 import types as d1types + +SCHEMA = [ + "CREATE TABLE events (" + "id INTEGER PRIMARY KEY, name TEXT DEFAULT 'anonymous', " + "created_at DATETIME, updated_at TIMESTAMP, day DATE, at TIME, " + "is_active BOOLEAN NOT NULL DEFAULT 1, price DECIMAL(10,2))", + "CREATE TABLE lowercase_key (id integer PRIMARY KEY, name TEXT)", + "CREATE TABLE composite_key " + "(a INTEGER, b INTEGER, c TEXT, PRIMARY KEY (b, a))", + "CREATE TABLE text_key (code TEXT PRIMARY KEY, amount INTEGER)", + "CREATE TABLE int_key (id INT PRIMARY KEY, name TEXT)", + "CREATE TABLE bigint_key (id BIGINT PRIMARY KEY, name TEXT)", + "CREATE TABLE without_rowid " + "(id INTEGER PRIMARY KEY, name TEXT) WITHOUT ROWID", + "CREATE TABLE strict_without_rowid " + "(id INTEGER PRIMARY KEY, name TEXT) STRICT, WITHOUT ROWID", + "CREATE VIEW event_names AS SELECT id, name, created_at FROM events", +] + + +class D1ReflectionTestSuite(unittest.TestCase): + """ + Runs reflection against an in-memory SQLite database. It answers + PRAGMA and sqlite_master queries the same way D1 does. + """ + + @classmethod + def setUpClass(cls): + cls.engine = create_engine( + "d1://acct:tok@dbid", + creator=lambda: sqlite3.connect(":memory:"), + poolclass=StaticPool, + ) + cls.addClassCleanup(cls.engine.dispose) + with cls.engine.begin() as conn: + for statement in SCHEMA: + conn.execute(text(statement)) + + def autoincrement(self, table_name): + cols = inspect(self.engine).get_columns(table_name) + return [c["autoincrement"] for c in cols] + + def test_get_columns_reflects_types_and_autoincrement(self): + cols = inspect(self.engine).get_columns("events") + + self.assertEqual( + [c["name"] for c in cols], + [ + "id", + "name", + "created_at", + "updated_at", + "day", + "at", + "is_active", + "price", + ], + ) + self.assertEqual( + [type(c["type"]) for c in cols], + [ + sqltypes.INTEGER, + sqltypes.TEXT, + d1types.DATETIME, + d1types.TIMESTAMP, + d1types.DATE, + d1types.TIME, + d1types.BOOLEAN, + sqltypes.DECIMAL, + ], + ) + self.assertEqual( + [c["primary_key"] for c in cols], [True] + [False] * 7 + ) + self.assertEqual( + [c["autoincrement"] for c in cols], [True] + [False] * 7 + ) + self.assertTrue(cols[1]["nullable"]) + self.assertFalse(cols[6]["nullable"]) + self.assertEqual(cols[1]["default"], "'anonymous'") + + def test_autoincrement_ignores_case_of_integer(self): + self.assertEqual(self.autoincrement("lowercase_key"), [True, False]) + + def test_autoincrement_false_for_composite_primary_key(self): + self.assertEqual( + self.autoincrement("composite_key"), [False, False, False] + ) + + def test_autoincrement_false_for_text_primary_key(self): + self.assertEqual(self.autoincrement("text_key"), [False, False]) + + def test_autoincrement_false_unless_declared_as_integer(self): + # Only INTEGER makes the column an alias for the rowid + self.assertEqual(self.autoincrement("int_key"), [False, False]) + self.assertEqual(self.autoincrement("bigint_key"), [False, False]) + + def test_autoincrement_false_without_rowid(self): + self.assertEqual(self.autoincrement("without_rowid"), [False, False]) + self.assertEqual( + self.autoincrement("strict_without_rowid"), [False, False] + ) + + def test_get_pk_constraint_keeps_key_order(self): + inspector = inspect(self.engine) + + self.assertEqual( + inspector.get_pk_constraint("composite_key"), + {"constrained_columns": ["b", "a"], "name": None}, + ) + self.assertEqual( + inspector.get_pk_constraint("events")["constrained_columns"], + ["id"], + ) + self.assertEqual( + inspector.get_pk_constraint("event_names")["constrained_columns"], + [], + ) + + table = Table("composite_key", MetaData(), autoload_with=self.engine) + self.assertEqual([c.name for c in table.primary_key], ["b", "a"]) + + def test_views_are_listed_and_reflected(self): + inspector = inspect(self.engine) + + self.assertEqual(inspector.get_view_names(), ["event_names"]) + self.assertNotIn("event_names", inspector.get_table_names()) + + cols = inspector.get_columns("event_names") + self.assertEqual( + [c["name"] for c in cols], ["id", "name", "created_at"] + ) + self.assertIsInstance(cols[2]["type"], d1types.DATETIME) + self.assertEqual( + [c["autoincrement"] for c in cols], [False, False, False] + ) + + def test_get_view_definition_returns_create_statement(self): + definition = inspect(self.engine).get_view_definition("event_names") + + self.assertEqual( + definition, + "CREATE VIEW event_names AS " + "SELECT id, name, created_at FROM events", + ) + + def test_get_view_definition_raises_for_missing_view(self): + inspector = inspect(self.engine) + + with self.assertRaises(NoSuchTableError): + inspector.get_view_definition("missing") + with self.assertRaises(NoSuchTableError): + inspector.get_view_definition("events") + + def test_has_table_finds_tables_and_views(self): + inspector = inspect(self.engine) + + self.assertTrue(inspector.has_table("events")) + self.assertTrue(inspector.has_table("event_names")) + self.assertFalse(inspector.has_table("missing")) + + def test_create_all_skips_a_name_taken_by_a_view(self): + metadata = MetaData() + Table("event_names", metadata, Column("id", Integer, primary_key=True)) + + # Would fail with "view event_names already exists" otherwise + metadata.create_all(self.engine) + + self.assertIn("event_names", inspect(self.engine).get_view_names()) + + def test_table_comment_and_check_constraints_are_empty(self): + inspector = inspect(self.engine) + + self.assertEqual(inspector.get_table_comment("events"), {"text": None}) + self.assertEqual(inspector.get_check_constraints("events"), []) + + def test_reflected_table_is_recreated_with_declared_types(self): + table = Table("events", MetaData(), autoload_with=self.engine) + + ddl = str(CreateTable(table).compile(dialect=self.engine.dialect)) + + for column_ddl in [ + "created_at DATETIME", + "updated_at TIMESTAMP", + "day DATE", + "at TIME", + "is_active BOOLEAN", + ]: + self.assertIn(column_ddl, ddl) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_d1_type_compiler.py b/tests/test_d1_type_compiler.py index 633a641..2493623 100644 --- a/tests/test_d1_type_compiler.py +++ b/tests/test_d1_type_compiler.py @@ -16,6 +16,7 @@ literal_column, select, ) +from sqlalchemy.exc import ArgumentError from sqlalchemy.schema import CreateTable from sqlalchemy_cloudflare_d1.dialect import ( CloudflareD1Dialect, @@ -25,6 +26,7 @@ D1Time, ) +from sqlalchemy_d1 import types as d1types from sqlalchemy_d1.dialect import D1Dialect SUPERSET_TEMPORAL = [ @@ -67,34 +69,65 @@ def build_table(self, **kw): **kw, ) - def test_bare_types_compile_to_real_names(self): + def test_reflected_types_compile_to_declared_names(self): cases = [ - (D1DateTime(), "DATETIME"), - (D1Date(), "DATE"), - (D1Time(), "TIME"), - (D1Boolean(), "BOOLEAN"), - (TIMESTAMP(), "TIMESTAMP"), - (DateTime(), "DATETIME"), - (Date(), "DATE"), - (Time(), "TIME"), - (Boolean(), "BOOLEAN"), + (d1types.DATETIME(), "DATETIME"), + (d1types.TIMESTAMP(), "TIMESTAMP"), + (d1types.DATE(), "DATE"), + (d1types.TIME(), "TIME"), + (d1types.BOOLEAN(), "BOOLEAN"), ] for sqla_type, expected in cases: self.assertEqual(sqla_type.compile(dialect=self.dialect), expected) - - def test_other_types_compile_like_upstream(self): - for sqla_type in [Integer(), String(), String(50)]: + self.assertEqual(str(sqla_type), expected) + + def test_generic_types_compile_like_upstream(self): + for sqla_type in [ + Integer(), + String(), + String(50), + DateTime(), + TIMESTAMP(), + Date(), + Time(), + Boolean(), + D1DateTime(), + D1Date(), + D1Time(), + D1Boolean(), + ]: self.assertEqual( sqla_type.compile(dialect=self.dialect), sqla_type.compile(dialect=self.upstream), ) + def test_type_name_is_the_same_bare_and_in_create_table(self): + table = Table( + "events", + MetaData(), + Column("created_at", DateTime), + Column("active", Boolean), + Column("declared_at", d1types.DATETIME()), + Column("declared_active", d1types.BOOLEAN()), + ) + + ddl = str(CreateTable(table).compile(dialect=self.dialect)) + + for column in table.columns: + bare = column.type.compile(dialect=self.dialect) + self.assertIn(f"{column.name} {bare}", ddl) + self.assertIn("created_at TEXT", ddl) + self.assertIn("declared_at DATETIME", ddl) + def test_superset_sees_dates_and_booleans(self): spec = DummySupersetColumnSpec(self.dialect) - for sqla_type in [D1DateTime(), D1Date(), D1Time(), TIMESTAMP()]: + for declared in ["DATETIME", "TIMESTAMP", "DATE", "TIME"]: + sqla_type = self.dialect._get_column_type(declared) self.assertTrue(spec.matches(sqla_type, SUPERSET_TEMPORAL)) self.assertFalse(spec.matches(sqla_type, SUPERSET_STRING)) - self.assertTrue(spec.matches(D1Boolean(), SUPERSET_BOOLEAN)) + for declared in ["BOOLEAN", "BOOL"]: + sqla_type = self.dialect._get_column_type(declared) + self.assertTrue(spec.matches(sqla_type, SUPERSET_BOOLEAN)) def test_create_table_matches_upstream(self): table = self.build_table() @@ -108,7 +141,10 @@ def test_create_table_matches_upstream(self): self.assertNotIn("BOOLEAN", ours) def test_create_strict_table_matches_upstream(self): - table = self.build_table(sqlite_strict=True) + try: + table = self.build_table(sqlite_strict=True) + except ArgumentError: + self.skipTest("this SQLAlchemy has no sqlite_strict option") ours = str(CreateTable(table).compile(dialect=self.dialect)) theirs = str(CreateTable(table).compile(dialect=self.upstream)) From 07a3d6090335eab293ef1fe86d791319f13ce560 Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 16:29:08 -0400 Subject: [PATCH 10/14] ci: added Python 3.13 and 3.14, a minimum versions job and a weekly run --- .github/workflows/ci.yml | 19 ++++++++++++++++++- pyproject.toml | 2 ++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea43b8b..08cf25e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: workflow_dispatch: + schedule: + # Weekly, so a new upstream release that breaks the dialect is noticed + - cron: "0 6 * * 1" permissions: contents: read @@ -15,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -32,6 +35,20 @@ jobs: - name: Unit tests run: pytest -m "not integration" --disable-socket + minimum-versions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install the lowest versions that pyproject.toml allows + run: | + pip install uv + uv pip install --system --resolution lowest-direct -e ".[dev]" + - name: Unit tests + run: pytest -m "not integration" --disable-socket + integration: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index 6884047..a354193 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Database", ] dependencies = [ From 0f458db4a7b27ed850b4b943094230b3f7b3ca97 Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 16:29:08 -0400 Subject: [PATCH 11/14] docs: updated README and CHANGELOG for the reflection changes --- CHANGELOG.md | 14 ++++++++++---- README.md | 21 +++++++++++++-------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a793e60..eeb9b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,17 +11,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), * Rewrote `D1Dialect` as a subclass of `CloudflareD1Dialect` from `sqlalchemy-cloudflare-d1`. The `d1://` connection string and the `d1` entry point are unchanged. * Requires **SQLAlchemy 2.0** (`>=2.0,<2.1`). Version 0.1.0 stays available for SQLAlchemy 1.4. * Lifted the Python cap. Python 3.11 or newer is supported. +* Reflection errors are no longer wrapped in `RuntimeError`. SQLAlchemy errors reach the caller unchanged. +* `get_view_definition` returns the SQL of the view and raises `NoSuchTableError` for a missing view. It returned `None` before. `get_table_comment` returns `{"text": None}` instead of an empty string. +* `has_table` is also true for a view, which is what SQLAlchemy 2.0 expects. ### Added -* Real type names for bare `DATETIME`, `TIMESTAMP`, `DATE`, `TIME` and `BOOLEAN` types, so Apache Superset marks those columns as dates and booleans. Tables created through SQLAlchemy are still declared as `TEXT` and `INTEGER`, the same as upstream. -* Reflection of columns declared as `DATETIME`, `TIMESTAMP`, `DATE`, `TIME` or `BOOLEAN`. -* `autoincrement` on reflected columns. -* `get_view_names`, and `get_schema_names` returning `main`. Upstream has neither. +* Reflection of columns declared as `DATETIME`, `TIMESTAMP`, `DATE`, `TIME`, `BOOLEAN` or `BOOL`. They are reflected as the new types in `sqlalchemy_d1.types`, which keep the declared name, so Apache Superset marks those columns as dates and booleans. Generic types compile the same as upstream, so tables created through SQLAlchemy are still declared as `TEXT` and `INTEGER`. +* `autoincrement` on reflected columns. It is true only for a single primary key column declared as `INTEGER`, in a table that is not `WITHOUT ROWID`. +* `get_view_names`, `get_view_definition`, and `get_schema_names` returning `main`. Upstream has none of these. * Tables and views that start with `_cf_` are hidden from the table and view lists. * `LICENSE` file and license metadata. The package is licensed under **Apache License 2.0**. * Unit tests that run offline, integration tests against a real D1 database, and a CI workflow. +### Fixed + +* A composite primary key is reflected in the order of the key. Version 0.1.0 returned it in column order. + ### Removed * The dependency on `dbapi-d1`. The driver built into `sqlalchemy-cloudflare-d1` replaces it. diff --git a/README.md b/README.md index d995031..086a5fd 100644 --- a/README.md +++ b/README.md @@ -51,22 +51,27 @@ In Superset, use the same URL as the SQLAlchemy URI of the database connection. | Feature | Description | |---------|-------------| -| `d1://` name | Registers the upstream dialect under the `d1` name that Superset uses. | -| Date and boolean type names | A bare `DATETIME`, `TIMESTAMP`, `DATE`, `TIME` or `BOOLEAN` type prints its real name. Superset reads that name to decide if a column is a date. | -| Date and boolean reflection | Columns declared as `DATETIME`, `TIMESTAMP`, `DATE`, `TIME` or `BOOLEAN` are reflected as those types. Upstream reflects them as `TEXT`. | -| `autoincrement` | Reflected columns report `autoincrement`. It is true for a single `INTEGER PRIMARY KEY` column. | -| Schema and view listing | `get_schema_names` returns `main` and `get_view_names` lists views. Upstream has neither. | +| `d1://` name | Registers the upstream dialect under the `d1` name that Superset uses. Only the account, token and database ID are read from the URL. Query parameters are ignored. | +| Date and boolean reflection | Columns declared as `DATETIME`, `TIMESTAMP`, `DATE`, `TIME`, `BOOLEAN` or `BOOL` are reflected as the types in `sqlalchemy_d1.types`. They behave like the upstream date and boolean types and keep the declared name. Superset reads that name to decide if a column is a date. Upstream reflects these columns as `TEXT`. | +| `DECIMAL` reflection | Columns declared as `DECIMAL` are reflected as numeric. Upstream reflects them as `TEXT`. | +| `autoincrement` | Reflected columns report `autoincrement`. It is true only for the column SQLite fills in by itself: a single primary key column declared as `INTEGER`, in a table that is not `WITHOUT ROWID`. | +| Primary key order | A composite primary key is reflected in the order of the key, not the order of the columns. | +| Schemas and views | `get_schema_names` returns `main`, `get_view_names` lists views and `get_view_definition` returns the SQL of a view. Upstream has none of these. `has_table` is also true for a view. | | Internal tables hidden | Tables and views that start with `_cf_`, such as `_cf_KV`, are left out of the lists. | -Everything else comes from `sqlalchemy-cloudflare-d1` unchanged. That includes the connection, the cursor, the SQL compiler and the rest of reflection. +Everything else comes from `sqlalchemy-cloudflare-d1` unchanged. That includes the connection, the cursor, the SQL compiler, and the reflection of foreign keys, indexes and unique constraints. --- -## Known limit +## Known limits Tables **created through SQLAlchemy** are identical to the ones upstream creates. A `DateTime` column is declared as `TEXT` and a `Boolean` column as `INTEGER`, so they are reflected as text and integer afterwards. Values still round trip correctly when you use the same `Table` object. -Tables created with plain SQL and a `DATETIME` column, which is the normal case for D1, are reflected as dates. +Tables created with plain SQL and a `DATETIME` column, which is the normal case for D1, are reflected as dates. A table reflected from D1 keeps its declared types when SQLAlchemy creates it again. + +Only the first word of a declared type is matched, so `TIMESTAMP WITH TIME ZONE` is a date and `UPDATED_INT` is an integer. + +**CHECK constraints** are not reflected. `get_check_constraints` returns an empty list, and `get_table_comment` returns no comment because SQLite has none. --- From 83c238b679ac6034661a5f020af8594711486a61 Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 16:44:01 -0400 Subject: [PATCH 12/14] fix: made reflected types compile on other dialects --- src/sqlalchemy_d1/dialect.py | 2 -- src/sqlalchemy_d1/type_compiler.py | 8 -------- src/sqlalchemy_d1/types.py | 29 ++++++++++++++++++++--------- tests/test_d1_reflection.py | 14 ++++++++++++++ tests/test_d1_type_compiler.py | 16 ++++++++++++++++ 5 files changed, 50 insertions(+), 19 deletions(-) delete mode 100644 src/sqlalchemy_d1/type_compiler.py diff --git a/src/sqlalchemy_d1/dialect.py b/src/sqlalchemy_d1/dialect.py index 1fefe9d..1b81bfe 100644 --- a/src/sqlalchemy_d1/dialect.py +++ b/src/sqlalchemy_d1/dialect.py @@ -6,7 +6,6 @@ from sqlalchemy_cloudflare_d1.dialect import CloudflareD1Dialect from . import types as d1types -from .type_compiler import D1TypeCompiler INTERNAL_PREFIX = "_cf_" @@ -32,7 +31,6 @@ def _visible(names): class D1Dialect(CloudflareD1Dialect): name = "d1" - type_compiler = D1TypeCompiler supports_statement_cache = True def create_connect_args(self, url): diff --git a/src/sqlalchemy_d1/type_compiler.py b/src/sqlalchemy_d1/type_compiler.py deleted file mode 100644 index e9a9c4d..0000000 --- a/src/sqlalchemy_d1/type_compiler.py +++ /dev/null @@ -1,8 +0,0 @@ -# sqlalchemy_d1/type_compiler.py -from sqlalchemy_cloudflare_d1.compiler import CloudflareD1TypeCompiler - - -class D1TypeCompiler(CloudflareD1TypeCompiler): - def visit_d1_declared(self, type_, **kw): - # Reflected types keep the name the column was declared with - return type(type_).__name__ diff --git a/src/sqlalchemy_d1/types.py b/src/sqlalchemy_d1/types.py index 84df25d..9e6717b 100644 --- a/src/sqlalchemy_d1/types.py +++ b/src/sqlalchemy_d1/types.py @@ -3,11 +3,12 @@ Types that reflection returns for columns declared with a date or boolean type name. -Each one behaves like the upstream D1 type it extends, and compiles to its -class name, which is the name the column was declared with. Generic types -such as DateTime are left to upstream. +Each one behaves like the upstream D1 type it extends. On the d1 dialect it +compiles to its class name, which is the name the column was declared with. +Generic types such as DateTime are left to upstream. """ +from sqlalchemy.ext.compiler import compiles from sqlalchemy_cloudflare_d1.dialect import ( D1Boolean, D1Date, @@ -16,22 +17,32 @@ class name, which is the name the column was declared with. Generic types ) -# SQLAlchemy only picks up a __visit_name__ set in the class body itself class DATETIME(D1DateTime): - __visit_name__ = "d1_declared" + pass class TIMESTAMP(D1DateTime): - __visit_name__ = "d1_declared" + # Other dialects print TIMESTAMP too, as for sqlalchemy.TIMESTAMP + __visit_name__ = "TIMESTAMP" class DATE(D1Date): - __visit_name__ = "d1_declared" + pass class TIME(D1Time): - __visit_name__ = "d1_declared" + pass class BOOLEAN(D1Boolean): - __visit_name__ = "d1_declared" + pass + + +# Other dialects compile these like the generic types they extend +@compiles(DATETIME, "d1") +@compiles(TIMESTAMP, "d1") +@compiles(DATE, "d1") +@compiles(TIME, "d1") +@compiles(BOOLEAN, "d1") +def compile_declared_name(type_, compiler, **kw): + return type(type_).__name__ diff --git a/tests/test_d1_reflection.py b/tests/test_d1_reflection.py index e0ecc3b..9398b28 100644 --- a/tests/test_d1_reflection.py +++ b/tests/test_d1_reflection.py @@ -11,6 +11,7 @@ text, types as sqltypes, ) +from sqlalchemy.dialects import postgresql, sqlite from sqlalchemy.exc import NoSuchTableError from sqlalchemy.pool import StaticPool from sqlalchemy.schema import CreateTable @@ -206,6 +207,19 @@ def test_reflected_table_is_recreated_with_declared_types(self): ]: self.assertIn(column_ddl, ddl) + def test_reflected_table_can_be_created_on_other_dialects(self): + table = Table("events", MetaData(), autoload_with=self.engine) + + on_sqlite = str(CreateTable(table).compile(dialect=sqlite.dialect())) + on_postgres = str( + CreateTable(table).compile(dialect=postgresql.dialect()) + ) + + self.assertIn("created_at DATETIME", on_sqlite) + self.assertIn("is_active BOOLEAN", on_sqlite) + self.assertIn("created_at TIMESTAMP WITHOUT TIME ZONE", on_postgres) + self.assertIn("is_active BOOLEAN", on_postgres) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_d1_type_compiler.py b/tests/test_d1_type_compiler.py index 2493623..0735842 100644 --- a/tests/test_d1_type_compiler.py +++ b/tests/test_d1_type_compiler.py @@ -16,6 +16,7 @@ literal_column, select, ) +from sqlalchemy.dialects import postgresql, sqlite from sqlalchemy.exc import ArgumentError from sqlalchemy.schema import CreateTable from sqlalchemy_cloudflare_d1.dialect import ( @@ -81,6 +82,21 @@ def test_reflected_types_compile_to_declared_names(self): self.assertEqual(sqla_type.compile(dialect=self.dialect), expected) self.assertEqual(str(sqla_type), expected) + def test_reflected_types_compile_like_generic_types_elsewhere(self): + cases = [ + (d1types.DATETIME(), DateTime()), + (d1types.TIMESTAMP(), TIMESTAMP()), + (d1types.DATE(), Date()), + (d1types.TIME(), Time()), + (d1types.BOOLEAN(), Boolean()), + ] + for other in [sqlite.dialect(), postgresql.dialect(), self.upstream]: + for declared, generic in cases: + self.assertEqual( + declared.compile(dialect=other), + generic.compile(dialect=other), + ) + def test_generic_types_compile_like_upstream(self): for sqla_type in [ Integer(), From 0f72cd071248f7f708b8af36dba2fa8e281a5ddd Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 16:44:31 -0400 Subject: [PATCH 13/14] fix: restored upstream handling of URL query parameters --- README.md | 2 +- src/sqlalchemy_d1/dialect.py | 13 ------------- tests/test_d1_dialect.py | 17 ----------------- 3 files changed, 1 insertion(+), 31 deletions(-) diff --git a/README.md b/README.md index 086a5fd..d8362da 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ In Superset, use the same URL as the SQLAlchemy URI of the database connection. | Feature | Description | |---------|-------------| -| `d1://` name | Registers the upstream dialect under the `d1` name that Superset uses. Only the account, token and database ID are read from the URL. Query parameters are ignored. | +| `d1://` name | Registers the upstream dialect under the `d1` name that Superset uses. | | Date and boolean reflection | Columns declared as `DATETIME`, `TIMESTAMP`, `DATE`, `TIME`, `BOOLEAN` or `BOOL` are reflected as the types in `sqlalchemy_d1.types`. They behave like the upstream date and boolean types and keep the declared name. Superset reads that name to decide if a column is a date. Upstream reflects these columns as `TEXT`. | | `DECIMAL` reflection | Columns declared as `DECIMAL` are reflected as numeric. Upstream reflects them as `TEXT`. | | `autoincrement` | Reflected columns report `autoincrement`. It is true only for the column SQLite fills in by itself: a single primary key column declared as `INTEGER`, in a table that is not `WITHOUT ROWID`. | diff --git a/src/sqlalchemy_d1/dialect.py b/src/sqlalchemy_d1/dialect.py index 1b81bfe..36cb857 100644 --- a/src/sqlalchemy_d1/dialect.py +++ b/src/sqlalchemy_d1/dialect.py @@ -33,19 +33,6 @@ class D1Dialect(CloudflareD1Dialect): name = "d1" supports_statement_cache = True - def create_connect_args(self, url): - # URL format: d1://:@ - # Query parameters are ignored. Upstream passes them to the driver, - # where base_url would send the API token to any host. - return ( - (), - { - "account_id": url.username, - "api_token": url.password, - "database_id": url.host, - }, - ) - @reflection.cache def get_schema_names(self, connection, **kwargs): # D1 is built on SQLite, which only uses one schema diff --git a/tests/test_d1_dialect.py b/tests/test_d1_dialect.py index 7f1a29d..d389556 100644 --- a/tests/test_d1_dialect.py +++ b/tests/test_d1_dialect.py @@ -94,23 +94,6 @@ def test_create_connect_args_parses_d1_url(self): }, ) - def test_create_connect_args_ignores_query_parameters(self): - url = make_url( - "d1://acct:tok@dbid?base_url=http://169.254.169.254/x&timeout=1" - ) - - pos, kw = self.dialect.create_connect_args(url) - - self.assertEqual(pos, ()) - self.assertEqual( - kw, - { - "account_id": "acct", - "api_token": "tok", - "database_id": "dbid", - }, - ) - def test_get_schema_names_returns_main(self): conn = DummyConnection(lambda query, *args, **kwargs: DummyResult([])) From 3df21502a3cff5751a351e6144f33d74b1375b3c Mon Sep 17 00:00:00 2001 From: danielalyoshin Date: Mon, 21 Sep 2026 16:44:39 -0400 Subject: [PATCH 14/14] docs: noted how reflected types compile on other dialects --- CHANGELOG.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeb9b57..986e5e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -* Reflection of columns declared as `DATETIME`, `TIMESTAMP`, `DATE`, `TIME`, `BOOLEAN` or `BOOL`. They are reflected as the new types in `sqlalchemy_d1.types`, which keep the declared name, so Apache Superset marks those columns as dates and booleans. Generic types compile the same as upstream, so tables created through SQLAlchemy are still declared as `TEXT` and `INTEGER`. +* Reflection of columns declared as `DATETIME`, `TIMESTAMP`, `DATE`, `TIME`, `BOOLEAN` or `BOOL`. They are reflected as the new types in `sqlalchemy_d1.types`, which keep the declared name on the `d1` dialect, so Apache Superset marks those columns as dates and booleans. On other dialects they compile like the generic types. Generic types compile the same as upstream, so tables created through SQLAlchemy are still declared as `TEXT` and `INTEGER`. * `autoincrement` on reflected columns. It is true only for a single primary key column declared as `INTEGER`, in a table that is not `WITHOUT ROWID`. * `get_view_names`, `get_view_definition`, and `get_schema_names` returning `main`. Upstream has none of these. * Tables and views that start with `_cf_` are hidden from the table and view lists. diff --git a/README.md b/README.md index d8362da..b50b83f 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Everything else comes from `sqlalchemy-cloudflare-d1` unchanged. That includes t Tables **created through SQLAlchemy** are identical to the ones upstream creates. A `DateTime` column is declared as `TEXT` and a `Boolean` column as `INTEGER`, so they are reflected as text and integer afterwards. Values still round trip correctly when you use the same `Table` object. -Tables created with plain SQL and a `DATETIME` column, which is the normal case for D1, are reflected as dates. A table reflected from D1 keeps its declared types when SQLAlchemy creates it again. +Tables created with plain SQL and a `DATETIME` column, which is the normal case for D1, are reflected as dates. A table reflected from D1 keeps its declared types when SQLAlchemy creates it again on D1. On another database the same columns compile like SQLAlchemy's generic date and boolean types. Only the first word of a declared type is matched, so `TIMESTAMP WITH TIME ZONE` is a date and `UPDATED_INT` is an integer.