diff --git a/poetry.lock b/poetry.lock index 88936cb2..26ad4901 100644 --- a/poetry.lock +++ b/poetry.lock @@ -215,18 +215,18 @@ cryptography = "*" [[package]] name = "awscli" -version = "1.38.29" +version = "1.38.31" description = "Universal Command Line Environment for AWS." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "awscli-1.38.29-py3-none-any.whl", hash = "sha256:a13c6a9c6a29b48a0b7bb2a0432a400aeabbac158f430a5bd6d1b5f717d441a8"}, - {file = "awscli-1.38.29.tar.gz", hash = "sha256:1f4176e606a40a6353aefc5f823801e56805b0634a9797d039188a9ef590e070"}, + {file = "awscli-1.38.31-py3-none-any.whl", hash = "sha256:20838a852e8c83c22f33d36b422d27fc75dfaeec8d4face4caa5024cc30fd967"}, + {file = "awscli-1.38.31.tar.gz", hash = "sha256:c103f8a2cc13b68cbb3c4b06c2919feeca522d6fc5b6dce9ec75d3cea6f69e37"}, ] [package.dependencies] -botocore = "1.37.29" +botocore = "1.37.31" colorama = ">=0.2.5,<0.4.7" docutils = ">=0.10,<0.17" PyYAML = ">=3.10,<6.1" @@ -287,18 +287,18 @@ files = [ [[package]] name = "boto3" -version = "1.37.29" +version = "1.37.31" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "boto3-1.37.29-py3-none-any.whl", hash = "sha256:869979050e2cf6f5461503e0f1c8f226e47ec02802e88a2210f085ec22485945"}, - {file = "boto3-1.37.29.tar.gz", hash = "sha256:5702e38356b93c56ed2a27e17f7664d791f1fe2eafd58ae6ab3853b2804cadd2"}, + {file = "boto3-1.37.31-py3-none-any.whl", hash = "sha256:cf8997be0742a5cab9d33a138ef56e423a8ebd8881f6f73e95076b26656b36dc"}, + {file = "boto3-1.37.31.tar.gz", hash = "sha256:dfee02b2f8f632a239a2f4ba6a2d568e2edd7f7464e9afd8a487fdb3fa9a0ad3"}, ] [package.dependencies] -botocore = ">=1.37.29,<1.38.0" +botocore = ">=1.37.31,<1.38.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.11.0,<0.12.0" @@ -307,14 +307,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.37.29" +version = "1.37.31" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "botocore-1.37.29-py3-none-any.whl", hash = "sha256:092c41e346df37a8d7cf60a799791f8225ad3a5ba7cda749047eb31d1440b9c5"}, - {file = "botocore-1.37.29.tar.gz", hash = "sha256:728c1ef3b66a0f79bc08008a59f6fd6bef2a0a0195e5b3b9e9bef255df519890"}, + {file = "botocore-1.37.31-py3-none-any.whl", hash = "sha256:598a33a7a0e5a014bd1416c999a0b9c634fbbba3d1363e2368e6a92da4544df4"}, + {file = "botocore-1.37.31.tar.gz", hash = "sha256:eb3dfa44a87187bd82c3b493d568d8436270d4d000f237b49b669a01fcd8a21c"}, ] [package.dependencies] @@ -1647,104 +1647,116 @@ files = [ [[package]] name = "multidict" -version = "6.3.2" +version = "6.4.2" description = "multidict implementation" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "multidict-6.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8b3dc0eec9304fa04d84a51ea13b0ec170bace5b7ddeaac748149efd316f1504"}, - {file = "multidict-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9534f3d84addd3b6018fa83f97c9d4247aaa94ac917d1ed7b2523306f99f5c16"}, - {file = "multidict-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a003ce1413ae01f0b8789c1c987991346a94620a4d22210f7a8fe753646d3209"}, - {file = "multidict-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b43f7384e68b1b982c99f489921a459467b5584bdb963b25e0df57c9039d0ad"}, - {file = "multidict-6.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d142ae84047262dc75c1f92eaf95b20680f85ce11d35571b4c97e267f96fadc4"}, - {file = "multidict-6.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec7e86fbc48aa1d6d686501a8547818ba8d645e7e40eaa98232a5d43ee4380ad"}, - {file = "multidict-6.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe019fb437632b016e6cac67a7e964f1ef827ef4023f1ca0227b54be354da97e"}, - {file = "multidict-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b60cb81214a9da7cfd8ae2853d5e6e47225ece55fe5833142fe0af321c35299"}, - {file = "multidict-6.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32d9e8ef2e0312d4e96ca9adc88e0675b6d8e144349efce4a7c95d5ccb6d88e0"}, - {file = "multidict-6.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:335d584312e3fa43633d63175dfc1a5f137dd7aa03d38d1310237d54c3032774"}, - {file = "multidict-6.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b8df917faa6b8cac3d6870fc21cb7e4d169faca68e43ffe568c156c9c6408a4d"}, - {file = "multidict-6.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc060b9b89b701dd8fedef5b99e1f1002b8cb95072693233a63389d37e48212d"}, - {file = "multidict-6.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f2ce3be2500658f3c644494b934628bb0c82e549dde250d2119689ce791cc8b8"}, - {file = "multidict-6.3.2-cp310-cp310-win32.whl", hash = "sha256:dbcb4490d8e74b484449abd51751b8f560dd0a4812eb5dacc6a588498222a9ab"}, - {file = "multidict-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:06944f9ced30f8602be873563ed4df7e3f40958f60b2db39732c11d615a33687"}, - {file = "multidict-6.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:45a034f41fcd16968c0470d8912d293d7b0d0822fc25739c5c2ff7835b85bc56"}, - {file = "multidict-6.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:352585cec45f5d83d886fc522955492bb436fca032b11d487b12d31c5a81b9e3"}, - {file = "multidict-6.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:da9d89d293511fd0a83a90559dc131f8b3292b6975eb80feff19e5f4663647e2"}, - {file = "multidict-6.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79fa716592224aa652b9347a586cfe018635229074565663894eb4eb21f8307f"}, - {file = "multidict-6.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0326278a44c56e94792475268e5cd3d47fbc0bd41ee56928c3bbb103ba7f58fe"}, - {file = "multidict-6.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb1ea87f7fe45e5079f6315e95d64d4ca8b43ef656d98bed63a02e3756853a22"}, - {file = "multidict-6.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cff3c5a98d037024a9065aafc621a8599fad7b423393685dc83cf7a32f8b691"}, - {file = "multidict-6.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed99834b053c655d980fb98029003cb24281e47a796052faad4543aa9e01b8e8"}, - {file = "multidict-6.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7048440e505d2b4741e5d0b32bd2f427c901f38c7760fc245918be2cf69b3b85"}, - {file = "multidict-6.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27248c27b563f5889556da8a96e18e98a56ff807ac1a7d56cf4453c2c9e4cd91"}, - {file = "multidict-6.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6323b4ba0e018bd266f776c35f3f0943fc4ee77e481593c9f93bd49888f24e94"}, - {file = "multidict-6.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:81f7ce5ec7c27d0b45c10449c8f0fed192b93251e2e98cb0b21fec779ef1dc4d"}, - {file = "multidict-6.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03bfcf2825b3bed0ba08a9d854acd18b938cab0d2dba3372b51c78e496bac811"}, - {file = "multidict-6.3.2-cp311-cp311-win32.whl", hash = "sha256:f32c2790512cae6ca886920e58cdc8c784bdc4bb2a5ec74127c71980369d18dc"}, - {file = "multidict-6.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:0b0c15e58e038a2cd75ef7cf7e072bc39b5e0488b165902efb27978984bbad70"}, - {file = "multidict-6.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d1e0ba1ce1b8cc79117196642d95f4365e118eaf5fb85f57cdbcc5a25640b2a4"}, - {file = "multidict-6.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:029bbd7d782251a78975214b78ee632672310f9233d49531fc93e8e99154af25"}, - {file = "multidict-6.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7db41e3b56817d9175264e5fe00192fbcb8e1265307a59f53dede86161b150e"}, - {file = "multidict-6.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fcab18e65cc555ac29981a581518c23311f2b1e72d8f658f9891590465383be"}, - {file = "multidict-6.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d50eff89aa4d145a5486b171a2177042d08ea5105f813027eb1050abe91839f"}, - {file = "multidict-6.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:643e57b403d3e240045a3681f9e6a04d35a33eddc501b4cbbbdbc9c70122e7bc"}, - {file = "multidict-6.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d17b37b9715b30605b5bab1460569742d0c309e5c20079263b440f5d7746e7e"}, - {file = "multidict-6.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68acd51fa94e63312b8ddf84bfc9c3d3442fe1f9988bbe1b6c703043af8867fe"}, - {file = "multidict-6.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:347eea2852ab7f697cc5ed9b1aae96b08f8529cca0c6468f747f0781b1842898"}, - {file = "multidict-6.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4d3f8e57027dcda84a1aa181501c15c45eab9566eb6fcc274cbd1e7561224f8"}, - {file = "multidict-6.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9ca57a841ffcf712e47875d026aa49d6e67f9560624d54b51628603700d5d287"}, - {file = "multidict-6.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7cafdafb44c4e646118410368307693e49d19167e5f119cbe3a88697d2d1a636"}, - {file = "multidict-6.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:430120c6ce3715a9c6075cabcee557daccbcca8ba25a9fedf05c7bf564532f2d"}, - {file = "multidict-6.3.2-cp312-cp312-win32.whl", hash = "sha256:13bec31375235a68457ab887ce1bbf4f59d5810d838ae5d7e5b416242e1f3ed4"}, - {file = "multidict-6.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:c3b6d7620e6e90c6d97eaf3a63bf7fbd2ba253aab89120a4a9c660bf2d675391"}, - {file = "multidict-6.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b9ca24700322816ae0d426aa33671cf68242f8cc85cee0d0e936465ddaee90b5"}, - {file = "multidict-6.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d9fbbe23667d596ff4f9f74d44b06e40ebb0ab6b262cf14a284f859a66f86457"}, - {file = "multidict-6.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9cb602c5bea0589570ad3a4a6f2649c4f13cc7a1e97b4c616e5e9ff8dc490987"}, - {file = "multidict-6.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93ca81dd4d1542e20000ed90f4cc84b7713776f620d04c2b75b8efbe61106c99"}, - {file = "multidict-6.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:18b6310b5454c62242577a128c87df8897f39dd913311cf2e1298e47dfc089eb"}, - {file = "multidict-6.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a6dda57de1fc9aedfdb600a8640c99385cdab59a5716cb714b52b6005797f77"}, - {file = "multidict-6.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d8ec42d03cc6b29845552a68151f9e623c541f1708328353220af571e24a247"}, - {file = "multidict-6.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80681969cee2fa84dafeb53615d51d24246849984e3e87fbe4fe39956f2e23bf"}, - {file = "multidict-6.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:01489b0c3592bb9d238e5690e9566db7f77a5380f054b57077d2c4deeaade0eb"}, - {file = "multidict-6.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:522d9f1fd995d04dfedc0a40bca7e2591bc577d920079df50b56245a4a252c1c"}, - {file = "multidict-6.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2014e9cf0b4e9c75bbad49c1758e5a9bf967a56184fc5fcc51527425baf5abba"}, - {file = "multidict-6.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:78ced9fcbee79e446ff4bb3018ac7ba1670703de7873d9c1f6f9883db53c71bc"}, - {file = "multidict-6.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1faf01af972bd01216a107c195f5294f9f393531bc3e4faddc9b333581255d4d"}, - {file = "multidict-6.3.2-cp313-cp313-win32.whl", hash = "sha256:7a699ab13d8d8e1f885de1535b4f477fb93836c87168318244c2685da7b7f655"}, - {file = "multidict-6.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:8666bb0d883310c83be01676e302587834dfd185b52758caeab32ef0eb387bc6"}, - {file = "multidict-6.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:d82c95aabee29612b1c4f48b98be98181686eb7d6c0152301f72715705cc787b"}, - {file = "multidict-6.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f47709173ea9e87a7fd05cd7e5cf1e5d4158924ff988a9a8e0fbd853705f0e68"}, - {file = "multidict-6.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c7f9d0276ceaab41b8ae78534ff28ea33d5de85db551cbf80c44371f2b55d13"}, - {file = "multidict-6.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6eab22df44a25acab2e738f882f5ec551282ab45b2bbda5301e6d2cfb323036"}, - {file = "multidict-6.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a947cb7c657f57874021b9b70c7aac049c877fb576955a40afa8df71d01a1390"}, - {file = "multidict-6.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5faa346e8e1c371187cf345ab1e02a75889f9f510c9cbc575c31b779f7df084d"}, - {file = "multidict-6.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc6e08d977aebf1718540533b4ba5b351ccec2db093370958a653b1f7f9219cc"}, - {file = "multidict-6.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:98eab7acf55275b5bf09834125fa3a80b143a9f241cdcdd3f1295ffdc3c6d097"}, - {file = "multidict-6.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:36863655630becc224375c0b99364978a0f95aebfb27fb6dd500f7fb5fb36e79"}, - {file = "multidict-6.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d9c0979c096c0d46a963331b0e400d3a9e560e41219df4b35f0d7a2f28f39710"}, - {file = "multidict-6.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0efc04f70f05e70e5945890767e8874da5953a196f5b07c552d305afae0f3bf6"}, - {file = "multidict-6.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:2c519b3b82c34539fae3e22e4ea965869ac6b628794b1eb487780dde37637ab7"}, - {file = "multidict-6.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:329160e301f2afd7b43725d3dda8a7ef8ee41d4ceac2083fc0d8c1cc8a4bd56b"}, - {file = "multidict-6.3.2-cp313-cp313t-win32.whl", hash = "sha256:420e5144a5f598dad8db3128f1695cd42a38a0026c2991091dab91697832f8cc"}, - {file = "multidict-6.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:875faded2861c7af2682c67088e6313fec35ede811e071c96d36b081873cea14"}, - {file = "multidict-6.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2516c5eb5732d6c4e29fa93323bfdc55186895124bc569e2404e3820934be378"}, - {file = "multidict-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:be5c8622e665cc5491c13c0fcd52915cdbae991a3514251d71129691338cdfb2"}, - {file = "multidict-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3ef33150eea7953cfdb571d862cff894e0ad97ab80d97731eb4b9328fc32d52b"}, - {file = "multidict-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b357738ce46e998f1b1bad9c4b79b2a9755915f71b87a8c01ce123a22a4f99"}, - {file = "multidict-6.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:27c60e059fcd3655a653ba99fec2556cd0260ec57f9cb138d3e6ffc413638a2e"}, - {file = "multidict-6.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:629e7c5e75bde83e54a22c7043ce89d68691d1f103be6d09a1c82b870df3b4b8"}, - {file = "multidict-6.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6c8fc97d893fdf1fff15a619fee8de2f31c9b289ef7594730e35074fa0cefb"}, - {file = "multidict-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52081d2f27e0652265d4637b03f09b82f6da5ce5e1474f07dc64674ff8bfc04c"}, - {file = "multidict-6.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:64529dc395b5fd0a7826ffa70d2d9a7f4abd8f5333d6aaaba67fdf7bedde9f21"}, - {file = "multidict-6.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2b7c3fad827770840f5399348c89635ed6d6e9bba363baad7d3c7f86a9cf1da3"}, - {file = "multidict-6.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:24aa42b1651c654ae9e5273e06c3b7ccffe9f7cc76fbde40c37e9ae65f170818"}, - {file = "multidict-6.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:04ceea01e9991357164b12882e120ce6b4d63a0424bb9f9cd37910aa56d30830"}, - {file = "multidict-6.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:943897a41160945416617db567d867ab34e9258adaffc56a25a4c3f99d919598"}, - {file = "multidict-6.3.2-cp39-cp39-win32.whl", hash = "sha256:76157a9a0c5380aadd3b5ff7b8deee355ff5adecc66c837b444fa633b4d409a2"}, - {file = "multidict-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:d091d123e44035cd5664554308477aff0b58db37e701e7598a67e907b98d1925"}, - {file = "multidict-6.3.2-py3-none-any.whl", hash = "sha256:71409d4579f716217f23be2f5e7afca5ca926aaeb398aa11b72d793bff637a1f"}, - {file = "multidict-6.3.2.tar.gz", hash = "sha256:c1035eea471f759fa853dd6e76aaa1e389f93b3e1403093fa0fd3ab4db490678"}, + {file = "multidict-6.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:48f775443154d99e1b1c727ea20137ddc1e4b29448a9b24875b2a348cc143b85"}, + {file = "multidict-6.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3d17d8b2d4643d4f59629758b0dd229cda61e2319f81b470aa4a99717081ba0c"}, + {file = "multidict-6.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf95ac57b44b6fb46a0151641b9905bbad27783314abc4f4b0b0a82f26b06b07"}, + {file = "multidict-6.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5055e039ebfc6e4589115717c4a6d1dd2f195327b8d5a3e21a68f374d79dbed9"}, + {file = "multidict-6.4.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f290e4eebf7764f8327a4bc6a459f09ca9a041cf7349bacfbb252da9feb0d37c"}, + {file = "multidict-6.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4660b75286c11b8f38c90b98ccf7541b7030dbec32b28f05031f8abebf7fc0e5"}, + {file = "multidict-6.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58f74a195b99189c9f7e2bc83cc95fcf169a43a63c5c8cad63c4027bf3233118"}, + {file = "multidict-6.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a0ba21b315113d39f7aa3ca4eb804f7984dab33c42ea14d07d790a640f81e77"}, + {file = "multidict-6.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64fccd2fa3cfd49c442c4995d58189e578560705b9b632faad8ffd9aaa390007"}, + {file = "multidict-6.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fb8b1cba597398698ec494794091dadd76eb8011bb95f43578930466e7beb20b"}, + {file = "multidict-6.4.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:159dae860e4d34bd4f48e72a0c033d78ae9215b43d423c19cbf47b7db5972599"}, + {file = "multidict-6.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:aa613b270de6215f9fc850f0fb18dbc20ba297013012793cf3d2f1295e271e91"}, + {file = "multidict-6.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:15c14a01dd95e860703a4ce78d4b0d49f18c14389eb91b4aa12444e31dfc2841"}, + {file = "multidict-6.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:93de2d3802a5ac7d5deadba1c956a93db29502f1b9f4e8d2e393747b9b28d881"}, + {file = "multidict-6.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7a3165a5bae6577d10858c35f6add83333ead621e8286a5d15f7d567ef44be78"}, + {file = "multidict-6.4.2-cp310-cp310-win32.whl", hash = "sha256:27d45a6a8495f2cfcf64d6cc4fbcc78a6e87ed840e54a261430d0d4331d9aae0"}, + {file = "multidict-6.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc86a31e3215ce037ae306a338ff2156d897aae627d5d4e3dfa0c9eded4249e8"}, + {file = "multidict-6.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6da367181104a57e77140ebf736652ed9a97c7bcb77c7640cf8a168893561bd2"}, + {file = "multidict-6.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dfb3870c5d4f5413988caf64243830f7ca13dc58ae2cf3eb48fe321ca6f26648"}, + {file = "multidict-6.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1c11e8460ff0629871f9703c87208e553fbfa6c9d92f94c20d4f86e56f021029"}, + {file = "multidict-6.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac87e173c5f3aeac06d01eeebfb35c2b1bc5f536b21210dd8b032f1dc0726fbc"}, + {file = "multidict-6.4.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9e984f9542fba175bc4d98e320e9e2bd6e7480682aa84b274e9aee7fb6575b9"}, + {file = "multidict-6.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75bb804375e0b6c5db4e474fa0b5052414b6cbc3e70394d11a7ce9a7f6a18a91"}, + {file = "multidict-6.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a894aed57c6edc514c9222989cf09eebd9ea7acb6185a26cfeac2ece2ddf265"}, + {file = "multidict-6.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ce52f84e793ee83082c4aa127605070a47c31597821ca84dec5c0ac809e8509"}, + {file = "multidict-6.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20050a17cc2f322598d181a5b2e2cf4787e4c2e3bf71aab5e96618b40665e8ae"}, + {file = "multidict-6.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bbd3cc064d272618b95c2f85ec61fe9d07e2a1de18c153c10923d3c2cdebff4c"}, + {file = "multidict-6.4.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b9c5dfa5c7cdc26f6b777ac6210d3621d556e2e244f0a7358afe8ec0135f8640"}, + {file = "multidict-6.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693858c9584cedc1685e67e78fa9e50285504e8d4bbfde7290ca04dbbac939a8"}, + {file = "multidict-6.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6d168a6693c1b9b950d9584178d12095bfbaf290748eb6e2726c914bc1d0d4f1"}, + {file = "multidict-6.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f915fc3287ddc23a5da8f61f5c2c3aaca0b754ce526c0ff81f55c27b25038cd5"}, + {file = "multidict-6.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df46fd268c765d186df8c7899bc4e0a2ee0d12458d356438af0329a49e6b15ca"}, + {file = "multidict-6.4.2-cp311-cp311-win32.whl", hash = "sha256:11d76b83dc93e98207514e1938b89854655087e3b27a09d89525fe17a0f1ce11"}, + {file = "multidict-6.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:8530ca574ba38478e97af20ff0f3fd04f59ffcad435b1548703424d71e2ed66a"}, + {file = "multidict-6.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d7533a9684e599b22a4beb699647dc3269d551e455886be8125f14f3c5a0869f"}, + {file = "multidict-6.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e80878904057edfd1d70ba31258f974d36c470fd95de2bcd98e0494942c4433e"}, + {file = "multidict-6.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d8fc8c7c092a48044bf942735eea6da198cac0c655d7a06550619b2a7fec2ffd"}, + {file = "multidict-6.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71dae164819f8aede109a596db84d508e670d7e0a968901aaf22445e87ae7519"}, + {file = "multidict-6.4.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:285058a0cdd284ba5bbc1d10d75fb33e3b3087b15d5aa9d23fc4787dd3a48384"}, + {file = "multidict-6.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cd34caed981a95964218e03a3f267537a0b1a2fae078949e880f757df9efe55"}, + {file = "multidict-6.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8f47485264b1168ef852dd6bea619703409ff2cbc5e610dcb0f15fe3c6750bb"}, + {file = "multidict-6.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49e01d212be64ceee18a45e8baef441aa86cc9bd365fb92fb8b214e5fa5bc08b"}, + {file = "multidict-6.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ca936b70414dbee02f218be1d36a535a5953ba63fd82dc812135ca3f5a525f"}, + {file = "multidict-6.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:64fdd7a4aef8f14e4a6917a434d0a4766345f5d544d0c0c0e53b14eb1e4be0ac"}, + {file = "multidict-6.4.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4030f5319aacf20e924c5218377df446507b314f03676549891e12a9f832a9f1"}, + {file = "multidict-6.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b9d3d5de87a88768be67b7ec20aeb531707174ba0645697a938df3afc2c62b1f"}, + {file = "multidict-6.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e7ab7ea72c3f66ae1e930ae776ad2c6f4f78e0184781f64f196f17ecce113101"}, + {file = "multidict-6.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3af088483bf2aba7f2886437d856a6ba4cd0c17946dd615cbe55d56552eaf187"}, + {file = "multidict-6.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:858b68e475f8e73e4014c078e9feced502273032f07840117ca12d2230d27135"}, + {file = "multidict-6.4.2-cp312-cp312-win32.whl", hash = "sha256:9f1edbf7e0d22a1ebf3c24ffaf0b8a39888bd630d6ff38c77c169272a3d4b9cc"}, + {file = "multidict-6.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:38e165c4d79ac98cabb5c08d8a3a3e1dda3206bcec19ed072992b62b200b180d"}, + {file = "multidict-6.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bcd21a1b5cfb45170280e5fcd382c25519d75bdf4634656868b91c05d5e15b23"}, + {file = "multidict-6.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4748c017fa201e6a296c9ef75dc3668a01532adb8f5c27a0bef4835dfa62f8e7"}, + {file = "multidict-6.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:796d1b070211554cc8193bd6764d2b44594ff7c7522989012eacf3fde778e5cf"}, + {file = "multidict-6.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fbd035ae06babb07c6b80535b0d4f6fe57eb73a23ae276eb4502b1f67f19e8ef"}, + {file = "multidict-6.4.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ad333e0b0d19294d3fc81d7aa9b65c45ebf49e6f8b3fab02db43284140d2b0a8"}, + {file = "multidict-6.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:458b235ae7af880e18c1476f968ef571689a574720eb9e6e8873d138fbba9c58"}, + {file = "multidict-6.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8d6bd6f3c7991083e741fd55cecea70d670971c73b9563a673eab96e5356b4"}, + {file = "multidict-6.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b622146bcfc6749944d14e8a61267dbea1bee96a9c7ca7605683506ed3817844"}, + {file = "multidict-6.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed731286f0ae399e65e3172c5808732b9ae5d896b3ab7f692eb686bba6bc9df"}, + {file = "multidict-6.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c5fdcac25ae451af93e7f997678ed2282bf6b5ef892e253eea13bba59849f00e"}, + {file = "multidict-6.4.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b4cd51b623b3dc8b55f9caef07424ac6d684b8047e601aca7053999e1b4a527"}, + {file = "multidict-6.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2e116fad10aa7c74aa3acc69adcdffe86f1415abb111653f85dd37171116b57d"}, + {file = "multidict-6.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:205da0f812c9128155bc1b7be69cf89280a5bb2f5b84426bced76e7d860ed5dc"}, + {file = "multidict-6.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:99b2401166c3b2d997b80479cdc7449a093e21f3d4b2f9c6e6ceee956aa10d63"}, + {file = "multidict-6.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b29a79f1e4313c397e00d89d4d83ff54fd377e7d654b640bbe9002b4272f205e"}, + {file = "multidict-6.4.2-cp313-cp313-win32.whl", hash = "sha256:1247de19497e13063eabf1df67e87530ac31497b91e9bd08621852a57d9b5ee7"}, + {file = "multidict-6.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:b0441ee8b9edc3fadb5cd9f181212edd72f7b016723bc549f9c1c00a9a8e6f93"}, + {file = "multidict-6.4.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c785581071f140d364821c210422ab5937d6c2c51d92e45a552a8e34b434a5a3"}, + {file = "multidict-6.4.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:147c962e8ae339d5fa84c52cc1e59546cb2d0d9359f9a8e1b8eb7b5ffac8dba0"}, + {file = "multidict-6.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b8a87a3686de1d215506ba13dd388433695bfff534839e045419ba2f36437c0e"}, + {file = "multidict-6.4.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:614021ba98b8052d4eb3d5900d3bd953421edf41b2aa85d37c1dc67bcee1ea9d"}, + {file = "multidict-6.4.2-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d6698fccb2f962021e467be6ba43fc02f7341f221e80545810303f5cc66461bb"}, + {file = "multidict-6.4.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54a291428e25f775193704f95a94f21a42a28ffb09c1bdc91e152c6b1faf4a91"}, + {file = "multidict-6.4.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15ddde4242134f1aeb1455ad60cc529e0a9f5eb251ae7be7fc37debdb257182c"}, + {file = "multidict-6.4.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b019f5c53471e8a010edf2fec321ece25ac6b079b535980b7228dcb9ee62d621"}, + {file = "multidict-6.4.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52bd2b6681beb34115eebbcbdcbd170ebc3fc98c4eb0dd789f2c01d4d6b189c0"}, + {file = "multidict-6.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:44ee27fb416c3d8981b7c3c97b9813d40b06575f6d477f4da21726a30aabb562"}, + {file = "multidict-6.4.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3e80621ebc9f045fab847a970b2430146f9121b95c3c695b044ade8488753126"}, + {file = "multidict-6.4.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fd9521b70b50aac341f59799301ba24acbe2897b9157035d139668b6c43bc406"}, + {file = "multidict-6.4.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd5240bea7d8de32e7ac95088b2ad95b89993a3825d9278d363d73ca40113cb3"}, + {file = "multidict-6.4.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:7a74764881276904564f8725da3dd2b82924838a114738933f58b75082b55dd3"}, + {file = "multidict-6.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:70d695f62a55f90ee894fdf1ede51ccdda3abbe25b43b667b51747b6f61b8da7"}, + {file = "multidict-6.4.2-cp313-cp313t-win32.whl", hash = "sha256:8223f74cf698f7992e1a71ab90ab4c169b508a0f25083ebab9259b9f33d7bdde"}, + {file = "multidict-6.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e36cfe9df4dfb6c2f4d80d20852ab6449257a01942e4808c3912ed413a4d40f7"}, + {file = "multidict-6.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1f62882c6d06e5e75b82444ce14f59b830f5b017b31630cc13d8b0b01379385a"}, + {file = "multidict-6.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:93f4545347f79121e9ba98a0c33a55f469ebc22fb45ae62692e01d367219796b"}, + {file = "multidict-6.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:05dcc73739ae8096f64f08282c8980dab2cf9acb02eb838aacd8ac56b795c405"}, + {file = "multidict-6.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af55e557e722c962f70cdc6a482d0df44b1f6622af49003e33dd114340724875"}, + {file = "multidict-6.4.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9777e194be00313f5a86a0dd52d9987b72cf6c43c8a42d6bd838adc46c70e98b"}, + {file = "multidict-6.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7cbc2e5116cdca0594bb39c64a8bd5e7f44d392cf76d19913216924688d42954"}, + {file = "multidict-6.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdb621264758b22ae3aaa9d60937751069d80d610d713887ff94cf7487fc3f5f"}, + {file = "multidict-6.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cd2a3c446162678d0a6d14d69ad38204128414ba40722a5ff6f2107782b723"}, + {file = "multidict-6.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8257f26cc2cc5d0426da488162060c6cea57079172b9d3eedf016fcb0cfdb830"}, + {file = "multidict-6.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d7bf1d52c69f3051a0ebae9cca1e5be06e2ad4677d670a920454ecf6d20e2c8e"}, + {file = "multidict-6.4.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:88ae724e79a524d1921b2799251f667e7fb0344a59637df3bec91dc303020467"}, + {file = "multidict-6.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:033edcc22211463be927ca407dc5c5f3aa84d3d093ed2d559a2e3c2d995d50e4"}, + {file = "multidict-6.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:cbd06fb147030d7781f10bd7542f29bbf56cefb51a9f042713d5fc52e68b8afd"}, + {file = "multidict-6.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:258414850b59fb820bd1e1e19a9b281409a6d0bd969d3fe7104cc87a7c08c191"}, + {file = "multidict-6.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a3da262f5b9f0dd01277575de4967683123ab47699fd7db2ce38cb1bb8f5c76f"}, + {file = "multidict-6.4.2-cp39-cp39-win32.whl", hash = "sha256:4aca2ab0969dc3781fefe523ce70c5d245b915ba0708ce2713e8ac561f9448e0"}, + {file = "multidict-6.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:79dc3f9893e32fcc564c60bb34c4e393daede2cdf0337a5b4627ed162a237fb3"}, + {file = "multidict-6.4.2-py3-none-any.whl", hash = "sha256:824b60427c92c44098cfbd58d8adf8a8c5a60ade16742dcb54385b43e6337b4e"}, + {file = "multidict-6.4.2.tar.gz", hash = "sha256:99f9b6596d2e126fa1777990868743fb4c1984ea5217606fabc153aff46160e6"}, ] [[package]] @@ -2052,14 +2064,14 @@ files = [ [[package]] name = "pydantic" -version = "2.11.2" +version = "2.11.3" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pydantic-2.11.2-py3-none-any.whl", hash = "sha256:7f17d25846bcdf89b670a86cdfe7b29a9f1c9ca23dee154221c9aa81845cfca7"}, - {file = "pydantic-2.11.2.tar.gz", hash = "sha256:2138628e050bd7a1e70b91d4bf4a91167f4ad76fdb83209b107c8d84b854917e"}, + {file = "pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f"}, + {file = "pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3"}, ] [package.dependencies] @@ -2285,14 +2297,14 @@ files = [ [[package]] name = "pyright" -version = "1.1.398" +version = "1.1.399" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "pyright-1.1.398-py3-none-any.whl", hash = "sha256:0a70bfd007d9ea7de1cf9740e1ad1a40a122592cfe22a3f6791b06162ad08753"}, - {file = "pyright-1.1.398.tar.gz", hash = "sha256:357a13edd9be8082dc73be51190913e475fa41a6efb6ec0d4b7aab3bc11638d8"}, + {file = "pyright-1.1.399-py3-none-any.whl", hash = "sha256:55f9a875ddf23c9698f24208c764465ffdfd38be6265f7faf9a176e1dc549f3b"}, + {file = "pyright-1.1.399.tar.gz", hash = "sha256:439035d707a36c3d1b443aec980bc37053fbda88158eded24b8eedcf1c7b7a1b"}, ] [package.dependencies] @@ -3105,4 +3117,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "8ce1a4f2f462b9d778ce3ffddbc85bc30d70b17c0877342c76136c947ebcf333" +content-hash = "c87fd6fe80783fab2222fd5ff799329e88bf7aa6e9a5c849f4e67f2ad237ce74" diff --git a/pyproject.toml b/pyproject.toml index 651b3321..7a4e6361 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ mangum = "^0.19.0" wireup = "^1.0.1" python-json-logger = "^3.3.0" fhir-resources = "^8.0.0" +python-dateutil = "^2.9.0" [tool.poetry.group.dev.dependencies] ruff = "^0.11.0" diff --git a/src/eligibility_signposting_api/error_handler.py b/src/eligibility_signposting_api/error_handler.py index 97bfef9b..640bbea4 100644 --- a/src/eligibility_signposting_api/error_handler.py +++ b/src/eligibility_signposting_api/error_handler.py @@ -24,4 +24,4 @@ def handle_exception(e: Exception) -> ResponseReturnValue | HTTPException: ) # pyright: ignore[reportCallIssue] ] ) - return make_response(problem.model_dump(), HTTPStatus.INTERNAL_SERVER_ERROR) + return make_response(problem.model_dump(by_alias=True), HTTPStatus.INTERNAL_SERVER_ERROR) diff --git a/src/eligibility_signposting_api/model/eligibility.py b/src/eligibility_signposting_api/model/eligibility.py index 94458880..98cf8db6 100644 --- a/src/eligibility_signposting_api/model/eligibility.py +++ b/src/eligibility_signposting_api/model/eligibility.py @@ -8,5 +8,7 @@ Postcode = NewType("Postcode", str) -class Eligibility(BaseModel): - processed_suggestions: list[dict] +class EligibilityStatus(BaseModel): + eligible: bool + reasons: list[dict] + actions: list[dict] diff --git a/src/eligibility_signposting_api/model/rules.py b/src/eligibility_signposting_api/model/rules.py index 2abdc9e0..959e5eae 100644 --- a/src/eligibility_signposting_api/model/rules.py +++ b/src/eligibility_signposting_api/model/rules.py @@ -17,10 +17,10 @@ IterationName = NewType("IterationName", str) IterationVersion = NewType("IterationVersion", str) IterationID = NewType("IterationID", str) +IterationDate = NewType("IterationDate", date) RuleName = NewType("RuleName", str) RuleDescription = NewType("RuleDescription", str) RulePriority = NewType("RulePriority", int) -RuleAttributeLevel = NewType("RuleAttributeLevel", str) RuleAttributeName = NewType("RuleAttributeName", str) RuleComparator = NewType("RuleComparator", str) StartDate = NewType("StartDate", date) @@ -34,17 +34,24 @@ class RuleType(str, Enum): class RuleOperator(str, Enum): + equals = "=" + ne = "!=" lt = "<" + lte = "<=" gt = ">" + gte = ">=" year_gt = "Y>" not_in = "not_in" - equals = "=" - lte = "<=" - ne = "!=" date_gte = "D>=" member_of = "MemberOf" +class RuleAttributeLevel(str, Enum): + PERSON = "PERSON" + TARGET = "TARGET" + COHORT = "COHORT" + + class IterationCohort(BaseModel): cohort_label: str | None = Field(None, alias="CohortLabel") priority: int | None = Field(None, alias="Priority") @@ -62,26 +69,38 @@ class IterationRule(BaseModel): operator: RuleOperator = Field(..., alias="Operator") comparator: RuleComparator = Field(..., alias="Comparator") attribute_target: str | None = Field(None, alias="AttributeTarget") - comms_routing: str | None = Field(None, alias="CommsRouting") model_config = {"populate_by_name": True} class Iteration(BaseModel): id: IterationID = Field(..., alias="ID") - default_comms_routing: str | None = Field(None, alias="DefaultCommsRouting") version: IterationVersion = Field(..., alias="Version") name: IterationName = Field(..., alias="Name") - iteration_date: str | None = Field(None, alias="IterationDate") + iteration_date: IterationDate = Field(..., alias="IterationDate") iteration_number: int | None = Field(None, alias="IterationNumber") - comms_type: Literal["I", "R"] = Field(..., alias="CommsType") approval_minimum: int | None = Field(None, alias="ApprovalMinimum") approval_maximum: int | None = Field(None, alias="ApprovalMaximum") type: Literal["A", "M", "S"] = Field(..., alias="Type") - iteration_cohorts: list[IterationCohort] | None = Field(None, alias="IterationCohorts") - iteration_rules: list[IterationRule] | None = Field(None, alias="IterationRules") + iteration_cohorts: list[IterationCohort] = Field(..., alias="IterationCohorts") + iteration_rules: list[IterationRule] = Field(..., alias="IterationRules") - model_config = {"populate_by_name": True} + model_config = { + "populate_by_name": True, + "arbitrary_types_allowed": True, + } + + @field_validator("iteration_date", mode="before") + @classmethod + def parse_dates(cls, v: str | date) -> date: + if isinstance(v, date): + return v + return datetime.strptime(v, "%Y%m%d").date() # noqa: DTZ007 + + @field_serializer("iteration_date", when_used="always") + @staticmethod + def serialize_dates(v: date, _info: SerializationInfo) -> str: + return v.strftime("%Y%m%d") class CampaignConfig(BaseModel): @@ -101,7 +120,7 @@ class CampaignConfig(BaseModel): end_date: EndDate = Field(..., alias="EndDate") approval_minimum: int | None = Field(None, alias="ApprovalMinimum") approval_maximum: int | None = Field(None, alias="ApprovalMaximum") - iterations: list[Iteration] | None = Field(None, alias="Iterations") + iterations: list[Iteration] = Field(..., alias="Iterations") model_config = { "populate_by_name": True, diff --git a/src/eligibility_signposting_api/repos/__init__.py b/src/eligibility_signposting_api/repos/__init__.py index a595dbc4..8f2aaa31 100644 --- a/src/eligibility_signposting_api/repos/__init__.py +++ b/src/eligibility_signposting_api/repos/__init__.py @@ -1,4 +1,5 @@ from .eligibility_repo import EligibilityRepo from .exceptions import NotFoundError +from .rules_repo import RulesRepo -__all__ = ["EligibilityRepo", "NotFoundError"] +__all__ = ["EligibilityRepo", "NotFoundError", "RulesRepo"] diff --git a/src/eligibility_signposting_api/repos/rules_repo.py b/src/eligibility_signposting_api/repos/rules_repo.py index 762f230c..a5c449eb 100644 --- a/src/eligibility_signposting_api/repos/rules_repo.py +++ b/src/eligibility_signposting_api/repos/rules_repo.py @@ -1,10 +1,11 @@ import json +from collections.abc import Generator from typing import Annotated from botocore.client import BaseClient from wireup import Inject, service -from eligibility_signposting_api.model.rules import BucketName, CampaignConfig, CampaignName, Rules +from eligibility_signposting_api.model.rules import BucketName, CampaignConfig, Rules @service @@ -18,7 +19,9 @@ def __init__( self.s3_client = s3_client self.bucket_name = bucket_name - def get_campaign_config(self, campaign: CampaignName) -> CampaignConfig: - response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{campaign}.json") - body = response["Body"].read() - return Rules.model_validate(json.loads(body)).campaign_config + def get_campaign_configs(self) -> Generator[CampaignConfig]: + campaign_objects = self.s3_client.list_objects(Bucket=self.bucket_name) + for campaign_object in campaign_objects["Contents"]: + response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{campaign_object['Key']}") + body = response["Body"].read() + yield Rules.model_validate(json.loads(body)).campaign_config diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 67905f54..3494e884 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -1,9 +1,13 @@ import logging +from datetime import datetime +from typing import Any +from dateutil.relativedelta import relativedelta from wireup import service -from eligibility_signposting_api.model.eligibility import Eligibility, NHSNumber -from eligibility_signposting_api.repos import EligibilityRepo, NotFoundError +from eligibility_signposting_api.model.eligibility import EligibilityStatus, NHSNumber +from eligibility_signposting_api.model.rules import CampaignConfig, IterationRule, RuleAttributeLevel, RuleOperator +from eligibility_signposting_api.repos import EligibilityRepo, NotFoundError, RulesRepo logger = logging.getLogger(__name__) @@ -14,23 +18,87 @@ class UnknownPersonError(Exception): @service class EligibilityService: - def __init__(self, eligibility_repo: EligibilityRepo) -> None: + def __init__(self, eligibility_repo: EligibilityRepo, rules_repo: RulesRepo) -> None: super().__init__() self.eligibility_repo = eligibility_repo + self.rules_repo = rules_repo - def get_eligibility(self, nhs_number: NHSNumber | None = None) -> Eligibility: + def get_eligibility_status(self, nhs_number: NHSNumber | None = None) -> EligibilityStatus: if nhs_number: try: - eligibility_data = self.eligibility_repo.get_eligibility_data(nhs_number) + person_data = self.eligibility_repo.get_eligibility_data(nhs_number) + campaign_configs = list(self.rules_repo.get_campaign_configs()) logger.debug( - "got eligibility_data %r", - eligibility_data, - extra={"eligibility_data": eligibility_data, "nhs_number": nhs_number}, + "got person_data for %r", + nhs_number, + extra={ + "campaign_configs": [c.model_dump(by_alias=True) for c in campaign_configs], + "person_data": person_data, + "nhs_number": nhs_number, + }, ) except NotFoundError as e: raise UnknownPersonError from e else: # TODO: Apply rules here # noqa: TD002, TD003, FIX002 - return Eligibility(processed_suggestions=[]) + return self.evaluate_eligibility(campaign_configs, person_data) raise UnknownPersonError + + @staticmethod + def evaluate_eligibility( + campaign_configs: list[CampaignConfig], person_data: list[dict[str, Any]] + ) -> EligibilityStatus: + eligible, reasons, actions = True, [], [] + for iteration_rule in [ + iteration_rule + for campaign_config in campaign_configs + for iteration in campaign_config.iterations + for iteration_rule in iteration.iteration_rules + ]: + if EligibilityService.evaluate_exclusion(iteration_rule, person_data): + eligible = False + + return EligibilityStatus(eligible=eligible, reasons=reasons, actions=actions) + + @staticmethod + def evaluate_exclusion(iteration_rule: IterationRule, person_data: list[dict[str, Any]]) -> bool: + attribute_value = EligibilityService.get_attribute_value(iteration_rule, person_data) + return EligibilityService.evaluate_rule(iteration_rule, attribute_value) + + @staticmethod + def get_attribute_value(iteration_rule: IterationRule, person_data: list[dict[str, Any]]) -> Any: # noqa: ANN401 + match iteration_rule.attribute_level: + case RuleAttributeLevel.PERSON: + person: dict[str, Any] | None = next( + (r for r in person_data if r.get("ATTRIBUTE_TYPE", "").startswith("PERSON")), None + ) + attribute_value = person.get(iteration_rule.attribute_name) if person else None + case _: + msg = f"{iteration_rule.attribute_level} not implemented" + raise NotImplementedError(msg) + return attribute_value + + @staticmethod + def evaluate_rule(iteration_rule: IterationRule, attribute_value: Any) -> bool: # noqa: PLR0911, ANN401 + match iteration_rule.operator: + case RuleOperator.equals: + return attribute_value == iteration_rule.comparator + case RuleOperator.ne: + return attribute_value != iteration_rule.comparator + case RuleOperator.lt: + return int(attribute_value) < int(iteration_rule.comparator) + case RuleOperator.lte: + return int(attribute_value) <= int(iteration_rule.comparator) + case RuleOperator.gt: + return int(attribute_value) > int(iteration_rule.comparator) + case RuleOperator.gte: + return int(attribute_value) >= int(iteration_rule.comparator) + case RuleOperator.year_gt: + attribute_date = datetime.strptime(str(attribute_value), "%Y%m%d") if attribute_value else None # noqa: DTZ007 + today = datetime.today() # noqa: DTZ002 + cutoff = today + relativedelta(years=int(iteration_rule.comparator)) + return (attribute_date > cutoff) if attribute_date else False + case _: + msg = f"{iteration_rule.operator} not implemented" + raise NotImplementedError(msg) diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 4e358c02..07e2e079 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -1,14 +1,18 @@ import logging from http import HTTPStatus -from fhir.resources.operationoutcome import OperationOutcome, OperationOutcomeIssue +from fhir.resources.R4B.bundle import Bundle, BundleEntry +from fhir.resources.R4B.guidanceresponse import GuidanceResponse +from fhir.resources.R4B.location import Location +from fhir.resources.R4B.operationoutcome import OperationOutcome, OperationOutcomeIssue +from fhir.resources.R4B.requestgroup import RequestGroup +from fhir.resources.R4B.task import Task from flask import Blueprint, make_response, request from flask.typing import ResponseReturnValue from wireup import Injected -from eligibility_signposting_api.model.eligibility import Eligibility, NHSNumber +from eligibility_signposting_api.model.eligibility import EligibilityStatus, NHSNumber from eligibility_signposting_api.services import EligibilityService, UnknownPersonError -from eligibility_signposting_api.views.response_models import EligibilityResponse logger = logging.getLogger(__name__) @@ -20,7 +24,7 @@ def check_eligibility(eligibility_service: Injected[EligibilityService]) -> Resp nhs_number = NHSNumber(request.args.get("nhs_number", "")) logger.debug("checking nhs_number %r in %r", nhs_number, eligibility_service, extra={"nhs_number": nhs_number}) try: - eligibility = eligibility_service.get_eligibility(nhs_number) + eligibility_status = eligibility_service.get_eligibility_status(nhs_number) except UnknownPersonError: logger.debug("nhs_number %r not found", nhs_number, extra={"nhs_number": nhs_number}) problem = OperationOutcome( @@ -32,11 +36,22 @@ def check_eligibility(eligibility_service: Injected[EligibilityService]) -> Resp ) # pyright: ignore[reportCallIssue] ] ) - return make_response(problem.model_dump(), HTTPStatus.NOT_FOUND) + return make_response(problem.model_dump(by_alias=True), HTTPStatus.NOT_FOUND) else: - eligibility_response = build_eligibility_response(eligibility) - return make_response(eligibility_response.model_dump(), HTTPStatus.OK) - - -def build_eligibility_response(eligibility: Eligibility) -> EligibilityResponse: - return EligibilityResponse(processed_suggestions=eligibility.processed_suggestions) + bundle = build_bundle(eligibility_status) + return make_response(bundle.model_dump(by_alias=True), HTTPStatus.OK) + + +def build_bundle(_eligibility_status: EligibilityStatus) -> Bundle: + return Bundle( # pyright: ignore[reportCallIssue] + id="dummy-bundle", + type="collection", + entry=[ + BundleEntry( # pyright: ignore[reportCallIssue] + resource=GuidanceResponse(id="dummy-guidance-response", status="requested", moduleCodeableConcept={}) # pyright: ignore[reportCallIssue] + ), + BundleEntry(resource=RequestGroup(id="dummy-request-group", intent="proposal", status="requested")), # pyright: ignore[reportCallIssue] + BundleEntry(resource=Task(id="dummy-task", intent="proposal", status="requested")), # pyright: ignore[reportCallIssue] + BundleEntry(resource=Location(id="dummy-location")), # pyright: ignore[reportCallIssue] + ], + ) diff --git a/src/eligibility_signposting_api/views/response_models.py b/src/eligibility_signposting_api/views/response_models.py deleted file mode 100644 index 30498f49..00000000 --- a/src/eligibility_signposting_api/views/response_models.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic import BaseModel - - -class EligibilityResponse(BaseModel): - processed_suggestions: list[dict] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 4dc8752d..39273398 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -17,6 +17,14 @@ from yarl import URL from eligibility_signposting_api.model.eligibility import DateOfBirth, NHSNumber, Postcode +from eligibility_signposting_api.model.rules import ( + BucketName, + CampaignConfig, + RuleAttributeLevel, + RuleOperator, + RuleType, +) +from tests.utils.builders import CampaignConfigFactory, IterationFactory, IterationRuleFactory if TYPE_CHECKING: from pytest_docker.plugin import Services @@ -232,3 +240,36 @@ def persisted_person(eligibility_table: Any, faker: Faker) -> Generator[tuple[NH yield nhs_number, date_of_birth, postcode eligibility_table.delete_item(Key={"NHS_NUMBER": f"PERSON#{nhs_number}", "ATTRIBUTE_TYPE": f"PERSON#{nhs_number}"}) eligibility_table.delete_item(Key={"NHS_NUMBER": f"PERSON#{nhs_number}", "ATTRIBUTE_TYPE": "COHORTS"}) + + +@pytest.fixture(scope="session") +def bucket(s3_client: BaseClient) -> Generator[BucketName]: + bucket_name = BucketName("test-rules-bucket") + s3_client.create_bucket(Bucket=bucket_name, CreateBucketConfiguration={"LocationConstraint": AWS_REGION}) + yield bucket_name + s3_client.delete_bucket(Bucket=bucket_name) + + +@pytest.fixture(scope="session") +def campaign_config(s3_client: BaseClient, bucket: BucketName) -> Generator[CampaignConfig]: + campaign: CampaignConfig = CampaignConfigFactory.build( + iterations=[ + IterationFactory.build( + iteration_rules=[ + IterationRuleFactory.build( + type=RuleType.filter, + operator=RuleOperator.lt, + attribute_level=RuleAttributeLevel.PERSON, + attribute_name="DATE_OF_BIRTH", + comparator="-75", + ) + ] + ) + ] + ) + campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} + s3_client.put_object( + Bucket=bucket, Key=f"{campaign.name}.json", Body=json.dumps(campaign_data), ContentType="application/json" + ) + yield campaign + s3_client.delete_object(Bucket=bucket, Key=f"{campaign.name}.json") diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 6a731836..69790a17 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -6,9 +6,14 @@ from hamcrest import assert_that, has_entries from eligibility_signposting_api.model.eligibility import DateOfBirth, NHSNumber, Postcode +from eligibility_signposting_api.model.rules import CampaignConfig -def test_nhs_number_given(client: FlaskClient, persisted_person: tuple[NHSNumber, DateOfBirth, Postcode]): +def test_nhs_number_given( + client: FlaskClient, + persisted_person: tuple[NHSNumber, DateOfBirth, Postcode], + campaign_config: CampaignConfig, # noqa: ARG001 +): # Given nhs_number, date_of_birth, postcode = persisted_person @@ -18,7 +23,7 @@ def test_nhs_number_given(client: FlaskClient, persisted_person: tuple[NHSNumber # Then assert_that( response, - is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_entries(processed_suggestions=[]))), + is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_entries(resourceType="Bundle"))), ) @@ -29,4 +34,9 @@ def test_no_nhs_number_given(client: FlaskClient): response = client.get("/eligibility/") # Then - assert_that(response, is_response().with_status_code(HTTPStatus.NOT_FOUND)) + assert_that( + response, + is_response() + .with_status_code(HTTPStatus.NOT_FOUND) + .and_text(is_json_that(has_entries(resourceType="OperationOutcome"))), + ) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 57d068e7..bc653cf3 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -14,12 +14,16 @@ from yarl import URL from eligibility_signposting_api.model.eligibility import DateOfBirth, NHSNumber, Postcode +from eligibility_signposting_api.model.rules import CampaignConfig logger = logging.getLogger(__name__) def test_install_and_call_lambda_flask( - lambda_client: BaseClient, flask_function: str, persisted_person: tuple[NHSNumber, DateOfBirth, Postcode] + lambda_client: BaseClient, + flask_function: str, + persisted_person: tuple[NHSNumber, DateOfBirth, Postcode], + campaign_config: CampaignConfig, # noqa: ARG001 ): """Given lambda installed into localstack, run it via boto3 lambda client""" # Given @@ -53,14 +57,16 @@ def test_install_and_call_lambda_flask( logger.info(response_payload) assert_that( response_payload, - has_entries(statusCode=HTTPStatus.OK, body=is_json_that(has_entries(processed_suggestions=[]))), + has_entries(statusCode=HTTPStatus.OK, body=is_json_that(has_entries(resourceType="Bundle"))), ) - assert_that(log_output, contains_string("got eligibility_data")) + assert_that(log_output, contains_string("person_data")) def test_install_and_call_flask_lambda_over_http( - flask_function_url: URL, persisted_person: tuple[NHSNumber, DateOfBirth, Postcode] + flask_function_url: URL, + persisted_person: tuple[NHSNumber, DateOfBirth, Postcode], + campaign_config: CampaignConfig, # noqa: ARG001 ): """Given lambda installed into localstack, run it via http""" # Given @@ -72,12 +78,16 @@ def test_install_and_call_flask_lambda_over_http( # Then assert_that( response, - is_response().with_status_code(HTTPStatus.OK).and_body(is_json_that(has_entries(processed_suggestions=[]))), + is_response().with_status_code(HTTPStatus.OK).and_body(is_json_that(has_entries(resourceType="Bundle"))), ) def test_install_and_call_flask_lambda_with_unknown_nhs_number( - flask_function_url: URL, flask_function: str, logs_client: BaseClient, faker: Faker + flask_function_url: URL, + flask_function: str, + campaign_config: CampaignConfig, # noqa: ARG001 + logs_client: BaseClient, + faker: Faker, ): """Given lambda installed into localstack, run it via http, with a nonexistent NHS number specified""" # Given diff --git a/tests/integration/repo/test_rules_repo.py b/tests/integration/repo/test_rules_repo.py index 50b2b167..bc744e39 100644 --- a/tests/integration/repo/test_rules_repo.py +++ b/tests/integration/repo/test_rules_repo.py @@ -3,25 +3,16 @@ import pytest from botocore.client import BaseClient -from hamcrest import assert_that +from hamcrest import assert_that, has_item from eligibility_signposting_api.model.rules import BucketName, CampaignConfig from eligibility_signposting_api.repos.rules_repo import RulesRepo -from tests.integration.conftest import AWS_REGION -from tests.utils.builders import CampaignConfigFactory, random_str -from tests.utils.rules.rules import is_campaign_config +from tests.utils.builders import CampaignConfigFactory +from tests.utils.matchers.rules import is_campaign_config, is_iteration, is_iteration_rule -@pytest.fixture -def bucket(s3_client: BaseClient) -> Generator[BucketName]: - bucket_name = BucketName(random_str(63)) - s3_client.create_bucket(Bucket=bucket_name, CreateBucketConfiguration={"LocationConstraint": AWS_REGION}) - yield bucket_name - s3_client.delete_bucket(Bucket=bucket_name) - - -@pytest.fixture -def campaign(s3_client: BaseClient, bucket: BucketName) -> Generator[CampaignConfig]: +@pytest.fixture(scope="module") +def campaign_config(s3_client: BaseClient, bucket: BucketName) -> Generator[CampaignConfig]: campaign: CampaignConfig = CampaignConfigFactory.build() campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} s3_client.put_object( @@ -31,12 +22,29 @@ def campaign(s3_client: BaseClient, bucket: BucketName) -> Generator[CampaignCon s3_client.delete_object(Bucket=bucket, Key=f"{campaign.name}.json") -def test_get_campaign_config(s3_client: BaseClient, bucket: BucketName, campaign: CampaignConfig): +def test_get_campaign_config(s3_client: BaseClient, bucket: BucketName, campaign_config: CampaignConfig): # Given repo = RulesRepo(s3_client, bucket) # When - actual = repo.get_campaign_config(campaign.name) + actual = list(repo.get_campaign_configs()) # Then - assert_that(actual, is_campaign_config().with_id(campaign.id).and_name(campaign.name).and_version(campaign.version)) + assert_that( + actual, + has_item( + is_campaign_config() + .with_id(campaign_config.id) + .and_name(campaign_config.name) + .and_version(campaign_config.version) + .and_iterations( + has_item( + is_iteration() + .with_id(campaign_config.iterations[0].id) + .and_iteration_rules( + has_item(is_iteration_rule().with_name(campaign_config.iterations[0].iteration_rules[0].name)) + ) + ) + ) + ), + ) diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index 022d15e0..f8e0a5d3 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -1,31 +1,227 @@ +from datetime import datetime from unittest.mock import MagicMock import pytest +from brunns.matchers.object import false, true +from dateutil.relativedelta import relativedelta +from faker import Faker +from hamcrest import assert_that -from eligibility_signposting_api.model.eligibility import Eligibility, NHSNumber -from eligibility_signposting_api.repos import EligibilityRepo, NotFoundError +from eligibility_signposting_api.model.eligibility import DateOfBirth, NHSNumber, Postcode +from eligibility_signposting_api.model.rules import RuleAttributeLevel, RuleOperator, RuleType +from eligibility_signposting_api.repos import EligibilityRepo, NotFoundError, RulesRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError +from tests.utils.builders import CampaignConfigFactory, IterationFactory, IterationRuleFactory +from tests.utils.matchers.eligibility import is_eligibility_status + + +@pytest.fixture(scope="session") +def faker() -> Faker: + return Faker("en_UK") def test_eligibility_service_returns_from_repo(): # Given eligibility_repo = MagicMock(spec=EligibilityRepo) + rules_repo = MagicMock(spec=RulesRepo) eligibility_repo.get_eligibility = MagicMock(return_value=[]) - ps = EligibilityService(eligibility_repo) + ps = EligibilityService(eligibility_repo, rules_repo) # When - actual = ps.get_eligibility(NHSNumber("1234567890")) + actual = ps.get_eligibility_status(NHSNumber("1234567890")) # Then - assert actual == Eligibility(processed_suggestions=[]) + assert_that(actual, is_eligibility_status().with_eligible(true())) def test_eligibility_service_for_nonexistent_nhs_number(): # Given eligibility_repo = MagicMock(spec=EligibilityRepo) + rules_repo = MagicMock(spec=RulesRepo) eligibility_repo.get_eligibility_data = MagicMock(side_effect=NotFoundError) - ps = EligibilityService(eligibility_repo) + ps = EligibilityService(eligibility_repo, rules_repo) # When with pytest.raises(UnknownPersonError): - ps.get_eligibility(NHSNumber("1234567890")) + ps.get_eligibility_status(NHSNumber("1234567890")) + + +def test_simple_rule_eligible(faker: Faker): + # Given + nhs_number = NHSNumber(f"5{faker.random_int(max=999999999):09d}") + date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=76, maximum_age=79)) + postcode = Postcode(faker.postcode()) + + eligibility_repo = MagicMock(spec=EligibilityRepo) + rules_repo = MagicMock(spec=RulesRepo) + eligibility_repo.get_eligibility_data = MagicMock( + return_value=[ + { + "NHS_NUMBER": f"PERSON#{nhs_number}", + "ATTRIBUTE_TYPE": f"PERSON#{nhs_number}", + "DATE_OF_BIRTH": date_of_birth.strftime("%Y%m%d"), + "POSTCODE": postcode, + } + ] + ) + rules_repo.get_campaign_configs = MagicMock( + return_value=[ + CampaignConfigFactory.build( + target="RSV", + iterations=[ + IterationFactory.build( + iteration_rules=[ + IterationRuleFactory.build( + type=RuleType.filter, + attribute_level=RuleAttributeLevel.PERSON, + attribute_name="DATE_OF_BIRTH", + operator=RuleOperator.year_gt, + comparator="-75", + ), + IterationRuleFactory.build( + type=RuleType.filter, + attribute_level=RuleAttributeLevel.PERSON, + attribute_name="DATE_OF_BIRTH", + operator=RuleOperator.lt, + comparator="19440902", + ), + ] + ) + ], + ) + ] + ) + + ps = EligibilityService(eligibility_repo, rules_repo) + + # When + actual = ps.get_eligibility_status(NHSNumber(nhs_number)) + + # Then + assert_that(actual, is_eligibility_status().with_eligible(true())) + + +def test_simple_rule_ineligible(faker: Faker): + # Given + nhs_number = NHSNumber(f"5{faker.random_int(max=999999999):09d}") + date_of_birth = DateOfBirth(faker.date_of_birth(maximum_age=74)) + postcode = Postcode(faker.postcode()) + + eligibility_repo = MagicMock(spec=EligibilityRepo) + rules_repo = MagicMock(spec=RulesRepo) + eligibility_repo.get_eligibility_data = MagicMock( + return_value=[ + { + "NHS_NUMBER": f"PERSON#{nhs_number}", + "ATTRIBUTE_TYPE": f"PERSON#{nhs_number}", + "DATE_OF_BIRTH": date_of_birth.strftime("%Y%m%d"), + "POSTCODE": postcode, + } + ] + ) + rules_repo.get_campaign_configs = MagicMock( + return_value=[ + CampaignConfigFactory.build( + target="RSV", + iterations=[ + IterationFactory.build( + iteration_rules=[ + IterationRuleFactory.build( + type=RuleType.filter, + attribute_level=RuleAttributeLevel.PERSON, + attribute_name="DATE_OF_BIRTH", + operator=RuleOperator.year_gt, + comparator="-75", + ), + IterationRuleFactory.build( + type=RuleType.filter, + attribute_level=RuleAttributeLevel.PERSON, + attribute_name="DATE_OF_BIRTH", + operator=RuleOperator.lt, + comparator="19440902", + ), + ] + ) + ], + ) + ] + ) + + ps = EligibilityService(eligibility_repo, rules_repo) + + # When + actual = ps.get_eligibility_status(NHSNumber(nhs_number)) + + # Then + assert_that(actual, is_eligibility_status().with_eligible(false())) + + +def test_equals_rule(): + rule = IterationRuleFactory.build(operator=RuleOperator.equals, comparator="42") + assert EligibilityService.evaluate_rule(rule, "42") + assert not EligibilityService.evaluate_rule(rule, "99") + + +def test_not_equals_rule(): + rule = IterationRuleFactory.build(operator=RuleOperator.ne, comparator="42") + assert EligibilityService.evaluate_rule(rule, "99") + assert not EligibilityService.evaluate_rule(rule, "42") + + +def test_less_than_rule(): + rule = IterationRuleFactory.build(operator=RuleOperator.lt, comparator="100") + assert EligibilityService.evaluate_rule(rule, "42") + assert EligibilityService.evaluate_rule(rule, "99") + assert not EligibilityService.evaluate_rule(rule, "100") + assert not EligibilityService.evaluate_rule(rule, "101") + + +def test_less_than_or_equal_rule(): + rule = IterationRuleFactory.build(operator=RuleOperator.lte, comparator="100") + assert EligibilityService.evaluate_rule(rule, "99") + assert EligibilityService.evaluate_rule(rule, "100") + assert not EligibilityService.evaluate_rule(rule, "101") + + +def test_greater_than_rule(): + rule = IterationRuleFactory.build(operator=RuleOperator.gt, comparator="100") + assert EligibilityService.evaluate_rule(rule, "101") + assert not EligibilityService.evaluate_rule(rule, "100") + assert not EligibilityService.evaluate_rule(rule, "99") + + +def test_greater_than_or_equal_rule(): + rule = IterationRuleFactory.build(operator=RuleOperator.gte, comparator="100") + assert EligibilityService.evaluate_rule(rule, "100") + assert EligibilityService.evaluate_rule(rule, "101") + assert not EligibilityService.evaluate_rule(rule, "99") + + +def test_year_gt_rule_future_date(): + today = datetime.today() # noqa: DTZ002 + years_offset = 2 + future_date = today + relativedelta(years=years_offset + 1) + attribute_value = future_date.strftime("%Y%m%d") + rule = IterationRuleFactory.build(operator=RuleOperator.year_gt, comparator=str(years_offset)) + assert EligibilityService.evaluate_rule(rule, attribute_value) + + +def test_year_gt_rule_past_date(): + today = datetime.today() # noqa: DTZ002 + years_offset = 2 + past_date = today + relativedelta(years=years_offset - 1) + attribute_value = past_date.strftime("%Y%m%d") + rule = IterationRuleFactory.build(operator=RuleOperator.year_gt, comparator=str(years_offset)) + assert not EligibilityService.evaluate_rule(rule, attribute_value) + + +def test_year_gt_rule_empty_value(): + rule = IterationRuleFactory.build(operator=RuleOperator.year_gt, comparator="2") + assert not EligibilityService.evaluate_rule(rule, None) + assert not EligibilityService.evaluate_rule(rule, "") + + +def test_unimplemented_operator(): + rule = IterationRuleFactory.build(operator=RuleOperator.member_of, comparator="something") + with pytest.raises(NotImplementedError, match="not implemented"): + EligibilityService.evaluate_rule(rule, "any_value") diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index fc9e7df6..5d8c4c9f 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -8,7 +8,7 @@ from hamcrest import assert_that, contains_exactly, has_entries from wireup.integration.flask import get_app_container -from eligibility_signposting_api.model.eligibility import Eligibility, NHSNumber +from eligibility_signposting_api.model.eligibility import EligibilityStatus, NHSNumber from eligibility_signposting_api.services import EligibilityService, UnknownPersonError logger = logging.getLogger(__name__) @@ -18,15 +18,15 @@ class FakeEligibilityService(EligibilityService): def __init__(self): pass - def get_eligibility(self, _: NHSNumber | None = None) -> Eligibility: - return Eligibility(processed_suggestions=[]) + def get_eligibility_status(self, _: NHSNumber | None = None) -> EligibilityStatus: + return EligibilityStatus(eligible=True, reasons=[], actions=[]) class FakeUnknownPersonEligibilityService(EligibilityService): def __init__(self): pass - def get_eligibility(self, _: NHSNumber | None = None) -> Eligibility: + def get_eligibility_status(self, _: NHSNumber | None = None) -> EligibilityStatus: raise UnknownPersonError @@ -34,7 +34,7 @@ class FakeUnexpectedErrorEligibilityService(EligibilityService): def __init__(self): pass - def get_eligibility(self, _: NHSNumber | None = None) -> Eligibility: + def get_eligibility_status(self, _: NHSNumber | None = None) -> EligibilityStatus: raise ValueError @@ -47,7 +47,7 @@ def test_nhs_number_given(app: Flask, client: FlaskClient): # Then assert_that( response, - is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_entries(processed_suggestions=[]))), + is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_entries(resourceType="Bundle"))), ) diff --git a/tests/utils/builders.py b/tests/utils/builders.py index 6f0ab4fe..a9bcfcef 100644 --- a/tests/utils/builders.py +++ b/tests/utils/builders.py @@ -7,22 +7,18 @@ from eligibility_signposting_api.model.rules import CampaignConfig, Iteration, IterationCohort, IterationRule -class IterationCohortFactory(ModelFactory[IterationCohort]): - __model__ = IterationCohort +class IterationCohortFactory(ModelFactory[IterationCohort]): ... -class IterationRuleFactory(ModelFactory[IterationRule]): - __model__ = IterationRule +class IterationRuleFactory(ModelFactory[IterationRule]): ... class IterationFactory(ModelFactory[Iteration]): - __model__ = Iteration iteration_cohorts = Use(IterationCohortFactory.batch, size=2) iteration_rules = Use(IterationRuleFactory.batch, size=2) class CampaignConfigFactory(ModelFactory[CampaignConfig]): - __model__ = CampaignConfig iterations = Use(IterationFactory.batch, size=2) diff --git a/tests/utils/matchers/eligibility.py b/tests/utils/matchers/eligibility.py new file mode 100644 index 00000000..ea18527d --- /dev/null +++ b/tests/utils/matchers/eligibility.py @@ -0,0 +1,12 @@ +from hamcrest.core.matcher import Matcher + +from eligibility_signposting_api.model.eligibility import EligibilityStatus + +from .meta import BaseAutoMatcher + + +class EligibilityStatusMatcher(BaseAutoMatcher[EligibilityStatus]): ... + + +def is_eligibility_status() -> Matcher[EligibilityStatus]: + return EligibilityStatusMatcher() diff --git a/tests/utils/matchers/meta.py b/tests/utils/matchers/meta.py new file mode 100644 index 00000000..3f5c5606 --- /dev/null +++ b/tests/utils/matchers/meta.py @@ -0,0 +1,145 @@ +import builtins +import types +from typing import Any, get_args, get_origin + +from hamcrest import anything +from hamcrest.core.base_matcher import BaseMatcher +from hamcrest.core.core.isanything import IsAnything +from hamcrest.core.description import Description +from hamcrest.core.helpers.wrap_matcher import wrap_matcher +from hamcrest.core.matcher import Matcher + +BUILTINS = {name for name in dir(builtins) if isinstance(getattr(builtins, name), (types.BuiltinFunctionType, type))} + + +class AutoMatcherMeta(type): + def __new__(cls, name, bases, namespace, **_kwargs): + if name == "BaseAutoMatcher": + return super().__new__(cls, name, bases, namespace) + + domain_class = namespace.get("__domain_class__") + + if domain_class is None: + orig_bases = namespace.get("__orig_bases__", []) + for orig in orig_bases: + origin = get_origin(orig) + args = get_args(orig) + if origin is BaseAutoMatcher and args: + inferred_type = args[0] + if hasattr(inferred_type, "__annotations__"): + domain_class = inferred_type + namespace["__domain_class__"] = domain_class + break + + if domain_class is None or not hasattr(domain_class, "__annotations__"): + msg = f"{name} must define or infer __domain_class__ with annotations" + raise TypeError(msg) + + for field_name in domain_class.__annotations__: + attr_name = f"{field_name}_" if field_name in BUILTINS else field_name + namespace[attr_name] = anything() + + return super().__new__(cls, name, bases, namespace) + + +class BaseAutoMatcher[T](BaseMatcher, metaclass=AutoMatcherMeta): + """Create matchers for classes. Use like so: + + ```python + from hamcrest import assert_that + from hamcrest.core.matcher import Matcher + from pydantic import BaseModel + + class Status(BaseModel): + status_code: str + reason: str | None = None + count: int + + class StatusMatcher(BaseAutoMatcher[Status]): ... + + def is_status() -> Matcher[Status]: return StatusMatcher() + + actual = Status(status_code="ACTIVE", count=99) + assert_that(actual, is_status().with_status_code("ACTIVE").and_reason(None)) + assert_that(actual, is_status().with_count(42)) # Will fail + ``` + + Works only for classes with `__annotations__`; typically manually annotated classes, dataclasses.dataclass and + pydantic.BaseModel instances. + """ + + __domain_class__ = None # Will be inferred when subclassed generically + + def describe_to(self, description: Description) -> None: + description.append_text(f"{self.__domain_class__.__name__} with") + for field_name in self.__domain_class__.__annotations__: + attr_name = f"{field_name}_" if field_name in BUILTINS else field_name + self.append_matcher_description(getattr(self, attr_name), field_name, description) + + def _matches(self, item: T) -> bool: + return all( + getattr(self, f"{field}_" if field in BUILTINS else field).matches(getattr(item, field)) + for field in self.__domain_class__.__annotations__ + ) + + def describe_mismatch(self, item: T, mismatch_description: Description) -> None: + mismatch_description.append_text(f"was {self.__domain_class__.__name__} with") + for field_name in self.__domain_class__.__annotations__: + matcher = getattr(self, f"{field_name}_" if field_name in BUILTINS else field_name) + value = getattr(item, field_name) + self.describe_field_mismatch(matcher, field_name, value, mismatch_description) + + def describe_match(self, item: T, match_description: Description) -> None: + match_description.append_text(f"was {self.__domain_class__.__name__} with") + for field_name in self.__domain_class__.__annotations__: + matcher = getattr(self, f"{field_name}_" if field_name in BUILTINS else field_name) + value = getattr(item, field_name) + self.describe_field_match(matcher, field_name, value, match_description) + + def __getattr__(self, name: str): + if name.startswith(("with_", "and_")): + base = name.removeprefix("with_").removeprefix("and_") + attr = f"{base}_" if base in BUILTINS else base + if hasattr(self, attr): + + def setter(value): + setattr(self, attr, wrap_matcher(value)) + return self + + return setter + msg = f"{type(self).__name__} object has no attribute {name}" + raise AttributeError(msg) + + def __dir__(self): + dynamic_methods = [] + for field_name in self.__domain_class__.__annotations__: + base = field_name.rstrip("_") if field_name in BUILTINS else field_name + dynamic_methods.extend([f"with_{base}", f"and_{base}"]) + return list(super().__dir__()) + dynamic_methods + + @staticmethod + def append_matcher_description(field_matcher: Matcher[Any], field_name: str, description: Description) -> None: + if not isinstance(field_matcher, IsAnything): + description.append_text(f" {field_name}: ").append_description_of(field_matcher) + + @staticmethod + def describe_field_mismatch( + field_matcher: Matcher[Any], + field_name: str, + actual_value: Any, + mismatch_description: Description, + ) -> None: + if not isinstance(field_matcher, IsAnything) and not field_matcher.matches(actual_value): + mismatch_description.append_text(f" {field_name}: ") + field_matcher.describe_mismatch(actual_value, mismatch_description) + + @staticmethod + def describe_field_match( + field_matcher: Matcher[Any], + field_name: str, + actual_value: Any, + match_description: Description, + ) -> None: + if not isinstance(field_matcher, IsAnything) and field_matcher.matches(actual_value): + match_description.append_text(f" {field_name}: ") + field_matcher.describe_match(actual_value, match_description) diff --git a/tests/utils/matchers/rules.py b/tests/utils/matchers/rules.py new file mode 100644 index 00000000..4aaadf31 --- /dev/null +++ b/tests/utils/matchers/rules.py @@ -0,0 +1,26 @@ +from hamcrest.core.matcher import Matcher + +from eligibility_signposting_api.model.rules import CampaignConfig, Iteration, IterationRule + +from .meta import BaseAutoMatcher + + +class CampaignConfigMatcher(BaseAutoMatcher[CampaignConfig]): ... + + +class IterationMatcher(BaseAutoMatcher[Iteration]): ... + + +class IterationRuleMatcher(BaseAutoMatcher[IterationRule]): ... + + +def is_campaign_config() -> Matcher[CampaignConfig]: + return CampaignConfigMatcher() + + +def is_iteration() -> Matcher[Iteration]: + return IterationMatcher() + + +def is_iteration_rule() -> Matcher[IterationRule]: + return IterationRuleMatcher() diff --git a/tests/utils/rules/rules.py b/tests/utils/rules/rules.py deleted file mode 100644 index 3b363fa5..00000000 --- a/tests/utils/rules/rules.py +++ /dev/null @@ -1,64 +0,0 @@ -from brunns.matchers.utils import append_matcher_description, describe_field_match, describe_field_mismatch -from hamcrest import anything -from hamcrest.core.base_matcher import BaseMatcher -from hamcrest.core.description import Description -from hamcrest.core.helpers.wrap_matcher import wrap_matcher -from hamcrest.core.matcher import Matcher - -from eligibility_signposting_api.model.rules import CampaignConfig, CampaignID, CampaignName, CampaignVersion - -ANYTHING = anything() - - -class CampaignConfigMatcher(BaseMatcher[CampaignConfig]): - def __init__(self): - super().__init__() - self.id_: Matcher[CampaignID] = ANYTHING - self.name: Matcher[CampaignName] = ANYTHING - self.version: Matcher[CampaignVersion] = ANYTHING - - def describe_to(self, description: Description) -> None: - description.append_text("CampaignConfig with") - append_matcher_description(self.id_, "id", description) - append_matcher_description(self.name, "name", description) - append_matcher_description(self.version, "version", description) - - def _matches(self, item: CampaignConfig) -> bool: - return self.id_.matches(item.id) and self.name.matches(item.name) and self.version.matches(item.version) - - def describe_mismatch(self, item: CampaignConfig, mismatch_description: Description) -> None: - mismatch_description.append_text("was CampaignConfig with") - describe_field_mismatch(self.id_, "id", item.id, mismatch_description) - describe_field_mismatch(self.name, "name", item.name, mismatch_description) - describe_field_mismatch(self.version, "version", item.version, mismatch_description) - - def describe_match(self, item: CampaignConfig, match_description: Description) -> None: - match_description.append_text("was CampaignConfig with") - describe_field_match(self.id_, "id", item.id, match_description) - describe_field_match(self.name, "name", item.name, match_description) - describe_field_match(self.version, "version", item.version, match_description) - - def with_id(self, id_: CampaignID | Matcher[CampaignID]): - self.id_ = wrap_matcher(id_) - return self - - def and_id(self, id_: CampaignID | Matcher[CampaignID]): - return self.with_id(id_) - - def with_name(self, name: CampaignName | Matcher[CampaignName]): - self.name = wrap_matcher(name) - return self - - def and_name(self, name: CampaignName | Matcher[CampaignName]): - return self.with_name(name) - - def with_version(self, version: CampaignVersion | Matcher[CampaignVersion]): - self.version = wrap_matcher(version) - return self - - def and_version(self, version: CampaignVersion | Matcher[CampaignVersion]): - return self.with_version(version) - - -def is_campaign_config() -> Matcher[CampaignConfig]: - return CampaignConfigMatcher()