diff --git a/pylock.toml b/pylock.toml index cd949d6b..bc29f41a 100644 --- a/pylock.toml +++ b/pylock.toml @@ -293,6 +293,21 @@ dependencies = [ "pytest>=7", ] +[[packages]] +name = "pytest-mock" +version = "3.15.1" +requires-python = ">=3.9" +sdist = {name = "pytest_mock-3.15.1.tar.gz", url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hashes = {sha256 = "1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}} +wheels = [ + {name = "pytest_mock-3.15.1-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl",hashes = {sha256 = "0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}}, +] +marker = "\"dev\" in extras or \"tests\" in extras" + +[packages.tool.pdm] +dependencies = [ + "pytest>=6.2.5", +] + [[packages]] name = "pytest-timeout" version = "2.4.0" @@ -549,28 +564,28 @@ dependencies = [] [[packages]] name = "ruff" -version = "0.14.13" +version = "0.14.14" requires-python = ">=3.7" -sdist = {name = "ruff-0.14.13.tar.gz", url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hashes = {sha256 = "83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47"}} -wheels = [ - {name = "ruff-0.14.13-py3-none-linux_armv6l.whl",url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl",hashes = {sha256 = "76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b"}}, - {name = "ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl",url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl",hashes = {sha256 = "914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed"}}, - {name = "ruff-0.14.13-py3-none-macosx_11_0_arm64.whl",url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl",hashes = {sha256 = "d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063"}}, - {name = "ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",hashes = {sha256 = "9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e"}}, - {name = "ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",hashes = {sha256 = "ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09"}}, - {name = "ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",hashes = {sha256 = "78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9"}}, - {name = "ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl",url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl",hashes = {sha256 = "7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032"}}, - {name = "ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",hashes = {sha256 = "a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c"}}, - {name = "ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",hashes = {sha256 = "642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427"}}, - {name = "ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",hashes = {sha256 = "4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841"}}, - {name = "ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl",url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl",hashes = {sha256 = "591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c"}}, - {name = "ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl",hashes = {sha256 = "774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b"}}, - {name = "ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl",url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl",hashes = {sha256 = "61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae"}}, - {name = "ruff-0.14.13-py3-none-musllinux_1_2_i686.whl",url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl",hashes = {sha256 = "6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e"}}, - {name = "ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl",hashes = {sha256 = "e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c"}}, - {name = "ruff-0.14.13-py3-none-win32.whl",url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl",hashes = {sha256 = "ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680"}}, - {name = "ruff-0.14.13-py3-none-win_amd64.whl",url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl",hashes = {sha256 = "6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef"}}, - {name = "ruff-0.14.13-py3-none-win_arm64.whl",url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl",hashes = {sha256 = "7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247"}}, +sdist = {name = "ruff-0.14.14.tar.gz", url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hashes = {sha256 = "2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b"}} +wheels = [ + {name = "ruff-0.14.14-py3-none-linux_armv6l.whl",url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl",hashes = {sha256 = "7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed"}}, + {name = "ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl",url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl",hashes = {sha256 = "6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c"}}, + {name = "ruff-0.14.14-py3-none-macosx_11_0_arm64.whl",url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl",hashes = {sha256 = "026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de"}}, + {name = "ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",hashes = {sha256 = "f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e"}}, + {name = "ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",hashes = {sha256 = "3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8"}}, + {name = "ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",hashes = {sha256 = "1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906"}}, + {name = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl",url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl",hashes = {sha256 = "27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480"}}, + {name = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",hashes = {sha256 = "01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df"}}, + {name = "ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",hashes = {sha256 = "1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b"}}, + {name = "ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",hashes = {sha256 = "bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974"}}, + {name = "ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl",url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl",hashes = {sha256 = "14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66"}}, + {name = "ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl",hashes = {sha256 = "e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13"}}, + {name = "ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl",url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl",hashes = {sha256 = "e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412"}}, + {name = "ruff-0.14.14-py3-none-musllinux_1_2_i686.whl",url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl",hashes = {sha256 = "cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3"}}, + {name = "ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl",hashes = {sha256 = "16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b"}}, + {name = "ruff-0.14.14-py3-none-win32.whl",url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl",hashes = {sha256 = "b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167"}}, + {name = "ruff-0.14.14-py3-none-win_amd64.whl",url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl",hashes = {sha256 = "3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd"}}, + {name = "ruff-0.14.14-py3-none-win_arm64.whl",url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl",hashes = {sha256 = "56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c"}}, ] marker = "\"dev\" in extras" @@ -747,11 +762,11 @@ dependencies = [] [[packages]] name = "packaging" -version = "25.0" +version = "26.0" requires-python = ">=3.8" -sdist = {name = "packaging-25.0.tar.gz", url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hashes = {sha256 = "d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}} +sdist = {name = "packaging-26.0.tar.gz", url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hashes = {sha256 = "00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}} wheels = [ - {name = "packaging-25.0-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl",hashes = {sha256 = "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}}, + {name = "packaging-26.0-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl",hashes = {sha256 = "b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}}, ] marker = "\"dev\" in extras or \"docs\" in extras or \"tests\" in extras" @@ -1129,40 +1144,47 @@ dependencies = [ [[packages]] name = "sqlalchemy" -version = "2.0.45" +version = "2.0.46" requires-python = ">=3.7" -sdist = {name = "sqlalchemy-2.0.45.tar.gz", url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hashes = {sha256 = "1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88"}} -wheels = [ - {name = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774"}}, - {name = "sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce"}}, - {name = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl",hashes = {sha256 = "2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33"}}, - {name = "sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl",hashes = {sha256 = "8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74"}}, - {name = "sqlalchemy-2.0.45-cp314-cp314-win32.whl",url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl",hashes = {sha256 = "e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f"}}, - {name = "sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl",url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl",hashes = {sha256 = "4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177"}}, - {name = "sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b"}}, - {name = "sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl",hashes = {sha256 = "9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b"}}, - {name = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf"}}, - {name = "sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e"}}, - {name = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl",hashes = {sha256 = "470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b"}}, - {name = "sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl",hashes = {sha256 = "9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8"}}, - {name = "sqlalchemy-2.0.45-cp313-cp313-win32.whl",url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl",hashes = {sha256 = "4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a"}}, - {name = "sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl",url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl",hashes = {sha256 = "afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee"}}, - {name = "sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6"}}, - {name = "sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl",hashes = {sha256 = "d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a"}}, - {name = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f"}}, - {name = "sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d"}}, - {name = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl",hashes = {sha256 = "215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4"}}, - {name = "sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl",hashes = {sha256 = "107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6"}}, - {name = "sqlalchemy-2.0.45-cp312-cp312-win32.whl",url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl",hashes = {sha256 = "0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953"}}, - {name = "sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl",url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl",hashes = {sha256 = "8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1"}}, - {name = "sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl",url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl",hashes = {sha256 = "2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56"}}, - {name = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b"}}, - {name = "sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac"}}, - {name = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl",hashes = {sha256 = "f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606"}}, - {name = "sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl",hashes = {sha256 = "d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c"}}, - {name = "sqlalchemy-2.0.45-cp311-cp311-win32.whl",url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl",hashes = {sha256 = "3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177"}}, - {name = "sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl",url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl",hashes = {sha256 = "a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2"}}, - {name = "sqlalchemy-2.0.45-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl",hashes = {sha256 = "5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0"}}, +sdist = {name = "sqlalchemy-2.0.46.tar.gz", url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hashes = {sha256 = "cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7"}} +wheels = [ + {name = "sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl",url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl",hashes = {sha256 = "56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada"}}, + {name = "sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366"}}, + {name = "sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d"}}, + {name = "sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl",hashes = {sha256 = "8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e"}}, + {name = "sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl",hashes = {sha256 = "4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf"}}, + {name = "sqlalchemy-2.0.46-cp314-cp314-win32.whl",url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl",hashes = {sha256 = "70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908"}}, + {name = "sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl",url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl",hashes = {sha256 = "3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b"}}, + {name = "sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa"}}, + {name = "sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863"}}, + {name = "sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl",hashes = {sha256 = "ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede"}}, + {name = "sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl",hashes = {sha256 = "82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330"}}, + {name = "sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl",url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl",hashes = {sha256 = "93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00"}}, + {name = "sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2"}}, + {name = "sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee"}}, + {name = "sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl",hashes = {sha256 = "64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad"}}, + {name = "sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl",hashes = {sha256 = "e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e"}}, + {name = "sqlalchemy-2.0.46-cp313-cp313-win32.whl",url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl",hashes = {sha256 = "8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f"}}, + {name = "sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl",url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl",hashes = {sha256 = "77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef"}}, + {name = "sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10"}}, + {name = "sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764"}}, + {name = "sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl",hashes = {sha256 = "b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b"}}, + {name = "sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl",hashes = {sha256 = "db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447"}}, + {name = "sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl",url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl",hashes = {sha256 = "3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c"}}, + {name = "sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9"}}, + {name = "sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b"}}, + {name = "sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl",hashes = {sha256 = "37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53"}}, + {name = "sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl",hashes = {sha256 = "b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e"}}, + {name = "sqlalchemy-2.0.46-cp312-cp312-win32.whl",url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl",hashes = {sha256 = "412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb"}}, + {name = "sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl",url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl",hashes = {sha256 = "ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff"}}, + {name = "sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl",url = "https://files.pythonhosted.org/packages/69/ac/b42ad16800d0885105b59380ad69aad0cce5a65276e269ce2729a2343b6a/sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl",hashes = {sha256 = "261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684"}}, + {name = "sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/a0/60/d8710068cb79f64d002ebed62a7263c00c8fd95f4ebd4b5be8f7ca93f2bc/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62"}}, + {name = "sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/2b/0f/20c71487c7219ab3aa7421c7c62d93824c97c1460f2e8bb72404b0192d13/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f"}}, + {name = "sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/65/80/d26d00b3b249ae000eee4db206fcfc564bf6ca5030e4747adf451f4b5108/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl",hashes = {sha256 = "7568fe771f974abadce52669ef3a03150ff03186d8eb82613bc8adc435a03f01"}}, + {name = "sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/da/ee/74dda7506640923821340541e8e45bd3edd8df78664f1f2e0aae8077192b/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl",hashes = {sha256 = "ebf7e1e78af38047e08836d33502c7a278915698b7c2145d045f780201679999"}}, + {name = "sqlalchemy-2.0.46-cp311-cp311-win32.whl",url = "https://files.pythonhosted.org/packages/9f/25/6dcf8abafff1389a21c7185364de145107b7394ecdcb05233815b236330d/sqlalchemy-2.0.46-cp311-cp311-win32.whl",hashes = {sha256 = "9d80ea2ac519c364a7286e8d765d6cd08648f5b21ca855a8017d9871f075542d"}}, + {name = "sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl",url = "https://files.pythonhosted.org/packages/93/5f/e081490f8523adc0088f777e4ebad3cac21e498ec8a3d4067074e21447a1/sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl",hashes = {sha256 = "585af6afe518732d9ccd3aea33af2edaae4a7aa881af5d8f6f4fe3a368699597"}}, + {name = "sqlalchemy-2.0.46-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl",hashes = {sha256 = "f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e"}}, ] marker = "\"dev\" in extras or \"docs\" in extras" @@ -1175,49 +1197,54 @@ dependencies = [ [[packages]] name = "greenlet" -version = "3.3.0" +version = "3.3.1" requires-python = ">=3.10" -sdist = {name = "greenlet-3.3.0.tar.gz", url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hashes = {sha256 = "a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb"}} -wheels = [ - {name = "greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl",url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl",hashes = {sha256 = "60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f"}}, - {name = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365"}}, - {name = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",hashes = {sha256 = "3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3"}}, - {name = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",hashes = {sha256 = "dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45"}}, - {name = "greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955"}}, - {name = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl",hashes = {sha256 = "ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55"}}, - {name = "greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl",hashes = {sha256 = "d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc"}}, - {name = "greenlet-3.3.0-cp314-cp314-win_amd64.whl",url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl",hashes = {sha256 = "73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170"}}, - {name = "greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl",url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl",hashes = {sha256 = "d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931"}}, - {name = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388"}}, - {name = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",hashes = {sha256 = "87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3"}}, - {name = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",hashes = {sha256 = "2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221"}}, - {name = "greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b"}}, - {name = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl",hashes = {sha256 = "286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd"}}, - {name = "greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl",hashes = {sha256 = "6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9"}}, - {name = "greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl",url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl",hashes = {sha256 = "a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739"}}, - {name = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808"}}, - {name = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",hashes = {sha256 = "7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54"}}, - {name = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",hashes = {sha256 = "30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492"}}, - {name = "greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527"}}, - {name = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl",hashes = {sha256 = "ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39"}}, - {name = "greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl",hashes = {sha256 = "5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8"}}, - {name = "greenlet-3.3.0-cp313-cp313-win_amd64.whl",url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl",hashes = {sha256 = "9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38"}}, - {name = "greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl",url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl",hashes = {sha256 = "b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb"}}, - {name = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3"}}, - {name = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",hashes = {sha256 = "e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655"}}, - {name = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",hashes = {sha256 = "c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7"}}, - {name = "greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b"}}, - {name = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl",hashes = {sha256 = "2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53"}}, - {name = "greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl",hashes = {sha256 = "83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614"}}, - {name = "greenlet-3.3.0-cp312-cp312-win_amd64.whl",url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl",hashes = {sha256 = "a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39"}}, - {name = "greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl",url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl",hashes = {sha256 = "e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e"}}, - {name = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62"}}, - {name = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",hashes = {sha256 = "4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32"}}, - {name = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",hashes = {sha256 = "670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45"}}, - {name = "greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948"}}, - {name = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl",hashes = {sha256 = "2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794"}}, - {name = "greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl",hashes = {sha256 = "4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5"}}, - {name = "greenlet-3.3.0-cp311-cp311-win_amd64.whl",url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl",hashes = {sha256 = "7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71"}}, +sdist = {name = "greenlet-3.3.1.tar.gz", url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hashes = {sha256 = "41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98"}} +wheels = [ + {name = "greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl",url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl",hashes = {sha256 = "bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5"}}, + {name = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b"}}, + {name = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",hashes = {sha256 = "02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e"}}, + {name = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",hashes = {sha256 = "3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d"}}, + {name = "greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f"}}, + {name = "greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl",hashes = {sha256 = "aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683"}}, + {name = "greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl",hashes = {sha256 = "71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1"}}, + {name = "greenlet-3.3.1-cp314-cp314-win_amd64.whl",url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl",hashes = {sha256 = "96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a"}}, + {name = "greenlet-3.3.1-cp314-cp314-win_arm64.whl",url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl",hashes = {sha256 = "b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79"}}, + {name = "greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl",url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl",hashes = {sha256 = "3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242"}}, + {name = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774"}}, + {name = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",hashes = {sha256 = "c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97"}}, + {name = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",hashes = {sha256 = "4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab"}}, + {name = "greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2"}}, + {name = "greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl",hashes = {sha256 = "ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53"}}, + {name = "greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl",hashes = {sha256 = "59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249"}}, + {name = "greenlet-3.3.1-cp314-cp314t-win_amd64.whl",url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl",hashes = {sha256 = "301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451"}}, + {name = "greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl",url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl",hashes = {sha256 = "7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3"}}, + {name = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac"}}, + {name = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",hashes = {sha256 = "7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd"}}, + {name = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",hashes = {sha256 = "12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e"}}, + {name = "greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3"}}, + {name = "greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl",hashes = {sha256 = "33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951"}}, + {name = "greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl",hashes = {sha256 = "4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2"}}, + {name = "greenlet-3.3.1-cp313-cp313-win_amd64.whl",url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl",hashes = {sha256 = "27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946"}}, + {name = "greenlet-3.3.1-cp313-cp313-win_arm64.whl",url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl",hashes = {sha256 = "2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d"}}, + {name = "greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl",url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl",hashes = {sha256 = "7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975"}}, + {name = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36"}}, + {name = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",hashes = {sha256 = "20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba"}}, + {name = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",hashes = {sha256 = "c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca"}}, + {name = "greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336"}}, + {name = "greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl",hashes = {sha256 = "7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1"}}, + {name = "greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl",hashes = {sha256 = "49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149"}}, + {name = "greenlet-3.3.1-cp312-cp312-win_amd64.whl",url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl",hashes = {sha256 = "cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a"}}, + {name = "greenlet-3.3.1-cp312-cp312-win_arm64.whl",url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl",hashes = {sha256 = "bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1"}}, + {name = "greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl",url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl",hashes = {sha256 = "5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c"}}, + {name = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",hashes = {sha256 = "09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd"}}, + {name = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl",hashes = {sha256 = "cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5"}}, + {name = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl",hashes = {sha256 = "67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f"}}, + {name = "greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",hashes = {sha256 = "39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2"}}, + {name = "greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl",url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl",hashes = {sha256 = "e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9"}}, + {name = "greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl",url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl",hashes = {sha256 = "80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f"}}, + {name = "greenlet-3.3.1-cp311-cp311-win_amd64.whl",url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl",hashes = {sha256 = "32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b"}}, + {name = "greenlet-3.3.1-cp311-cp311-win_arm64.whl",url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl",hashes = {sha256 = "da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4"}}, ] marker = "\"dev\" in extras and (platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\") or \"docs\" in extras and (platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\")" @@ -2667,11 +2694,11 @@ dependencies = [] [[packages]] name = "pycparser" -version = "2.23" -requires-python = ">=3.8" -sdist = {name = "pycparser-2.23.tar.gz", url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hashes = {sha256 = "78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}} +version = "3.0" +requires-python = ">=3.10" +sdist = {name = "pycparser-3.0.tar.gz", url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hashes = {sha256 = "600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}} wheels = [ - {name = "pycparser-2.23-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl",hashes = {sha256 = "e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}}, + {name = "pycparser-3.0-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl",hashes = {sha256 = "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}}, ] marker = "\"dev\" in extras and implementation_name != \"PyPy\" or \"docs\" in extras and implementation_name != \"PyPy\" or \"tests\" in extras and implementation_name != \"PyPy\" or \"zmq\" in extras and implementation_name != \"PyPy\"" @@ -2706,11 +2733,11 @@ dependencies = [] [[packages]] name = "wcwidth" -version = "0.2.14" -requires-python = ">=3.6" -sdist = {name = "wcwidth-0.2.14.tar.gz", url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hashes = {sha256 = "4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605"}} +version = "0.3.2" +requires-python = ">=3.8" +sdist = {name = "wcwidth-0.3.2.tar.gz", url = "https://files.pythonhosted.org/packages/05/07/0b5bcc9812b1b2fd331cc88289ef4d47d428afdbbf0216bb7d53942d93d6/wcwidth-0.3.2.tar.gz", hashes = {sha256 = "d469b3059dab6b1077def5923ed0a8bf5738bd4a1a87f686d5e2de455354c4ad"}} wheels = [ - {name = "wcwidth-0.2.14-py2.py3-none-any.whl",url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl",hashes = {sha256 = "a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1"}}, + {name = "wcwidth-0.3.2-py3-none-any.whl",url = "https://files.pythonhosted.org/packages/72/c6/1452e716c5af065c018f75d42ca97517a04ac6aae4133722e0424649a07c/wcwidth-0.3.2-py3-none-any.whl",hashes = {sha256 = "817abc6a89e47242a349b5d100cbd244301690d6d8d2ec6335f26fe6640a6315"}}, ] marker = "\"dev\" in extras or \"docs\" in extras" @@ -2718,7 +2745,7 @@ marker = "\"dev\" in extras or \"docs\" in extras" dependencies = [] [tool.pdm] -hashes = {sha256 = "06cadfeb160c3da38784a855b7e6581dddc2b7ad36d78afeeb824ad5252e23ea"} +hashes = {sha256 = "c6cc2d8206ca50bb2f0477bf7bece34c4d4842d43e85a4fe1da3b202b5df6570"} strategy = ["inherit_metadata", "static_urls"] [[tool.pdm.targets]] diff --git a/pyproject.toml b/pyproject.toml index cab1da12..4d98b50e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ tests = [ "pytest-timeout>=2.4.0", "pytest-asyncio>=1.3.0", "pytest-codspeed>=4.2.0", + "pytest-mock>=3.15.1", ] docs = [ "sphinx>=8.2.3,<9.0.0", # until myst parser catches up and stops emitting invalid references diff --git a/src/noob/logging.py b/src/noob/logging.py index 98d17016..54a27191 100644 --- a/src/noob/logging.py +++ b/src/noob/logging.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any, Literal -from rich import get_console +from rich.console import Console from rich.logging import RichHandler from noob.config import LOG_LEVELS, config @@ -29,6 +29,13 @@ def init_logger( Log to a set of rotating files in the ``log_dir`` according to ``name`` , as well as using the :class:`~rich.RichHandler` for pretty-formatted stdout logs. + If this method is called from a process that isn't the root process, + it will create new rich and file handlers in the root noob logger to avoid + deadlocks from threading locks that are copied on forked processes. + Since the handlers will be different across processes, + to avoid file access conflicts, logging files will have the process's ``pid`` + appended (e.g. ``noob_12345.log`` ) + Args: name (str): Name of this logger. Ideally names are hierarchical and indicate what they are logging for, eg. ``noob.api.auth`` @@ -85,30 +92,6 @@ def init_logger( logger = logging.getLogger(name) logger.setLevel(min_level) - # if run from a forked process, need to add different handlers to not collide - if mp.parent_process() is not None: - handler_name = f"{name}_{mp.current_process().pid}" - if log_dir is not False and not any([h.name == handler_name for h in logger.handlers]): - logger.addHandler( - _file_handler( - name=f"{name}_{mp.current_process().pid}", - file_level=file_level, - log_dir=log_dir, - log_file_n=log_file_n, - log_file_size=log_file_size, - ) - ) - - if not any( - [ - handler_name in h.keywords - for h in logger.handlers - if isinstance(h, RichHandler) and h.keywords is not None - ] - ): - logger.addHandler(_rich_handler(level, keywords=[handler_name], width=width)) - logger.propagate = False - return logger @@ -121,6 +104,18 @@ def _init_root( width: int | None = None, ) -> None: root_logger = logging.getLogger("noob") + + # ensure each root logger has fresh handlers in subprocesses + if mp.parent_process() is not None: + current_pid = mp.current_process().pid + file_name = f"noob_{current_pid}" + rich_name = f"{file_name}_rich" + else: + file_name = "noob" + rich_name = "noob_rich" + + root_logger.handlers = [h for h in root_logger.handlers if h.name in (rich_name, file_name)] + file_handlers = [ handler for handler in root_logger.handlers if isinstance(handler, RotatingFileHandler) ] @@ -131,7 +126,7 @@ def _init_root( if log_dir is not False and not file_handlers: root_logger.addHandler( _file_handler( - "noob", + file_name, file_level, log_dir, log_file_n, @@ -143,7 +138,7 @@ def _init_root( file_handler.setLevel(file_level) if not stream_handlers: - root_logger.addHandler(_rich_handler(stdout_level, width=width)) + root_logger.addHandler(_rich_handler(stdout_level, name=rich_name, width=width)) else: for stream_handler in stream_handlers: stream_handler.setLevel(stdout_level) @@ -171,12 +166,15 @@ def _file_handler( return file_handler -def _rich_handler(level: LOG_LEVELS, width: int | None = None, **kwargs: Any) -> RichHandler: - console = get_console() +def _rich_handler( + level: LOG_LEVELS, name: str, width: int | None = None, **kwargs: Any +) -> RichHandler: + console = _get_console() if width: console.width = width - rich_handler = RichHandler(rich_tracebacks=True, markup=True, **kwargs) + rich_handler = RichHandler(console=console, rich_tracebacks=True, markup=True, **kwargs) + rich_handler.name = name rich_formatter = logging.Formatter( r"[bold green]\[%(name)s][/bold green] %(message)s", datefmt="[%y-%m-%dT%H:%M:%S]", @@ -184,3 +182,16 @@ def _rich_handler(level: LOG_LEVELS, width: int | None = None, **kwargs: Any) -> rich_handler.setFormatter(rich_formatter) rich_handler.setLevel(level) return rich_handler + + +_console_by_pid: dict[int | None, Console] = {} + + +def _get_console() -> Console: + """get a console that was spawned in this process""" + global _console_by_pid + current_pid = mp.current_process().pid + console = _console_by_pid.get(current_pid) + if console is None: + _console_by_pid[current_pid] = console = Console() + return console diff --git a/src/noob/network/message.py b/src/noob/network/message.py index 420047ee..339986ec 100644 --- a/src/noob/network/message.py +++ b/src/noob/network/message.py @@ -33,6 +33,9 @@ class MessageType(StrEnum): announce = "announce" identify = "identify" process = "process" + init = "init" + deinit = "deinit" + ping = "ping" start = "start" status = "status" stop = "stop" @@ -111,6 +114,13 @@ class IdentifyMsg(Message): value: IdentifyValue +class PingMsg(Message): + """Request other nodes to identify themselves and report their status""" + + type_: Literal[MessageType.ping] = Field(MessageType.ping, alias="type") + value: None = None + + class ProcessMsg(Message): """Process a single iteration of the graph""" @@ -119,11 +129,25 @@ class ProcessMsg(Message): """Any process-scoped input passed to the `process` call""" +class InitMsg(Message): + """Initialize nodes within node runners""" + + type_: Literal[MessageType.init] = Field(MessageType.init, alias="type") + value: None = None + + +class DeinitMsg(Message): + """Deinitializes nodes within node runners""" + + type_: Literal[MessageType.deinit] = Field(MessageType.deinit, alias="type") + value: None = None + + class StartMsg(Message): - """Start free running nodes""" + """Start free-running nodes""" type_: Literal[MessageType.start] = Field(MessageType.start, alias="type") - value: None = None + value: int | None = None class StatusMsg(Message): @@ -196,6 +220,9 @@ def _type_discriminator(v: dict | Message) -> str: A[AnnounceMsg, Tag("announce")] | A[IdentifyMsg, Tag("identify")] | A[ProcessMsg, Tag("process")] + | A[InitMsg, Tag("init")] + | A[DeinitMsg, Tag("deinit")] + | A[PingMsg, Tag("ping")] | A[StartMsg, Tag("start")] | A[StatusMsg, Tag("status")] | A[StopMsg, Tag("stop")] diff --git a/src/noob/node/base.py b/src/noob/node/base.py index 78b8f48f..b5b3e9a2 100644 --- a/src/noob/node/base.py +++ b/src/noob/node/base.py @@ -12,7 +12,6 @@ cast, get_args, get_origin, - overload, ) from pydantic import ( @@ -26,7 +25,6 @@ from noob.introspection import is_optional, is_union from noob.node.spec import NodeSpecification -from noob.types import RunnerContext from noob.utils import resolve_python_identifier if TYPE_CHECKING: @@ -195,14 +193,8 @@ def model_post_init(self, __context: Any) -> None: if inspect.isgeneratorfunction(self.process): self._wrap_generator(self.process) - @overload - def init(self) -> None: ... - - @overload - def init(self, context: RunnerContext) -> None: ... - # TODO: Support dependency injection in mypy plugin - def init(self) -> None: # type: ignore[misc] + def init(self) -> None: """ Start producing, processing, or receiving data. diff --git a/src/noob/runner/base.py b/src/noob/runner/base.py index a84bdcf0..876dd65c 100644 --- a/src/noob/runner/base.py +++ b/src/noob/runner/base.py @@ -13,7 +13,7 @@ from datetime import UTC, datetime from functools import partial from logging import Logger -from typing import TYPE_CHECKING, Any, ParamSpec, Self, TypeVar +from typing import TYPE_CHECKING, Any, ParamSpec, Self, TypeVar, overload from noob import Tube, init_logger from noob.asset import AssetScope @@ -180,7 +180,20 @@ def iter(self, n: int | None = None) -> Generator[ReturnNodeType, None, None]: finally: self.deinit() + @overload + def run(self, n: int) -> list[ReturnNodeType]: ... + + @overload + def run(self, n: None) -> None: ... + def run(self, n: int | None = None) -> None | list[ReturnNodeType]: + """ + Run the tube infinitely or for a fixed number of iterations in a row. + + Returns results if ``n`` is not ``None`` - + If ``n`` is ``None`` , we assume we are going to be running for a very long time, + and don't want to have an infinitely-growing collection in memory. + """ try: _ = self.tube.input_collection.validate_input(InputScope.process, {}) except InputMissingError as e: @@ -540,25 +553,33 @@ def call_async_from_sync( result_future: asyncio.Future[_TReturn] = asyncio.Future() work_ready = threading.Condition() + finished = False # Closures because this code should never escape the containment tomb of this crime against god async def _wrap(call_result: asyncio.Future[_TReturn], fn: Coroutine) -> None: + nonlocal finished try: result = await fn call_result.set_result(result) except Exception as e: call_result.set_exception(e) + finally: + finished = True def _done(_: ConcurrentFuture) -> None: + nonlocal finished + + finished = True with work_ready: work_ready.notify_all() future_inner = executor.submit(asyncio.run, _wrap(result_future, coro)) future_inner.add_done_callback(_done) - with work_ready: - work_ready.wait() try: + while not finished and not future_inner.done(): + with work_ready: + work_ready.wait(timeout=1) res = result_future.result() return res finally: diff --git a/src/noob/runner/zmq.py b/src/noob/runner/zmq.py index b54d2564..ac85991d 100644 --- a/src/noob/runner/zmq.py +++ b/src/noob/runner/zmq.py @@ -22,6 +22,7 @@ """ +import math import multiprocessing as mp import os import signal @@ -34,7 +35,7 @@ from multiprocessing.synchronize import Event as EventType from time import time from types import FrameType -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, cast, overload from noob.network.loop import EventloopMixin @@ -49,12 +50,14 @@ from zmq.eventloop.zmqstream import ZMQStream from noob.config import config -from noob.event import Event +from noob.event import Event, MetaSignal +from noob.exceptions import InputMissingError from noob.input import InputCollection, InputScope from noob.logging import init_logger from noob.network.message import ( AnnounceMsg, AnnounceValue, + DeinitMsg, ErrorMsg, ErrorValue, EventMsg, @@ -63,7 +66,9 @@ Message, MessageType, NodeStatus, + PingMsg, ProcessMsg, + StartMsg, StatusMsg, StopMsg, ) @@ -131,17 +136,24 @@ def router_address(self) -> str: else: raise NotImplementedError() - def start(self) -> None: + def init(self) -> None: self.logger.debug("Starting command runner") self.start_loop() self._init_sockets() self.logger.debug("Command runner started") + def deinit(self) -> None: + """Close the eventloop, stop processing messages, reset state""" + self.logger.debug("Deinitializing") + msg = DeinitMsg(node_id="command") + self._outbox.send_multipart([b"deinit", msg.to_bytes()]) + self.stop_loop() + self.logger.debug("Deinitialized") + def stop(self) -> None: self.logger.debug("Stopping command runner") msg = StopMsg(node_id="command") self._outbox.send_multipart([b"stop", msg.to_bytes()]) - self.stop_loop() self.logger.debug("Command runner stopped") def _init_sockets(self) -> None: @@ -181,6 +193,18 @@ def announce(self) -> None: ) self._outbox.send_multipart([b"announce", msg.to_bytes()]) + def ping(self) -> None: + """Send a ping message asking everyone to identify themselves""" + msg = PingMsg(node_id="command") + self._outbox.send_multipart([b"ping", msg.to_bytes()]) + + def start(self, n: int | None = None) -> None: + """ + Start running in free-run mode + """ + self._outbox.send_multipart([b"start", StartMsg(node_id="command", value=n).to_bytes()]) + self.logger.debug("Sent start message") + def process(self, epoch: int, input: dict | None = None) -> None: """Emit a ProcessMsg to process a single round through the graph""" # no empty dicts @@ -204,27 +228,40 @@ def add_callback(self, type_: Literal["inbox", "router"], cb: Callable[[Message] def clear_callbacks(self) -> None: self._callbacks = defaultdict(list) - def await_ready(self, node_ids: list[NodeID]) -> None: + def await_ready(self, node_ids: list[NodeID], timeout: float = 10) -> None: """ Wait until all the node_ids have announced themselves """ - with self._ready_condition: - if set(node_ids) == set(self._nodes): - return - def _is_ready() -> bool: - ready_nodes = { - node_id for node_id, state in self._nodes.items() if state["status"] == "ready" - } - waiting_for = set(node_ids) - self.logger.debug( - "Checking if ready, ready nodes are: %s, waiting for %s", - ready_nodes, - waiting_for, - ) - return waiting_for == ready_nodes + def _ready_nodes() -> set[str]: + return {node_id for node_id, state in self._nodes.items() if state["status"] == "ready"} + + def _is_ready() -> bool: + ready_nodes = _ready_nodes() + waiting_for = set(node_ids) + self.logger.debug( + "Checking if ready, ready nodes are: %s, waiting for %s", + ready_nodes, + waiting_for, + ) + return waiting_for.issubset(ready_nodes) - self._ready_condition.wait_for(_is_ready) + with self._ready_condition: + # ping periodically for identifications in case we have slow subscribers + start_time = time() + ready = False + while time() < start_time + timeout and not ready: + ready = self._ready_condition.wait_for(_is_ready, timeout=1) + if not ready: + self.ping() + + # if still not ready, timeout + if not ready: + raise TimeoutError( + f"Nodes were not ready after the timeout. " + f"Waiting for: {set(node_ids)}, " + f"ready: {_ready_nodes()}" + ) def on_router(self, msg: list[bytes]) -> None: try: @@ -367,6 +404,7 @@ def run(cls, spec: NodeSpecification, **kwargs: Any) -> None: try: def _handler(sig: int, frame: FrameType | None = None) -> None: + signal.signal(signal.SIGTERM, signal.SIG_DFL) raise KeyboardInterrupt() signal.signal(signal.SIGTERM, _handler) @@ -425,6 +463,8 @@ def await_inputs(self) -> Generator[tuple[tuple[Any], dict[str, Any], int]]: if inputs is None: inputs = {} args, kwargs = self.store.split_args_kwargs(inputs) + # clear events for this epoch, since we have consumed what we need here. + self.store.clear(ready["epoch"]) yield args, kwargs, ready["epoch"] def update_graph(self, events: list[Event]) -> None: @@ -464,6 +504,8 @@ def identify(self) -> None: "Node was not initialized by the time we tried to " "identify ourselves to the command node." ) + + self.logger.debug("Identifying") with self._status_lock: ann = IdentifyMsg( node_id=self.spec.id, @@ -482,10 +524,12 @@ def identify(self) -> None: def update_status(self, status: NodeStatus) -> None: """Update our internal status and announce it to the command node""" + self.logger.debug("Updating status as %s", status) with self._status_lock: self.status = status msg = StatusMsg(node_id=self.spec.id, value=status) self._dealer.send_multipart([msg.to_bytes()]) + self.logger.debug("Updated status") def start_sockets(self) -> None: self.start_loop() @@ -560,9 +604,17 @@ def on_inbox(self, msg: list[bytes]) -> None: elif message.type_ == MessageType.process: message = cast(ProcessMsg, message) self.on_process(message) + elif message.type_ == MessageType.start: + message = cast(StartMsg, message) + self.on_start(message) elif message.type_ == MessageType.stop: message = cast(StopMsg, message) self.on_stop(message) + elif message.type_ == MessageType.deinit: + message = cast(DeinitMsg, message) + self.on_deinit(message) + elif message.type_ == MessageType.ping: + self.identify() else: # log but don't throw - other nodes shouldn't be able to crash us self.logger.error(f"{message.type_} not implemented!") @@ -573,12 +625,16 @@ def on_announce(self, msg: AnnounceMsg) -> None: Store map, connect to the nodes we depend on """ self._node = cast(Node, self._node) + self.logger.debug("Processing announce") with self._status_lock: depended_nodes = {edge.source_node for edge in self._node.edges} + if depended_nodes: + self.logger.debug("Should subscribe to %s", depended_nodes) for node_id in msg.value["nodes"]: if node_id in depended_nodes and node_id not in self._nodes: # TODO: a way to check if we're already connected, without storing it locally? outbox = msg.value["nodes"][node_id]["outbox"] + self.logger.debug("Subscribing to %s at %s", node_id, outbox) self._inbox.connect(outbox) self.logger.debug("Subscribed to %s at %s", node_id, outbox) self._nodes = msg.value["nodes"] @@ -604,6 +660,17 @@ def on_event(self, msg: EventMsg) -> None: self.scheduler.update(events) + def on_start(self, msg: StartMsg) -> None: + """ + Start running in free mode + """ + self.update_status(NodeStatus.running) + if msg.value is None: + self._freerun.set() + else: + self._to_process += msg.value + self._process_one.set() + def on_process(self, msg: ProcessMsg) -> None: """ Process a single graph iteration @@ -636,7 +703,19 @@ def on_process(self, msg: ProcessMsg) -> None: self._process_one.set() def on_stop(self, msg: StopMsg) -> None: - """Stop processing!""" + """Stop processing (but stay responsive)""" + self._process_one.clear() + self._to_process = 0 + self._freerun.clear() + self.update_status(NodeStatus.stopped) + self.logger.debug("Stopped") + + def on_deinit(self, msg: DeinitMsg) -> None: + """ + Deinitialize the node, close networking thread. + + Cause the main loop to end, which calls deinit + """ self._process_quitting.set() pid = mp.current_process().pid if pid is None: @@ -673,18 +752,31 @@ class ZMQRunner(TubeRunner): quit_timeout: float = 10 """time in seconds to wait after calling deinit to wait before killing runner processes""" store: EventStore = field(default_factory=EventStore) + autoclear_store: bool = True + """ + If ``True`` (default), clear the event store after events are processed and returned. + If ``False`` , don't clear events from the event store + """ + _initialized: EventType = field(default_factory=mp.Event) _running: EventType = field(default_factory=mp.Event) + _init_lock: threading.RLock = field(default_factory=threading.RLock) + _running_lock: threading.Lock = field(default_factory=threading.Lock) + _ignore_events: bool = False _return_node: Return | None = None - _init_lock: threading.Lock = field(default_factory=threading.Lock) _to_throw: ErrorValue | None = None - _current_epoch: int | None = None + _current_epoch: int = 0 @property def running(self) -> bool: - with self._init_lock: + with self._running_lock: return self._running.is_set() + @property + def initialized(self) -> bool: + with self._init_lock: + return self._initialized.is_set() + def init(self) -> None: if self.running: return @@ -693,7 +785,7 @@ def init(self) -> None: self.command = CommandNode(runner_id=self.runner_id) self.command.add_callback("inbox", self.on_event) self.command.add_callback("router", self.on_router) - self.command.start() + self.command.init() self._logger.debug("Command node initialized") for node_id, node in self.tube.nodes.items(): @@ -714,14 +806,22 @@ def init(self) -> None: ) self.node_procs[node_id].start() self._logger.debug("Started node processes, awaiting ready") - self.command.await_ready( - [k for k, v in self.tube.nodes.items() if not isinstance(v, Return)] - ) + try: + self.command.await_ready( + [k for k, v in self.tube.nodes.items() if not isinstance(v, Return)] + ) + except TimeoutError as e: + self._logger.debug("Timeouterror, deinitializing before throwing") + self._initialized.set() + self.deinit() + self._logger.exception(e) + raise + self._logger.debug("Nodes ready") - self._running.set() + self._initialized.set() def deinit(self) -> None: - if not self.running: + if not self.initialized: return with self._init_lock: @@ -743,30 +843,173 @@ def deinit(self) -> None: f"NodeRunner {proc.name} was still alive after timeout expired, killing it" ) proc.kill() - proc.close() + try: + proc.close() + except ValueError: + self._logger.info( + f"NodeRunner {proc.name} still not closed! making an unclean exit." + ) + self.command.clear_callbacks() + self.command.deinit() self.tube.scheduler.clear() - self._running.clear() + self._initialized.clear() def process(self, **kwargs: Any) -> ReturnNodeType: - if not self.running: + if not self.initialized: self._logger.info("Runner called process without calling `init`, initializing now.") self.init() + if self.running: + raise RuntimeError( + "Runner is already running in free run mode! use iter to gather results" + ) input = self.tube.input_collection.validate_input(InputScope.process, kwargs) + self._running.set() + try: + self._current_epoch = self.tube.scheduler.add_epoch() + # we want to mark 'input' as done if it's in the topo graph, + # but input can be present and only used as a param, + # so we can't check presence of inputs in the input collection + if "input" in self.tube.scheduler._epochs[self._current_epoch].ready_nodes: + self.tube.scheduler.done(self._current_epoch, "input") + self.command = cast(CommandNode, self.command) + self.command.process(self._current_epoch, input) + self._logger.debug("awaiting epoch %s", self._current_epoch) + self.tube.scheduler.await_epoch(self._current_epoch) + if self._to_throw: + self._throw_error() + self._logger.debug("collecting return") + + return self.collect_return(self._current_epoch) + finally: + self._running.clear() + + def iter(self, n: int | None = None) -> Generator[ReturnNodeType, None, None]: + """ + Iterate over results as they are available. + + Tube runs in free-run mode for n iterations, + This method is usually only useful for tubes with :class:`.Return` nodes. + This method yields only when return is available: + the tube will run more than n ``process`` calls if there are e.g. gather nodes + that cause the return value to be empty. + + To call the tube a specific number of times and do something with the events + other than returning a value, use callbacks and :meth:`.run` ! + + Note that backpressure control is not yet implemented!!! + If the outer iter method is slow, or there is a bottleneck in your tube, + you might incur some serious memory usage! + Backpressure and observability is a WIP! - self._current_epoch = self.tube.scheduler.add_epoch() - # we want to mark 'input' as done if it's in the topo graph, - # but input can be present and only used as a param, so we can't check presence of inputs - if "input" in self.tube.scheduler._epochs[self._current_epoch].ready_nodes: - self.tube.scheduler.done(self._current_epoch, "input") + If you need a version of this method that *always* makes a fixed number of process calls, + raise an issue! + """ + if not self.initialized: + raise RuntimeError( + "ZMQRunner must be explicitly initialized and deinitialized, " + "use the runner as a contextmanager or call `init()` and `deinit()`" + ) + try: + _ = self.tube.input_collection.validate_input(InputScope.process, {}) + except InputMissingError as e: + raise InputMissingError( + "Can't use the `iter` method with tubes with process-scoped input " + "that was not provided when instantiating the tube! " + "Use `process()` directly, providing required inputs to each call." + ) from e + if self.running: + raise RuntimeError("Already Running!") self.command = cast(CommandNode, self.command) - self.command.process(self._current_epoch, input) - self._logger.debug("awaiting epoch %s", self._current_epoch) - self.tube.scheduler.await_epoch(self._current_epoch) - if self._to_throw: - self._throw_error() - self._logger.debug("collecting return") - return self.collect_return(self._current_epoch) + + epoch = self._current_epoch + start_epoch = epoch + stop_epoch = epoch + n if n is not None else epoch + # start running without a limit - we'll check as we go. + self.command.start(n) + self._running.set() + current_iter = 0 + try: + while n is None or current_iter < n: + ret = MetaSignal.NoEvent + loop = 0 + while ret is MetaSignal.NoEvent: + self._logger.debug("Awaiting epoch %s", epoch) + self.tube.scheduler.await_epoch(epoch) + ret = self.collect_return(epoch) + epoch += 1 + self._current_epoch = epoch + if loop > self.max_iter_loops: + raise RuntimeError("Reached maximum process calls per iteration") + # if we have run out of epochs to run, request some more with a cheap heuristic + if n is not None and epoch >= stop_epoch: + stop_epoch += self._request_more( + n=n, current_iter=current_iter, n_epochs=stop_epoch - start_epoch + ) + + current_iter += 1 + yield ret + + finally: + self.stop() + + @overload + def run(self, n: int) -> list[ReturnNodeType]: ... + + @overload + def run(self, n: None = None) -> None: ... + + def run(self, n: int | None = None) -> None | list[ReturnNodeType]: + """ + Run the tube in freerun mode - every node runs as soon as its dependencies are satisfied, + not waiting for epochs to complete before starting the next epoch. + + Blocks when ``n`` is not None - + This is for consistency with the synchronous/asyncio runners, + but may change in the future. + + If ``n`` is None, does not block. + stop processing by calling :meth:`.stop` or deinitializing + (exiting the contextmanager, or calling :meth:`.deinit`) + """ + if not self.initialized: + raise RuntimeError( + "ZMQRunner must be explicitly initialized and deinitialized, " + "use the runner as a contextmanager or call `init()` and `deinit()`" + ) + if self.running: + raise RuntimeError("Already Running!") + try: + _ = self.tube.input_collection.validate_input(InputScope.process, {}) + except InputMissingError as e: + raise InputMissingError( + "Can't use the `iter` method with tubes with process-scoped input " + "that was not provided when instantiating the tube! " + "Use `process()` directly, providing required inputs to each call." + ) from e + self.command = cast(CommandNode, self.command) + + if n is None: + if self.autoclear_store: + self._ignore_events = True + self.command.start() + self._running.set() + return None + + else: + results = [] + for res in self.iter(n): + results.append(res) + return results + + def stop(self) -> None: + """ + Stop running the tube. + """ + self.command = cast(CommandNode, self.command) + self._ignore_events = False + self.command.stop() + self._running.clear() def on_event(self, msg: Message) -> None: self._logger.debug("EVENT received: %s", msg) @@ -775,8 +1018,10 @@ def on_event(self, msg: Message) -> None: return msg = cast(EventMsg, msg) - for event in msg.value: - self.store.add(event) + # store events (if we are not in freerun mode, where we don't want to store infinite events) + if not self._ignore_events: + for event in msg.value: + self.store.add(event) self.tube.scheduler.update(msg.value) if self._return_node is not None: # mark the return node done if we've received the expected events for an epoch @@ -803,10 +1048,13 @@ def collect_return(self, epoch: int | None = None) -> Any: else: events = self.store.collect(self._return_node.edges, epoch) if events is None: - return None + return MetaSignal.NoEvent args, kwargs = self.store.split_args_kwargs(events) self._return_node.process(*args, **kwargs) - return self._return_node.get(keep=False) + ret = self._return_node.get(keep=False) + if self.autoclear_store: + self.store.clear(epoch) + return ret def _handle_error(self, msg: ErrorMsg) -> None: """Cancel current epoch, stash error for process method to throw""" @@ -842,6 +1090,39 @@ def _throw_error(self) -> None: raise err + def _request_more(self, n: int, current_iter: int, n_epochs: int) -> int: + """ + During iteration with cardinality-reducing nodes, + if we haven't gotten the requested n return values in n epochs, + request more epochs based on how many return values we got for n iterations + + Args: + n (int): number of requested return values + current_iter (int): current number of return values that have been collected + n_epochs (int): number of epochs that have run + """ + self.command = cast(CommandNode, self.command) + n_remaining = n - current_iter + if n_remaining <= 0: + self._logger.warning( + "Asked to request more epochs, but already collected enough return values. " + "Ignoring. " + "Requested n: %s, collected n: %s", + n, + current_iter, + ) + return 0 + + # if we get one return value every 5 epochs, + # and we ran 5 epochs to get 1 result, + # then we need to run 20 more to get the other 4, + # or, (n remaining) * (epochs per result) + # so... + divisor = current_iter if current_iter > 0 else 1 + get_more = math.ceil(n_remaining * (n_epochs / divisor)) + self.command.start(get_more) + return get_more + def enable_node(self, node_id: str) -> None: raise NotImplementedError() diff --git a/src/noob/scheduler.py b/src/noob/scheduler.py index 5bf26236..8c2efffa 100644 --- a/src/noob/scheduler.py +++ b/src/noob/scheduler.py @@ -66,7 +66,7 @@ def add_epoch(self, epoch: int | None = None) -> int: Add another epoch with a prepared graph to the scheduler. """ with self._ready_condition: - if epoch: + if epoch is not None: this_epoch = epoch # ensure that the next iteration of the clock will return the next number # if we create epochs out of order @@ -316,7 +316,9 @@ def epoch_completed(self, epoch: int) -> bool: """ with self._epoch_condition: previously_completed = ( - len(self._epoch_log) > 0 and epoch not in self._epochs and epoch in self._epoch_log + len(self._epoch_log) > 0 + and epoch not in self._epochs + and (epoch in self._epoch_log or epoch < min(self._epoch_log)) ) active_completed = epoch in self._epochs and not self._epochs[epoch].is_active() return previously_completed or active_completed diff --git a/src/noob/store.py b/src/noob/store.py index 56aa6595..1dae0089 100644 --- a/src/noob/store.py +++ b/src/noob/store.py @@ -2,7 +2,9 @@ Tube runners for running tubes """ +import contextlib from collections import defaultdict +from collections.abc import Generator from dataclasses import dataclass, field from datetime import UTC, datetime from itertools import count @@ -192,7 +194,8 @@ def clear(self, epoch: int | None = None) -> None: if epoch is None: self.events = _make_event_dict() else: - del self.events[epoch] + with contextlib.suppress(KeyError): + del self.events[epoch] @staticmethod def transform_events(edges: list[Edge], events: list[Event]) -> dict: @@ -231,3 +234,10 @@ def split_args_kwargs(inputs: dict) -> tuple[tuple, dict]: # cast to tuple since `*args` is a tuple args_tuple = tuple(item[1] for item in sorted(args, key=lambda x: x[0])) return args_tuple, kwargs + + def iter(self) -> Generator[Event, None, None]: + """Iterate through all events""" + for nodes in self.events.values(): + for signals in nodes.values(): + for events in signals.values(): + yield from events diff --git a/src/noob/testing/__init__.py b/src/noob/testing/__init__.py index dffdf022..cd182695 100644 --- a/src/noob/testing/__init__.py +++ b/src/noob/testing/__init__.py @@ -2,6 +2,7 @@ from noob.testing.nodes import ( CountSource, CountSourceDecor, + InitCounter, Multiply, Now, NumberToLetterCls, @@ -56,6 +57,7 @@ "Multiply", "VolumeProcess", "Volume", + "InitCounter", "Now", "NumberToLetterCls", "StatefulMultiply", diff --git a/src/noob/testing/nodes.py b/src/noob/testing/nodes.py index b24962b2..1996f88f 100644 --- a/src/noob/testing/nodes.py +++ b/src/noob/testing/nodes.py @@ -157,7 +157,7 @@ def input_party( def long_add(value: float) -> float: - sleep(0.5) + sleep(0.25) return value + 1 @@ -224,3 +224,23 @@ def increment( def passthrough(value: Any) -> Any: return value + + +class InitCounter(Node): + """Count how many times we have been initialized and deinitalized""" + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self._inits = 0 + self._deinits = 0 + + def process(self) -> tuple[A[int, Name("inits")], A[int, Name("deinits")]]: + # sleep to just not have this flood the networking modules. + sleep(0.01) + return self._inits, self._deinits + + def init(self) -> None: + self._inits += 1 + + def deinit(self) -> None: + self._deinits += 1 diff --git a/tests/conftest.py b/tests/conftest.py index b0dbf590..5265c3bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,13 +28,6 @@ def _config_sources(cls: type[ConfigYAMLMixin]) -> list[Path]: monkeypatch_session.setattr(ConfigYAMLMixin, "config_sources", classmethod(_config_sources)) -@pytest.fixture(scope="session", autouse=True) -def patch_env_config(monkeypatch_session: MonkeyPatch) -> None: - """Patch env settings, e.g. setting log levels and etc.""" - - monkeypatch_session.setenv("NOOB_LOGS__LEVEL", "DEBUG") - - def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: # While zmq runner uses IPC, can't run on windows if platform.system() == "Windows": diff --git a/tests/data/pipelines/special/count_inits.yaml b/tests/data/pipelines/special/count_inits.yaml new file mode 100644 index 00000000..5ac62559 --- /dev/null +++ b/tests/data/pipelines/special/count_inits.yaml @@ -0,0 +1,7 @@ +noob_id: testing-count-init +noob_model: noob.tube.TubeSpecification +noob_version: 0.1.1.dev86+gcf9e11b.d20250725 + +nodes: + counter: + type: noob.testing.InitCounter diff --git a/tests/test_pipelines/test_basic.py b/tests/test_pipelines/test_basic.py index 4a8219ba..b2a93932 100644 --- a/tests/test_pipelines/test_basic.py +++ b/tests/test_pipelines/test_basic.py @@ -15,7 +15,7 @@ def test_basic_process(loaded_tube: Tube, runner: TubeRunner): @pytest.mark.parametrize("loaded_tube", ["testing-basic"], indirect=True) -def test_basic(loaded_tube: Tube, runner: TubeRunner): +def test_basic_run(loaded_tube: Tube, runner: TubeRunner): """The most basic tube! We can process a fixed number of events""" outputs = runner.run(n=5) assert len(outputs) == 5 @@ -102,7 +102,8 @@ def test_multi_signal(loaded_tube: Tube, sync_runner_cls): tube = Tube.from_specification("testing-multi-signal") runner = sync_runner_cls(tube) - for value in runner.iter(n=5): - assert isinstance(value, dict) - assert isinstance(value["word"], str) - assert value["count_sum"] == sum(value["counts"]) + with runner: + for value in runner.iter(n=5): + assert isinstance(value, dict) + assert isinstance(value["word"], str) + assert value["count_sum"] == sum(value["counts"]) diff --git a/tests/test_runners/test_zmq.py b/tests/test_runners/test_zmq.py index 5606e847..f49f2114 100644 --- a/tests/test_runners/test_zmq.py +++ b/tests/test_runners/test_zmq.py @@ -1,4 +1,5 @@ from asyncio import sleep +from datetime import UTC, datetime from time import time from typing import cast @@ -6,8 +7,10 @@ from noob import Tube from noob.event import Event +from noob.input import InputCollection from noob.network.message import EventMsg, IdentifyMsg, IdentifyValue, Message, NodeStatus -from noob.runner.zmq import ZMQRunner +from noob.node.spec import NodeSpecification +from noob.runner.zmq import NodeRunner, ZMQRunner pytestmark = pytest.mark.zmq_runner @@ -81,7 +84,7 @@ def _event_cb(event: Message) -> None: event = cast(EventMsg, event) events.extend(event.value) - runner = ZMQRunner(tube=tube) + runner = ZMQRunner(tube=tube, autoclear_store=False) with runner: runner.command.add_callback("inbox", _event_cb) # skip first epoch @@ -144,7 +147,7 @@ def _event_cb(event: Message) -> None: event = cast(EventMsg, event) events.extend(event.value) - runner = ZMQRunner(tube=tube) + runner = ZMQRunner(tube=tube, autoclear_store=False) for i in range(3): runner.tube.scheduler.add_epoch(i) runner.tube.scheduler.done(i, "input") @@ -191,3 +194,246 @@ def _event_cb(event: Message) -> None: assert runner.store.events[2]["a"]["value"][0]["value"] == 7 * 2 assert runner.store.events[2]["b"]["value"][0]["value"] == 7 assert runner.store.events[2]["d"]["value"][0]["value"] == 7 * 3 * 3 + + +@pytest.mark.asyncio +async def test_run_freeruns(): + """ + When `run` is called, the zmq runner should "freerun" - + allow nodes to execute as quickly as they can, whenever their deps are satisfied. + """ + tube = Tube.from_specification("testing-long-add") + runner = ZMQRunner(tube, autoclear_store=False) + + # events = [] + # + # def _event_cb(event: Message) -> None: + # nonlocal events + # events.append(event) + + with runner: + runner.run() + assert runner.running + await sleep(0.5) + runner.stop() + + # main thing we're testing here is whether we freerun - + # i.e. that nodes don't wait for an epoch to complete before running, if they're ready. + # so we should have way more events from the source node than from the long_add nodes, + # which sleep and take a long time on purpose. + # since there are way more long_add nodes than the single count node, + # if we ran epoch by epoch, we would expect there to be more non-count events. + count_events = [] + non_count_events = [] + for event in runner.store.iter(): + if event["node_id"] == "count": + count_events.append(event) + else: + non_count_events.append(event) + + assert len(count_events) > 0 + assert len(non_count_events) > 0 + assert len(count_events) > len(non_count_events) + # we should theoretically get only 2 epochs from the long add nodes, + # but allow up to 5 in the case of network latency, this is not that important + assert len(set(e["epoch"] for e in non_count_events)) < 5 + # it should be in the hundreds, but all we care about is that it's more than 1 greater + # 10 is a good number. + assert len(set(e["epoch"] for e in count_events)) > 10 + + +@pytest.mark.asyncio +async def test_start_stop(): + """ + The runner can be started and stopped without deinitializing + """ + tube = Tube.from_specification("testing-count-init") + events: list[Event] = [] + router_events: list[Message] = [] + + def _event_cb(event: Message) -> None: + nonlocal events + event = cast(EventMsg, event) + events.extend(event.value) + + def _router_cb(msg: Message) -> None: + nonlocal router_events + router_events.append(msg) + + runner = ZMQRunner(tube=tube) + with runner: + runner.command.add_callback("inbox", _event_cb) + runner.command.add_callback("router", _router_cb) + runner.run() + await sleep(0.1) + runner.stop() + await sleep(0.1) + runner.run() + await sleep(0.2) + + router_events = [ + e for e in router_events if e.type_ == "status" and e.value in ("stopped", "running") + ] + # we can get duplicate status messages if the command node has to ping to wake up the sockets + # so filter just to the transitions + router_events = [ + e for i, e in enumerate(router_events) if i == 0 or router_events[i - 1].value != e.value + ] + + # sometimes we get the last stop event, + # sometimes we dont - we don't wait for it + assert 4 >= len(router_events) >= 3, str(router_events) + assert router_events[1].value == "stopped" + first_events = [e for e in events if e["timestamp"] < router_events[1].timestamp] + stopped_events = [ + e + for e in events + if e["timestamp"] > router_events[1].timestamp + and e["timestamp"] < router_events[2].timestamp + ] + end_events = [e for e in events if e["timestamp"] > router_events[2].timestamp] + + # with the node's sleep, there are time for ~10 runs if there was no latency, + # but all we really care about is that we got any + # (macos runner on gh is very slow to process events, and we don't want to increase wait time) + assert len(first_events) > 0 + # there can be one additional run of the node after the stopped message is sent + # if the node is already running when the stop message is received. + # (two events, because the node emits two signals) + assert len(stopped_events) <= 2 + assert len(end_events) > 0 + + # even though we stopped and started, the nodes should have stated initialized + # (and not been deinit'd and reinit'd) + inits = [e for e in events if e["signal"] == "inits"] + deinits = [e for e in events if e["signal"] == "deinits"] + assert len(inits) > 0 + assert len(deinits) > 0 + assert all(e["value"] == 1 for e in inits) + assert all(e["value"] == 0 for e in deinits) + + +def test_iter_gather(mocker): + """ + itering over gather should heuristically request more iterations as we go + """ + tube = Tube.from_specification("testing-gather-n") + runner = ZMQRunner(tube=tube) + # the gather_n pipeline only returns every 5 epochs. + # so we are going to request 11 return values, which should require 55 epochs + # after we run 11 epochs, (after returning two results) + # we should notice that we haven't returned the correct amount yet + # and request more. + spy = mocker.spy(runner, "_request_more") + stop_spy = mocker.spy(runner, "stop") + results = [] + with runner: + for result in runner.iter(11): + results.append(result) + + # after exhausting the iterator, but before deinit, we should have called stop + stop_spy.assert_called_once() + + # for cases like this with deterministic n_gather, only should need to call once. + assert spy.call_count == 1 + spy.assert_called_once_with(n=11, current_iter=2, n_epochs=11) + # we don't get an *exact* number of iters to run, + # e.g. here we have chosen 11 since it isn't a multiple of 5, + # and all we see is "we've run 11 times and gotten 2 results," + # so if 11 epochs yields 2 results, and we want 9 more, + # then a reasonable amount of additional times to run might be + # ceil((11/2)*9) = 50 + assert spy.spy_return == 50 + + +def test_noderunner_stores_clear(): + """ + Stores in the noderunners should clear after they use the events from an epoch + """ + spec = NodeSpecification( + id="test_node", + type="noob.testing.multiply", + depends=[{"left": "other.left"}, {"right": "other.right"}], + ) + runner = NodeRunner( + spec=spec, + runner_id="testing", + command_outbox="/notreal/unused", + command_router="/notreal/unused", + input_collection=InputCollection(), + ) + runner.init_node() + + # fake a few events + events = [] + for i in range(3): + + msg = EventMsg( + node_id="other", + value=[ + Event( + id=i * 2, + timestamp=datetime.now(UTC), + signal="left", + value=i * 2, + node_id="other", + epoch=i, + ), + Event( + id=(i * 2) + 1, + timestamp=datetime.now(UTC), + signal="right", + value=(i * 2) + 1, + node_id="other", + epoch=i, + ), + ], + ) + runner.on_event(msg) + events.append(msg) + + runner._freerun.set() + assert len(runner.store.events) == 3 + args, kwargs, epoch = next(runner.await_inputs()) + assert len(runner.store.events) == 2 + assert epoch not in runner.store.events + + +@pytest.fixture +def _zmq_runner_basic() -> ZMQRunner: + tube = Tube.from_specification("testing-basic") + runner = ZMQRunner(tube=tube) + runner.init() + yield runner + runner.deinit() + + +def test_zmqrunner_stores_clear_process(_zmq_runner_basic): + """ + ZMQRunner stores clear after returning values from process + """ + runner = _zmq_runner_basic + runner.process() + assert len(runner.store.events) == 0 + + +def test_zmqrunner_stores_clear_iter(_zmq_runner_basic): + """ + ZMQRunner stores clear after returning values while iterating + """ + runner = _zmq_runner_basic + for i, _ in enumerate(runner.iter(5)): + # we will receive more events from epochs that run ahead of our processing + # but we should clear the epoch as we return it from iter + assert i not in runner.store.events + + +@pytest.mark.asyncio +async def test_zmqrunner_stores_clear_freerun(_zmq_runner_basic): + """ + ZMQRunner doesn't store events while freerunning. + """ + runner = _zmq_runner_basic + runner.run() + await sleep(0.1) + assert len(runner.store.events) == 0 diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 407b6a65..c839f31e 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -6,6 +6,7 @@ from noob import SynchronousRunner, Tube from noob.event import Event, MetaEventType +from noob.exceptions import EpochCompletedError from noob.toposort import TopoSorter @@ -178,8 +179,9 @@ def test_clear_ended_epochs(): assert len(scheduler._epochs) == 2 else: assert scheduler[1] - with pytest.raises(KeyError): + with pytest.raises(EpochCompletedError): _ = scheduler[0] + assert len(scheduler._epochs) == 1 @pytest.mark.xfail(raises=Empty) diff --git a/tests/test_store.py b/tests/test_store.py index 5c78c466..d806e1ba 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,5 +1,7 @@ from datetime import UTC, datetime +import pytest + from noob.event import Event from noob.store import EventStore @@ -22,3 +24,11 @@ def test_store_get_by_epoch(): event = store.get(node_id="a", signal="b", epoch=-1) assert event assert event["epoch"] == 4 + + +@pytest.mark.xfail() +def test_store_iter(): + """ + Store can iterate over all events in its nested dictionary + """ + raise NotImplementedError()