diff --git a/backend_py/primary/poetry.lock b/backend_py/primary/poetry.lock index 08f4dfa53..1f2395a65 100644 --- a/backend_py/primary/poetry.lock +++ b/backend_py/primary/poetry.lock @@ -1,5 +1,141 @@ # This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1"}, + {file = "aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a"}, + {file = "aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685"}, + {file = "aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b"}, + {file = "aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3"}, + {file = "aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1"}, + {file = "aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51"}, + {file = "aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0"}, + {file = "aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:691d203c2bdf4f4637792efbbcdcd157ae11e55eaeb5e9c360c1206fb03d4d98"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e995e1abc4ed2a454c731385bf4082be06f875822adc4c6d9eaadf96e20d406"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bd44d5936ab3193c617bfd6c9a7d8d1085a8dc8c3f44d5f1dcf554d17d04cf7d"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46749be6e89cd78d6068cdf7da51dbcfa4321147ab8e4116ee6678d9a056a0cf"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c643f4d75adea39e92c0f01b3fb83d57abdec8c9279b3078b68a3a52b3933b6"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a23918fedc05806966a2438489dcffccbdf83e921a1170773b6178d04ade142"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74bdd8c864b36c3673741023343565d95bfbd778ffe1eb4d412c135a28a8dc89"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a146708808c9b7a988a4af3821379e379e0f0e5e466ca31a73dbdd0325b0263"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7011a70b56facde58d6d26da4fec3280cc8e2a78c714c96b7a01a87930a9530"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3bdd6e17e16e1dbd3db74d7f989e8af29c4d2e025f9828e6ef45fbdee158ec75"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57d16590a351dfc914670bd72530fd78344b885a00b250e992faea565b7fdc05"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc9a0f6569ff990e0bbd75506c8d8fe7214c8f6579cca32f0546e54372a3bb54"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:536ad7234747a37e50e7b6794ea868833d5220b49c92806ae2d7e8a9d6b5de02"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f0adb4177fa748072546fb650d9bd7398caaf0e15b370ed3317280b13f4083b0"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14954a2988feae3987f1eb49c706bff39947605f4b6fa4027c1d75743723eb09"}, + {file = "aiohttp-3.12.15-cp39-cp39-win32.whl", hash = "sha256:b784d6ed757f27574dca1c336f968f4e81130b27595e458e69457e6878251f5d"}, + {file = "aiohttp-3.12.15-cp39-cp39-win_amd64.whl", hash = "sha256:86ceded4e78a992f835209e236617bffae649371c4a50d5e5a3987f237db84b8"}, + {file = "aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aiosignal" +version = "1.4.0" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" +typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} + [[package]] name = "annotated-types" version = "0.7.0" @@ -78,6 +214,26 @@ files = [ {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, ] +[[package]] +name = "attrs" +version = "25.3.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, + {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, +] + +[package.extras] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] + [[package]] name = "azure-core" version = "1.35.0" @@ -115,6 +271,22 @@ files = [ azure-core = ">=1.24.0" opentelemetry-api = ">=1.12.0" +[[package]] +name = "azure-cosmos" +version = "4.9.0" +description = "Microsoft Azure Cosmos Client Library for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "azure_cosmos-4.9.0-py3-none-any.whl", hash = "sha256:3b60eaa01a16a857d0faf0cec304bac6fa8620a81bc268ce760339032ef617fe"}, + {file = "azure_cosmos-4.9.0.tar.gz", hash = "sha256:c70db4cbf55b0ff261ed7bb8aa325a5dfa565d3c6eaa43d75d26ae5e2ad6d74f"}, +] + +[package.dependencies] +azure-core = ">=1.30.0" +typing-extensions = ">=4.6.0" + [[package]] name = "azure-identity" version = "1.23.0" @@ -922,6 +1094,120 @@ ufo = ["fs (>=2.2.0,<3)"] unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""] woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] +[[package]] +name = "frozenlist" +version = "1.7.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, + {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, + {file = "frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718"}, + {file = "frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e"}, + {file = "frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56"}, + {file = "frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7"}, + {file = "frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43"}, + {file = "frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3"}, + {file = "frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e"}, + {file = "frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1"}, + {file = "frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf"}, + {file = "frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81"}, + {file = "frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb"}, + {file = "frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e"}, + {file = "frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63"}, + {file = "frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e"}, + {file = "frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f"}, +] + [[package]] name = "h11" version = "0.16.0" @@ -1625,6 +1911,126 @@ requests-oauthlib = ">=0.5.0" [package.extras] async = ["aiodns ; python_version >= \"3.5\"", "aiohttp (>=3.0) ; python_version >= \"3.5\""] +[[package]] +name = "multidict" +version = "6.6.3" +description = "multidict implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a2be5b7b35271f7fff1397204ba6708365e3d773579fe2a30625e16c4b4ce817"}, + {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12f4581d2930840295c461764b9a65732ec01250b46c6b2c510d7ee68872b140"}, + {file = "multidict-6.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dd7793bab517e706c9ed9d7310b06c8672fd0aeee5781bfad612f56b8e0f7d14"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:72d8815f2cd3cf3df0f83cac3f3ef801d908b2d90409ae28102e0553af85545a"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:531e331a2ee53543ab32b16334e2deb26f4e6b9b28e41f8e0c87e99a6c8e2d69"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42ca5aa9329a63be8dc49040f63817d1ac980e02eeddba763a9ae5b4027b9c9c"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:208b9b9757060b9faa6f11ab4bc52846e4f3c2fb8b14d5680c8aac80af3dc751"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:acf6b97bd0884891af6a8b43d0f586ab2fcf8e717cbd47ab4bdddc09e20652d8"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68e9e12ed00e2089725669bdc88602b0b6f8d23c0c95e52b95f0bc69f7fe9b55"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05db2f66c9addb10cfa226e1acb363450fab2ff8a6df73c622fefe2f5af6d4e7"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0db58da8eafb514db832a1b44f8fa7906fdd102f7d982025f816a93ba45e3dcb"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14117a41c8fdb3ee19c743b1c027da0736fdb79584d61a766da53d399b71176c"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:877443eaaabcd0b74ff32ebeed6f6176c71850feb7d6a1d2db65945256ea535c"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:70b72e749a4f6e7ed8fb334fa8d8496384840319512746a5f42fa0aec79f4d61"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43571f785b86afd02b3855c5ac8e86ec921b760298d6f82ff2a61daf5a35330b"}, + {file = "multidict-6.6.3-cp310-cp310-win32.whl", hash = "sha256:20c5a0c3c13a15fd5ea86c42311859f970070e4e24de5a550e99d7c271d76318"}, + {file = "multidict-6.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab0a34a007704c625e25a9116c6770b4d3617a071c8a7c30cd338dfbadfe6485"}, + {file = "multidict-6.6.3-cp310-cp310-win_arm64.whl", hash = "sha256:769841d70ca8bdd140a715746199fc6473414bd02efd678d75681d2d6a8986c5"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:18f4eba0cbac3546b8ae31e0bbc55b02c801ae3cbaf80c247fcdd89b456ff58c"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef43b5dd842382329e4797c46f10748d8c2b6e0614f46b4afe4aee9ac33159df"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bd1fd5eec01494e0f2e8e446a74a85d5e49afb63d75a9934e4a5423dba21d"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5bd8d6f793a787153956cd35e24f60485bf0651c238e207b9a54f7458b16d539"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf99b4daf908c73856bd87ee0a2499c3c9a3d19bb04b9c6025e66af3fd07462"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b9e59946b49dafaf990fd9c17ceafa62976e8471a14952163d10a7a630413a9"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2db616467070d0533832d204c54eea6836a5e628f2cb1e6dfd8cd6ba7277cb7"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7394888236621f61dcdd25189b2768ae5cc280f041029a5bcf1122ac63df79f9"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f114d8478733ca7388e7c7e0ab34b72547476b97009d643644ac33d4d3fe1821"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdf22e4db76d323bcdc733514bf732e9fb349707c98d341d40ebcc6e9318ef3d"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e995a34c3d44ab511bfc11aa26869b9d66c2d8c799fa0e74b28a473a692532d6"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766a4a5996f54361d8d5a9050140aa5362fe48ce51c755a50c0bc3706460c430"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3893a0d7d28a7fe6ca7a1f760593bc13038d1d35daf52199d431b61d2660602b"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:934796c81ea996e61914ba58064920d6cad5d99140ac3167901eb932150e2e56"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ed948328aec2072bc00f05d961ceadfd3e9bfc2966c1319aeaf7b7c21219183"}, + {file = "multidict-6.6.3-cp311-cp311-win32.whl", hash = "sha256:9f5b28c074c76afc3e4c610c488e3493976fe0e596dd3db6c8ddfbb0134dcac5"}, + {file = "multidict-6.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc7f6fbc61b1c16050a389c630da0b32fc6d4a3d191394ab78972bf5edc568c2"}, + {file = "multidict-6.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:d4e47d8faffaae822fb5cba20937c048d4f734f43572e7079298a6c39fb172cb"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2334cfb0fa9549d6ce2c21af2bfbcd3ac4ec3646b1b1581c88e3e2b1779ec92b"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8fee016722550a2276ca2cb5bb624480e0ed2bd49125b2b73b7010b9090e888"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5511cb35f5c50a2db21047c875eb42f308c5583edf96bd8ebf7d770a9d68f6d"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:712b348f7f449948e0a6c4564a21c7db965af900973a67db432d724619b3c680"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e15d2138ee2694e038e33b7c3da70e6b0ad8868b9f8094a72e1414aeda9c1a"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8df25594989aebff8a130f7899fa03cbfcc5d2b5f4a461cf2518236fe6f15961"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:159ca68bfd284a8860f8d8112cf0521113bffd9c17568579e4d13d1f1dc76b65"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e098c17856a8c9ade81b4810888c5ad1914099657226283cab3062c0540b0643"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:67c92ed673049dec52d7ed39f8cf9ebbadf5032c774058b4406d18c8f8fe7063"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd0578596e3a835ef451784053cfd327d607fc39ea1a14812139339a18a0dbc3"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:346055630a2df2115cd23ae271910b4cae40f4e336773550dca4889b12916e75"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555ff55a359302b79de97e0468e9ee80637b0de1fce77721639f7cd9440b3a10"}, + {file = "multidict-6.6.3-cp312-cp312-win32.whl", hash = "sha256:73ab034fb8d58ff85c2bcbadc470efc3fafeea8affcf8722855fb94557f14cc5"}, + {file = "multidict-6.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:04cbcce84f63b9af41bad04a54d4cc4e60e90c35b9e6ccb130be2d75b71f8c17"}, + {file = "multidict-6.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:0f1130b896ecb52d2a1e615260f3ea2af55fa7dc3d7c3003ba0c3121a759b18b"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6"}, + {file = "multidict-6.6.3-cp313-cp313-win32.whl", hash = "sha256:5633a82fba8e841bc5c5c06b16e21529573cd654f67fd833650a215520a6210e"}, + {file = "multidict-6.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:e93089c1570a4ad54c3714a12c2cef549dc9d58e97bcded193d928649cab78e9"}, + {file = "multidict-6.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:c60b401f192e79caec61f166da9c924e9f8bc65548d4246842df91651e83d600"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c"}, + {file = "multidict-6.6.3-cp313-cp313t-win32.whl", hash = "sha256:639ecc9fe7cd73f2495f62c213e964843826f44505a3e5d82805aa85cac6f89e"}, + {file = "multidict-6.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:9f97e181f344a0ef3881b573d31de8542cc0dbc559ec68c8f8b5ce2c2e91646d"}, + {file = "multidict-6.6.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ce8b7693da41a3c4fde5871c738a81490cea5496c671d74374c8ab889e1834fb"}, + {file = "multidict-6.6.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c8161b5a7778d3137ea2ee7ae8a08cce0010de3b00ac671c5ebddeaa17cefd22"}, + {file = "multidict-6.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1328201ee930f069961ae707d59c6627ac92e351ed5b92397cf534d1336ce557"}, + {file = "multidict-6.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b1db4d2093d6b235de76932febf9d50766cf49a5692277b2c28a501c9637f616"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53becb01dd8ebd19d1724bebe369cfa87e4e7f29abbbe5c14c98ce4c383e16cd"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41bb9d1d4c303886e2d85bade86e59885112a7f4277af5ad47ab919a2251f306"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:775b464d31dac90f23192af9c291dc9f423101857e33e9ebf0020a10bfcf4144"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d04d01f0a913202205a598246cf77826fe3baa5a63e9f6ccf1ab0601cf56eca0"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d25594d3b38a2e6cabfdcafef339f754ca6e81fbbdb6650ad773ea9775af35ab"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35712f1748d409e0707b165bf49f9f17f9e28ae85470c41615778f8d4f7d9609"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1c8082e5814b662de8589d6a06c17e77940d5539080cbab9fe6794b5241b76d9"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:61af8a4b771f1d4d000b3168c12c3120ccf7284502a94aa58c68a81f5afac090"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:448e4a9afccbf297577f2eaa586f07067441e7b63c8362a3540ba5a38dc0f14a"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:233ad16999afc2bbd3e534ad8dbe685ef8ee49a37dbc2cdc9514e57b6d589ced"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:bb933c891cd4da6bdcc9733d048e994e22e1883287ff7540c2a0f3b117605092"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37b09ca60998e87734699e88c2363abfd457ed18cfbf88e4009a4e83788e63ed"}, + {file = "multidict-6.6.3-cp39-cp39-win32.whl", hash = "sha256:f54cb79d26d0cd420637d184af38f0668558f3c4bbe22ab7ad830e67249f2e0b"}, + {file = "multidict-6.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:295adc9c0551e5d5214b45cf29ca23dbc28c2d197a9c30d51aed9e037cb7c578"}, + {file = "multidict-6.6.3-cp39-cp39-win_arm64.whl", hash = "sha256:15332783596f227db50fb261c2c251a58ac3873c457f3a550a95d5c0aa3c770d"}, + {file = "multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a"}, + {file = "multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc"}, +] + [[package]] name = "mypy" version = "1.15.0" @@ -1690,6 +2096,18 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "nanoid" +version = "2.0.0" +description = "A tiny, secure, URL-friendly, unique string ID generator for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb"}, + {file = "nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68"}, +] + [[package]] name = "ndindex" version = "1.10.0" @@ -2680,6 +3098,114 @@ mmh3 = "*" redis = ">=4.2.0rc1" typing_extensions = "*" +[[package]] +name = "propcache" +version = "0.3.1" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "propcache-0.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f27785888d2fdd918bc36de8b8739f2d6c791399552333721b58193f68ea3e98"}, + {file = "propcache-0.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4e89cde74154c7b5957f87a355bb9c8ec929c167b59c83d90654ea36aeb6180"}, + {file = "propcache-0.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:730178f476ef03d3d4d255f0c9fa186cb1d13fd33ffe89d39f2cda4da90ceb71"}, + {file = "propcache-0.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:967a8eec513dbe08330f10137eacb427b2ca52118769e82ebcfcab0fba92a649"}, + {file = "propcache-0.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b9145c35cc87313b5fd480144f8078716007656093d23059e8993d3a8fa730f"}, + {file = "propcache-0.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e64e948ab41411958670f1093c0a57acfdc3bee5cf5b935671bbd5313bcf229"}, + {file = "propcache-0.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:319fa8765bfd6a265e5fa661547556da381e53274bc05094fc9ea50da51bfd46"}, + {file = "propcache-0.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66d8ccbc902ad548312b96ed8d5d266d0d2c6d006fd0f66323e9d8f2dd49be7"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2d219b0dbabe75e15e581fc1ae796109b07c8ba7d25b9ae8d650da582bed01b0"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd6a55f65241c551eb53f8cf4d2f4af33512c39da5d9777694e9d9c60872f519"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9979643ffc69b799d50d3a7b72b5164a2e97e117009d7af6dfdd2ab906cb72cd"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4cf9e93a81979f1424f1a3d155213dc928f1069d697e4353edb8a5eba67c6259"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2fce1df66915909ff6c824bbb5eb403d2d15f98f1518e583074671a30fe0c21e"}, + {file = "propcache-0.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4d0dfdd9a2ebc77b869a0b04423591ea8823f791293b527dc1bb896c1d6f1136"}, + {file = "propcache-0.3.1-cp310-cp310-win32.whl", hash = "sha256:1f6cc0ad7b4560e5637eb2c994e97b4fa41ba8226069c9277eb5ea7101845b42"}, + {file = "propcache-0.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:47ef24aa6511e388e9894ec16f0fbf3313a53ee68402bc428744a367ec55b833"}, + {file = "propcache-0.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7f30241577d2fef2602113b70ef7231bf4c69a97e04693bde08ddab913ba0ce5"}, + {file = "propcache-0.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:43593c6772aa12abc3af7784bff4a41ffa921608dd38b77cf1dfd7f5c4e71371"}, + {file = "propcache-0.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a75801768bbe65499495660b777e018cbe90c7980f07f8aa57d6be79ea6f71da"}, + {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6f1324db48f001c2ca26a25fa25af60711e09b9aaf4b28488602776f4f9a744"}, + {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cdb0f3e1eb6dfc9965d19734d8f9c481b294b5274337a8cb5cb01b462dcb7e0"}, + {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1eb34d90aac9bfbced9a58b266f8946cb5935869ff01b164573a7634d39fbcb5"}, + {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f35c7070eeec2cdaac6fd3fe245226ed2a6292d3ee8c938e5bb645b434c5f256"}, + {file = "propcache-0.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b23c11c2c9e6d4e7300c92e022046ad09b91fd00e36e83c44483df4afa990073"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3e19ea4ea0bf46179f8a3652ac1426e6dcbaf577ce4b4f65be581e237340420d"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bd39c92e4c8f6cbf5f08257d6360123af72af9f4da75a690bef50da77362d25f"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0313e8b923b3814d1c4a524c93dfecea5f39fa95601f6a9b1ac96cd66f89ea0"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e861ad82892408487be144906a368ddbe2dc6297074ade2d892341b35c59844a"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:61014615c1274df8da5991a1e5da85a3ccb00c2d4701ac6f3383afd3ca47ab0a"}, + {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:71ebe3fe42656a2328ab08933d420df5f3ab121772eef78f2dc63624157f0ed9"}, + {file = "propcache-0.3.1-cp311-cp311-win32.whl", hash = "sha256:58aa11f4ca8b60113d4b8e32d37e7e78bd8af4d1a5b5cb4979ed856a45e62005"}, + {file = "propcache-0.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:9532ea0b26a401264b1365146c440a6d78269ed41f83f23818d4b79497aeabe7"}, + {file = "propcache-0.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f78eb8422acc93d7b69964012ad7048764bb45a54ba7a39bb9e146c72ea29723"}, + {file = "propcache-0.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89498dd49c2f9a026ee057965cdf8192e5ae070ce7d7a7bd4b66a8e257d0c976"}, + {file = "propcache-0.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09400e98545c998d57d10035ff623266927cb784d13dd2b31fd33b8a5316b85b"}, + {file = "propcache-0.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8efd8c5adc5a2c9d3b952815ff8f7710cefdcaf5f2c36d26aff51aeca2f12f"}, + {file = "propcache-0.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2fe5c910f6007e716a06d269608d307b4f36e7babee5f36533722660e8c4a70"}, + {file = "propcache-0.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0ab8cf8cdd2194f8ff979a43ab43049b1df0b37aa64ab7eca04ac14429baeb7"}, + {file = "propcache-0.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563f9d8c03ad645597b8d010ef4e9eab359faeb11a0a2ac9f7b4bc8c28ebef25"}, + {file = "propcache-0.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb6e0faf8cb6b4beea5d6ed7b5a578254c6d7df54c36ccd3d8b3eb00d6770277"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1c5c7ab7f2bb3f573d1cb921993006ba2d39e8621019dffb1c5bc94cdbae81e8"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:050b571b2e96ec942898f8eb46ea4bfbb19bd5502424747e83badc2d4a99a44e"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e1c4d24b804b3a87e9350f79e2371a705a188d292fd310e663483af6ee6718ee"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e4fe2a6d5ce975c117a6bb1e8ccda772d1e7029c1cca1acd209f91d30fa72815"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:feccd282de1f6322f56f6845bf1207a537227812f0a9bf5571df52bb418d79d5"}, + {file = "propcache-0.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ec314cde7314d2dd0510c6787326bbffcbdc317ecee6b7401ce218b3099075a7"}, + {file = "propcache-0.3.1-cp312-cp312-win32.whl", hash = "sha256:7d2d5a0028d920738372630870e7d9644ce437142197f8c827194fca404bf03b"}, + {file = "propcache-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:88c423efef9d7a59dae0614eaed718449c09a5ac79a5f224a8b9664d603f04a3"}, + {file = "propcache-0.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1528ec4374617a7a753f90f20e2f551121bb558fcb35926f99e3c42367164b8"}, + {file = "propcache-0.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc1915ec523b3b494933b5424980831b636fe483d7d543f7afb7b3bf00f0c10f"}, + {file = "propcache-0.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a110205022d077da24e60b3df8bcee73971be9575dec5573dd17ae5d81751111"}, + {file = "propcache-0.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d249609e547c04d190e820d0d4c8ca03ed4582bcf8e4e160a6969ddfb57b62e5"}, + {file = "propcache-0.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ced33d827625d0a589e831126ccb4f5c29dfdf6766cac441d23995a65825dcb"}, + {file = "propcache-0.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4114c4ada8f3181af20808bedb250da6bae56660e4b8dfd9cd95d4549c0962f7"}, + {file = "propcache-0.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:975af16f406ce48f1333ec5e912fe11064605d5c5b3f6746969077cc3adeb120"}, + {file = "propcache-0.3.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a34aa3a1abc50740be6ac0ab9d594e274f59960d3ad253cd318af76b996dd654"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9cec3239c85ed15bfaded997773fdad9fb5662b0a7cbc854a43f291eb183179e"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:05543250deac8e61084234d5fc54f8ebd254e8f2b39a16b1dce48904f45b744b"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5cb5918253912e088edbf023788de539219718d3b10aef334476b62d2b53de53"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bbecd2f34d0e6d3c543fdb3b15d6b60dd69970c2b4c822379e5ec8f6f621d5"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aca63103895c7d960a5b9b044a83f544b233c95e0dcff114389d64d762017af7"}, + {file = "propcache-0.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a0a9898fdb99bf11786265468571e628ba60af80dc3f6eb89a3545540c6b0ef"}, + {file = "propcache-0.3.1-cp313-cp313-win32.whl", hash = "sha256:3a02a28095b5e63128bcae98eb59025924f121f048a62393db682f049bf4ac24"}, + {file = "propcache-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:813fbb8b6aea2fc9659815e585e548fe706d6f663fa73dff59a1677d4595a037"}, + {file = "propcache-0.3.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a444192f20f5ce8a5e52761a031b90f5ea6288b1eef42ad4c7e64fef33540b8f"}, + {file = "propcache-0.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fbe94666e62ebe36cd652f5fc012abfbc2342de99b523f8267a678e4dfdee3c"}, + {file = "propcache-0.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f011f104db880f4e2166bcdcf7f58250f7a465bc6b068dc84c824a3d4a5c94dc"}, + {file = "propcache-0.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e584b6d388aeb0001d6d5c2bd86b26304adde6d9bb9bfa9c4889805021b96de"}, + {file = "propcache-0.3.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a17583515a04358b034e241f952f1715243482fc2c2945fd99a1b03a0bd77d6"}, + {file = "propcache-0.3.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5aed8d8308215089c0734a2af4f2e95eeb360660184ad3912686c181e500b2e7"}, + {file = "propcache-0.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d8e309ff9a0503ef70dc9a0ebd3e69cf7b3894c9ae2ae81fc10943c37762458"}, + {file = "propcache-0.3.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b655032b202028a582d27aeedc2e813299f82cb232f969f87a4fde491a233f11"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f64d91b751df77931336b5ff7bafbe8845c5770b06630e27acd5dbb71e1931c"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:19a06db789a4bd896ee91ebc50d059e23b3639c25d58eb35be3ca1cbe967c3bf"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bef100c88d8692864651b5f98e871fb090bd65c8a41a1cb0ff2322db39c96c27"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:87380fb1f3089d2a0b8b00f006ed12bd41bd858fabfa7330c954c70f50ed8757"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e474fc718e73ba5ec5180358aa07f6aded0ff5f2abe700e3115c37d75c947e18"}, + {file = "propcache-0.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:17d1c688a443355234f3c031349da69444be052613483f3e4158eef751abcd8a"}, + {file = "propcache-0.3.1-cp313-cp313t-win32.whl", hash = "sha256:359e81a949a7619802eb601d66d37072b79b79c2505e6d3fd8b945538411400d"}, + {file = "propcache-0.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e7fb9a84c9abbf2b2683fa3e7b0d7da4d8ecf139a1c635732a8bda29c5214b0e"}, + {file = "propcache-0.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ed5f6d2edbf349bd8d630e81f474d33d6ae5d07760c44d33cd808e2f5c8f4ae6"}, + {file = "propcache-0.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:668ddddc9f3075af019f784456267eb504cb77c2c4bd46cc8402d723b4d200bf"}, + {file = "propcache-0.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0c86e7ceea56376216eba345aa1fc6a8a6b27ac236181f840d1d7e6a1ea9ba5c"}, + {file = "propcache-0.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83be47aa4e35b87c106fc0c84c0fc069d3f9b9b06d3c494cd404ec6747544894"}, + {file = "propcache-0.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:27c6ac6aa9fc7bc662f594ef380707494cb42c22786a558d95fcdedb9aa5d035"}, + {file = "propcache-0.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a956dff37080b352c1c40b2966b09defb014347043e740d420ca1eb7c9b908"}, + {file = "propcache-0.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82de5da8c8893056603ac2d6a89eb8b4df49abf1a7c19d536984c8dd63f481d5"}, + {file = "propcache-0.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c3c3a203c375b08fd06a20da3cf7aac293b834b6f4f4db71190e8422750cca5"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b303b194c2e6f171cfddf8b8ba30baefccf03d36a4d9cab7fd0bb68ba476a3d7"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:916cd229b0150129d645ec51614d38129ee74c03293a9f3f17537be0029a9641"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a461959ead5b38e2581998700b26346b78cd98540b5524796c175722f18b0294"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:069e7212890b0bcf9b2be0a03afb0c2d5161d91e1bf51569a64f629acc7defbf"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ef2e4e91fb3945769e14ce82ed53007195e616a63aa43b40fb7ebaaf907c8d4c"}, + {file = "propcache-0.3.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8638f99dca15b9dff328fb6273e09f03d1c50d9b6512f3b65a4154588a7595fe"}, + {file = "propcache-0.3.1-cp39-cp39-win32.whl", hash = "sha256:6f173bbfe976105aaa890b712d1759de339d8a7cef2fc0a1714cc1a1e1c47f64"}, + {file = "propcache-0.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:603f1fe4144420374f1a69b907494c3acbc867a581c2d49d4175b0de7cc64566"}, + {file = "propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40"}, + {file = "propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf"}, +] + [[package]] name = "psutil" version = "7.0.0" @@ -3959,6 +4485,125 @@ xtgeo = "*" docs = ["autoapi", "myst-parser", "sphinx", "sphinx-argparse", "sphinx-autodoc-typehints (<2.4)", "sphinx-copybutton", "sphinx-togglebutton", "sphinx_rtd_theme", "sphinxcontrib-apidoc"] tests = ["coverage (>=4.1)", "mypy", "pylint", "pytest", "pytest-cov", "pytest-mock", "pytest-runner", "pytest-xdist", "rstcheck", "ruff", "types-PyYAML"] +[[package]] +name = "yarl" +version = "1.20.0" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "yarl-1.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f1f6670b9ae3daedb325fa55fbe31c22c8228f6e0b513772c2e1c623caa6ab22"}, + {file = "yarl-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85a231fa250dfa3308f3c7896cc007a47bc76e9e8e8595c20b7426cac4884c62"}, + {file = "yarl-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a06701b647c9939d7019acdfa7ebbfbb78ba6aa05985bb195ad716ea759a569"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7595498d085becc8fb9203aa314b136ab0516c7abd97e7d74f7bb4eb95042abe"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af5607159085dcdb055d5678fc2d34949bd75ae6ea6b4381e784bbab1c3aa195"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:95b50910e496567434cb77a577493c26bce0f31c8a305135f3bda6a2483b8e10"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b594113a301ad537766b4e16a5a6750fcbb1497dcc1bc8a4daae889e6402a634"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:083ce0393ea173cd37834eb84df15b6853b555d20c52703e21fbababa8c129d2"}, + {file = "yarl-1.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f1a350a652bbbe12f666109fbddfdf049b3ff43696d18c9ab1531fbba1c977a"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fb0caeac4a164aadce342f1597297ec0ce261ec4532bbc5a9ca8da5622f53867"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d88cc43e923f324203f6ec14434fa33b85c06d18d59c167a0637164863b8e995"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e52d6ed9ea8fd3abf4031325dc714aed5afcbfa19ee4a89898d663c9976eb487"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ce360ae48a5e9961d0c730cf891d40698a82804e85f6e74658fb175207a77cb2"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:06d06c9d5b5bc3eb56542ceeba6658d31f54cf401e8468512447834856fb0e61"}, + {file = "yarl-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c27d98f4e5c4060582f44e58309c1e55134880558f1add7a87c1bc36ecfade19"}, + {file = "yarl-1.20.0-cp310-cp310-win32.whl", hash = "sha256:f4d3fa9b9f013f7050326e165c3279e22850d02ae544ace285674cb6174b5d6d"}, + {file = "yarl-1.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc906b636239631d42eb8a07df8359905da02704a868983265603887ed68c076"}, + {file = "yarl-1.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fdb5204d17cb32b2de2d1e21c7461cabfacf17f3645e4b9039f210c5d3378bf3"}, + {file = "yarl-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eaddd7804d8e77d67c28d154ae5fab203163bd0998769569861258e525039d2a"}, + {file = "yarl-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:634b7ba6b4a85cf67e9df7c13a7fb2e44fa37b5d34501038d174a63eaac25ee2"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d409e321e4addf7d97ee84162538c7258e53792eb7c6defd0c33647d754172e"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ea52f7328a36960ba3231c6677380fa67811b414798a6e071c7085c57b6d20a9"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8703517b924463994c344dcdf99a2d5ce9eca2b6882bb640aa555fb5efc706a"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:077989b09ffd2f48fb2d8f6a86c5fef02f63ffe6b1dd4824c76de7bb01e4f2e2"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0acfaf1da020253f3533526e8b7dd212838fdc4109959a2c53cafc6db611bff2"}, + {file = "yarl-1.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4230ac0b97ec5eeb91d96b324d66060a43fd0d2a9b603e3327ed65f084e41f8"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a6a1e6ae21cdd84011c24c78d7a126425148b24d437b5702328e4ba640a8902"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:86de313371ec04dd2531f30bc41a5a1a96f25a02823558ee0f2af0beaa7ca791"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dd59c9dd58ae16eaa0f48c3d0cbe6be8ab4dc7247c3ff7db678edecbaf59327f"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a0bc5e05f457b7c1994cc29e83b58f540b76234ba6b9648a4971ddc7f6aa52da"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c9471ca18e6aeb0e03276b5e9b27b14a54c052d370a9c0c04a68cefbd1455eb4"}, + {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40ed574b4df723583a26c04b298b283ff171bcc387bc34c2683235e2487a65a5"}, + {file = "yarl-1.20.0-cp311-cp311-win32.whl", hash = "sha256:db243357c6c2bf3cd7e17080034ade668d54ce304d820c2a58514a4e51d0cfd6"}, + {file = "yarl-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:8c12cd754d9dbd14204c328915e23b0c361b88f3cffd124129955e60a4fbfcfb"}, + {file = "yarl-1.20.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e06b9f6cdd772f9b665e5ba8161968e11e403774114420737f7884b5bd7bdf6f"}, + {file = "yarl-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9ae2fbe54d859b3ade40290f60fe40e7f969d83d482e84d2c31b9bff03e359e"}, + {file = "yarl-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d12b8945250d80c67688602c891237994d203d42427cb14e36d1a732eda480e"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:087e9731884621b162a3e06dc0d2d626e1542a617f65ba7cc7aeab279d55ad33"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69df35468b66c1a6e6556248e6443ef0ec5f11a7a4428cf1f6281f1879220f58"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b2992fe29002fd0d4cbaea9428b09af9b8686a9024c840b8a2b8f4ea4abc16f"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c903e0b42aab48abfbac668b5a9d7b6938e721a6341751331bcd7553de2dcae"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf099e2432131093cc611623e0b0bcc399b8cddd9a91eded8bfb50402ec35018"}, + {file = "yarl-1.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7f62f5dc70a6c763bec9ebf922be52aa22863d9496a9a30124d65b489ea672"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:54ac15a8b60382b2bcefd9a289ee26dc0920cf59b05368c9b2b72450751c6eb8"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:25b3bc0763a7aca16a0f1b5e8ef0f23829df11fb539a1b70476dcab28bd83da7"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b2586e36dc070fc8fad6270f93242124df68b379c3a251af534030a4a33ef594"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:866349da9d8c5290cfefb7fcc47721e94de3f315433613e01b435473be63daa6"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:33bb660b390a0554d41f8ebec5cd4475502d84104b27e9b42f5321c5192bfcd1"}, + {file = "yarl-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737e9f171e5a07031cbee5e9180f6ce21a6c599b9d4b2c24d35df20a52fabf4b"}, + {file = "yarl-1.20.0-cp312-cp312-win32.whl", hash = "sha256:839de4c574169b6598d47ad61534e6981979ca2c820ccb77bf70f4311dd2cc64"}, + {file = "yarl-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d7dbbe44b443b0c4aa0971cb07dcb2c2060e4a9bf8d1301140a33a93c98e18c"}, + {file = "yarl-1.20.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2137810a20b933b1b1b7e5cf06a64c3ed3b4747b0e5d79c9447c00db0e2f752f"}, + {file = "yarl-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:447c5eadd750db8389804030d15f43d30435ed47af1313303ed82a62388176d3"}, + {file = "yarl-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42fbe577272c203528d402eec8bf4b2d14fd49ecfec92272334270b850e9cd7d"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18e321617de4ab170226cd15006a565d0fa0d908f11f724a2c9142d6b2812ab0"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4345f58719825bba29895011e8e3b545e6e00257abb984f9f27fe923afca2501"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d9b980d7234614bc4674468ab173ed77d678349c860c3af83b1fffb6a837ddc"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af4baa8a445977831cbaa91a9a84cc09debb10bc8391f128da2f7bd070fc351d"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123393db7420e71d6ce40d24885a9e65eb1edefc7a5228db2d62bcab3386a5c0"}, + {file = "yarl-1.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab47acc9332f3de1b39e9b702d9c916af7f02656b2a86a474d9db4e53ef8fd7a"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a34c52ed158f89876cba9c600b2c964dfc1ca52ba7b3ab6deb722d1d8be6df2"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:04d8cfb12714158abf2618f792c77bc5c3d8c5f37353e79509608be4f18705c9"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7dc63ad0d541c38b6ae2255aaa794434293964677d5c1ec5d0116b0e308031f5"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d02b591a64e4e6ca18c5e3d925f11b559c763b950184a64cf47d74d7e41877"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:95fc9876f917cac7f757df80a5dda9de59d423568460fe75d128c813b9af558e"}, + {file = "yarl-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb769ae5760cd1c6a712135ee7915f9d43f11d9ef769cb3f75a23e398a92d384"}, + {file = "yarl-1.20.0-cp313-cp313-win32.whl", hash = "sha256:70e0c580a0292c7414a1cead1e076c9786f685c1fc4757573d2967689b370e62"}, + {file = "yarl-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:4c43030e4b0af775a85be1fa0433119b1565673266a70bf87ef68a9d5ba3174c"}, + {file = "yarl-1.20.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b6c4c3d0d6a0ae9b281e492b1465c72de433b782e6b5001c8e7249e085b69051"}, + {file = "yarl-1.20.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8681700f4e4df891eafa4f69a439a6e7d480d64e52bf460918f58e443bd3da7d"}, + {file = "yarl-1.20.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:84aeb556cb06c00652dbf87c17838eb6d92cfd317799a8092cee0e570ee11229"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f166eafa78810ddb383e930d62e623d288fb04ec566d1b4790099ae0f31485f1"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5d3d6d14754aefc7a458261027a562f024d4f6b8a798adb472277f675857b1eb"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a8f64df8ed5d04c51260dbae3cc82e5649834eebea9eadfd829837b8093eb00"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d9949eaf05b4d30e93e4034a7790634bbb41b8be2d07edd26754f2e38e491de"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c366b254082d21cc4f08f522ac201d0d83a8b8447ab562732931d31d80eb2a5"}, + {file = "yarl-1.20.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91bc450c80a2e9685b10e34e41aef3d44ddf99b3a498717938926d05ca493f6a"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c2aa4387de4bc3a5fe158080757748d16567119bef215bec643716b4fbf53f9"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d2cbca6760a541189cf87ee54ff891e1d9ea6406079c66341008f7ef6ab61145"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:798a5074e656f06b9fad1a162be5a32da45237ce19d07884d0b67a0aa9d5fdda"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f106e75c454288472dbe615accef8248c686958c2e7dd3b8d8ee2669770d020f"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3b60a86551669c23dc5445010534d2c5d8a4e012163218fc9114e857c0586fdd"}, + {file = "yarl-1.20.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e429857e341d5e8e15806118e0294f8073ba9c4580637e59ab7b238afca836f"}, + {file = "yarl-1.20.0-cp313-cp313t-win32.whl", hash = "sha256:65a4053580fe88a63e8e4056b427224cd01edfb5f951498bfefca4052f0ce0ac"}, + {file = "yarl-1.20.0-cp313-cp313t-win_amd64.whl", hash = "sha256:53b2da3a6ca0a541c1ae799c349788d480e5144cac47dba0266c7cb6c76151fe"}, + {file = "yarl-1.20.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:119bca25e63a7725b0c9d20ac67ca6d98fa40e5a894bd5d4686010ff73397914"}, + {file = "yarl-1.20.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:35d20fb919546995f1d8c9e41f485febd266f60e55383090010f272aca93edcc"}, + {file = "yarl-1.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:484e7a08f72683c0f160270566b4395ea5412b4359772b98659921411d32ad26"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d8a3d54a090e0fff5837cd3cc305dd8a07d3435a088ddb1f65e33b322f66a94"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0cf05ae2d3d87a8c9022f3885ac6dea2b751aefd66a4f200e408a61ae9b7f0d"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a884b8974729e3899d9287df46f015ce53f7282d8d3340fa0ed57536b440621c"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8d8aa8dd89ffb9a831fedbcb27d00ffd9f4842107d52dc9d57e64cb34073d5c"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4e88d6c3c8672f45a30867817e4537df1bbc6f882a91581faf1f6d9f0f1b5a"}, + {file = "yarl-1.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdb77efde644d6f1ad27be8a5d67c10b7f769804fff7a966ccb1da5a4de4b656"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4ba5e59f14bfe8d261a654278a0f6364feef64a794bd456a8c9e823071e5061c"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:d0bf955b96ea44ad914bc792c26a0edcd71b4668b93cbcd60f5b0aeaaed06c64"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:27359776bc359ee6eaefe40cb19060238f31228799e43ebd3884e9c589e63b20"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:04d9c7a1dc0a26efb33e1acb56c8849bd57a693b85f44774356c92d610369efa"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:faa709b66ae0e24c8e5134033187a972d849d87ed0a12a0366bedcc6b5dc14a5"}, + {file = "yarl-1.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:44869ee8538208fe5d9342ed62c11cc6a7a1af1b3d0bb79bb795101b6e77f6e0"}, + {file = "yarl-1.20.0-cp39-cp39-win32.whl", hash = "sha256:b7fa0cb9fd27ffb1211cde944b41f5c67ab1c13a13ebafe470b1e206b8459da8"}, + {file = "yarl-1.20.0-cp39-cp39-win_amd64.whl", hash = "sha256:d4fad6e5189c847820288286732075f213eabf81be4d08d6cc309912e62be5b7"}, + {file = "yarl-1.20.0-py3-none-any.whl", hash = "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124"}, + {file = "yarl-1.20.0.tar.gz", hash = "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.1" + [[package]] name = "zipp" version = "3.23.0" @@ -3982,4 +4627,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "d33fe14399685017201e1a181796597f34d61eea5af3ffb9e19f8308172a48f8" +content-hash = "982ccf2afc51348f6573db6552b7f7ddc33b6cccbdea0242f2bc57a0e226c906" diff --git a/backend_py/primary/primary/auth/enforce_logged_in_middleware.py b/backend_py/primary/primary/auth/enforce_logged_in_middleware.py index 4ecc4503a..9062b8fc7 100644 --- a/backend_py/primary/primary/auth/enforce_logged_in_middleware.py +++ b/backend_py/primary/primary/auth/enforce_logged_in_middleware.py @@ -128,8 +128,10 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - path_is_protected = True - if path_to_check in ["/login", "/auth-callback"] + self._unprotected_paths: - path_is_protected = False + for unprotected in ["/login", "/auth-callback"] + self._unprotected_paths: + if path_to_check.startswith(unprotected): + path_is_protected = False + break if path_is_protected: diff --git a/backend_py/primary/primary/config.py b/backend_py/primary/primary/config.py index 14c3d9df8..510227a2e 100644 --- a/backend_py/primary/primary/config.py +++ b/backend_py/primary/primary/config.py @@ -33,3 +33,11 @@ DEFAULT_STALE_WHILE_REVALIDATE = 3600 * 24 # 24 hour REDIS_USER_SESSION_URL = "redis://redis-user-session:6379" REDIS_CACHE_URL = "redis://redis-cache:6379" + +COSMOS_DB_PROD_CONNECTION_STRING = os.environ.get("WEBVIZ_DB_CONNECTION_STRING", None) +# pylint: disable=line-too-long +COSMOS_DB_EMULATOR_URI = "https://host.docker.internal:8081/" +COSMOS_DB_EMULATOR_KEY = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==;" + +PERSISTENCE_DB_NAME = "persistence" +DASHBOARDS_CONTAINER_NAME = "dashboards" diff --git a/backend_py/primary/primary/main.py b/backend_py/primary/primary/main.py index 0132dd6b1..1126ee736 100644 --- a/backend_py/primary/primary/main.py +++ b/backend_py/primary/primary/main.py @@ -12,6 +12,7 @@ from primary.auth.auth_helper import AuthHelper from primary.auth.enforce_logged_in_middleware import EnforceLoggedInMiddleware from primary.middleware.add_process_time_to_server_timing_middleware import AddProcessTimeToServerTimingMiddleware +from primary.services.database_access.setup_local_database import maybe_setup_local_database from primary.middleware.add_browser_cache import AddBrowserCacheMiddleware from primary.routers.dev.router import router as dev_router @@ -32,6 +33,9 @@ from primary.routers.vfp.router import router as vfp_router from primary.routers.well.router import router as well_router from primary.routers.well_completions.router import router as well_completions_router +from primary.routers.persistence.sessions.router import router as sessions_router +from primary.routers.persistence.snapshots.router import router as snapshots_router +from primary.routers.persistence.snapshot_preview.router import router as snapshot_preview_router from primary.services.utils.httpx_async_client_wrapper import HTTPX_ASYNC_CLIENT_WRAPPER from primary.utils.azure_monitor_setup import setup_azure_monitor_telemetry from primary.utils.exception_handlers import configure_service_level_exception_handlers @@ -58,6 +62,9 @@ LOGGER = logging.getLogger(__name__) +# Setup Cosmos DB emulator database if running locally +maybe_setup_local_database() + def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.name}" @@ -106,6 +113,9 @@ async def shutdown_event_async() -> None: app.include_router(rft_router, prefix="/rft", tags=["rft"]) app.include_router(vfp_router, prefix="/vfp", tags=["vfp"]) app.include_router(dev_router, prefix="/dev", tags=["dev"], include_in_schema=False) +app.include_router(sessions_router, prefix="/sessions", tags=["sessions"]) +app.include_router(snapshots_router, prefix="/snapshots", tags=["snapshots"]) +app.include_router(snapshot_preview_router, prefix="/snapshot-preview", tags=["snapshot_preview"]) auth_helper = AuthHelper() app.include_router(auth_helper.router) @@ -120,7 +130,7 @@ async def shutdown_event_async() -> None: # Add out custom middleware to enforce that user is logged in # Also redirects to /login endpoint for some select paths -unprotected_paths = ["/logout", "/logged_in_user", "/alive", "/openapi.json"] +unprotected_paths = ["/logout", "/logged_in_user", "/alive", "/openapi.json", "/snapshot-preview"] paths_redirected_to_login = ["/", "/alive_protected"] app.add_middleware( diff --git a/backend_py/primary/primary/routers/general.py b/backend_py/primary/primary/routers/general.py index a43159165..281cc6daa 100644 --- a/backend_py/primary/primary/routers/general.py +++ b/backend_py/primary/primary/routers/general.py @@ -15,6 +15,7 @@ class UserInfo(BaseModel): + user_id: str username: str display_name: str | None = None avatar_b64str: str | None = None @@ -63,6 +64,7 @@ async def get_logged_in_user( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No user is logged in") user_info = UserInfo( + user_id=authenticated_user.get_user_id(), username=authenticated_user.get_username(), avatar_b64str=None, display_name=None, diff --git a/backend_py/primary/primary/routers/graph/router.py b/backend_py/primary/primary/routers/graph/router.py index f4e35bd2c..f84745ca6 100644 --- a/backend_py/primary/primary/routers/graph/router.py +++ b/backend_py/primary/primary/routers/graph/router.py @@ -1,36 +1,71 @@ import logging import httpx -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Path from primary.auth.auth_helper import AuthHelper from primary.services.utils.authenticated_user import AuthenticatedUser from primary.services.graph_access.graph_access import GraphApiAccess +from primary.services.service_exceptions import Service, AuthorizationError, ServiceRequestError -from .schemas import GraphUserPhoto + +from . import schemas LOGGER = logging.getLogger(__name__) router = APIRouter() +@router.get("/user_info/{user_id_or_email}") +async def get_user_info( + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + user_id_or_email: str = Path(description="User email, id or 'me' for the authenticated user"), +) -> schemas.GraphUser | None: + if not authenticated_user.has_graph_access_token(): + raise AuthorizationError("User can't access Graph API", Service.GENERAL) + + graph_api_access = GraphApiAccess(authenticated_user.get_graph_access_token()) + + try: + user_info = await graph_api_access.get_user_info(user_id_or_email) + + if not user_info: + return None + + return schemas.GraphUser( + id=user_info["id"], + display_name=user_info["displayName"], + principal_name=user_info["userPrincipalName"], + email=user_info["mail"], + ) + + except httpx.HTTPError as exc: + raise ServiceRequestError( + "Error while fetching user from Microsoft Graph API (HTTP error)", Service.GENERAL + ) from exc + except httpx.InvalidURL as exc: + raise ServiceRequestError( + "Error while fetching user from Microsoft Graph API (HTTP error)", Service.GENERAL + ) from exc + + @router.get("/user_photo/") async def get_user_photo( # fmt:off authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), - user_email: str = Query(description="User email or 'me' for the authenticated user"), + user_id_or_email: str = Query(description="User email or 'me' for the authenticated user"), # fmt:on -) -> GraphUserPhoto: +) -> schemas.GraphUserPhoto: """Get username, display name and avatar from Microsoft Graph API for a given user email""" - user_photo = GraphUserPhoto( + user_photo = schemas.GraphUserPhoto( avatar_b64str=None, ) if authenticated_user.has_graph_access_token(): graph_api_access = GraphApiAccess(authenticated_user.get_graph_access_token()) try: - avatar_b64str = await graph_api_access.get_user_profile_photo(user_email) + avatar_b64str = await graph_api_access.get_user_profile_photo(user_id_or_email) user_photo.avatar_b64str = avatar_b64str except httpx.HTTPError as exc: diff --git a/backend_py/primary/primary/routers/graph/schemas.py b/backend_py/primary/primary/routers/graph/schemas.py index 01946540a..65c1fa1da 100644 --- a/backend_py/primary/primary/routers/graph/schemas.py +++ b/backend_py/primary/primary/routers/graph/schemas.py @@ -3,3 +3,10 @@ class GraphUserPhoto(BaseModel): avatar_b64str: str | None = None + + +class GraphUser(BaseModel): + id: str + principal_name: str + display_name: str + email: str diff --git a/backend_py/primary/primary/routers/persistence/sessions/__init__.py b/backend_py/primary/primary/routers/persistence/sessions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend_py/primary/primary/routers/persistence/sessions/converters.py b/backend_py/primary/primary/routers/persistence/sessions/converters.py new file mode 100644 index 000000000..00b9a5efc --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/sessions/converters.py @@ -0,0 +1,44 @@ +from primary.services.database_access.session_access.model import SessionDocument +from primary.services.database_access.session_access.types import SessionMetadata +from . import schemas + + +def to_api_session_metadata_summary(session: SessionDocument) -> schemas.SessionMetadataWithId: + return schemas.SessionMetadataWithId( + id=session.id, + title=session.metadata.title, + description=session.metadata.description, + createdAt=session.metadata.created_at.isoformat(), + updatedAt=session.metadata.updated_at.isoformat(), + version=session.metadata.version, + hash=session.metadata.hash, + ) + + +def to_api_session_metadata(metadata: SessionMetadata) -> schemas.SessionMetadata: + return schemas.SessionMetadata( + title=metadata.title, + description=metadata.description, + createdAt=metadata.created_at.isoformat(), + updatedAt=metadata.updated_at.isoformat(), + version=metadata.version, + hash=metadata.hash, + ) + + +def to_api_session_record(document: SessionDocument) -> schemas.SessionDocument: + return schemas.SessionDocument( + id=document.id, + ownerId=document.owner_id, + metadata=to_api_session_metadata(document.metadata), + content=document.content, + ) + + +def to_api_session_index_page( + sessions: list[SessionDocument], continuation_token: str | None +) -> schemas.SessionIndexPage: + return schemas.SessionIndexPage( + continuation_token=continuation_token, + items=[to_api_session_metadata_summary(session) for session in sessions], + ) diff --git a/backend_py/primary/primary/routers/persistence/sessions/router.py b/backend_py/primary/primary/routers/persistence/sessions/router.py new file mode 100644 index 000000000..e2089229d --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/sessions/router.py @@ -0,0 +1,107 @@ +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from primary.middleware.add_browser_cache import no_cache +from primary.services.database_access.session_access.session_access import SessionAccess +from primary.services.database_access.query_collation_options import SortDirection +from primary.auth.auth_helper import AuthHelper, AuthenticatedUser +from primary.services.database_access.session_access.types import NewSession, SessionUpdate, SessionSortBy + +from . import schemas +from .converters import ( + to_api_session_metadata, + to_api_session_record, + to_api_session_index_page, +) + + +LOGGER = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/sessions", response_model=schemas.SessionIndexPage) +@no_cache +async def get_sessions_metadata( + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + # ! Must be named "cursor" or "page" to make hey-api generate infinite-queries + # ! When we've updated to the latest hey-api version, we can change this to something custom + cursor: None | str = Query(None), + sort_by: Optional[SessionSortBy] = Query(None, description="Sort the result by"), + sort_direction: Optional[SortDirection] = Query(SortDirection.ASC, description="Sort direction: 'asc' or 'desc'"), + limit: int = Query(10, ge=1, le=100, description="Limit the number of results"), + # ? Is this becoming too many args? Should we make a post-search endpoint instead? + filter_title: Optional[str] = Query(None, description="Filter results by title (case insensitive)"), + filter_updated_from: Optional[str] = Query(None, description="Filter results by date"), + filter_updated_to: Optional[str] = Query(None, description="Filter results by date"), +) -> schemas.SessionIndexPage: + access = SessionAccess.create(user.get_user_id()) + + async with access: + (items, cont_token) = await access.get_user_sessions_by_page_async( + continuation_token=cursor, + page_size=limit, + sort_by=sort_by, + sort_direction=sort_direction, + filter_title=filter_title, + filter_updated_from=filter_updated_from, + filter_updated_to=filter_updated_to, + ) + + return to_api_session_index_page(items, cont_token) + + +@router.get("/sessions/{session_id}", response_model=schemas.SessionDocument) +@no_cache +async def get_session( + session_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> schemas.SessionDocument: + access = SessionAccess.create(user.get_user_id()) + async with access: + session = await access.get_session_by_id_async(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return to_api_session_record(session) + + +@router.get("/sessions/metadata/{session_id}", response_model=schemas.SessionMetadata) +@no_cache +async def get_session_metadata( + session_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> schemas.SessionMetadata: + access = SessionAccess.create(user.get_user_id()) + async with access: + metadata = await access.get_session_metadata_async(session_id) + if not metadata: + raise HTTPException(status_code=404, detail="Session metadata not found") + return to_api_session_metadata(metadata) + + +@router.post("/sessions", response_model=str) +async def create_session( + session: NewSession, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> str: + access = SessionAccess.create(user.get_user_id()) + async with access: + session_id = await access.insert_session_async(session) + return session_id + + +@router.put("/sessions/{session_id}", description="Updates a session object. Allows for partial update objects") +async def update_session( + session_id: str, + session_update: SessionUpdate, + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), +) -> schemas.SessionDocument: + access = SessionAccess.create(user.get_user_id()) + async with access: + updated_session = await access.update_session_async(session_id, session_update) + return to_api_session_record(updated_session) + + +@router.delete("/sessions/{session_id}") +async def delete_session(session_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user)) -> None: + access = SessionAccess.create(user.get_user_id()) + async with access: + await access.delete_session_async(session_id) diff --git a/backend_py/primary/primary/routers/persistence/sessions/schemas.py b/backend_py/primary/primary/routers/persistence/sessions/schemas.py new file mode 100644 index 000000000..abe554aaf --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/sessions/schemas.py @@ -0,0 +1,27 @@ +from typing import Optional +from pydantic import BaseModel + + +class SessionMetadata(BaseModel): + title: str + description: Optional[str] + createdAt: str + updatedAt: str + version: int + hash: str + + +class SessionMetadataWithId(SessionMetadata): + id: str + + +class SessionDocument(BaseModel): + id: str + ownerId: str + metadata: SessionMetadata + content: str + + +class SessionIndexPage(BaseModel): + items: list[SessionMetadataWithId] + continuation_token: str | None diff --git a/backend_py/primary/primary/routers/persistence/snapshot_preview/router.py b/backend_py/primary/primary/routers/persistence/snapshot_preview/router.py new file mode 100644 index 000000000..68cacc2c1 --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/snapshot_preview/router.py @@ -0,0 +1,44 @@ +import html +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse + +from primary.services.database_access.snapshot_access.snapshot_access import SnapshotAccess + +router = APIRouter() + + +@router.get("/{snapshot_id}", response_class=HTMLResponse) +async def snapshot_preview(snapshot_id: str, request: Request): + access = await SnapshotAccess.create("") + async with access: + metadata = await access.get_snapshot_metadata(snapshot_id) + if not metadata: + raise HTTPException(status_code=404, detail="Snapshot metadata not found") + + base_url = get_external_base_url(request) + snapshot_url = f"{base_url}/snapshot/{snapshot_id}" + + title = html.escape(metadata.title) + description = html.escape(metadata.description or "No description available") + + return f""" + + +
+ + + + + + + + Redirecting… + + + """ + + +def get_external_base_url(request: Request) -> str: + forwarded_proto = request.headers.get("x-forwarded-proto", "http") + forwarded_host = request.headers.get("x-forwarded-host", request.headers.get("host", "localhost")) + return f"{forwarded_proto}://{forwarded_host}" diff --git a/backend_py/primary/primary/routers/persistence/snapshots/__init__.py b/backend_py/primary/primary/routers/persistence/snapshots/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend_py/primary/primary/routers/persistence/snapshots/converters.py b/backend_py/primary/primary/routers/persistence/snapshots/converters.py new file mode 100644 index 000000000..984a947bd --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/snapshots/converters.py @@ -0,0 +1,56 @@ +from primary.services.database_access.snapshot_access.models import SnapshotAccessLogDocument, SnapshotDocument +from primary.services.database_access.snapshot_access.types import SnapshotMetadata, SnapshotMetadataWithId + +from . import schemas + + +def to_api_snapshot_metadata_summary(metadata: SnapshotMetadataWithId) -> schemas.SnapshotMetadataWithId: + return schemas.SnapshotMetadataWithId( + id=metadata.id, + ownerId=metadata.owner_id, + title=metadata.title, + description=metadata.description, + createdAt=metadata.created_at.isoformat(), + updatedAt=metadata.updated_at.isoformat(), + hash=metadata.hash, + ) + + +def to_api_snapshot_metadata(metadata: SnapshotMetadata) -> schemas.SnapshotMetadata: + return schemas.SnapshotMetadata( + ownerId=metadata.owner_id, + title=metadata.title, + description=metadata.description, + createdAt=metadata.created_at.isoformat(), + updatedAt=metadata.updated_at.isoformat(), + hash=metadata.hash, + ) + + +def to_api_snapshot(snapshot: SnapshotDocument) -> schemas.Snapshot: + return schemas.Snapshot( + id=snapshot.id, + metadata=to_api_snapshot_metadata(snapshot.metadata), + content=snapshot.content, + ) + + +def to_api_snapshot_access_log(access_log: SnapshotAccessLogDocument) -> schemas.SnapshotAccessLog: + return schemas.SnapshotAccessLog( + visitorId=access_log.visitor_id, + snapshotId=access_log.snapshot_id, + visits=access_log.visits, + firstVisitedAt=access_log.first_visited_at.isoformat() if access_log.first_visited_at else None, + lastVisitedAt=access_log.last_visited_at.isoformat() if access_log.last_visited_at else None, + snapshotDeleted=access_log.snapshot_deleted, + snapshotMetadata=to_api_snapshot_metadata(access_log.snapshot_metadata), + ) + + +def to_api_access_log_index_page( + access_logs: list[SnapshotAccessLogDocument], continuation_token: str | None +) -> schemas.SnapshotAccessLogIndexPage: + return schemas.SnapshotAccessLogIndexPage( + continuation_token=continuation_token, + items=[to_api_snapshot_access_log(log) for log in access_logs], + ) diff --git a/backend_py/primary/primary/routers/persistence/snapshots/router.py b/backend_py/primary/primary/routers/persistence/snapshots/router.py new file mode 100644 index 000000000..d5c98f97d --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/snapshots/router.py @@ -0,0 +1,133 @@ +import logging +from typing import List, Optional + +from fastapi import APIRouter, BackgroundTasks, Depends, Query + +from primary.services.database_access.snapshot_access.types import ( + NewSnapshot, + SnapshotSortBy, + SnapshotAccessLogSortBy, +) +from primary.middleware.add_browser_cache import no_cache +from primary.services.database_access.snapshot_access.snapshot_access import SnapshotAccess +from primary.services.database_access.snapshot_access.snapshot_log_access import SnapshotLogAccess +from primary.services.database_access.query_collation_options import SortDirection +from primary.services.database_access.workers.mark_logs_deleted import mark_logs_deleted_worker + + +from primary.auth.auth_helper import AuthHelper, AuthenticatedUser + + +from . import schemas +from .converters import ( + to_api_snapshot, + to_api_snapshot_metadata, + to_api_snapshot_metadata_summary, + to_api_access_log_index_page, +) + + +LOGGER = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/visited_snapshots", response_model=schemas.SnapshotAccessLogIndexPage) +async def get_visited_snapshots( + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + # ! Must be named "cursor" or "page" to make hey-api generate infinite-queries + # ! When we've updated to the latest hey-api version, we can change this to something custom + cursor: Optional[str] = Query(None, description="Continuation token for pagination"), + limit: Optional[int] = Query(10, ge=1, le=100, description="Limit the number of results"), + sort_by: Optional[SnapshotAccessLogSortBy] = Query(None, description="Sort the result by"), + sort_direction: Optional[SortDirection] = Query(None, description="Sort direction: 'asc' or 'desc'"), + # ? Is this becoming too many args? Should we make a post-search endpoint instead? + filter_title: Optional[str] = Query(None, description="Filter results by title (case insensitive)"), + filter_updated_from: Optional[str] = Query(None, description="Filter results by date"), + filter_updated_to: Optional[str] = Query(None, description="Filter results by date"), +) -> schemas.SnapshotAccessLogIndexPage: + async with SnapshotLogAccess.create(user.get_user_id()) as log_access: + (items, cont_token) = await log_access.get_user_access_log_by_page_async( + continuation_token=cursor, + page_size=limit, + sort_by=sort_by, + sort_direction=sort_direction, + filter_title=filter_title, + filter_updated_from=filter_updated_from, + filter_updated_to=filter_updated_to, + ) + + return to_api_access_log_index_page(items, cont_token) + + +@router.get("/snapshots", response_model=List[schemas.SnapshotMetadata]) +@no_cache +async def get_snapshots_metadata( + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + sort_by: Optional[SnapshotSortBy] = Query(SnapshotSortBy.UPDATED_AT, description="Sort the result by"), + sort_direction: Optional[SortDirection] = Query(SortDirection.DESC, description="Sort direction: 'asc' or 'desc'"), + limit: Optional[int] = Query(10, ge=1, le=100, description="Limit the number of results"), +) -> List[schemas.SnapshotMetadata]: + access = SnapshotAccess.create(user.get_user_id()) + async with access: + items = await access.get_filtered_snapshots_metadata_for_user_async( + sort_by=sort_by, sort_direction=sort_direction, limit=limit, offset=0 + ) + return [to_api_snapshot_metadata_summary(item) for item in items] + + +@router.get("/snapshots/{snapshot_id}", response_model=schemas.Snapshot) +@no_cache +async def get_snapshot( + snapshot_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> schemas.Snapshot: + snapshot_access = SnapshotAccess.create(user.get_user_id()) + log_access = SnapshotLogAccess.create(user_id=user.get_user_id()) + + async with snapshot_access, log_access: + snapshot = await snapshot_access.get_snapshot_by_id_async(snapshot_id) + # Should we clear the log if a snapshot was not found? This could mean that the snapshot was + # deleted but deletion of logs has failed + await log_access.log_snapshot_visit_async(snapshot_id, snapshot.owner_id) + return to_api_snapshot(snapshot) + + +@router.get("/snapshots/metadata/{snapshot_id}", response_model=schemas.SnapshotMetadata) +@no_cache +async def get_snapshot_metadata( + snapshot_id: str, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> schemas.SnapshotMetadata: + access = SnapshotAccess.create(user.get_user_id()) + async with access: + metadata = await access.get_snapshot_metadata_async(snapshot_id) + return to_api_snapshot_metadata(metadata) + + +@router.post("/snapshots", response_model=str) +async def create_snapshot( + session: NewSnapshot, user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user) +) -> str: + snapshot_access = SnapshotAccess.create(user.get_user_id()) + log_access = SnapshotLogAccess.create(user.get_user_id()) + + async with snapshot_access, log_access: + snapshot_id = await snapshot_access.insert_snapshot_async(session) + + # We count snapshot creation as implicit visit. This also makes it so we can get recently created ones alongside other shared screenshots + await log_access.log_snapshot_visit_async(snapshot_id=snapshot_id, snapshot_owner_id=user.get_user_id()) + return snapshot_id + + +@router.delete("/snapshots/{snapshot_id}") +async def delete_snapshot( + snapshot_id: str, + background_tasks: BackgroundTasks, + user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), +) -> None: + snapshot_access = SnapshotAccess.create(user.get_user_id()) + async with snapshot_access: + await snapshot_access.delete_snapshot_async(snapshot_id) + + # This is the fastest solution for the moment. As we are expecting <= 150 logs per snapshot + # and consistency is not critical, we can afford to do this in the background and without + # a safety net. We can later consider adding this to a queue for better reliability. + background_tasks.add_task(mark_logs_deleted_worker, snapshot_id=snapshot_id) diff --git a/backend_py/primary/primary/routers/persistence/snapshots/schemas.py b/backend_py/primary/primary/routers/persistence/snapshots/schemas.py new file mode 100644 index 000000000..bcf60335c --- /dev/null +++ b/backend_py/primary/primary/routers/persistence/snapshots/schemas.py @@ -0,0 +1,46 @@ +from typing import Optional +from pydantic import BaseModel + +from primary.services.database_access.snapshot_access.util import make_access_log_item_id + + +class SnapshotMetadata(BaseModel): + ownerId: str + title: str + description: Optional[str] + createdAt: str + updatedAt: str + hash: str + + +class SnapshotMetadataWithId(SnapshotMetadata): + id: str + + +class Snapshot(BaseModel): + id: str + metadata: SnapshotMetadata + content: str + + +class SnapshotAccessLog(BaseModel): + visitorId: str + snapshotId: str + visits: int + firstVisitedAt: str | None + lastVisitedAt: str | None + snapshotDeleted: bool + + snapshotMetadata: SnapshotMetadata + + # Internal item id + @property + # pylint: disable=invalid-name + # ↳ pylint v2 will complain about names that are shorter than 3 characters + def id(self) -> str: + return make_access_log_item_id(self.snapshotId, self.visitorId) + + +class SnapshotAccessLogIndexPage(BaseModel): + items: list[SnapshotAccessLog] + continuation_token: str | None diff --git a/backend_py/primary/primary/services/database_access/__init__.py b/backend_py/primary/primary/services/database_access/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend_py/primary/primary/services/database_access/_utils.py b/backend_py/primary/primary/services/database_access/_utils.py new file mode 100644 index 000000000..f5f944738 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/_utils.py @@ -0,0 +1,37 @@ +import hashlib +from typing import Any, cast, TypeVar + +from azure.core.async_paging import AsyncPageIterator, AsyncItemPaged + +T = TypeVar("T") + + +# Utility function to hash a JSON string using SHA-256 +# This function mimics the behavior of TextEncoder in JavaScript, which encodes strings to +# UTF-8 before hashing. The output is a hexadecimal string representation of the hash. +# +# It is important that this function returns the same hash as the JavaScript version +def hash_json_string(json_string: str) -> str: + data = json_string.encode("utf-8") # Matches TextEncoder behavior + hash_bytes = hashlib.sha256(data).digest() + hash_hex = "".join(f"{b:02x}" for b in hash_bytes) + return hash_hex + + +def cast_query_params(params: list[dict[str, Any]]) -> list[dict[str, object]]: + return cast(list[dict[str, object]], params) + + +def query_by_page(query_iterable: AsyncItemPaged[T], page_token: str | None) -> AsyncPageIterator[T]: + """ + Cosmosdb's `by_page` returns a more narrow subtype than anticipated. This makes + extra's like `.continuation_token` not show up in returned value's type. + + This util function correctly casts the return value to the expected type + """ + pager = query_iterable.by_page(page_token) + + if not isinstance(pager, AsyncPageIterator): + raise TypeError("Expected AsyncPageIterator from query_items_by_page_token_async") + + return cast(AsyncPageIterator[T], pager) diff --git a/backend_py/primary/primary/services/database_access/container_access.py b/backend_py/primary/primary/services/database_access/container_access.py new file mode 100644 index 000000000..6681ba86c --- /dev/null +++ b/backend_py/primary/primary/services/database_access/container_access.py @@ -0,0 +1,242 @@ +import logging +from typing import Any, Dict, Generic, List, Optional, Sequence, Type, TypeVar, NoReturn +from azure.cosmos.aio import ContainerProxy +from azure.cosmos import exceptions +from pydantic import BaseModel, ValidationError + +from primary.services.database_access.database_access_exceptions import ( + DatabaseAccessNotFoundError, + DatabaseAccessConflictError, + DatabaseAccessPreconditionFailedError, + DatabaseAccessPermissionError, + DatabaseAccessThrottledError, + DatabaseAccessTransportError, +) + +from ._utils import query_by_page +from .database_access import DatabaseAccess + +logger = logging.getLogger(__name__) + +T = TypeVar("T", bound=BaseModel) + +""" +ContainerAccess provides access to a specific container in a Cosmos DB database. +It allows for querying, inserting, updating, and deleting items in the container. +It uses a Pydantic model for item validation and serialization. + +It is designed to be used with asynchronous context management, ensuring proper resource cleanup. +It raises ServiceRequestError for any issues encountered during operations, providing a clear error message. +""" + + +class ContainerAccess(Generic[T]): + def __init__( + self, + database_name: str, + container_name: str, + database_access: DatabaseAccess, + container: ContainerProxy, + validation_model: Type[T], + ): + self._database_name = database_name + self._container_name = container_name + self._database_access = database_access + self._container = container + self._validation_model: Type[T] = validation_model + + @classmethod + def create(cls, database_name: str, container_name: str, validation_model: Type[T]) -> "ContainerAccess[T]": + """Create a ContainerAccess instance.""" + db_access = DatabaseAccess.create(database_name) + container = db_access.get_container(container_name) + logger.debug("[ContainerAccess] Created for container '%s' in database '%s'", container_name, database_name) + return cls(database_name, container_name, db_access, container, validation_model) + + async def __aenter__(self) -> "ContainerAccess": + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object | None + ) -> None: + await self.close_async() + + def _raise_exception(self, operation: str, exc: exceptions.CosmosHttpResponseError) -> NoReturn: + """Map Cosmos error to a data-access exception with rich context and re-raise.""" + headers = getattr(exc, "headers", {}) or {} + status = getattr(exc, "status_code", None) + # Cosmos uses x-ms-substatus for more detail (e.g., 1002) + substatus_raw = headers.get("x-ms-substatus") + try: + substatus = int(substatus_raw) if substatus_raw is not None else None + except ValueError: + substatus = None + activity_id = headers.get("x-ms-activity-id") + + msg = ( + f"[{operation}] Cosmos error on {self._database_name}/{self._container_name}: " + f"{getattr(exc, 'message', None) or str(exc)} " + f"(status={status}, substatus={substatus}, activity_id={activity_id})" + ) + + # Log with stack trace + logger.exception( + "[ContainerAccess] %s", + msg, + extra={ + "database": self._database_name, + "container": self._container_name, + "operation": operation, + "status_code": status, + "sub_status": substatus, + "activity_id": activity_id, + }, + ) + + if status == 404: + raise DatabaseAccessNotFoundError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + if status == 409: + raise DatabaseAccessConflictError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + if status == 412: + raise DatabaseAccessPreconditionFailedError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + if status in (401, 403): + raise DatabaseAccessPermissionError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + if status in (429, 503): + # Typically retryable + raise DatabaseAccessThrottledError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + + # Fallback + raise DatabaseAccessTransportError( + msg, status_code=status, sub_status=substatus, activity_id=activity_id + ) from exc + + async def query_items_async(self, query: str, parameters: Optional[List[Dict[str, object]]] = None) -> List[T]: + try: + items_iterable = self._container.query_items( + query=query, + parameters=parameters or [], + ) + items = [item async for item in items_iterable] + return [self._validation_model.model_validate(item) for item in items] + except ValidationError as validation_error: + logger.error("[ContainerAccess] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + self._raise_exception("query_items_async", error) + + async def query_items_by_page_token_async( + self, + query: str, + page_token: str | None, + parameters: Optional[List[Dict[str, object]]] = None, + page_size: Optional[int] = None, + ) -> tuple[list[T], str | None]: + query_iterable = self._container.query_items(query=query, parameters=parameters, max_item_count=page_size) + + pager = query_by_page(query_iterable, page_token) + page = await anext(pager) + + token = pager.continuation_token + + items = [self._validation_model.model_validate(item) async for item in page] + + return (items, token) + + async def get_item_async(self, item_id: str, partition_key: str) -> T: + try: + item = await self._container.read_item(item=item_id, partition_key=partition_key) + return self._validation_model.model_validate(item) + except ValidationError as validation_error: + logger.error("[ContainerAccess] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + self._raise_exception("get_item_async", error) + + async def insert_item_async(self, item: T) -> str: + try: + validated_item = self._validation_model.model_validate(item) + dumped_item = validated_item.model_dump(by_alias=True, mode="json") + result = await self._container.upsert_item(dumped_item) + return result["id"] + except ValidationError as validation_error: + logger.error("[ContainerAccess] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + self._raise_exception("insert_item_async", error) + + async def delete_item_async(self, item_id: str, partition_key: str) -> None: + try: + await self._container.delete_item(item=item_id, partition_key=partition_key) + logger.debug("[ContainerAccess] Deleted item '%s' from '%s'", item_id, self._container_name) + except exceptions.CosmosHttpResponseError as error: + self._raise_exception("delete_item_async", error) + + async def update_item_async(self, item_id: str, updated_item: T) -> None: + try: + validated = self._validation_model.model_validate(updated_item).model_dump(by_alias=True, mode="json") + await self._container.upsert_item(validated) + logger.debug("[ContainerAccess] Updated item '%s' in '%s'", item_id, self._container_name) + except ValidationError as validation_error: + logger.error("[ContainerAccess] Validation error in '%s': %s", self._container_name, validation_error) + raise + except exceptions.CosmosHttpResponseError as error: + self._raise_exception("update_item_async", error) + + async def patch_item_async( + self, + item_id: str, + partition_key: str, + patch_operations: Sequence[Dict[str, object]], + *, + filter_predicate: str | None = None, + ) -> None: + try: + await self._container.patch_item( + item=item_id, + partition_key=partition_key, + patch_operations=list(patch_operations), + filter_predicate=filter_predicate, + no_response=True, + ) + logger.debug("[ContainerAccess] Patched item '%s' in '%s'", item_id, self._container_name) + except exceptions.CosmosHttpResponseError as error: + self._raise_exception("patch_item_async", error) + + async def query_projection_async( + self, + query: str, + parameters: Optional[List[Dict[str, object]]] = None, + ) -> List[Dict[str, Any]]: + """ + Run a query that returns raw dicts (no Pydantic validation), useful for + projections like SELECT c.id, c.partitionKey. + """ + try: + items_iterable = self._container.query_items( + query=query, + parameters=parameters or [], + ) + return [item async for item in items_iterable] + except exceptions.CosmosHttpResponseError as error: + self._raise_exception("query_items_async", error) + + async def close_async(self) -> None: + """Close the container access.""" + if self._database_access: + logger.debug("[ContainerAccess] Closing access to '%s/%s'", self._database_name, self._container_name) + await self._database_access.close_async() + + # These should never be accessed anymore. We'll ignore the + # typing, and unset them to crash on further access attempts + self._database_access = None # type: ignore[assignment] + self._container = None # type: ignore[assignment] diff --git a/backend_py/primary/primary/services/database_access/database_access.py b/backend_py/primary/primary/services/database_access/database_access.py new file mode 100644 index 000000000..b8bc9f369 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/database_access.py @@ -0,0 +1,54 @@ +from azure.cosmos.aio import CosmosClient, ContainerProxy +from azure.cosmos import exceptions + +from primary.config import COSMOS_DB_PROD_CONNECTION_STRING, COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY +from primary.services.service_exceptions import Service, ServiceRequestError + + +class DatabaseAccess: + def __init__(self, database_name: str, client: CosmosClient): + self._database_name = database_name + self._client = client + self._database = self._client.get_database_client(database_name) + + @classmethod + def create(cls, database_name: str) -> "DatabaseAccess": + if COSMOS_DB_PROD_CONNECTION_STRING: + client = CosmosClient.from_connection_string(COSMOS_DB_PROD_CONNECTION_STRING) + elif COSMOS_DB_EMULATOR_URI and COSMOS_DB_EMULATOR_KEY: + client = CosmosClient(COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY, connection_verify=False) + else: + raise ServiceRequestError( + "No Cosmos DB production connection string or emulator URI/key provided.", Service.DATABASE + ) + self = cls(database_name, client) + return self + + async def __aenter__(self): # pylint: disable=C9001 + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): # pylint: disable=C9001 + await self.close_async() + + def _raise_exception(self, message: str): + raise ServiceRequestError(f"DatabaseAccess ({self._database_name}): {message}", Service.DATABASE) + + def get_container(self, container_name: str) -> ContainerProxy: + if not self._client or not self._database: + self._raise_exception("Database client is not initialized or already closed.") + if not container_name or not isinstance(container_name, str): + self._raise_exception("Invalid container name.") + + try: + container = self._database.get_container_client(container_name) + return container + except exceptions.CosmosHttpResponseError as error: + self._raise_exception(f"Unable to access container '{container_name}': {error.message}") + + return None # unreachable; satisfies pylint R1710 + + async def close_async(self): + if self._client: + await self._client.close() + self._client = None + self._database = None diff --git a/backend_py/primary/primary/services/database_access/database_access_exceptions.py b/backend_py/primary/primary/services/database_access/database_access_exceptions.py new file mode 100644 index 000000000..d5a291b4c --- /dev/null +++ b/backend_py/primary/primary/services/database_access/database_access_exceptions.py @@ -0,0 +1,37 @@ +class DatabaseAccessError(RuntimeError): + def __init__( + self, + message: str, + *, + status_code: int | None = None, + sub_status: int | None = None, + activity_id: str | None = None + ): + super().__init__(message) + self.status_code = status_code + self.sub_status = sub_status + self.activity_id = activity_id + + +class DatabaseAccessNotFoundError(DatabaseAccessError): + """Resource not found (404).""" + + +class DatabaseAccessConflictError(DatabaseAccessError): + """Conflict (409).""" + + +class DatabaseAccessPreconditionFailedError(DatabaseAccessError): + """Precondition failed / ETag mismatch (412).""" + + +class DatabaseAccessPermissionError(DatabaseAccessError): + """Auth/permission denied (401/403).""" + + +class DatabaseAccessThrottledError(DatabaseAccessError): + """Throttled / transient (429/503).""" + + +class DatabaseAccessTransportError(DatabaseAccessError): + """Other transport / HTTP errors.""" diff --git a/backend_py/primary/primary/services/database_access/error_converter.py b/backend_py/primary/primary/services/database_access/error_converter.py new file mode 100644 index 000000000..1b5397d9c --- /dev/null +++ b/backend_py/primary/primary/services/database_access/error_converter.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import Dict, NoReturn, Optional, Type + +from primary.services.service_exceptions import Service, ServiceRequestError + +from primary.services.database_access.database_access_exceptions import ( + DatabaseAccessError, + DatabaseAccessNotFoundError, + DatabaseAccessConflictError, + DatabaseAccessPreconditionFailedError, + DatabaseAccessPermissionError, + DatabaseAccessThrottledError, + DatabaseAccessTransportError, +) + +_DEFAULT_MESSAGES: Dict[Type[DatabaseAccessError], str] = { + DatabaseAccessNotFoundError: "Resource not found.", + DatabaseAccessConflictError: "Conflict while writing resource.", + DatabaseAccessPreconditionFailedError: "Precondition failed (ETag mismatch).", + DatabaseAccessPermissionError: "Permission denied for database operation.", + DatabaseAccessThrottledError: "Database is throttling requests; please retry later.", + DatabaseAccessTransportError: "Database transport error.", + DatabaseAccessError: "Database error.", +} + + +def convert_data_access_error_to_service_error( + err: DatabaseAccessError, + *, + context: Optional[str] = None, + messages: Optional[Dict[Type[DatabaseAccessError], str]] = None, +) -> ServiceRequestError: + """ + Convert a DatabaseAccess* error to a ServiceRequestError (without raising). + You can customize messages per exception type via the 'messages' dict. + """ + msgs = {**_DEFAULT_MESSAGES, **(messages or {})} + + # Find the most specific message for the concrete type + msg = None + for err_type, text in msgs.items(): + if isinstance(err, err_type): + msg = text + break + if msg is None: + msg = msgs[DatabaseAccessError] + + # Append context and technical details (status/substatus/activity_id) if available + details = [] + if getattr(err, "status_code", None) is not None: + details.append(f"status={err.status_code}") + if getattr(err, "sub_status", None) is not None: + details.append(f"substatus={err.sub_status}") + if getattr(err, "activity_id", None): + details.append(f"activity_id={err.activity_id}") + + prefix = f"{context}: " if context else "" + suffix = f" ({', '.join(details)})" if details else "" + message = f"{prefix}{msg}{suffix}" + + # Chain the original exception for traceback preservation + return ServiceRequestError(message, Service.DATABASE) + + +def raise_service_error_from_database_access( + err: DatabaseAccessError, + *, + context: Optional[str] = None, + messages: Optional[Dict[Type[DatabaseAccessError], str]] = None, +) -> NoReturn: + """ + Convert and raise immediately, chaining the original error. + """ + service_err = convert_data_access_error_to_service_error(err, context=context, messages=messages) + raise service_err from err diff --git a/backend_py/primary/primary/services/database_access/query_collation_options.py b/backend_py/primary/primary/services/database_access/query_collation_options.py new file mode 100644 index 000000000..763ff4886 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/query_collation_options.py @@ -0,0 +1,81 @@ +from enum import Enum +from dataclasses import dataclass +from typing import Literal + + +class SortDirection(str, Enum): + ASC = "asc" + DESC = "desc" + + +LOWER_CASE_PREFIX = "__lower" +FilterOperator = Literal["CONTAINS", "EQUAL", "LESS", "MORE"] + + +@dataclass +class Filter: + field: str + value: str | float + operator: FilterOperator = "EQUAL" + prefix: str = "" + + @property + def prop_name(self) -> str: + return self.field.replace(".", "") + self.prefix + + +@dataclass +class QueryCollationOptions: + """Helper class for defining NoSQL collation options""" + + sort_by: str | None = None + sort_dir: SortDirection | None = None # "asc" or "desc" + sort_lowercase: bool | None = None + limit: int | None = None + offset: int | None = 0 + + filters: list[Filter] | None = None + + def is_any_collation(self) -> bool: + return self.sort_by is not None and self.limit is not None + + def make_query_params(self) -> list[dict[str, object]]: + if not self.filters: + return [] + + return [{"name": f"@{q_filter.prop_name}", "value": q_filter.value} for q_filter in self.filters] + + def to_sql_query_string(self, variable_name: str = "c") -> str | None: + tokens = [] + + if self.filters: + for idx, query_filter in enumerate(self.filters): + tokens.append("WHERE" if (idx == 0) else "AND") + + if query_filter.operator == "EQUAL": + tokens.append(f"{variable_name}.{query_filter.field} = @{query_filter.prop_name}") + elif query_filter.operator == "LESS": + tokens.append(f"{variable_name}.{query_filter.field} <= @{query_filter.prop_name}") + elif query_filter.operator == "MORE": + tokens.append(f"{variable_name}.{query_filter.field} >= @{query_filter.prop_name}") + elif query_filter.operator == "CONTAINS": + tokens.append(f"CONTAINS({variable_name}.{query_filter.field}, @{query_filter.prop_name})") + else: + raise NotImplementedError(f"{query_filter.operator} has not been implemented yet") + + if self.sort_by: + sort_by_field = self.sort_by + (LOWER_CASE_PREFIX if self.sort_lowercase else "") + + tokens.append(f"ORDER BY {variable_name}.{sort_by_field}") + + if self.sort_dir: + tokens.append(self.sort_dir.value) + + # Zero is arguably a valid limit, so explicitly check None + if self.limit is not None: + tokens.append(f"OFFSET {self.offset or 0} LIMIT {self.limit}") + + if tokens: + return " ".join(tokens) + + return None diff --git a/backend_py/primary/primary/services/database_access/session_access/model.py b/backend_py/primary/primary/services/database_access/session_access/model.py new file mode 100644 index 000000000..ed6d86311 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/session_access/model.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, ConfigDict +from primary.services.database_access.session_access.types import SessionMetadata + + +class SessionDocument(BaseModel): + id: str + owner_id: str + metadata: SessionMetadata + content: str + + model_config = ConfigDict(extra="ignore") diff --git a/backend_py/primary/primary/services/database_access/session_access/session_access.py b/backend_py/primary/primary/services/database_access/session_access/session_access.py new file mode 100644 index 000000000..5b56cb882 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/session_access/session_access.py @@ -0,0 +1,225 @@ +from typing import Any, List +from datetime import datetime, timezone +from nanoid import generate + +from primary.services.database_access.session_access.model import SessionDocument +from primary.services.database_access._utils import hash_json_string, cast_query_params +from primary.services.service_exceptions import Service, ServiceRequestError +from primary.services.database_access.container_access import ContainerAccess +from primary.services.database_access.query_collation_options import QueryCollationOptions, SortDirection, Filter +from primary.services.database_access.session_access.types import ( + NewSession, + SessionMetadataWithId, + SessionMetadata, + SessionUpdate, + SessionSortBy, +) +from primary.services.database_access.database_access_exceptions import ( + DatabaseAccessError, +) +from primary.services.database_access.error_converter import raise_service_error_from_database_access + +# Util dict to handle case insensitive collation +LOWERCASED_FIELDS = [SessionSortBy.TITLE] + + +class SessionAccess: + CONTAINER_NAME = "sessions" + DATABASE_NAME = "persistence" + + def __init__(self, user_id: str, session_container_access: ContainerAccess[SessionDocument]): + self.user_id = user_id + self.session_container_access = session_container_access + + async def __aenter__(self) -> "SessionAccess": + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object | None + ) -> None: + await self.session_container_access.close_async() + + @classmethod + def create(cls, user_id: str) -> "SessionAccess": + session_container_access = ContainerAccess.create(cls.DATABASE_NAME, cls.CONTAINER_NAME, SessionDocument) + return cls(user_id=user_id, session_container_access=session_container_access) + + async def get_session_by_id_async(self, session_id: str) -> SessionDocument: + try: + document = await self.session_container_access.get_item_async( + item_id=session_id, partition_key=self.user_id + ) + return document + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_all_sessions_metadata_for_user_async(self) -> List[SessionMetadataWithId]: + try: + query = "SELECT * FROM c WHERE c.owner_id = @owner_id" + params = cast_query_params([{"name": "@owner_id", "value": self.user_id}]) + items = await self.session_container_access.query_items_async(query=query, parameters=params) + return [self._to_metadata_summary(item) for item in items] + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_user_sessions_by_page_async( + self, + continuation_token: str | None = None, + page_size: int | None = None, + sort_by: SessionSortBy | None = None, + sort_direction: SortDirection | None = None, + filter_title: str | None = None, + filter_updated_from: str | None = None, + filter_updated_to: str | None = None, + ) -> tuple[list[SessionDocument], str | None]: + sort_by_field = sort_by.value if sort_by else None + sort_by_lowercase = sort_by in LOWERCASED_FIELDS + + filters: list[Filter] = [Filter("owner_id", self.user_id)] + + if filter_title: + filters.append(Filter("metadata.title__lower", filter_title.lower(), "CONTAINS")) + if filter_updated_from: + filters.append(Filter("metadata.updated_at", filter_updated_from, "MORE", "_from")) + if filter_updated_to: + filters.append(Filter("metadata.updated_at", filter_updated_to, "LESS", "_to")) + + collation_options = QueryCollationOptions( + sort_lowercase=sort_by_lowercase, + sort_dir=sort_direction, + sort_by=sort_by_field, + filters=filters, + ) + + query = "SELECT * from c" + params = collation_options.make_query_params() + search_options = collation_options.to_sql_query_string() + + if search_options: + query = f"{query} {search_options}" + + return await self.session_container_access.query_items_by_page_token_async( + query=query, parameters=params, page_size=page_size, page_token=continuation_token + ) + + async def get_filtered_sessions_metadata_for_user_async( + self, + sort_by: SessionSortBy | None, + sort_direction: SortDirection | None, + limit: int | None, + offset: int | None, + filter_title: str | None, + filter_updated_from: str | None, + filter_updated_to: str | None, + ) -> List[SessionMetadataWithId]: + try: + sort_by_field = sort_by.value if sort_by else None + sort_by_lowercase = sort_by in LOWERCASED_FIELDS + + filters: list[Filter] = [Filter("owner_id", self.user_id)] + + if filter_title: + filters.append(Filter("metadata.title__lower", filter_title.lower(), "CONTAINS")) + if filter_updated_from: + filters.append(Filter("metadata.updated_at", filter_updated_from, "MORE", "_from")) + if filter_updated_to: + filters.append(Filter("metadata.updated_at", filter_updated_to, "LESS", "_to")) + + collation_options = QueryCollationOptions( + sort_lowercase=sort_by_lowercase, + sort_dir=sort_direction, + sort_by=sort_by_field, + offset=offset, + limit=limit, + filters=filters, + ) + + query = "SELECT * from c" + params = collation_options.make_query_params() + search_options = collation_options.to_sql_query_string() + + if search_options: + query = f"{query} {search_options}" + + items = await self.session_container_access.query_items_async(query=query, parameters=params) + + return [self._to_metadata_summary(item) for item in items] + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_session_metadata_async(self, session_id: str) -> SessionMetadata: + try: + document = await self._assert_ownership_async(session_id) + return document.metadata + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def insert_session_async(self, new_session: NewSession) -> str: + try: + now = datetime.now(timezone.utc) + session_id = str(generate(size=8)) # Generate a unique session ID + session = SessionDocument( + id=session_id, + owner_id=self.user_id, + metadata=SessionMetadata( + title=new_session.title, + description=new_session.description, + created_at=now, + updated_at=now, + version=1, + hash=hash_json_string(new_session.content), + ), + content=new_session.content, + ) + return await self.session_container_access.insert_item_async(session) + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def delete_session_async(self, session_id: str) -> None: + await self._assert_ownership_async(session_id) + await self.session_container_access.delete_item_async(session_id, partition_key=self.user_id) + + async def update_session_async(self, session_id: str, session_update: SessionUpdate) -> SessionDocument: + try: + existing = await self._assert_ownership_async(session_id) + + # Get all explicitly defined changes + document_update_dict = session_update.model_dump(exclude_unset=True, exclude=set(["id"])) + metadata_update_dict: dict[str, Any] = document_update_dict.get("metadata", {}) + + # Early return if there are no changes + if not document_update_dict and not metadata_update_dict: + return existing + + # Inject computed fields + metadata_update_dict.update({"updated_at": datetime.now(timezone.utc)}) + metadata_update_dict.update({"version": existing.metadata.version + 1}) + + if session_update.content: + metadata_update_dict.update({"hash": hash_json_string(session_update.content)}) + + updated_metadata = existing.metadata.model_copy(update=metadata_update_dict) + document_update_dict.update({"metadata": updated_metadata}) + + updated_session = existing.model_copy(update=document_update_dict) + + await self.session_container_access.update_item_async(session_id, updated_session) + + return updated_session + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def _assert_ownership_async(self, session_id: str) -> SessionDocument: + try: + session = await self.session_container_access.get_item_async(item_id=session_id, partition_key=self.user_id) + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + if session.owner_id != self.user_id: + raise ServiceRequestError(f"You do not have permission to access session '{session_id}'.", Service.DATABASE) + + return session + + @staticmethod + def _to_metadata_summary(doc: SessionDocument) -> SessionMetadataWithId: + return SessionMetadataWithId(**doc.metadata.model_dump(), id=doc.id) diff --git a/backend_py/primary/primary/services/database_access/session_access/types.py b/backend_py/primary/primary/services/database_access/session_access/types.py new file mode 100644 index 000000000..ee864382c --- /dev/null +++ b/backend_py/primary/primary/services/database_access/session_access/types.py @@ -0,0 +1,70 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + + +from pydantic import BaseModel, computed_field +from pydantic.json_schema import SkipJsonSchema + + +class SessionUserEditableMetadata(BaseModel): + title: str + description: Optional[str] = None + + # Computed lowercase fields for case-insensitive collation + @computed_field # type: ignore[prop-decorator] + @property + def title__lower(self) -> str: + return self.title.lower() + + @computed_field # type: ignore[prop-decorator] + @property + def description__lower(self) -> str | None: + if self.description is None: + return None + + return self.description.lower() + + +class SessionMetadataInternal(BaseModel): + created_at: datetime + updated_at: datetime + hash: str + version: int + + +class SessionMetadata(SessionUserEditableMetadata, SessionMetadataInternal): + pass + + +class SessionMetadataWithId(SessionMetadata): + id: str + + +# SkipJsonSchema is so the field is optional, but not nullable +class SessionMetadataUpdate(BaseModel): + title: str | SkipJsonSchema[None] = None + description: str | None = None + + +class SessionUpdate(BaseModel): + id: str + metadata: SessionMetadataUpdate | SkipJsonSchema[None] = None + content: str | SkipJsonSchema[None] = None + + +class NewSession(BaseModel): + title: str + description: Optional[str] + content: str + + +class SessionSortBy(str, Enum): + CREATED_AT = "metadata.created_at" + UPDATED_AT = "metadata.updated_at" + TITLE = "metadata.title" + + +class SessionSortDirection(str, Enum): + ASC = "asc" + DESC = "desc" diff --git a/backend_py/primary/primary/services/database_access/setup_local_database.py b/backend_py/primary/primary/services/database_access/setup_local_database.py new file mode 100644 index 000000000..6bd18fed3 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/setup_local_database.py @@ -0,0 +1,107 @@ +""" +This file is only used for setting up the local database for development and testing purposes. +""" + +import logging +import time +from typing import Optional, List, Dict, Any +import ssl +import urllib.request +from urllib.error import URLError + +from azure.cosmos import CosmosClient, PartitionKey, DatabaseProxy + +from primary.config import COSMOS_DB_PROD_CONNECTION_STRING, COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY + +LOGGER = logging.getLogger(__name__) + +# Declarative schema definition +COSMOS_SCHEMA: List[Dict[str, Any]] = [ + { + "database": "persistence", + "offer_throughput": 4000, + "containers": [ + {"id": "sessions", "partition_key": "/owner_id"}, + {"id": "snapshots", "partition_key": "/id"}, + {"id": "snapshot_access_logs", "partition_key": "/visitor_id"}, + ], + }, +] + + +def wait_for_emulator(uri: str, key: str, retries: int = 50, delay: int = 10) -> CosmosClient: + probe_url = f"{uri.rstrip('/')}/_explorer/emulator.pem" + context = ssl._create_unverified_context() + + for attempt in range(retries): + try: + with urllib.request.urlopen(probe_url, context=context) as response: + if response.status == 200: + LOGGER.info("✅ Emulator HTTPS endpoint is up. Proceeding to create CosmosClient.") + break + except URLError as e: + LOGGER.warning("⏳ Emulator cert endpoint not ready (attempt %d): %s", attempt + 1, e.reason) + time.sleep(delay) + else: + raise RuntimeError("❌ Cosmos Emulator certificate endpoint not ready after timeout") + + # Now that we know HTTPS works, create the CosmosClient + return CosmosClient(uri, key, connection_verify=False) + + +def create_database_with_retry(client: CosmosClient, db_def: Dict[str, Any], max_attempts: int = 5) -> DatabaseProxy: + db_name: str = db_def["database"] + for attempt in range(1, max_attempts + 1): + try: + return client.create_database_if_not_exists(db_name, offer_throughput=db_def.get("offer_throughput")) + except Exception as error: + LOGGER.warning("⚠️ Failed to create database '%s' (attempt %d): %s", db_name, attempt, error) + if attempt == max_attempts: + raise + time.sleep(2 * attempt) + + raise RuntimeError(f"Failed to create database '{db_name}' after {max_attempts} attempts.") + + +def maybe_setup_local_database() -> None: + if COSMOS_DB_PROD_CONNECTION_STRING: + LOGGER.info("Using production Cosmos DB - skipping local setup.") + return + + if COSMOS_DB_EMULATOR_URI is None or COSMOS_DB_EMULATOR_KEY is None: + raise ValueError("No Cosmos DB production connection string or emulator URI/key provided.") + + client: CosmosClient = wait_for_emulator(COSMOS_DB_EMULATOR_URI, COSMOS_DB_EMULATOR_KEY) + + total_containers = 0 + + for db_def in COSMOS_SCHEMA: + database: DatabaseProxy = create_database_with_retry(client, db_def) + + for container_def in db_def["containers"]: + max_attempts = 5 + for attempt in range(1, max_attempts + 1): + try: + database.create_container_if_not_exists( + id=container_def["id"], + partition_key=PartitionKey(path=container_def["partition_key"]), + offer_throughput=container_def.get("throughput"), + indexing_policy=container_def.get("indexing_policy"), + ) + LOGGER.info(" ✅ Created container '%s' (attempt %d)", container_def["id"], attempt) + break + except Exception as error: + LOGGER.warning( + " ⚠️ Failed to create container '%s' (attempt %d): %s", container_def["id"], attempt, error + ) + if attempt == max_attempts: + raise + time.sleep(2 * attempt) + + total_containers += 1 + + LOGGER.info( + "✅ Local Cosmos DB emulator setup complete: %d database(s), %d container(s).", + len(COSMOS_SCHEMA), + total_containers, + ) diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/__init__.py b/backend_py/primary/primary/services/database_access/snapshot_access/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/models.py b/backend_py/primary/primary/services/database_access/snapshot_access/models.py new file mode 100644 index 000000000..8832a001e --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/models.py @@ -0,0 +1,40 @@ +from datetime import datetime +from pydantic import BaseModel, ConfigDict, ValidationInfo +from pydantic import computed_field, field_validator + + +from primary.services.database_access.snapshot_access.types import SnapshotMetadata + +from .util import make_access_log_item_id + + +class SnapshotDocument(BaseModel): + id: str # Partition key + owner_id: str + metadata: SnapshotMetadata + content: str + + model_config = ConfigDict(extra="ignore") + + +class SnapshotAccessLogDocument(BaseModel): + model_config = ConfigDict(extra="ignore") + + visitor_id: str # Partition key + snapshot_id: str + snapshot_owner_id: str + visits: int = 0 + first_visited_at: datetime | None = None + last_visited_at: datetime | None = None + snapshot_deleted: bool = False + snapshot_deleted_at: datetime | None = None + + snapshot_metadata: SnapshotMetadata + + # Internal item id + @computed_field # type: ignore[prop-decorator] + @property + # pylint: disable=invalid-name + # ↳ pylint v2 will complain about names that are shorter than 3 characters + def id(self) -> str: + return make_access_log_item_id(self.snapshot_id, self.visitor_id) diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_access.py b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_access.py new file mode 100644 index 000000000..d058aa400 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_access.py @@ -0,0 +1,156 @@ +from typing import Optional, List +from datetime import datetime, timezone +from nanoid import generate + +from primary.services.database_access.snapshot_access.models import SnapshotDocument +from primary.services.database_access._utils import hash_json_string, cast_query_params +from primary.services.service_exceptions import Service, ServiceRequestError +from primary.services.database_access.query_collation_options import QueryCollationOptions, SortDirection +from primary.services.database_access.container_access import ContainerAccess +from primary.services.database_access.database_access_exceptions import DatabaseAccessError, DatabaseAccessNotFoundError +from primary.services.database_access.error_converter import raise_service_error_from_database_access + +from .types import ( + NewSnapshot, + SnapshotMetadata, + SnapshotMetadataWithId, + SnapshotSortBy, +) + + +# Util dict to handle case insensitive collation +CASING_FIELD_LOOKUP: dict[SnapshotSortBy | None, SnapshotSortBy] = {SnapshotSortBy.TITLE_LOWER: SnapshotSortBy.TITLE} + + +class SnapshotAccess: + CONTAINER_NAME = "snapshots" + DATABASE_NAME = "persistence" + + def __init__( + self, + user_id: str, + container_access: ContainerAccess[SnapshotDocument], + ): + self.user_id = user_id + self.container_access = container_access + + async def __aenter__(self) -> "SnapshotAccess": + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object | None + ) -> None: + await self.container_access.close_async() + + @classmethod + def create(cls, user_id: str) -> "SnapshotAccess": + container_access = ContainerAccess.create(cls.DATABASE_NAME, cls.CONTAINER_NAME, SnapshotDocument) + return cls(user_id, container_access) + + async def get_snapshot_by_id_async(self, snapshot_id: str) -> SnapshotDocument: + try: + document = await self.container_access.get_item_async(item_id=snapshot_id, partition_key=snapshot_id) + + return document + except DatabaseAccessNotFoundError as err: + raise ServiceRequestError( + f"Snapshot with id '{snapshot_id}' not found. It might have been deleted.", Service.DATABASE + ) from err + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_all_snapshots_metadata_for_user_async(self) -> List[SnapshotMetadataWithId]: + try: + query = "SELECT * FROM c WHERE c.owner_id = @owner_id" + params = cast_query_params([{"name": "@owner_id", "value": self.user_id}]) + items = await self.container_access.query_items_async(query=query, parameters=params) + return [self._to_metadata_summary(item) for item in items] + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_filtered_snapshots_metadata_for_user_async( + self, + sort_by: SnapshotSortBy | None, + sort_direction: SortDirection | None, + limit: int | None, + offset: int | None, + ) -> List[SnapshotMetadataWithId]: + try: + # pylint: disable=consider-iterating-dictionary + sort_by_lowercase = sort_by in CASING_FIELD_LOOKUP.keys() + sort_by = CASING_FIELD_LOOKUP.get(sort_by, sort_by) + + collation_options = QueryCollationOptions( + sort_lowercase=sort_by_lowercase, + sort_dir=sort_direction, + sort_by=sort_by, + offset=offset, + limit=limit, + ) + + query = "SELECT * FROM c WHERE c.owner_id = @owner_id" + params = cast_query_params([{"name": "@owner_id", "value": self.user_id}]) + search_options = collation_options.to_sql_query_string("c.metadata") + + if search_options: + query = f"{query} {search_options}" + + items = await self.container_access.query_items_async(query=query, parameters=params) + + return [self._to_metadata_summary(item) for item in items] + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def get_snapshot_metadata_async(self, snapshot_id: str, _owner_id: Optional[str] = None) -> SnapshotMetadata: + try: + document = await self.container_access.get_item_async(snapshot_id, partition_key=snapshot_id) + return document.metadata + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def insert_snapshot_async(self, new_snapshot: NewSnapshot) -> str: + try: + now = datetime.now(timezone.utc) + snapshot_id = generate(size=8) + + snapshot = SnapshotDocument( + id=snapshot_id, + owner_id=self.user_id, + metadata=SnapshotMetadata( + owner_id=self.user_id, + title=new_snapshot.title, + description=new_snapshot.description, + created_at=now, + updated_at=now, + hash=hash_json_string(new_snapshot.content), + ), + content=new_snapshot.content, + ) + + return await self.container_access.insert_item_async(snapshot) + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + async def delete_snapshot_async(self, snapshot_id: str) -> None: + await self._assert_ownership_async(snapshot_id) + await self.container_access.delete_item_async(snapshot_id, partition_key=snapshot_id) + + async def _assert_ownership_async(self, snapshot_id: str) -> SnapshotDocument: + """Assert that the user owns the snapshot with the given ID.""" + try: + document = await self.container_access.get_item_async(item_id=snapshot_id, partition_key=snapshot_id) + except DatabaseAccessError as err: + raise_service_error_from_database_access(err) + + # Check if the snapshot belongs to the user - this should not be necessary if the partition key is set correctly, + # but it's a good practice to ensure the user has access. + if document.owner_id != self.user_id: + raise ServiceRequestError( + f"You do not have permission to access snapshot '{snapshot_id}'.", Service.DATABASE + ) + + return document + + @staticmethod + def _to_metadata_summary(doc: SnapshotDocument) -> SnapshotMetadataWithId: + return SnapshotMetadataWithId(**doc.metadata.model_dump(), id=doc.id) diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_log_access.py b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_log_access.py new file mode 100644 index 000000000..a256d8a6a --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/snapshot_log_access.py @@ -0,0 +1,149 @@ +import logging +from datetime import datetime, timezone +from typing import Any + + +from primary.services.database_access.container_access import ContainerAccess +from primary.services.database_access.database_access_exceptions import DatabaseAccessError, DatabaseAccessNotFoundError +from primary.services.service_exceptions import Service, ServiceRequestError + +from ..query_collation_options import QueryCollationOptions, SortDirection, Filter +from .types import SnapshotAccessLogSortBy +from .models import SnapshotAccessLogDocument +from .util import make_access_log_item_id + +from .snapshot_access import SnapshotAccess + + +LOGGER = logging.getLogger(__name__) + +LOWERCASED_FIELDS = [SnapshotAccessLogSortBy.TITLE] + + +class SnapshotLogAccess: + DATABASE_NAME = "persistence" + CONTAINER_NAME = "snapshot_access_logs" + + def __init__(self, user_id: str, container_access: ContainerAccess[SnapshotAccessLogDocument]): + self._user_id = user_id + self._container_access = container_access + + @classmethod + def create(cls, user_id: str) -> "SnapshotLogAccess": + container_access = ContainerAccess.create(cls.DATABASE_NAME, cls.CONTAINER_NAME, SnapshotAccessLogDocument) + + return cls(user_id, container_access) + + async def __aenter__(self) -> "SnapshotLogAccess": + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object | None + ) -> None: + # Clean up if needed (e.g., closing DB connections) + await self._container_access.close_async() + + async def update_log_async(self, snapshot_id: str, changes: dict[str, Any]) -> None: + try: + existing = await self.get_access_log_async(snapshot_id) + + updated_item = existing.model_copy(update=changes) + + await self._container_access.update_item_async(snapshot_id, updated_item) + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to update access log: {str(err)}", Service.DATABASE) from err + + async def get_user_access_log_by_page_async( + self, + continuation_token: str | None, + page_size: int | None, + sort_by: SnapshotAccessLogSortBy | None, + sort_direction: SortDirection | None, + filter_title: str | None, + filter_updated_from: str | None, + filter_updated_to: str | None, + ) -> tuple[list[SnapshotAccessLogDocument], str | None]: + sort_by_field = sort_by.value if sort_by else None + sort_by_lowercase = sort_by in LOWERCASED_FIELDS + + filters: list[Filter] = [Filter("visitor_id", self._user_id)] + + if filter_title: + filters.append(Filter("snapshot_metadata.title__lower", filter_title.lower(), "CONTAINS")) + if filter_updated_from: + filters.append(Filter("snapshot_metadata.updated_at", filter_updated_from, "MORE", "_from")) + if filter_updated_to: + filters.append(Filter("snapshot_metadata.updated_at", filter_updated_to, "LESS", "_to")) + + collation_options = QueryCollationOptions( + sort_lowercase=sort_by_lowercase, + sort_dir=sort_direction, + sort_by=sort_by_field, + filters=filters, + ) + + query = "SELECT * from c" + params = collation_options.make_query_params() + search_options = collation_options.to_sql_query_string() + + if search_options: + query = f"{query} {search_options}" + + return await self._container_access.query_items_by_page_token_async( + query=query, parameters=params, page_size=page_size, page_token=continuation_token + ) + + async def create_access_log_async(self, snapshot_id: str, snapshot_owner_id: str) -> SnapshotAccessLogDocument: + try: + snapshots = SnapshotAccess.create(self._user_id) + + snapshot_meta = await snapshots.get_snapshot_metadata_async(snapshot_id, snapshot_owner_id) + + new_log = SnapshotAccessLogDocument( + visitor_id=self._user_id, + snapshot_id=snapshot_id, + snapshot_owner_id=snapshot_owner_id, + snapshot_metadata=snapshot_meta, + ) + + _inserted_id = await self._container_access.insert_item_async(new_log) + + return new_log + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to create access log: {str(err)}", Service.DATABASE) from err + + async def get_access_log_async(self, snapshot_id: str) -> SnapshotAccessLogDocument: + item_id = make_access_log_item_id(snapshot_id, self._user_id) + + return await self._container_access.get_item_async(item_id, partition_key=self._user_id) + + async def get_existing_or_new_log_item_async( + self, snapshot_id: str, snapshot_owner_id: str + ) -> SnapshotAccessLogDocument: + """ + Returns an already stored log item if it exists, otherwise, creates a new instance. + + **Note: This does create a new entry in the database!** + """ + try: + return await self.get_access_log_async(snapshot_id) + except DatabaseAccessNotFoundError: + return await self.create_access_log_async(snapshot_id=snapshot_id, snapshot_owner_id=snapshot_owner_id) + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to get or create access log: {str(err)}", Service.DATABASE) from err + + async def log_snapshot_visit_async(self, snapshot_id: str, snapshot_owner_id: str) -> SnapshotAccessLogDocument: + timestamp = datetime.now(timezone.utc) + try: + log = await self.get_existing_or_new_log_item_async(snapshot_id, snapshot_owner_id) + log.visits += 1 + log.last_visited_at = timestamp + + if not log.first_visited_at: + log.first_visited_at = timestamp + + await self._container_access.update_item_async(log.id, log) + + return log + except DatabaseAccessError as err: + raise ServiceRequestError(f"Failed to log snapshot visit: {str(err)}", Service.DATABASE) from err diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/types.py b/backend_py/primary/primary/services/database_access/snapshot_access/types.py new file mode 100644 index 000000000..4131c5215 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/types.py @@ -0,0 +1,65 @@ +from typing import Optional +from datetime import datetime +from enum import Enum +from pydantic import BaseModel, computed_field + + +class SnapshotUserEditableMetadata(BaseModel): + title: str + description: Optional[str] = None + + # Computed lowercase fields for case-insensitive collation + @computed_field # type: ignore[prop-decorator] + @property + def title__lower(self) -> str: + return self.title.lower() + + @computed_field # type: ignore[prop-decorator] + @property + def description__lower(self) -> str | None: + if self.description is None: + return None + + return self.description.lower() + + +class SnapshotMetadataInternal(BaseModel): + owner_id: str + created_at: datetime + updated_at: datetime + hash: str + + +class SnapshotMetadata(SnapshotUserEditableMetadata, SnapshotMetadataInternal): + pass + + +class Snapshot(BaseModel): + id: str + owner_id: str + metadata: SnapshotMetadata + content: str + + +class SnapshotMetadataWithId(SnapshotMetadata): + id: str + + +class NewSnapshot(BaseModel): + title: str + description: Optional[str] + content: str + + +class SnapshotSortBy(str, Enum): + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + TITLE = "title" + TITLE_LOWER = "title_lower" + + +class SnapshotAccessLogSortBy(str, Enum): + VISITS = "visits" + LAST_VISIT = "last_visited_at" + TITLE = "snapshot_metadata.title" + CREATED_AT = "snapshot_metadata.created_at" diff --git a/backend_py/primary/primary/services/database_access/snapshot_access/util.py b/backend_py/primary/primary/services/database_access/snapshot_access/util.py new file mode 100644 index 000000000..837a8e71f --- /dev/null +++ b/backend_py/primary/primary/services/database_access/snapshot_access/util.py @@ -0,0 +1,2 @@ +def make_access_log_item_id(snapshot_id: str, visitor_id: str) -> str: + return f"{snapshot_id}__{visitor_id}" diff --git a/backend_py/primary/primary/services/database_access/workers/mark_logs_deleted.py b/backend_py/primary/primary/services/database_access/workers/mark_logs_deleted.py new file mode 100644 index 000000000..6f4af6680 --- /dev/null +++ b/backend_py/primary/primary/services/database_access/workers/mark_logs_deleted.py @@ -0,0 +1,79 @@ +import asyncio +from datetime import datetime, timezone +import logging +from typing import Any, Dict, List + +from primary.services.database_access.container_access import ContainerAccess +from primary.services.database_access.snapshot_access.models import SnapshotAccessLogDocument +from primary.services.service_exceptions import ServiceRequestError + +LOGGER = logging.getLogger(__name__) + +DATABASE_NAME = "persistence" +CONTAINER_NAME = "snapshot_access_logs" + + +async def mark_logs_deleted_worker(snapshot_id: str) -> None: + """ + Marks all access-log docs for the given snapshot_id as deleted (PATCH /snapshot_deleted = true). + Runs with bounded concurrency and is idempotent/safe to re-run. + """ + container_access: ContainerAccess[SnapshotAccessLogDocument] = ContainerAccess.create( + DATABASE_NAME, CONTAINER_NAME, SnapshotAccessLogDocument + ) + + try: + query = ( + "SELECT c.id, c.visitor_id " + "FROM c " + "WHERE c.snapshot_id = @sid " + "AND (NOT IS_DEFINED(c.snapshot_deleted) OR c.snapshot_deleted != true)" + ) + params = [{"name": "@sid", "value": snapshot_id}] + + rows: List[Dict[str, Any]] = await container_access.query_projection_async(query, params) + + if not rows: + LOGGER.info("No snapshot_access_logs to update for snapshot '%s'.", snapshot_id) + return + + deleted_at = datetime.now(timezone.utc).isoformat() + + operations = [ + {"op": "set", "path": "/snapshot_deleted", "value": True}, + {"op": "set", "path": "/snapshot_deleted_at", "value": deleted_at}, + ] + + # Limit concurrency to avoid RU spikes/throttling + sem = asyncio.Semaphore(32) + + async def _patch_one(rec: Dict[str, Any]) -> bool: + async with sem: + try: + await container_access.patch_item_async( + item_id=rec["id"], + partition_key=rec["visitor_id"], # /visitor_id is the PK + patch_operations=operations, + ) + return True + except asyncio.CancelledError: + # always re-raise cancellation so shutdowns are graceful + raise + except ServiceRequestError as e: + LOGGER.warning("PATCH failed for log id=%s pk=%s: %s", rec.get("id"), rec.get("visitor_id"), e) + # Do not re-raise - we want to continue with other items + return False + + results = await asyncio.gather(*(_patch_one(r) for r in rows)) + success = sum(1 for ok in results if ok) + fail = len(rows) - success + LOGGER.info( + "Marked %d/%d access-log docs deleted for snapshot '%s' (failures=%d).", + success, + len(rows), + snapshot_id, + fail, + ) + + finally: + await container_access.close_async() diff --git a/backend_py/primary/primary/services/graph_access/graph_access.py b/backend_py/primary/primary/services/graph_access/graph_access.py index e8937c251..4d9017baf 100644 --- a/backend_py/primary/primary/services/graph_access/graph_access.py +++ b/backend_py/primary/primary/services/graph_access/graph_access.py @@ -23,10 +23,10 @@ async def _request(self, url: str) -> httpx.Response: return response - async def get_user_profile_photo(self, user_email: str) -> str | None: + async def get_user_profile_photo(self, user_id_email: str) -> str | None: request_url = urljoin( self.base_url, - "me/photo/$value" if user_email == "me" else f"users/{user_email}/photo/$value", + "me/photo/$value" if user_id_email == "me" else f"users/{user_id_email}/photo/$value", ) response = await self._request(request_url) diff --git a/backend_py/primary/primary/services/service_exceptions.py b/backend_py/primary/primary/services/service_exceptions.py index 673d1d73f..aad95adee 100644 --- a/backend_py/primary/primary/services/service_exceptions.py +++ b/backend_py/primary/primary/services/service_exceptions.py @@ -8,6 +8,7 @@ class Service(str, Enum): VDS = "vds" USER_SESSION = "user_session" SSDL = "ssdl" + DATABASE = "database" class ServiceLayerException(Exception): diff --git a/backend_py/primary/pyproject.toml b/backend_py/primary/pyproject.toml index 1c5dcf0c6..80f7d17d9 100644 --- a/backend_py/primary/pyproject.toml +++ b/backend_py/primary/pyproject.toml @@ -35,6 +35,9 @@ core_utils = { path = "../libs/core_utils", develop = true } server_schemas = { path = "../libs/server_schemas", develop = true } polars = "^1.6.0" fmu-datamodels = "0.0.1" +azure-cosmos = "^4.9.0" +aiohttp = "^3.11.18" +nanoid = "^2.0.0" [tool.poetry.group.dev.dependencies] black = "^25.1.0" diff --git a/docker-compose.yml b/docker-compose.yml index faabb1500..baf0f84c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,96 +2,123 @@ # for doing development of the frontend + backend in combination. services: - frontend: - build: - context: . - dockerfile: frontend-dev.Dockerfile - ports: - - 8080:8080 - volumes: - - ./frontend/public:/usr/src/app/frontend/public - - ./frontend/src:/usr/src/app/frontend/src - - ./frontend/theme:/usr/src/app/frontend/theme - - ./frontend/index.html:/usr/src/app/frontend/index.html + frontend: + build: + context: . + dockerfile: frontend-dev.Dockerfile + ports: + - 8080:8080 + volumes: + - ./frontend/public:/usr/src/app/frontend/public + - ./frontend/src:/usr/src/app/frontend/src + - ./frontend/theme:/usr/src/app/frontend/theme + - ./frontend/index.html:/usr/src/app/frontend/index.html - backend-primary: - build: - context: . - dockerfile: ./backend_py/primary/Dockerfile - ports: - - 5000:5000 - - 5678:5678 - environment: - - UVICORN_PORT=5000 - - UVICORN_RELOAD=true - - WEBVIZ_CLIENT_SECRET - - WEBVIZ_SMDA_RESOURCE_SCOPE - - WEBVIZ_SMDA_SUBSCRIPTION_KEY - - WEBVIZ_ENTERPRISE_SUBSCRIPTION_KEY - - WEBVIZ_SSDL_RESOURCE_SCOPE - - WEBVIZ_SUMO_ENV - - WEBVIZ_VDS_HOST_ADDRESS - - APPLICATIONINSIGHTS_CONNECTION_STRING - - OTEL_RESOURCE_ATTRIBUTES=service.name=primary-backend, service.namespace=local - - CODESPACE_NAME # Automatically set env. variable by GitHub codespace - volumes: - - ./backend_py/primary/primary:/home/appuser/backend_py/primary/primary - - ./backend_py/libs:/home/appuser/backend_py/libs - command: - [ - "sh", - "-c", - "pip install debugpy && python -m debugpy --listen 0.0.0.0:5678 -m uvicorn --proxy-headers --host=0.0.0.0 primary.main:app", - ] + backend-primary: + depends_on: + cosmos-db: + condition: service_healthy + build: + context: . + dockerfile: ./backend_py/primary/Dockerfile + ports: + - 5000:5000 + - 5678:5678 + environment: + - UVICORN_PORT=5000 + - UVICORN_RELOAD=true + - WEBVIZ_CLIENT_SECRET + - WEBVIZ_SMDA_RESOURCE_SCOPE + - WEBVIZ_SMDA_SUBSCRIPTION_KEY + - WEBVIZ_ENTERPRISE_SUBSCRIPTION_KEY + - WEBVIZ_SSDL_RESOURCE_SCOPE + - WEBVIZ_SUMO_ENV + - WEBVIZ_VDS_HOST_ADDRESS + - APPLICATIONINSIGHTS_CONNECTION_STRING + - OTEL_RESOURCE_ATTRIBUTES=service.name=primary-backend, service.namespace=local + - CODESPACE_NAME # Automatically set env. variable by GitHub codespace + - WEBVIZ_DB_CONNECTION_STRING + volumes: + - ./backend_py/primary/primary:/home/appuser/backend_py/primary/primary + - ./backend_py/libs:/home/appuser/backend_py/libs + command: + [ + "sh", + "-c", + "pip install debugpy && python -m debugpy --listen 0.0.0.0:5678 -m uvicorn --proxy-headers --host=0.0.0.0 primary.main:app", + ] - surface-query: - build: - context: . - dockerfile: ./backend_go/surface-query-dev.Dockerfile - ports: - - 5001:5001 - volumes: - - ./backend_go/surface_query:/home/appuser/backend_go/surface_query + surface-query: + build: + context: . + dockerfile: ./backend_go/surface-query-dev.Dockerfile + ports: + - 5001:5001 + volumes: + - ./backend_go/surface_query:/home/appuser/backend_go/surface_query - user-mock: - build: - context: . - dockerfile: ./backend_py/user_mock/Dockerfile - ports: - - 8001:8001 - environment: - - UVICORN_PORT=8001 - - UVICORN_RELOAD=true - volumes: - - ./backend_py/user_mock/user_mock:/home/appuser/backend_py/user_mock/user_mock + user-mock: + build: + context: . + dockerfile: ./backend_py/user_mock/Dockerfile + ports: + - 8001:8001 + environment: + - UVICORN_PORT=8001 + - UVICORN_RELOAD=true + volumes: + - ./backend_py/user_mock/user_mock:/home/appuser/backend_py/user_mock/user_mock - user-grid3d-ri: - build: - context: . - dockerfile: ./backend_py/user_grid3d_ri/Dockerfile - ports: - - 8002:8002 - environment: - - UVICORN_PORT=8002 - - UVICORN_RELOAD=true - - APPLICATIONINSIGHTS_CONNECTION_STRING - - OTEL_RESOURCE_ATTRIBUTES=service.name=user-grid3d-ri, service.namespace=local - volumes: - - ./backend_py/user_grid3d_ri/user_grid3d_ri:/home/appuser/backend_py/user_grid3d_ri/user_grid3d_ri - - ./backend_py/libs:/home/appuser/backend_py/libs + user-grid3d-ri: + build: + context: . + dockerfile: ./backend_py/user_grid3d_ri/Dockerfile + ports: + - 8002:8002 + environment: + - UVICORN_PORT=8002 + - UVICORN_RELOAD=true + - APPLICATIONINSIGHTS_CONNECTION_STRING + - OTEL_RESOURCE_ATTRIBUTES=service.name=user-grid3d-ri, service.namespace=local + volumes: + - ./backend_py/user_grid3d_ri/user_grid3d_ri:/home/appuser/backend_py/user_grid3d_ri/user_grid3d_ri + - ./backend_py/libs:/home/appuser/backend_py/libs - redis-user-session: - image: bitnami/redis:6.2.10@sha256:bd42fcdab5959ce2b21b6ea8410d4b3ee87ecb2e320260326ec731ecfcffbd0e - expose: - - 6379 - environment: - - ALLOW_EMPTY_PASSWORD=yes + redis-user-session: + image: bitnami/redis:6.2.10@sha256:bd42fcdab5959ce2b21b6ea8410d4b3ee87ecb2e320260326ec731ecfcffbd0e + expose: + - 6379 + environment: + - ALLOW_EMPTY_PASSWORD=yes - redis-cache: - image: bitnami/redis:6.2.10@sha256:bd42fcdab5959ce2b21b6ea8410d4b3ee87ecb2e320260326ec731ecfcffbd0e - expose: - - 6379 - environment: - - ALLOW_EMPTY_PASSWORD=yes - # https://redis.io/docs/management/config/#configuring-redis-as-a-cache - - REDIS_EXTRA_FLAGS=--maxmemory 1gb --maxmemory-policy allkeys-lru --save '' --appendonly no --loglevel notice + redis-cache: + image: bitnami/redis:6.2.10@sha256:bd42fcdab5959ce2b21b6ea8410d4b3ee87ecb2e320260326ec731ecfcffbd0e + expose: + - 6379 + environment: + - ALLOW_EMPTY_PASSWORD=yes + # https://redis.io/docs/management/config/#configuring-redis-as-a-cache + - REDIS_EXTRA_FLAGS=--maxmemory 1gb --maxmemory-policy allkeys-lru --save '' --appendonly no --loglevel notice + + cosmos-db: + image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest + platform: linux/amd64 + tty: true + mem_limit: 4GB + ports: + - "8081:8081" # HTTPS endpoint + - "10250-10255:10250-10255" # Internal emulator ports (needed) + environment: + - AZURE_COSMOS_EMULATOR_PARTITION_COUNT=1 + - AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=true + healthcheck: + test: curl -k https://127.0.0.1:8081/_explorer/emulator.pem || exit 1 + interval: 10s + retries: 12 # 2 minutes + start_period: 30s + timeout: 5s + volumes: + - cosmosdrive:/var/lib/cosmosdb/emulator + +volumes: + cosmosdrive: diff --git a/frontend/aliases.json b/frontend/aliases.json index 2782caac6..66b364e81 100644 --- a/frontend/aliases.json +++ b/frontend/aliases.json @@ -7,7 +7,8 @@ "@framework/*": ["./src/framework/*"], "@lib/*": ["./src/lib/*"], "@modules/*": ["./src/modules/*"], - "@modules_shared/*": ["./src/modules/_shared/*"] + "@modules_shared/*": ["./src/modules/_shared/*"], + "@src/*": ["./src/*"] } } } diff --git a/frontend/index.html b/frontend/index.html index 80a372409..e7eeafea2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,7 @@- Disclaimer: Webviz is a service provided by Equinor and is not a way of sharing - official data. Data should continue to be shared through L2S, FTP and/or Dasha. -
-- References to e.g. earlier models, model results and data should still be done through the mentioned - tools, and not Webviz. Since Webviz is currently under heavy development and not production ready, there - is no guarantee given as of now that calculations are error-free. -
-Please sign in to continue.
- -