diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index c8bfd677..58491c6c 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Setup dependencies diff --git a/poetry.lock b/poetry.lock index 0e25552a..47431922 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,50 +1,97 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "aiocsv" -version = "1.2.5" -description = "Asynchronous CSV reading/writing" +version = "1.4.0" +description = "" optional = false -python-versions = ">=3.6, <4" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiocsv-1.2.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a8221a24220c3dfed5df80c87bb1e15d4863816954b5f1fca1dcfc14328c0131"}, - {file = "aiocsv-1.2.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:274df72bc8d0d11060c148523203f93cfa830dc9901a053d27032e4be0acb50e"}, - {file = "aiocsv-1.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c99bc418c869e23bbae52dc7c9f05c97b351460c848cb033d7b09b472d8d3e46"}, - {file = "aiocsv-1.2.5-cp310-cp310-win_amd64.whl", hash = "sha256:f78600e29e9dd7c35711bc1a07176fc76921fc42a65da0135e1c213abb5dc6d5"}, - {file = "aiocsv-1.2.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a499b9b2edf618142e107a54f91536e9b9457f182d721e993c9ed14a8c7353b5"}, - {file = "aiocsv-1.2.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4681ae9b7f57423fa09718369e5ecd234889c58ba4f4c203f825e682afc240a"}, - {file = "aiocsv-1.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ceff6f3ea9e09b7c48736823261de7b5e7b9b9daec18862c25033ed5ef426590"}, - {file = "aiocsv-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:7064193c3d3145d763315118df1abe93a57c2b879ab62c73fc6da77009652e50"}, - {file = "aiocsv-1.2.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f1768d44e15ab2f8789e1fd0fbe2feaf9168642cf65fcf760bf61fbc1a100ada"}, - {file = "aiocsv-1.2.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:733c820e8138b96b6110a44f7eebe3e4e9d9e1261ed67c6a93e8b964807c4346"}, - {file = "aiocsv-1.2.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:030d8f1bc33b7746ced9ff328ce881406e1c78a52f83ad26832c220d2dec8777"}, - {file = "aiocsv-1.2.5-cp36-cp36m-win_amd64.whl", hash = "sha256:790393c322db1be0353045b2db255e3147a0cab1ee78ecc1b14a1df7d8651460"}, - {file = "aiocsv-1.2.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a23c450fe3f6f3b96b826348a4b30cd884edfdc46a3c11ac30802e720cabafc2"}, - {file = "aiocsv-1.2.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1663f81741a2660d0669acb71e2f8c820c997f8761531018800d74cc669889a"}, - {file = "aiocsv-1.2.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:cf867084f3ef09075a605847fec54715eeccaf1510f0f49d0cd68bfd83535f99"}, - {file = "aiocsv-1.2.5-cp37-cp37m-win_amd64.whl", hash = "sha256:b8db0fc1269e9b432616eb5af90f896188b93ea6b971524a1216145070c43e4b"}, - {file = "aiocsv-1.2.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2a6b99770d044a59de244ad2f0c5de8461a7bbf689277a46f9251e95e0909c88"}, - {file = "aiocsv-1.2.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f84f92ed95f4fb0b59e377f6b400a2b252b613c8319c1005add1b33ab052fd77"}, - {file = "aiocsv-1.2.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:72a302a7f6f80fabfc3c36273d8cee2782245bd1cceb6cc8bbd1ea611905498b"}, - {file = "aiocsv-1.2.5-cp38-cp38-win_amd64.whl", hash = "sha256:9fa70db03e3c4dc053c2fc469c2d7cfaedfcf254bd9ad90bdf08ba021fc0909e"}, - {file = "aiocsv-1.2.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d4c285f01e18fdd47381cb72a8e18f2ce02ecfe36f02976f038ef9c5e0fbc770"}, - {file = "aiocsv-1.2.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c9064f0ed61b63af0ca26d32b78c790e8a9f5b2e2907b04b6021b2c7de6e1e3"}, - {file = "aiocsv-1.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c7a959f40131bf8cc598e93e542fc341a56062e39254ca35f4f61fc3d17d01aa"}, - {file = "aiocsv-1.2.5-cp39-cp39-win_amd64.whl", hash = "sha256:53544ba47224d67f4123bae894de77fd89a4472b98873da86d5814d44f7c4a41"}, - {file = "aiocsv-1.2.5.tar.gz", hash = "sha256:807a61335ff3b461e84abcdb68445207d1dbd518d046f570a0048f4fcb0bb8ec"}, + {file = "aiocsv-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34f971a0d20100154affec72c8e4f4191a50a92eea321988e09342cab74c4bfe"}, + {file = "aiocsv-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2395d5608664760ceab402afef5f3ea4978490ce07d90d7bdfe6f9952eb10526"}, + {file = "aiocsv-1.4.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f5e86d872668eff761f537f1e8168030917358f2e571cd01d360eb360917fce"}, + {file = "aiocsv-1.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b67c6fb3afca5805f464f97067c09b59fff6d19fb805f7a7cb53591b44191703"}, + {file = "aiocsv-1.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c96be3bf9d726d67099c5ade15255337d6b0288e92282ebb93379b076bf46b54"}, + {file = "aiocsv-1.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9f1caf2414a74a10ef7ab220898a87bbf09f4c6b7c96b6622f55206746d78e6"}, + {file = "aiocsv-1.4.0-cp310-cp310-win32.whl", hash = "sha256:37caa55e3a72ca0e7b51995749e98a3afc011cea6d86633878cf45e2dc2c82d9"}, + {file = "aiocsv-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:30d21c37bb61825f2ee067b45f0d261da39545517cec995ba18e0bb20670bc62"}, + {file = "aiocsv-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ecdaaf771f8c76076d7c491f7b189f3ff13dea2fbb6cccc020b900dce904e3d3"}, + {file = "aiocsv-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f38933132a22ea5ebb8123f8f72fd72a2f014631e21e205c7bbf4d4a68721d84"}, + {file = "aiocsv-1.4.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1f0bdddada91ebfe08f12f7631f5b12a1578a82fb61f91bdf0a4a4ff93fe3099"}, + {file = "aiocsv-1.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ab69a41409ec1731624ed25e39d18544bb1c9cb21283f2eb1d9c02bad56e91c"}, + {file = "aiocsv-1.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e07c3bac73968bd4a676377dfa23027d00270d918115d0faf8ce087df6ab1d83"}, + {file = "aiocsv-1.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bd92976566ecb250301d35096f9334e6aadf987b58b78a3fd6434b44318d98b6"}, + {file = "aiocsv-1.4.0-cp311-cp311-win32.whl", hash = "sha256:281da9eb23626482fcf47b0c83e7166e9221f143e1a0e64cebd87996a45c6c56"}, + {file = "aiocsv-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:64de35600a125752303a649b0df2edae73e9c6c8b5ba5dee2775f2d1f39a69e7"}, + {file = "aiocsv-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:eaf0caa8a951df866cf0b7ff6298a142fa76555c5b338058d372d26619682b93"}, + {file = "aiocsv-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3e587d38cc97a7f2c45316c75995b43c35c8f9d77840ef76226e53d4beeebe94"}, + {file = "aiocsv-1.4.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e08fdb3bb6f531f3ce6bc15158368cfe348fa2a483be390e5a4a972c24c5a867"}, + {file = "aiocsv-1.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97a9db9e073ae1afbfc479dbfdc3764eab1866b7678766d4a7c226b09ee55a05"}, + {file = "aiocsv-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:890f41d90de7e6a5340098e914dae47cd9580e077172b7baa5ba5dbd3c561b29"}, + {file = "aiocsv-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9b7c2935c585499320ca94d98ed7bc8cd67db770b25fa6c5e6f72bff4782575"}, + {file = "aiocsv-1.4.0-cp312-cp312-win32.whl", hash = "sha256:736ff2465414d7e047915ddae5fa48f7ed755d125e60b08ff6d59450650ef9b3"}, + {file = "aiocsv-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:d1c727460a4e82cd2aba43db0ed94a310433ec81b55e21ceb9b504053db558f6"}, + {file = "aiocsv-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0fca59ed869218a403496a05f0d4bf99affc4d8191ed930f34df1c5c8240d0e8"}, + {file = "aiocsv-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d14f5fc7e84f5e3e142eb8a9ec57442688532d5f891a5dd86a517e4ccd768907"}, + {file = "aiocsv-1.4.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6eeed4d5b9fb2d78666ad0b84ef85609ff3da237bff1d39e8c8c9c8e68ee06f9"}, + {file = "aiocsv-1.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccf1cb4280476c167aa5f83569f8640e22deff29bd0575076e5f8ebddd5bfc53"}, + {file = "aiocsv-1.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66b5e7ae6388e0cf8928f73a414ae770615c84a47f1c3d959b400fbcb1ff25d6"}, + {file = "aiocsv-1.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7dc66116ca2856c50f3b00d7577a67b8ab623335b2f397144e7bc4a812c58485"}, + {file = "aiocsv-1.4.0-cp313-cp313-win32.whl", hash = "sha256:e868eecaae6739658541f4418b095bc94e6f62584f3e2d80777c1335e738e463"}, + {file = "aiocsv-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5c20240f59a8a6faa288a56f99b196b1754bc1c8d52233e59c6a6df03c47f86"}, + {file = "aiocsv-1.4.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:03a91aa96c3341277956b95248e818628d36f7218cebcba36262a1bc014a183d"}, + {file = "aiocsv-1.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b2b95faa180a8e7563a44545a9fb7a0ab535f827729183a7d595c69337f604a5"}, + {file = "aiocsv-1.4.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e4ee48ddf88e63a27caf6f3a797cc2107113a6cd4a2579a90a0ce7066ecf29fd"}, + {file = "aiocsv-1.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe2462c5da8387fe594d2905cab096abf2982c6d44c1eb1ee03ccca183ea5338"}, + {file = "aiocsv-1.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c3147be681208231d53367794cbcde557c461ec2cc6e240f68ce637176bcf172"}, + {file = "aiocsv-1.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce1cca5da676c55ddda624a4afcb1db9874897cac0d029e680cf114779daef19"}, + {file = "aiocsv-1.4.0-cp314-cp314-win32.whl", hash = "sha256:c926ac5dd442bd390a2fbae20c5207aabf275374de15359f37bcddaa60bfaedc"}, + {file = "aiocsv-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7a703643191b742d67d4e047dcc4b2314d2914f03879732a81139c62784f278"}, + {file = "aiocsv-1.4.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b78c992cf58ca1808182490b928261a11028d6b0cf6940a641ddd74ba8acccf2"}, + {file = "aiocsv-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4199dc97fbfcc6757c7de3e300f1c9de3736adf7700b009bd8795c6e708f855f"}, + {file = "aiocsv-1.4.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:84c7cf52ec30b7a78beec457c234defb837c970b4fbbcac113ddc769e533891c"}, + {file = "aiocsv-1.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b3075b482c7f419262b994f25827da5d917fb974b725d75f84b885eb634832"}, + {file = "aiocsv-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:71756714498cae7775bd2f58b44d66d20d59be99b3c6231ba880282fe0bf0524"}, + {file = "aiocsv-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c72822d9e405bef16a6c9aa06940c8671fc76bea67d13dad8d5766c5d2033dd"}, + {file = "aiocsv-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:036433ef0e512921c265d5ace346748a60d8bfdd0b09bac558b8682f654f5018"}, + {file = "aiocsv-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e5f655da021fb32303f676f22c643d7348788619120e864e22de92c9421c7eaa"}, + {file = "aiocsv-1.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3863d5d7c988438f5278f99946c346a257648e67fdb813f2b5f32bd14097ad71"}, + {file = "aiocsv-1.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ca3134e3fa3ea2f062e66d16be796e8796214b81580de8113eef87ff8d124bcd"}, + {file = "aiocsv-1.4.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a18d5e68802557c2cd9ce1cc09dfacc836642f4608ac88fdbe83dbf2ae620503"}, + {file = "aiocsv-1.4.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f586c8e06399944e899d859edab114e967456d576200f2bd749ba9eb37644195"}, + {file = "aiocsv-1.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:cdbbe20e01ad1a338cfcd1c6815cfbdb201a42927cf5bab788b9fa910ad78b8e"}, + {file = "aiocsv-1.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:fdbf733223933ae57c3afaf2bb76489058ad5d2bb3dff48f46a004cd18c964c2"}, + {file = "aiocsv-1.4.0-cp39-cp39-win32.whl", hash = "sha256:1cd4b86b37c9ec99db01acfea1dd614f2feca42aa4ddcfe60ba274345bdbfe96"}, + {file = "aiocsv-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:3d8c7a32c36b813f38847f8c4fa10d8162702a2ebb05cf4be3b3c14d4ef06311"}, + {file = "aiocsv-1.4.0.tar.gz", hash = "sha256:f3877e5ef493616a1e0c1365744bbff89184b6f84e9a70859fe319d92060079a"}, ] +[package.dependencies] +typing_extensions = "*" + [[package]] name = "aiofiles" -version = "23.2.1" +version = "25.1.0" description = "File support for asyncio." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiofiles-23.2.1-py3-none-any.whl", hash = "sha256:19297512c647d4b27a2cf7c34caa7e405c0d60b5560618a29a9fe027b18b0107"}, - {file = "aiofiles-23.2.1.tar.gz", hash = "sha256:84ec2218d8419404abcb9f0c02df3f34c6e0a68ed41072acfb1cef5cbc29051a"}, + {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, + {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, ] [[package]] @@ -61,37 +108,47 @@ files = [ [[package]] name = "anyio" -version = "4.9.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" +version = "4.12.1" +description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, ] [package.dependencies] exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" -sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] -trio = ["trio (>=0.26.1)"] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] [[package]] name = "bandit" -version = "1.8.3" +version = "1.9.3" description = "Security oriented static analyser for python code." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "bandit-1.8.3-py3-none-any.whl", hash = "sha256:28f04dc0d258e1dd0f99dee8eefa13d1cb5e3fde1a5ab0c523971f97b289bcd8"}, - {file = "bandit-1.8.3.tar.gz", hash = "sha256:f5847beb654d309422985c36644649924e0ea4425c76dec2e89110b87506193a"}, + {file = "bandit-1.9.3-py3-none-any.whl", hash = "sha256:4745917c88d2246def79748bde5e08b9d5e9b92f877863d43fab70cd8814ce6a"}, + {file = "bandit-1.9.3.tar.gz", hash = "sha256:ade4b9b7786f89ef6fc7344a52b34558caec5da74cb90373aed01de88472f774"}, ] [package.dependencies] @@ -109,128 +166,149 @@ yaml = ["PyYAML"] [[package]] name = "certifi" -version = "2025.4.26" +version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" groups = ["main", "dev"] files = [ - {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, - {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] [[package]] name = "charset-normalizer" -version = "3.4.2" +version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, - {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, - {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] [[package]] name = "click" -version = "8.1.8" +version = "8.3.1" description = "Composable command line interface toolkit" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, ] [package.dependencies] @@ -251,75 +329,104 @@ markers = {main = "sys_platform == \"win32\"", dev = "platform_system == \"Windo [[package]] name = "coverage" -version = "7.8.0" +version = "7.13.3" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"}, - {file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"}, - {file = "coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3"}, - {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676"}, - {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d"}, - {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a"}, - {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c"}, - {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f"}, - {file = "coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f"}, - {file = "coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23"}, - {file = "coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27"}, - {file = "coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea"}, - {file = "coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7"}, - {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040"}, - {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543"}, - {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2"}, - {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318"}, - {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9"}, - {file = "coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c"}, - {file = "coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78"}, - {file = "coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc"}, - {file = "coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6"}, - {file = "coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d"}, - {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05"}, - {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a"}, - {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6"}, - {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47"}, - {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe"}, - {file = "coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545"}, - {file = "coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b"}, - {file = "coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd"}, - {file = "coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00"}, - {file = "coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64"}, - {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067"}, - {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008"}, - {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733"}, - {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323"}, - {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3"}, - {file = "coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d"}, - {file = "coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487"}, - {file = "coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25"}, - {file = "coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42"}, - {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502"}, - {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1"}, - {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4"}, - {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73"}, - {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a"}, - {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883"}, - {file = "coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada"}, - {file = "coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257"}, - {file = "coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f"}, - {file = "coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a"}, - {file = "coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82"}, - {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814"}, - {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c"}, - {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd"}, - {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4"}, - {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899"}, - {file = "coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f"}, - {file = "coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3"}, - {file = "coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd"}, - {file = "coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7"}, - {file = "coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501"}, + {file = "coverage-7.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b4f345f7265cdbdb5ec2521ffff15fa49de6d6c39abf89fc7ad68aa9e3a55f0"}, + {file = "coverage-7.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96c3be8bae9d0333e403cc1a8eb078a7f928b5650bae94a18fb4820cc993fb9b"}, + {file = "coverage-7.13.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d6f4a21328ea49d38565b55599e1c02834e76583a6953e5586d65cb1efebd8f8"}, + {file = "coverage-7.13.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc970575799a9d17d5c3fafd83a0f6ccf5d5117cdc9ad6fbd791e9ead82418b0"}, + {file = "coverage-7.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ff33b652b3556b05e204ae20793d1f872161b0fa5ec8a9ac76f8430e152ed6"}, + {file = "coverage-7.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7df8759ee57b9f3f7b66799b7660c282f4375bef620ade1686d6a7b03699e75f"}, + {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f45c9bcb16bee25a798ccba8a2f6a1251b19de6a0d617bb365d7d2f386c4e20e"}, + {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:318b2e4753cbf611061e01b6cc81477e1cdfeb69c36c4a14e6595e674caadb56"}, + {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:24db3959de8ee394eeeca89ccb8ba25305c2da9a668dd44173394cbd5aa0777f"}, + {file = "coverage-7.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be14d0622125edef21b3a4d8cd2d138c4872bf6e38adc90fd92385e3312f406a"}, + {file = "coverage-7.13.3-cp310-cp310-win32.whl", hash = "sha256:53be4aab8ddef18beb6188f3a3fdbf4d1af2277d098d4e618be3a8e6c88e74be"}, + {file = "coverage-7.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:bfeee64ad8b4aae3233abb77eb6b52b51b05fa89da9645518671b9939a78732b"}, + {file = "coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73"}, + {file = "coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00"}, + {file = "coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2"}, + {file = "coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c"}, + {file = "coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b"}, + {file = "coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0"}, + {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14"}, + {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4"}, + {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad"}, + {file = "coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222"}, + {file = "coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb"}, + {file = "coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301"}, + {file = "coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba"}, + {file = "coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595"}, + {file = "coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6"}, + {file = "coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395"}, + {file = "coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23"}, + {file = "coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34"}, + {file = "coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8"}, + {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a"}, + {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4"}, + {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7"}, + {file = "coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0"}, + {file = "coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1"}, + {file = "coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d"}, + {file = "coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f"}, + {file = "coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25"}, + {file = "coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a"}, + {file = "coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627"}, + {file = "coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8"}, + {file = "coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1"}, + {file = "coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b"}, + {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc"}, + {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea"}, + {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67"}, + {file = "coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86"}, + {file = "coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43"}, + {file = "coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587"}, + {file = "coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051"}, + {file = "coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9"}, + {file = "coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e"}, + {file = "coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107"}, + {file = "coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43"}, + {file = "coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3"}, + {file = "coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a"}, + {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e"}, + {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155"}, + {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e"}, + {file = "coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96"}, + {file = "coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f"}, + {file = "coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c"}, + {file = "coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9"}, + {file = "coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b"}, + {file = "coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10"}, + {file = "coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39"}, + {file = "coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f"}, + {file = "coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4"}, + {file = "coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef"}, + {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75"}, + {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895"}, + {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c"}, + {file = "coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a"}, + {file = "coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4"}, + {file = "coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0"}, + {file = "coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3"}, + {file = "coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8"}, + {file = "coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca"}, + {file = "coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba"}, + {file = "coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f"}, + {file = "coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508"}, + {file = "coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba"}, + {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd"}, + {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab"}, + {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e"}, + {file = "coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024"}, + {file = "coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3"}, + {file = "coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8"}, + {file = "coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3"}, + {file = "coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910"}, + {file = "coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac"}, ] [package.dependencies] @@ -330,14 +437,14 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "deepdiff" -version = "8.5.0" +version = "8.6.1" description = "Deep Difference and Search of any Python object/data. Recreate objects by adding adding deltas to each other." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "deepdiff-8.5.0-py3-none-any.whl", hash = "sha256:d4599db637f36a1c285f5fdfc2cd8d38bde8d8be8636b65ab5e425b67c54df26"}, - {file = "deepdiff-8.5.0.tar.gz", hash = "sha256:a4dd3529fa8d4cd5b9cbb6e3ea9c95997eaa919ba37dac3966c1b8f872dc1cd1"}, + {file = "deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b"}, + {file = "deepdiff-8.6.1.tar.gz", hash = "sha256:ec56d7a769ca80891b5200ec7bd41eec300ced91ebcc7797b41eb2b3f3ff643a"}, ] [package.dependencies] @@ -346,7 +453,7 @@ orderly-set = ">=5.4.1,<6" [package.extras] cli = ["click (>=8.1.0,<8.2.0)", "pyyaml (>=6.0.0,<6.1.0)"] coverage = ["coverage (>=7.6.0,<7.7.0)"] -dev = ["bump2version (>=1.0.0,<1.1.0)", "ipdb (>=0.13.0,<0.14.0)", "jsonpickle (>=4.0.0,<4.1.0)", "nox (==2025.5.1)", "numpy (>=2.0,<3.0) ; python_version < \"3.10\"", "numpy (>=2.2.0,<2.3.0) ; python_version >= \"3.10\"", "orjson (>=3.10.0,<3.11.0)", "pandas (>=2.2.0,<2.3.0)", "polars (>=1.21.0,<1.22.0)", "python-dateutil (>=2.9.0,<2.10.0)", "tomli (>=2.2.0,<2.3.0)", "tomli-w (>=1.2.0,<1.3.0)"] +dev = ["bump2version (>=1.0.0,<1.1.0)", "ipdb (>=0.13.0,<0.14.0)", "jsonpickle (>=4.0.0,<4.1.0)", "nox (==2025.5.1)", "numpy (>=2.0,<3.0) ; python_version < \"3.10\"", "numpy (>=2.2.0,<2.3.0) ; python_version >= \"3.10\"", "orjson (>=3.10.0,<3.11.0)", "pandas (>=2.2.0,<2.3.0)", "polars (>=1.21.0,<1.22.0)", "python-dateutil (>=2.9.0,<2.10.0)", "tomli (>=2.2.0,<2.3.0)", "tomli-w (>=1.2.0,<1.3.0)", "uuid6 (==2025.0.1)"] docs = ["Sphinx (>=6.2.0,<6.3.0)", "sphinx-sitemap (>=2.6.0,<2.7.0)", "sphinxemoji (>=0.3.0,<0.4.0)"] optimize = ["orjson"] static = ["flake8 (>=7.1.0,<7.2.0)", "flake8-pyproject (>=1.2.3,<1.3.0)", "pydantic (>=2.10.0,<2.11.0)"] @@ -354,15 +461,15 @@ test = ["pytest (>=8.3.0,<8.4.0)", "pytest-benchmark (>=5.1.0,<5.2.0)", "pytest- [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] [package.dependencies] @@ -371,6 +478,21 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "execnet" +version = "2.1.2" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, + {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + [[package]] name = "factory-boy" version = "3.3.3" @@ -392,39 +514,44 @@ doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] [[package]] name = "faker" -version = "37.1.0" +version = "40.1.2" description = "Faker is a Python package that generates fake data for you." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "faker-37.1.0-py3-none-any.whl", hash = "sha256:dc2f730be71cb770e9c715b13374d80dbcee879675121ab51f9683d262ae9a1c"}, - {file = "faker-37.1.0.tar.gz", hash = "sha256:ad9dc66a3b84888b837ca729e85299a96b58fdaef0323ed0baace93c9614af06"}, + {file = "faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42"}, + {file = "faker-40.1.2.tar.gz", hash = "sha256:b76a68163aa5f171d260fc24827a8349bc1db672f6a665359e8d0095e8135d30"}, ] [package.dependencies] -tzdata = "*" +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +tzdata = ["tzdata"] [[package]] name = "fastapi" -version = "0.115.12" +version = "0.128.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d"}, - {file = "fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681"}, + {file = "fastapi-0.128.1-py3-none-any.whl", hash = "sha256:ee82146bbf91ea5bbf2bb8629e4c6e056c4fbd997ea6068501b11b15260b50fb"}, + {file = "fastapi-0.128.1.tar.gz", hash = "sha256:ce5be4fa26d4ce6f54debcc873d1fb8e0e248f5c48d7502ba6c61457ab2dc766"}, ] [package.dependencies] -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.40.0,<0.47.0" +annotated-doc = ">=0.0.2" +pydantic = ">=2.7.0" +starlette = ">=0.40.0,<0.51.0" typing-extensions = ">=4.8.0" [package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "h11" @@ -485,16 +612,50 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "hypothesis" +version = "6.151.5" +description = "The property-based testing library for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "hypothesis-6.151.5-py3-none-any.whl", hash = "sha256:c0e15c91fa0e67bc0295551ef5041bebad42753b7977a610cd7a6ec1ad04ef13"}, + {file = "hypothesis-6.151.5.tar.gz", hash = "sha256:ae3a0622f9693e6b19c697777c2c266c02801f9769ab7c2c37b7ec83d4743783"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.102)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.27)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\"", "watchdog (>=4.0.0)"] +cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"] +codemods = ["libcst (>=0.3.16)"] +crosshair = ["crosshair-tool (>=0.0.102)", "hypothesis-crosshair (>=0.0.27)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["django (>=4.2)"] +dpcontracts = ["dpcontracts (>=0.4)"] +ghostwriter = ["black (>=20.8b0)"] +lark = ["lark (>=0.10.1)"] +numpy = ["numpy (>=1.21.6)"] +pandas = ["pandas (>=1.1)"] +pytest = ["pytest (>=4.6)"] +pytz = ["pytz (>=2014.1)"] +redis = ["redis (>=3.0.0)"] +watchdog = ["watchdog (>=4.0.0)"] +zoneinfo = ["tzdata (>=2025.3) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""] + [[package]] name = "idna" -version = "3.10" +version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] [package.extras] @@ -502,26 +663,113 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "librt" +version = "0.7.8" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, + {file = "librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d"}, + {file = "librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b"}, + {file = "librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d"}, + {file = "librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d"}, + {file = "librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c"}, + {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c"}, + {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d"}, + {file = "librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0"}, + {file = "librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85"}, + {file = "librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c"}, + {file = "librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f"}, + {file = "librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac"}, + {file = "librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c"}, + {file = "librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8"}, + {file = "librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff"}, + {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3"}, + {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75"}, + {file = "librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873"}, + {file = "librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7"}, + {file = "librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c"}, + {file = "librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232"}, + {file = "librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63"}, + {file = "librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93"}, + {file = "librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592"}, + {file = "librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850"}, + {file = "librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62"}, + {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b"}, + {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714"}, + {file = "librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449"}, + {file = "librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac"}, + {file = "librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708"}, + {file = "librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0"}, + {file = "librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc"}, + {file = "librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2"}, + {file = "librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3"}, + {file = "librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6"}, + {file = "librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d"}, + {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e"}, + {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca"}, + {file = "librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93"}, + {file = "librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951"}, + {file = "librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34"}, + {file = "librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09"}, + {file = "librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418"}, + {file = "librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611"}, + {file = "librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758"}, + {file = "librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea"}, + {file = "librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac"}, + {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398"}, + {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81"}, + {file = "librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83"}, + {file = "librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d"}, + {file = "librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44"}, + {file = "librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce"}, + {file = "librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f"}, + {file = "librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde"}, + {file = "librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e"}, + {file = "librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b"}, + {file = "librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666"}, + {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581"}, + {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a"}, + {file = "librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca"}, + {file = "librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365"}, + {file = "librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32"}, + {file = "librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06"}, + {file = "librt-0.7.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c7e8f88f79308d86d8f39c491773cbb533d6cb7fa6476f35d711076ee04fceb6"}, + {file = "librt-0.7.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:389bd25a0db916e1d6bcb014f11aa9676cedaa485e9ec3752dfe19f196fd377b"}, + {file = "librt-0.7.8-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73fd300f501a052f2ba52ede721232212f3b06503fa12665408ecfc9d8fd149c"}, + {file = "librt-0.7.8-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d772edc6a5f7835635c7562f6688e031f0b97e31d538412a852c49c9a6c92d5"}, + {file = "librt-0.7.8-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde8a130bd0f239e45503ab39fab239ace094d63ee1d6b67c25a63d741c0f71"}, + {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fdec6e2368ae4f796fc72fad7fd4bd1753715187e6d870932b0904609e7c878e"}, + {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:00105e7d541a8f2ee5be52caacea98a005e0478cfe78c8080fbb7b5d2b340c63"}, + {file = "librt-0.7.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c6f8947d3dfd7f91066c5b4385812c18be26c9d5a99ca56667547f2c39149d94"}, + {file = "librt-0.7.8-cp39-cp39-win32.whl", hash = "sha256:41d7bb1e07916aeb12ae4a44e3025db3691c4149ab788d0315781b4d29b86afb"}, + {file = "librt-0.7.8-cp39-cp39-win_amd64.whl", hash = "sha256:e90a8e237753c83b8e484d478d9a996dc5e39fd5bd4c6ce32563bc8123f132be"}, + {file = "librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862"}, ] [[package]] name = "loguru" -version = "0.6.0" +version = "0.7.3" description = "Python logging made (stupidly) simple" optional = false -python-versions = ">=3.5" +python-versions = "<4.0,>=3.5" groups = ["main"] files = [ - {file = "loguru-0.6.0-py3-none-any.whl", hash = "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3"}, - {file = "loguru-0.6.0.tar.gz", hash = "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c"}, + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, ] [package.dependencies] @@ -529,18 +777,18 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} [package.extras] -dev = ["Sphinx (>=4.1.1) ; python_version >= \"3.6\"", "black (>=19.10b0) ; python_version >= \"3.6\"", "colorama (>=0.3.4)", "docutils (==0.16)", "flake8 (>=3.7.7)", "isort (>=5.1.1) ; python_version >= \"3.6\"", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "sphinx-autobuild (>=0.7.1) ; python_version >= \"3.6\"", "sphinx-rtd-theme (>=0.4.3) ; python_version >= \"3.6\"", "tox (>=3.9.0)"] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] [[package]] name = "markdown-it-py" -version = "3.0.0" +version = "4.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, ] [package.dependencies] @@ -548,13 +796,12 @@ mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] +plugins = ["mdit-py-plugins (>=0.5.0)"] profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] [[package]] name = "mdurl" @@ -570,48 +817,56 @@ files = [ [[package]] name = "mypy" -version = "1.15.0" +version = "1.19.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, - {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, - {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, - {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, - {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, - {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, - {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, - {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, - {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, - {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, - {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, - {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, - {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, - {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, - {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, - {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, - {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, - {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, - {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, - {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, - {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, - {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, - {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, - {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, - {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, - {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, - {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, - {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, - {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, - {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, - {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, - {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, + {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, + {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, + {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, + {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, + {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, + {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, + {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, + {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, + {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, + {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, + {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, + {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, + {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, + {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, + {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, + {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, ] [package.dependencies] +librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing_extensions = ">=4.6.0" @@ -636,76 +891,86 @@ files = [ [[package]] name = "orderly-set" -version = "5.4.1" +version = "5.5.0" description = "Orderly set" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "orderly_set-5.4.1-py3-none-any.whl", hash = "sha256:b5e21d21680bd9ef456885db800c5cb4f76a03879880c0175e1b077fb166fd83"}, - {file = "orderly_set-5.4.1.tar.gz", hash = "sha256:a1fb5a4fdc5e234e9e8d8e5c1bbdbc4540f4dfe50d12bf17c8bc5dbf1c9c878d"}, + {file = "orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7"}, + {file = "orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce"}, ] +[package.extras] +coverage = ["coverage (>=7.6.0,<7.7.0)"] +dev = ["bump2version (>=1.0.0,<1.1.0)", "ipdb (>=0.13.0,<0.14.0)"] +optimize = ["orjson"] +static = ["flake8 (>=7.1.0,<7.2.0)", "flake8-pyproject (>=1.2.3,<1.3.0)"] +test = ["pytest (>=8.3.0,<8.4.0)", "pytest-benchmark (>=5.1.0,<5.2.0)", "pytest-cov (>=6.0.0,<6.1.0)", "python-dotenv (>=1.0.0,<1.1.0)"] + [[package]] name = "packaging" -version = "25.0" +version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] [[package]] -name = "pbr" -version = "6.1.1" -description = "Python Build Reasonableness" +name = "pathspec" +version = "1.0.4" +description = "Utility library for gitignore style pattern matching of file paths." optional = false -python-versions = ">=2.6" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pbr-6.1.1-py2.py3-none-any.whl", hash = "sha256:38d4daea5d9fa63b3f626131b9d34947fd0c8be9b05a29276870580050a25a76"}, - {file = "pbr-6.1.1.tar.gz", hash = "sha256:93ea72ce6989eb2eed99d0f75721474f69ad88128afdef5ac377eb797c4bf76b"}, + {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, + {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, ] -[package.dependencies] -setuptools = "*" +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] +tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pydantic" -version = "2.11.7" +version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, - {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.33.2" -typing-extensions = ">=4.12.2" -typing-inspection = ">=0.4.0" +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] @@ -713,126 +978,148 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, ] [package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +typing-extensions = ">=4.14.1" [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, - {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, ] [package.extras] @@ -840,60 +1127,62 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.11.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, + {file = "pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469"}, + {file = "pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623"}, ] [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pytest" -version = "8.3.5" +version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, - {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" +pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.26.0" +version = "1.3.0" description = "Pytest support for asyncio" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"}, - {file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"}, + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, ] [package.dependencies] -pytest = ">=8.2,<9" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.10\""} +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.2,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] @@ -901,101 +1190,158 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "6.1.1" +version = "7.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, - {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, ] [package.dependencies] -coverage = {version = ">=7.5", extras = ["toml"]} -pytest = ">=4.6" +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" [package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +description = "pytest plugin to abort hanging tests" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, + {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"}, + {file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] [[package]] name = "pyyaml" -version = "6.0.2" +version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] [[package]] name = "requests" -version = "2.32.3" +version = "2.32.5" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -1020,184 +1366,174 @@ httpx = ">=0.25.0" [[package]] name = "rich" -version = "14.0.0" +version = "14.3.2" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" groups = ["dev"] files = [ - {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, - {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, + {file = "rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69"}, + {file = "rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.12.3" +version = "0.15.0" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:47552138f7206454eaf0c4fe827e546e9ddac62c2a3d2585ca54d29a890137a2"}, - {file = "ruff-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a9153b000c6fe169bb307f5bd1b691221c4286c133407b8827c406a55282041"}, - {file = "ruff-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6b24600cf3b750e48ddb6057e901dd5b9aa426e316addb2a1af185a7509882"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2506961bf6ead54887ba3562604d69cb430f59b42133d36976421bc8bd45901"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4faaff1f90cea9d3033cbbcdf1acf5d7fb11d8180758feb31337391691f3df0"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40dced4a79d7c264389de1c59467d5d5cefd79e7e06d1dfa2c75497b5269a5a6"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0262d50ba2767ed0fe212aa7e62112a1dcbfd46b858c5bf7bbd11f326998bafc"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12371aec33e1a3758597c5c631bae9a5286f3c963bdfb4d17acdd2d395406687"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:560f13b6baa49785665276c963edc363f8ad4b4fc910a883e2625bdb14a83a9e"}, - {file = "ruff-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023040a3499f6f974ae9091bcdd0385dd9e9eb4942f231c23c57708147b06311"}, - {file = "ruff-0.12.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:883d844967bffff5ab28bba1a4d246c1a1b2933f48cb9840f3fdc5111c603b07"}, - {file = "ruff-0.12.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2120d3aa855ff385e0e562fdee14d564c9675edbe41625c87eeab744a7830d12"}, - {file = "ruff-0.12.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6b16647cbb470eaf4750d27dddc6ebf7758b918887b56d39e9c22cce2049082b"}, - {file = "ruff-0.12.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e1417051edb436230023575b149e8ff843a324557fe0a265863b7602df86722f"}, - {file = "ruff-0.12.3-py3-none-win32.whl", hash = "sha256:dfd45e6e926deb6409d0616078a666ebce93e55e07f0fb0228d4b2608b2c248d"}, - {file = "ruff-0.12.3-py3-none-win_amd64.whl", hash = "sha256:a946cf1e7ba3209bdef039eb97647f1c77f6f540e5845ec9c114d3af8df873e7"}, - {file = "ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1"}, - {file = "ruff-0.12.3.tar.gz", hash = "sha256:f1b5a4b6668fd7b7ea3697d8d98857390b40c1320a63a178eee6be0899ea2d77"}, + {file = "ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455"}, + {file = "ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d"}, + {file = "ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce"}, + {file = "ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621"}, + {file = "ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9"}, + {file = "ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179"}, + {file = "ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d"}, + {file = "ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78"}, + {file = "ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4"}, + {file = "ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e"}, + {file = "ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662"}, + {file = "ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1"}, + {file = "ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16"}, + {file = "ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3"}, + {file = "ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3"}, + {file = "ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18"}, + {file = "ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a"}, + {file = "ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a"}, ] [[package]] -name = "setuptools" -version = "80.4.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" optional = false -python-versions = ">=3.9" +python-versions = "*" groups = ["dev"] files = [ - {file = "setuptools-80.4.0-py3-none-any.whl", hash = "sha256:6cdc8cb9a7d590b237dbe4493614a9b75d0559b888047c1f67d49ba50fc3edb2"}, - {file = "setuptools-80.4.0.tar.gz", hash = "sha256:5a78f61820bc088c8e4add52932ae6b8cf423da2aff268c23f813cfbb13b4006"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] [[package]] name = "starlette" -version = "0.46.2" +version = "0.50.0" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, - {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, + {file = "starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca"}, + {file = "starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca"}, ] [package.dependencies] anyio = ">=3.6.2,<5" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "stevedore" -version = "5.4.1" +version = "5.6.0" description = "Manage dynamic plugins for Python applications" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "stevedore-5.4.1-py3-none-any.whl", hash = "sha256:d10a31c7b86cba16c1f6e8d15416955fc797052351a56af15e608ad20811fcfe"}, - {file = "stevedore-5.4.1.tar.gz", hash = "sha256:3135b5ae50fe12816ef291baff420acb727fcd356106e3e9cbfa9e5985cd6f4b"}, + {file = "stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820"}, + {file = "stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945"}, ] -[package.dependencies] -pbr = ">=2.0.0" - [[package]] name = "tomli" -version = "2.2.1" +version = "2.4.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev"] markers = "python_full_version <= \"3.11.0a6\"" files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, + {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, + {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, + {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, + {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, + {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, + {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, + {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, + {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, + {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, + {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, + {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, + {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, + {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, + {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, + {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, + {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, + {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, ] [[package]] name = "typing-extensions" -version = "4.13.2" -description = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, - {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] [package.dependencies] @@ -1205,44 +1541,45 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2025.2" +version = "2025.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["dev"] +markers = "platform_system == \"Windows\"" files = [ - {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, - {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, ] [[package]] name = "urllib3" -version = "2.4.0" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"}, - {file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uvicorn" -version = "0.34.2" +version = "0.40.0" description = "The lightning-fast ASGI server." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403"}, - {file = "uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328"}, + {file = "uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee"}, + {file = "uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea"}, ] [package.dependencies] @@ -1251,7 +1588,7 @@ h11 = ">=0.8" typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] [[package]] name = "win32-setctime" @@ -1271,5 +1608,5 @@ dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] [metadata] lock-version = "2.1" -python-versions = ">=3.9,<3.14" -content-hash = "a8cf8ae3d3681d747a0416ede87c2c3b4108d6d7989bbc296a19f457c1f220ef" +python-versions = ">=3.10,<4.0" +content-hash = "9682483f4310c7ed97cf9763d5e0c5c20aac4e77bab84999fc7e9a905a213448" diff --git a/pybotx/__init__.py b/pybotx/__init__.py index 5ac50d3f..20eee9b0 100644 --- a/pybotx/__init__.py +++ b/pybotx/__init__.py @@ -13,6 +13,7 @@ BotAPIUnverifiedRequestResponse, build_unverified_request_response, ) +from pybotx.auth import BotXAuthVersion from pybotx.bot.bot import Bot from pybotx.bot.callbacks.callback_repo_proto import CallbackRepoProto from pybotx.bot.exceptions import ( @@ -38,6 +39,8 @@ CantUpdatePersonalChatError, ChatCreationError, ChatCreationProhibitedError, + ChatLinkCreationError, + ChatLinkCreationProhibitedError, InvalidUsersListError, ThreadAlreadyExistsError, ThreadCreationError, @@ -88,9 +91,16 @@ from pybotx.models.bot_account import BotAccount, BotAccountWithSecret from pybotx.models.bot_catalog import BotsListItem from pybotx.models.bot_sender import BotSender -from pybotx.models.chats import Chat, ChatInfo, ChatInfoMember, ChatListItem +from pybotx.models.chats import ( + Chat, + ChatInfo, + ChatInfoMember, + ChatLink, + ChatListItem, +) from pybotx.models.enums import ( AttachmentTypes, + ChatLinkTypes, ChatTypes, ClientPlatforms, ConferenceLinkTypes, @@ -127,10 +137,14 @@ from pybotx.models.message.outgoing_message import OutgoingMessage from pybotx.models.message.reply import Reply from pybotx.models.message.reply_message import ReplyMessage -from pybotx.models.method_callbacks import BotAPIMethodFailedCallback +from pybotx.models.method_callbacks import ( + BotAPIMethodFailedCallback, + BotAPIMethodSuccessfulCallback, + BotXMethodCallback, +) from pybotx.models.smartapps import SmartApp from pybotx.models.status import BotMenu, StatusRecipient -from pybotx.models.stickers import Sticker, StickerPack +from pybotx.models.stickers import Sticker, StickerPack, StickerPackFromList from pybotx.models.sync_smartapp_event import ( BotAPISyncSmartAppEventErrorResponse, BotAPISyncSmartAppEventResponse, @@ -166,6 +180,7 @@ "BotAPIBotDisabledErrorData", "BotAPIBotDisabledResponse", "BotAPIMethodFailedCallback", + "BotAPIMethodSuccessfulCallback", "BotAPISyncSmartAppEventErrorResponse", "BotAPISyncSmartAppEventResponse", "BotAPISyncSmartAppEventResultResponse", @@ -173,12 +188,14 @@ "BotAPIUnverifiedRequestResponse", "BotAccount", "BotAccountWithSecret", + "BotXAuthVersion", "BotIsNotChatMemberError", "BotMenu", "BotSender", "BotShuttingDownError", "BotXMethodCallbackNotFoundError", "BotXMethodFailedCallbackReceivedError", + "BotXMethodCallback", "BotsListItem", "BubbleMarkup", "Button", @@ -197,8 +214,12 @@ "ChatDeletedByUserEvent", "ChatInfo", "ChatInfoMember", + "ChatLink", + "ChatLinkCreationError", + "ChatLinkCreationProhibitedError", "ChatListItem", "ChatNotFoundError", + "ChatLinkTypes", "ChatTypes", "ClientPlatforms", "ConferenceChangedEvent", @@ -261,6 +282,7 @@ "StealthModeDisabledError", "Sticker", "StickerPack", + "StickerPackFromList", "StickerPackOrStickerNotFoundError", "SyncSmartAppEventHandlerFunc", "SyncSmartAppEventHandlerNotFoundError", diff --git a/pybotx/async_buffer.py b/pybotx/async_buffer.py index 1dc4aa13..d5ec9958 100644 --- a/pybotx/async_buffer.py +++ b/pybotx/async_buffer.py @@ -1,11 +1,7 @@ import abc import os -from typing import Optional -try: - from typing import Protocol -except ImportError: - from typing_extensions import Protocol # type: ignore +from typing import Protocol class AsyncBufferBase(Protocol): @@ -27,7 +23,7 @@ class AsyncBufferReadable(AsyncBufferBase): @abc.abstractmethod async def read( self, - bytes_to_read: Optional[int] = None, + bytes_to_read: int | None = None, ) -> bytes: ... # pragma: no cover diff --git a/pybotx/auth.py b/pybotx/auth.py new file mode 100644 index 00000000..5f4a38e6 --- /dev/null +++ b/pybotx/auth.py @@ -0,0 +1,35 @@ +import secrets +import time +from enum import Enum +from uuid import UUID + +import jwt + + +class BotXAuthVersion(str, Enum): + V1 = "v1" + V2 = "v2" + + +def build_botx_jwt_v2( + *, + bot_id: UUID, + bot_host: str, + secret_key: str, + issued_at: int | None = None, + token_id: str | None = None, +) -> str: + iat = int(time.time()) if issued_at is None else issued_at + jti = token_id or secrets.token_hex(12) + + payload = { + "iss": str(bot_id), + "aud": bot_host, + "exp": iat + 60, + "nbf": iat, + "jti": jti, + "iat": iat, + "version": 2, + } + + return jwt.encode(payload=payload, key=secret_key, algorithm="HS256") diff --git a/pybotx/bot/api/responses/bot_disabled.py b/pybotx/bot/api/responses/bot_disabled.py index f550c22f..c51f8c72 100644 --- a/pybotx/bot/api/responses/bot_disabled.py +++ b/pybotx/bot/api/responses/bot_disabled.py @@ -1,13 +1,13 @@ from dataclasses import asdict, dataclass, field -from typing import Any, Dict, List, Literal +from typing import Any, Literal -@dataclass +@dataclass(slots=True) class BotAPIBotDisabledErrorData: status_message: str -@dataclass +@dataclass(slots=True) class BotAPIBotDisabledResponse: """Disabled bot response model. @@ -16,11 +16,11 @@ class BotAPIBotDisabledResponse: """ error_data: BotAPIBotDisabledErrorData - errors: List[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) reason: Literal["bot_disabled"] = "bot_disabled" -def build_bot_disabled_response(status_message: str) -> Dict[str, Any]: +def build_bot_disabled_response(status_message: str) -> dict[str, Any]: """Build bot disabled response for BotX. It should be sent if the bot can't process the command. diff --git a/pybotx/bot/api/responses/command_accepted.py b/pybotx/bot/api/responses/command_accepted.py index a0713734..228735d6 100644 --- a/pybotx/bot/api/responses/command_accepted.py +++ b/pybotx/bot/api/responses/command_accepted.py @@ -1,7 +1,7 @@ -from typing import Any, Dict +from typing import Any -def build_command_accepted_response() -> Dict[str, Any]: +def build_command_accepted_response() -> dict[str, Any]: """Build accepted response for BotX. It should be sent if the bot started processing a command. diff --git a/pybotx/bot/api/responses/unverified_request.py b/pybotx/bot/api/responses/unverified_request.py index 23ba8efb..d495bfdd 100644 --- a/pybotx/bot/api/responses/unverified_request.py +++ b/pybotx/bot/api/responses/unverified_request.py @@ -1,13 +1,13 @@ from dataclasses import asdict, dataclass, field -from typing import Any, Dict, List, Literal +from typing import Any, Literal -@dataclass +@dataclass(slots=True) class BotAPIUnverifiedRequestErrorData: status_message: str -@dataclass +@dataclass(slots=True) class BotAPIUnverifiedRequestResponse: """`Unverified request` response model. @@ -16,11 +16,11 @@ class BotAPIUnverifiedRequestResponse: """ error_data: BotAPIUnverifiedRequestErrorData - errors: List[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) reason: Literal["unverified_request"] = "unverified_request" -def build_unverified_request_response(status_message: str) -> Dict[str, Any]: +def build_unverified_request_response(status_message: str) -> dict[str, Any]: """Build `unverified request` response for BotX. It should be sent if the header with the authorization token is missing or diff --git a/pybotx/bot/bot.py b/pybotx/bot/bot.py index c6a65b65..aa66112d 100644 --- a/pybotx/bot/bot.py +++ b/pybotx/bot/bot.py @@ -1,9 +1,9 @@ from asyncio import Task from collections.abc import AsyncIterable, AsyncIterator, Iterator, Mapping, Sequence -from contextlib import asynccontextmanager +from contextlib import AsyncExitStack, asynccontextmanager from datetime import datetime from types import SimpleNamespace -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from typing import Any, TypeAlias from uuid import UUID import aiofiles @@ -14,6 +14,7 @@ from pybotx.async_buffer import AsyncBufferReadable, AsyncBufferWritable from pybotx.bot.bot_accounts_storage import BotAccountsStorage +from pybotx.auth import BotXAuthVersion from pybotx.bot.callbacks.callback_manager import CallbackManager from pydantic import TypeAdapter from pybotx.bot.callbacks.callback_memory_repo import CallbackMemoryRepo @@ -49,6 +50,10 @@ BotXAPICreateChatRequestPayload, CreateChatMethod, ) +from pybotx.client.chats_api.create_chat_link import ( + BotXAPICreateChatLinkRequestPayload, + CreateChatLinkMethod, +) from pybotx.client.chats_api.create_thread import ( BotXAPICreateThreadRequestPayload, CreateThreadMethod, @@ -98,7 +103,7 @@ BotXAPITypingEventRequestPayload, TypingEventMethod, ) -from pybotx.client.exceptions.common import InvalidBotAccountError +from pybotx.client.exceptions.common import ChatNotFoundError, InvalidBotAccountError from pybotx.client.files_api.download_file import ( BotXAPIDownloadFileRequestPayload, DownloadFileMethod, @@ -115,6 +120,7 @@ from pybotx.client.notifications_api.direct_notification import ( BotXAPIDirectNotificationRequestPayload, DirectNotificationMethod, + DirectNotificationSyncMethod, ) from pybotx.client.notifications_api.internal_bot_notification import ( BotXAPIInternalBotNotificationRequestPayload, @@ -191,6 +197,7 @@ from pybotx.client.users_api.search_user_by_email import ( BotXAPISearchUserByEmailRequestPayload, SearchUserByEmailMethod, + SearchUserByEmailPostMethod, ) from pybotx.client.users_api.search_user_by_emails import ( BotXAPISearchUserByEmailsRequestPayload, @@ -229,13 +236,13 @@ from pybotx.models.attachments import IncomingFileAttachment, OutgoingAttachment from pybotx.models.bot_account import BotAccountWithSecret from pybotx.models.bot_catalog import BotsListItem -from pybotx.models.chats import ChatInfo, ChatListItem +from pybotx.models.chats import ChatInfo, ChatLink, ChatListItem from pybotx.models.commands import ( BotAPISystemEvent, BotAPIIncomingMessage, BotCommand, ) -from pybotx.models.enums import BotAPICommandTypes, ChatTypes +from pybotx.models.enums import BotAPICommandTypes, ChatLinkTypes, ChatTypes from pybotx.models.message.edit_message import EditMessage from pybotx.models.message.markup import BubbleMarkup, KeyboardMarkup from pybotx.models.message.message_status import MessageStatus @@ -258,8 +265,8 @@ from pybotx.models.users import UserFromCSV, UserFromSearch from pydantic import ValidationError -MissingOptionalAttachment = MissingOptional[ - Union[IncomingFileAttachment, OutgoingAttachment] +MissingOptionalAttachment: TypeAlias = MissingOptional[ + IncomingFileAttachment | OutgoingAttachment ] @@ -269,11 +276,12 @@ def __init__( *, collectors: Sequence[HandlerCollector], bot_accounts: Sequence[BotAccountWithSecret], - middlewares: Optional[Sequence[Middleware]] = None, - httpx_client: Optional[httpx.AsyncClient] = None, - exception_handlers: Optional[ExceptionHandlersDict] = None, + middlewares: Sequence[Middleware] | None = None, + httpx_client: httpx.AsyncClient | None = None, + exception_handlers: ExceptionHandlersDict | None = None, default_callback_timeout: float = BOTX_DEFAULT_TIMEOUT, - callback_repo: Optional[CallbackRepoProto] = None, + callback_repo: CallbackRepoProto | None = None, + auth_version: BotXAuthVersion = BotXAuthVersion.V2, ) -> None: if not collectors: logger.warning("Bot has no connected collectors") @@ -288,7 +296,10 @@ def __init__( ) self._default_callback_timeout = default_callback_timeout - self._bot_accounts_storage = BotAccountsStorage(list(bot_accounts)) + self._bot_accounts_storage = BotAccountsStorage( + list(bot_accounts), + auth_version=auth_version, + ) self._httpx_client = httpx_client or httpx.AsyncClient() if not callback_repo: @@ -300,11 +311,11 @@ def __init__( def async_execute_raw_bot_command( self, - raw_bot_command: Dict[str, Any], + raw_bot_command: dict[str, Any], verify_request: bool = True, - request_headers: Optional[Mapping[str, str]] = None, + request_headers: Mapping[str, str] | None = None, logging_command: bool = True, - trusted_issuers: Optional[Set[str]] = None, + trusted_issuers: set[str] | None = None, ) -> None: if logging_command: log_incoming_request(raw_bot_command, message="Got command: ") @@ -337,11 +348,11 @@ def async_execute_bot_command( async def sync_execute_raw_smartapp_event( self, - raw_smartapp_event: Dict[str, Any], + raw_smartapp_event: dict[str, Any], verify_request: bool = True, - request_headers: Optional[Mapping[str, str]] = None, + request_headers: Mapping[str, str] | None = None, logging_command: bool = True, - trusted_issuers: Optional[Set[str]] = None, + trusted_issuers: set[str] | None = None, ) -> BotAPISyncSmartAppEventResponse: if logging_command: log_incoming_request( @@ -376,11 +387,11 @@ async def sync_execute_smartapp_event( async def raw_get_status( self, - query_params: Dict[str, str], + query_params: dict[str, str], verify_request: bool = True, - request_headers: Optional[Mapping[str, str]] = None, - trusted_issuers: Optional[Set[str]] = None, - ) -> Dict[str, Any]: + request_headers: Mapping[str, str] | None = None, + trusted_issuers: set[str] | None = None, + ) -> dict[str, Any]: logger.opt(lazy=True).debug( "Got status: {status}", status=lambda: pformat_jsonable_obj(query_params), @@ -409,10 +420,10 @@ async def get_status(self, status_recipient: StatusRecipient) -> BotMenu: async def set_raw_botx_method_result( self, - raw_botx_method_result: Dict[str, Any], + raw_botx_method_result: dict[str, Any], verify_request: bool = True, - request_headers: Optional[Mapping[str, str]] = None, - trusted_issuers: Optional[Set[str]] = None, + request_headers: Mapping[str, str] | None = None, + trusted_issuers: set[str] | None = None, ) -> None: logger.debug("Got callback: {callback}", callback=raw_botx_method_result) @@ -441,6 +452,8 @@ def bot_accounts(self) -> Iterator[BotAccountWithSecret]: yield from self._bot_accounts_storage.iter_bot_accounts() async def fetch_tokens(self) -> None: + if self._bot_accounts_storage.get_auth_version() != BotXAuthVersion.V1: + return for bot_account in self.bot_accounts: try: token = await self.get_token(bot_id=bot_account.id) @@ -482,7 +495,7 @@ async def get_bots_list( *, bot_id: UUID, since: Missing[datetime] = Undefined, - ) -> Tuple[List[BotsListItem], datetime]: + ) -> tuple[list[BotsListItem], datetime]: """Get list of Bots on the current CTS. :param bot_id: Bot which should perform the request. @@ -507,18 +520,18 @@ async def answer_message( self, body: str, *, - metadata: Missing[Dict[str, Any]] = Undefined, + metadata: Missing[dict[str, Any]] = Undefined, bubbles: Missing[BubbleMarkup] = Undefined, keyboard: Missing[KeyboardMarkup] = Undefined, - file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined, - recipients: Missing[List[UUID]] = Undefined, + file: Missing[IncomingFileAttachment | OutgoingAttachment] = Undefined, + recipients: Missing[list[UUID]] = Undefined, silent_response: Missing[bool] = Undefined, markup_auto_adjust: Missing[bool] = Undefined, stealth_mode: Missing[bool] = Undefined, send_push: Missing[bool] = Undefined, ignore_mute: Missing[bool] = Undefined, wait_callback: bool = True, - callback_timeout: Optional[float] = None, + callback_timeout: float | None = None, ) -> UUID: """Answer to incoming message. @@ -579,7 +592,7 @@ async def send( *, message: OutgoingMessage, wait_callback: bool = True, - callback_timeout: Optional[float] = None, + callback_timeout: float | None = None, ) -> UUID: """Send internal notification. @@ -614,18 +627,18 @@ async def send_message( bot_id: UUID, chat_id: UUID, body: str, - metadata: Missing[Dict[str, Any]] = Undefined, + metadata: Missing[dict[str, Any]] = Undefined, bubbles: Missing[BubbleMarkup] = Undefined, keyboard: Missing[KeyboardMarkup] = Undefined, - file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined, + file: Missing[IncomingFileAttachment | OutgoingAttachment] = Undefined, silent_response: Missing[bool] = Undefined, markup_auto_adjust: Missing[bool] = Undefined, - recipients: Missing[List[UUID]] = Undefined, + recipients: Missing[list[UUID]] = Undefined, stealth_mode: Missing[bool] = Undefined, send_push: Missing[bool] = Undefined, ignore_mute: Missing[bool] = Undefined, wait_callback: bool = True, - callback_timeout: Optional[float] = None, + callback_timeout: float | None = None, ) -> UUID: """Send message to chat. @@ -683,16 +696,80 @@ async def send_message( return botx_api_sync_id.to_domain() + async def send_message_sync( + self, + *, + bot_id: UUID, + chat_id: UUID, + body: str, + metadata: Missing[dict[str, Any]] = Undefined, + bubbles: Missing[BubbleMarkup] = Undefined, + keyboard: Missing[KeyboardMarkup] = Undefined, + file: Missing[IncomingFileAttachment | OutgoingAttachment] = Undefined, + silent_response: Missing[bool] = Undefined, + markup_auto_adjust: Missing[bool] = Undefined, + recipients: Missing[list[UUID]] = Undefined, + stealth_mode: Missing[bool] = Undefined, + send_push: Missing[bool] = Undefined, + ignore_mute: Missing[bool] = Undefined, + ) -> UUID: + """Send message to chat synchronously (BotX >= 3.58). + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param body: Message body. + :param metadata: Notification options. + :param bubbles: Bubbles (buttons attached to message) markup. + :param keyboard: Keyboard (buttons below message input) markup. + :param file: Attachment. + :param recipients: List of recipients, empty for all in chat. + :param silent_response: (BotX default: False) Exclude next user + messages from history. + :param markup_auto_adjust: (BotX default: False) Move button to next + row, if its text doesn't fit. + :param stealth_mode: (BotX default: False) Enable stealth mode. + :param send_push: (BotX default: True) Send push notification on + devices. + :param ignore_mute: (BotX default: False) Ignore mute or dnd (do not + disturb). + + :return: Notification sync_id. + """ + + method = DirectNotificationSyncMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + + payload = BotXAPIDirectNotificationRequestPayload.from_domain( + chat_id=chat_id, + body=body, + metadata=metadata, + bubbles=bubbles, + keyboard=keyboard, + file=file, + recipients=recipients, + silent_response=silent_response, + markup_auto_adjust=markup_auto_adjust, + stealth_mode=stealth_mode, + send_push=send_push, + ignore_mute=ignore_mute, + ) + botx_api_sync_id = await method.execute(payload) + + return botx_api_sync_id.to_domain() + async def send_internal_bot_notification( self, *, bot_id: UUID, chat_id: UUID, - data: Dict[str, Any], - opts: Missing[Dict[str, Any]] = Undefined, - recipients: Missing[List[UUID]] = Undefined, + data: dict[str, Any], + opts: Missing[dict[str, Any]] = Undefined, + recipients: Missing[list[UUID]] = Undefined, wait_callback: bool = True, - callback_timeout: Optional[float] = None, + callback_timeout: float | None = None, ) -> UUID: """Send internal notification. @@ -757,7 +834,7 @@ async def edit_message( bot_id: UUID, sync_id: UUID, body: Missing[str] = Undefined, - metadata: Missing[Dict[str, Any]] = Undefined, + metadata: Missing[dict[str, Any]] = Undefined, bubbles: Missing[BubbleMarkup] = Undefined, keyboard: Missing[KeyboardMarkup] = Undefined, file: MissingOptionalAttachment = Undefined, @@ -828,10 +905,10 @@ async def reply_message( bot_id: UUID, sync_id: UUID, body: str, - metadata: Missing[Dict[str, Any]] = Undefined, + metadata: Missing[dict[str, Any]] = Undefined, bubbles: Missing[BubbleMarkup] = Undefined, keyboard: Missing[KeyboardMarkup] = Undefined, - file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined, + file: Missing[IncomingFileAttachment | OutgoingAttachment] = Undefined, silent_response: Missing[bool] = Undefined, markup_auto_adjust: Missing[bool] = Undefined, stealth_mode: Missing[bool] = Undefined, @@ -970,7 +1047,7 @@ async def list_chats( self, *, bot_id: UUID, - ) -> List[ChatListItem]: + ) -> list[ChatListItem]: """Get all bot chats. :param bot_id: Bot which should perform the request. @@ -1032,12 +1109,43 @@ async def personal_chat( return botx_api_personal_chat.to_domain() + async def ensure_personal_chat( + self, + *, + bot_id: UUID, + user_huid: UUID, + name: str | None = None, + ) -> ChatInfo: + """Get or create personal chat with user. + + Tries to fetch existing personal chat. If not found, creates it and + returns chat info for the new chat. + + :param bot_id: Bot which should perform the request. + :param user_huid: Target user HUID. + :param name: Optional chat name for creation. + + :return: Chat information. + """ + + try: + return await self.personal_chat(bot_id=bot_id, user_huid=user_huid) + except ChatNotFoundError: + chat_name = name or f"Personal chat {user_huid}" + chat_id = await self.create_chat( + bot_id=bot_id, + name=chat_name, + chat_type=ChatTypes.PERSONAL_CHAT, + huids=[user_huid], + ) + return await self.chat_info(bot_id=bot_id, chat_id=chat_id) + async def add_users_to_chat( self, *, bot_id: UUID, chat_id: UUID, - huids: List[UUID], + huids: list[UUID], ) -> None: """Add user to chat. @@ -1056,7 +1164,7 @@ async def remove_users_from_chat( *, bot_id: UUID, chat_id: UUID, - huids: List[UUID], + huids: list[UUID], ) -> None: """Remove eXpress accounts from chat. @@ -1082,7 +1190,7 @@ async def promote_to_chat_admins( *, bot_id: UUID, chat_id: UUID, - huids: List[UUID], + huids: list[UUID], ) -> None: """Promote users in chat to admins. @@ -1167,10 +1275,10 @@ async def create_chat( bot_id: UUID, name: str, chat_type: ChatTypes, - huids: List[UUID], - description: Optional[str] = None, + huids: list[UUID], + description: str | None = None, shared_history: Missing[bool] = Undefined, - avatar: Optional[str] = None, + avatar: str | None = None, ) -> UUID: """Create chat. @@ -1204,6 +1312,42 @@ async def create_chat( return botx_api_chat_id.to_domain() + async def create_chat_link( + self, + *, + bot_id: UUID, + chat_id: UUID, + link_type: ChatLinkTypes, + access_code: Missing[str | None] = Undefined, + link_ttl: Missing[int | None] = Undefined, + ) -> ChatLink: + """Create chat invite link (BotX >= 3.58). + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param link_type: Link type. + :param access_code: Link access code (or `None` to make it public). + :param link_ttl: Link ttl in seconds (or `None` for infinite). + + :return: Created chat link. + """ + + method = CreateChatLinkMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + + payload = BotXAPICreateChatLinkRequestPayload.from_domain( + chat_id=chat_id, + link_type=link_type, + access_code=access_code, + link_ttl=link_ttl, + ) + botx_api_chat_link = await method.execute(payload) + + return botx_api_chat_link.to_domain() + async def create_thread(self, bot_id: UUID, sync_id: UUID) -> UUID: """ Create thread. @@ -1276,8 +1420,8 @@ async def search_user_by_emails( self, *, bot_id: UUID, - emails: List[str], - ) -> List[UserFromSearch]: + emails: list[str], + ) -> list[UserFromSearch]: """Search user by emails for search. :param bot_id: Bot which should perform the request. @@ -1298,6 +1442,34 @@ async def search_user_by_emails( return botx_api_users_from_search.to_domain() # - Users API - + async def search_user_by_email_post( + self, + *, + bot_id: UUID, + email: str, + ) -> UserFromSearch: + """Search user by email for search. + + Wraps the single email into a list payload and returns the first result. + For multiple emails use `search_user_by_emails`. + + :param bot_id: Bot which should perform the request. + :param email: User email. + + :return: User information. + """ + + method = SearchUserByEmailPostMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPISearchUserByEmailRequestPayload.from_domain(email=email) + + botx_api_user_from_search = await method.execute(payload) + + return botx_api_user_from_search.to_domain() + async def search_user_by_email( self, *, @@ -1306,7 +1478,7 @@ async def search_user_by_email( ) -> UserFromSearch: """Search user by email for search. - DEPRECATED. + DEPRECATED. Use `search_user_by_email_post`. :param bot_id: Bot which should perform the request. :param email: User email. @@ -1412,7 +1584,7 @@ async def update_user_profile( *, bot_id: UUID, user_huid: UUID, - avatar: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined, + avatar: Missing[IncomingFileAttachment | OutgoingAttachment] = Undefined, name: Missing[str] = Undefined, public_name: Missing[str] = Undefined, company: Missing[str] = Undefined, @@ -1486,7 +1658,8 @@ async def users_as_csv( botx=botx, ) - async with TemporaryDirectory() as tmpdir: + async with AsyncExitStack() as stack: + tmpdir = await stack.enter_async_context(TemporaryDirectory()) async with NamedTemporaryFile( mode="wb", dir=tmpdir, @@ -1495,11 +1668,13 @@ async def users_as_csv( write_buffer_path = write_buffer.name await method.execute(payload, write_buffer) - async with aiofiles.open(write_buffer_path, mode="r") as read_buffer: - yield ( - BotXAPIUserFromCSVResult(**row).to_domain() - async for row in AsyncDictReader(read_buffer) - ) + read_buffer = await stack.enter_async_context( + aiofiles.open(write_buffer_path), + ) + yield ( + BotXAPIUserFromCSVResult(**row).to_domain() + async for row in AsyncDictReader(read_buffer) + ) # - SmartApps API - async def send_smartapp_event( @@ -1507,11 +1682,11 @@ async def send_smartapp_event( *, bot_id: UUID, chat_id: UUID, - data: Dict[str, Any], + data: dict[str, Any], encrypted: bool = True, ref: MissingOptional[UUID] = Undefined, - opts: Missing[Dict[str, Any]] = Undefined, - files: Missing[List[File]] = Undefined, + opts: Missing[dict[str, Any]] = Undefined, + files: Missing[list[File]] = Undefined, ) -> None: """Send SmartApp event. @@ -1547,8 +1722,8 @@ async def send_smartapp_notification( chat_id: UUID, smartapp_counter: int, body: Missing[str] = Undefined, - opts: Missing[Dict[str, Any]] = Undefined, - meta: Missing[Dict[str, Any]] = Undefined, + opts: Missing[dict[str, Any]] = Undefined, + meta: Missing[dict[str, Any]] = Undefined, ) -> None: """Send SmartApp notification. @@ -1580,7 +1755,7 @@ async def get_smartapps_list( *, bot_id: UUID, version: Missing[int] = Undefined, - ) -> Tuple[List[SmartApp], int]: + ) -> tuple[list[SmartApp], int]: """Get list of SmartApps on the current CTS. :param bot_id: Bot which should perform the request. @@ -1667,9 +1842,9 @@ async def send_smartapp_custom_notification( group_chat_id: UUID, title: str, body: str, - meta: Missing[Dict[str, Any]] = Undefined, + meta: Missing[dict[str, Any]] = Undefined, wait_callback: bool = True, - callback_timeout: Optional[float] = None, + callback_timeout: float | None = None, ) -> UUID: """Send SmartApp custom notification. @@ -1714,7 +1889,7 @@ async def send_smartapp_unread_counter( group_chat_id: UUID, counter: int, wait_callback: bool = True, - callback_timeout: Optional[float] = None, + callback_timeout: float | None = None, ) -> UUID: """Send SmartApp unread counter. @@ -1963,7 +2138,7 @@ async def edit_sticker_pack( sticker_pack_id: UUID, name: str, preview: UUID, - stickers_order: List[UUID], + stickers_order: list[UUID], ) -> StickerPack: """Edit Sticker pack. @@ -2000,6 +2175,7 @@ async def download_file( chat_id: UUID, file_id: UUID, async_buffer: AsyncBufferWritable, + is_preview: bool = False, ) -> None: """Download file form file service. @@ -2007,6 +2183,7 @@ async def download_file( :param chat_id: Target chat id. :param file_id: Async file id. :param async_buffer: Buffer to write downloaded file. + :param is_preview: If true and file has preview, return it instead of original. """ method = DownloadFileMethod( @@ -2017,6 +2194,7 @@ async def download_file( payload = BotXAPIDownloadFileRequestPayload.from_domain( chat_id=chat_id, file_id=file_id, + is_preview=is_preview, ) await method.execute(payload, async_buffer) @@ -2064,7 +2242,7 @@ async def refresh_access_token( *, bot_id: UUID, huid: UUID, - ref: Optional[UUID] = None, + ref: UUID | None = None, ) -> None: """Refresh OpenID access token. @@ -2091,7 +2269,7 @@ async def collect_metric( self, bot_id: UUID, bot_function: str, - huids: List[UUID], + huids: list[UUID], chat_id: UUID, ) -> None: """Collect a new use of the bot function. @@ -2117,9 +2295,9 @@ async def collect_metric( def _verify_request( self, - headers: Optional[Mapping[str, str]], + headers: Mapping[str, str] | None, *, - trusted_issuers: Optional[Set[str]] = None, + trusted_issuers: set[str] | None = None, ) -> None: if headers is None: raise RequestHeadersNotProvidedError @@ -2141,9 +2319,87 @@ def _verify_request( ) except jwt.DecodeError as decode_exc: raise UnverifiedRequestError(decode_exc.args[0]) from decode_exc + if self._is_v2_payload(token_payload): + self._verify_request_v2(token, token_payload, decode_algorithms) + else: + self._verify_request_v1( + token, + token_payload, + decode_algorithms, + trusted_issuers, + ) + + @staticmethod + def _is_v2_payload(token_payload: Mapping[str, Any]) -> bool: + if token_payload.get("version") == 2: + return True + + audience = token_payload.get("aud") + issuer = token_payload.get("iss") + if not isinstance(audience, str) or not isinstance(issuer, str): + return False + + try: + UUID(issuer) + except (TypeError, ValueError): + return False + return True + + def _verify_request_v2( + self, + token: str, + token_payload: Mapping[str, Any], + decode_algorithms: list[str], + ) -> None: + issuer = token_payload.get("iss") + if issuer is None: + raise UnverifiedRequestError('Token is missing the "iss" claim') + if not isinstance(issuer, str): + raise UnverifiedRequestError("Invalid issuer") + + try: + bot_id = UUID(issuer) + except (TypeError, ValueError) as exc: + raise UnverifiedRequestError("Invalid issuer") from exc + + try: + bot_account = self._bot_accounts_storage.get_bot_account(bot_id) + except UnknownBotAccountError as unknown_bot_exc: + raise UnverifiedRequestError(unknown_bot_exc.args[0]) from unknown_bot_exc + + audience = token_payload.get("aud") + if not audience or not isinstance(audience, str): + raise UnverifiedRequestError("Invalid audience parameter was provided.") + if audience != bot_account.host: + raise UnverifiedRequestError("Invalid audience parameter was provided.") + + try: + jwt.decode( + jwt=token, + key=bot_account.secret_key, + algorithms=decode_algorithms, + issuer=str(bot_account.id), + audience=bot_account.host, + leeway=1, + ) + except jwt.InvalidTokenError as exc: + raise UnverifiedRequestError(exc.args[0]) from exc + + def _verify_request_v1( + self, + token: str, + token_payload: Mapping[str, Any], + decode_algorithms: list[str], + trusted_issuers: set[str] | None, + ) -> None: audience = token_payload.get("aud") - if not audience or not isinstance(audience, Sequence) or len(audience) != 1: + if ( + not audience + or not isinstance(audience, Sequence) + or isinstance(audience, str) + or len(audience) != 1 + ): raise UnverifiedRequestError("Invalid audience parameter was provided.") try: @@ -2177,8 +2433,8 @@ def _verify_request( @staticmethod def _build_main_collector( collectors: Sequence[HandlerCollector], - middlewares: List[Middleware], - exception_handlers: Optional[ExceptionHandlersDict] = None, + middlewares: list[Middleware], + exception_handlers: ExceptionHandlersDict | None = None, ) -> HandlerCollector: main_collector = HandlerCollector(middlewares=middlewares) main_collector.insert_exception_middleware(exception_handlers) diff --git a/pybotx/bot/bot_accounts_storage.py b/pybotx/bot/bot_accounts_storage.py index c722b921..d4d1a556 100644 --- a/pybotx/bot/bot_accounts_storage.py +++ b/pybotx/bot/bot_accounts_storage.py @@ -1,17 +1,23 @@ import base64 import hashlib import hmac -from typing import Dict, Iterator, List, Optional +from collections.abc import Iterator from uuid import UUID +from pybotx.auth import BotXAuthVersion, build_botx_jwt_v2 from pybotx.bot.exceptions import UnknownBotAccountError from pybotx.models.bot_account import BotAccountWithSecret class BotAccountsStorage: - def __init__(self, bot_accounts: List[BotAccountWithSecret]) -> None: + def __init__( + self, + bot_accounts: list[BotAccountWithSecret], + auth_version: BotXAuthVersion = BotXAuthVersion.V2, + ) -> None: self._bot_accounts = bot_accounts - self._auth_tokens: Dict[UUID, str] = {} + self._auth_tokens: dict[UUID, str] = {} + self._auth_version = auth_version def get_bot_account(self, bot_id: UUID) -> BotAccountWithSecret: for bot_account in self._bot_accounts: @@ -23,6 +29,9 @@ def get_bot_account(self, bot_id: UUID) -> BotAccountWithSecret: def iter_bot_accounts(self) -> Iterator[BotAccountWithSecret]: yield from self._bot_accounts + def get_auth_version(self) -> BotXAuthVersion: + return self._auth_version + def get_cts_url(self, bot_id: UUID) -> str: bot_account = self.get_bot_account(bot_id) return str(bot_account.cts_url) @@ -30,9 +39,17 @@ def get_cts_url(self, bot_id: UUID) -> str: def set_token(self, bot_id: UUID, token: str) -> None: self._auth_tokens[bot_id] = token - def get_token_or_none(self, bot_id: UUID) -> Optional[str]: + def get_token_or_none(self, bot_id: UUID) -> str | None: return self._auth_tokens.get(bot_id) + def build_jwt_v2(self, bot_id: UUID) -> str: + bot_account = self.get_bot_account(bot_id) + return build_botx_jwt_v2( + bot_id=bot_account.id, + bot_host=bot_account.host, + secret_key=bot_account.secret_key, + ) + def build_signature(self, bot_id: UUID) -> str: bot_account = self.get_bot_account(bot_id) diff --git a/pybotx/bot/callbacks/callback_manager.py b/pybotx/bot/callbacks/callback_manager.py index 7b17a2b9..a396a1c7 100644 --- a/pybotx/bot/callbacks/callback_manager.py +++ b/pybotx/bot/callbacks/callback_manager.py @@ -1,17 +1,20 @@ import asyncio -from typing import Dict, Literal, NamedTuple, Optional, overload +from typing import Literal, NamedTuple, overload from uuid import UUID from pybotx.bot.callbacks.callback_repo_proto import CallbackRepoProto from pybotx.bot.exceptions import BotXMethodCallbackNotFoundError +from pybotx.client.exceptions.callbacks import CallbackNotReceivedError from pybotx.logger import logger from pybotx.models.method_callbacks import BotXMethodCallback +ORPHAN_CALLBACK_TTL_SECONDS = 5.0 +ORPHAN_PENDING_CALLBACKS_LIMIT = 1000 + class CallbackAlarm(NamedTuple): alarm_time: float - # TODO: Fix after dropping Python 3.8 - task: asyncio.Future # type: ignore + task: asyncio.Task[None] async def _callback_timeout_alarm( @@ -27,26 +30,87 @@ async def _callback_timeout_alarm( logger.error("Callback `{sync_id}` wasn't waited", sync_id=sync_id) +async def _orphan_callback_alarm( + callbacks_manager: "CallbackManager", + sync_id: UUID, + timeout: float, +) -> None: + await asyncio.sleep(timeout) + + callbacks_manager.cancel_orphan_callback_alarm(sync_id) + callbacks_manager.drop_orphan_callback(sync_id) + + logger.warning( + "Callback `{sync_id}` received without a registered handler and expired", + sync_id=sync_id, + ) + + class CallbackManager: def __init__(self, callback_repo: CallbackRepoProto) -> None: self._callback_repo = callback_repo - self._callback_alarms: Dict[UUID, CallbackAlarm] = {} + self._callback_alarms: dict[UUID, CallbackAlarm] = {} + self._orphan_callback_alarms: dict[UUID, CallbackAlarm] = {} + self._expected_sync_ids: set[UUID] = set() + self._pending_callbacks: dict[UUID, BotXMethodCallback] = {} + self._expired_sync_ids: set[UUID] = set() + + def register_expected_callback(self, sync_id: UUID) -> None: + self._expected_sync_ids.add(sync_id) + self.cancel_orphan_callback_alarm(sync_id) async def create_botx_method_callback(self, sync_id: UUID) -> None: await self._callback_repo.create_botx_method_callback(sync_id) + pending = self._pending_callbacks.pop(sync_id, None) + if pending is not None: + await self._callback_repo.set_botx_method_callback_result(pending) + self.cancel_orphan_callback_alarm(sync_id) + self._expected_sync_ids.discard(sync_id) async def set_botx_method_callback_result( self, callback: BotXMethodCallback, ) -> None: - await self._callback_repo.set_botx_method_callback_result(callback) + sync_id = callback.sync_id + if sync_id in self._expired_sync_ids: + raise BotXMethodCallbackNotFoundError(sync_id) from None + try: + await self._callback_repo.set_botx_method_callback_result(callback) + except BotXMethodCallbackNotFoundError: + if sync_id in self._pending_callbacks: + self._pending_callbacks[sync_id] = callback + return + if sync_id in self._expected_sync_ids: + self._pending_callbacks[sync_id] = callback + return + if len(self._orphan_callback_alarms) >= ORPHAN_PENDING_CALLBACKS_LIMIT: + logger.warning( + "Pending callbacks limit reached; dropping orphan callback " + "`{sync_id}`", + sync_id=sync_id, + ) + return + self._pending_callbacks[sync_id] = callback + self._setup_orphan_callback_alarm(sync_id, ORPHAN_CALLBACK_TTL_SECONDS) + logger.warning( + "Callback `{sync_id}` received without a registered handler; " + "buffering", + sync_id=sync_id, + ) + return async def wait_botx_method_callback( self, sync_id: UUID, timeout: float, ) -> BotXMethodCallback: - return await self._callback_repo.wait_botx_method_callback(sync_id, timeout) + try: + return await self._callback_repo.wait_botx_method_callback( + sync_id, timeout + ) + except CallbackNotReceivedError: + self._mark_callback_expired(sync_id) + raise async def pop_botx_method_callback( self, @@ -58,7 +122,7 @@ async def stop_callbacks_waiting(self) -> None: await self._callback_repo.stop_callbacks_waiting() def setup_callback_timeout_alarm(self, sync_id: UUID, timeout: float) -> None: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() self._callback_alarms[sync_id] = CallbackAlarm( alarm_time=loop.time() + timeout, @@ -69,31 +133,59 @@ def setup_callback_timeout_alarm(self, sync_id: UUID, timeout: float) -> None: def cancel_callback_timeout_alarm( self, sync_id: UUID, - ) -> None: ... # noqa: WPS428, E704 + ) -> None: ... # pragma: no cover @overload def cancel_callback_timeout_alarm( self, sync_id: UUID, return_remaining_time: Literal[True], - ) -> float: ... # noqa: WPS428, E704 + ) -> float: ... # pragma: no cover def cancel_callback_timeout_alarm( self, sync_id: UUID, return_remaining_time: bool = False, - ) -> Optional[float]: + ) -> float | None: try: alarm_time, alarm = self._callback_alarms.pop(sync_id) except KeyError: raise BotXMethodCallbackNotFoundError(sync_id) from None - time_before_alarm: Optional[float] = None + time_before_alarm: float | None = None if return_remaining_time: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() time_before_alarm = alarm_time - loop.time() alarm.cancel() return time_before_alarm + + def _setup_orphan_callback_alarm(self, sync_id: UUID, timeout: float) -> None: + if sync_id in self._orphan_callback_alarms: + return + loop = asyncio.get_running_loop() + self._orphan_callback_alarms[sync_id] = CallbackAlarm( + alarm_time=loop.time() + timeout, + task=asyncio.create_task(_orphan_callback_alarm(self, sync_id, timeout)), + ) + + def cancel_orphan_callback_alarm(self, sync_id: UUID) -> None: + alarm = self._orphan_callback_alarms.pop(sync_id, None) + if alarm is None: + return + alarm.task.cancel() + + def mark_callback_expired(self, sync_id: UUID) -> None: + self._mark_callback_expired(sync_id) + + def _mark_callback_expired(self, sync_id: UUID) -> None: + self._expired_sync_ids.add(sync_id) + self._pending_callbacks.pop(sync_id, None) + self._expected_sync_ids.discard(sync_id) + self.cancel_orphan_callback_alarm(sync_id) + + def drop_orphan_callback(self, sync_id: UUID) -> None: + self._pending_callbacks.pop(sync_id, None) + self.cancel_orphan_callback_alarm(sync_id) diff --git a/pybotx/bot/callbacks/callback_memory_repo.py b/pybotx/bot/callbacks/callback_memory_repo.py index 566f3fc3..31ca7cf0 100644 --- a/pybotx/bot/callbacks/callback_memory_repo.py +++ b/pybotx/bot/callbacks/callback_memory_repo.py @@ -1,5 +1,5 @@ import asyncio -from typing import TYPE_CHECKING, Dict +from typing import TYPE_CHECKING from uuid import UUID from pybotx.bot.callbacks.callback_repo_proto import CallbackRepoProto @@ -8,15 +8,16 @@ from pybotx.models.method_callbacks import BotXMethodCallback if TYPE_CHECKING: - from asyncio import Future # noqa: WPS458 + from asyncio import Future class CallbackMemoryRepo(CallbackRepoProto): def __init__(self) -> None: - self._callback_futures: Dict[UUID, "Future[BotXMethodCallback]"] = {} + self._callback_futures: dict[UUID, Future[BotXMethodCallback]] = {} async def create_botx_method_callback(self, sync_id: UUID) -> None: - self._callback_futures[sync_id] = asyncio.Future() + loop = asyncio.get_running_loop() + self._callback_futures[sync_id] = loop.create_future() async def set_botx_method_callback_result( self, @@ -37,7 +38,7 @@ async def wait_botx_method_callback( try: return await asyncio.wait_for(future, timeout=timeout) except asyncio.TimeoutError as exc: - del self._callback_futures[sync_id] # noqa: WPS420 + del self._callback_futures[sync_id] raise CallbackNotReceivedError(sync_id) from exc async def pop_botx_method_callback( @@ -54,6 +55,9 @@ async def stop_callbacks_waiting(self) -> None: f"Callback with sync_id `{sync_id!s}` can't be received", ), ) + # Mark exception as retrieved to avoid "Future exception was never retrieved" + future.exception() + self._callback_futures.clear() def _get_botx_method_callback(self, sync_id: UUID) -> "Future[BotXMethodCallback]": try: diff --git a/pybotx/bot/callbacks/callback_repo_proto.py b/pybotx/bot/callbacks/callback_repo_proto.py index 7c34bf11..3bacb5cd 100644 --- a/pybotx/bot/callbacks/callback_repo_proto.py +++ b/pybotx/bot/callbacks/callback_repo_proto.py @@ -4,34 +4,31 @@ from pybotx.models.method_callbacks import BotXMethodCallback if TYPE_CHECKING: - from asyncio import Future # noqa: WPS458 + from asyncio import Future -try: - from typing import Protocol -except ImportError: - from typing_extensions import Protocol # type: ignore # noqa: WPS440 +from typing import Protocol class CallbackRepoProto(Protocol): async def create_botx_method_callback( self, sync_id: UUID, - ) -> None: ... # noqa: WPS428, E704 + ) -> None: ... # pragma: no cover async def set_botx_method_callback_result( self, callback: BotXMethodCallback, - ) -> None: ... # noqa: WPS428, E704 + ) -> None: ... # pragma: no cover async def wait_botx_method_callback( self, sync_id: UUID, timeout: float, - ) -> BotXMethodCallback: ... # noqa: WPS428, E704 + ) -> BotXMethodCallback: ... # pragma: no cover async def pop_botx_method_callback( self, sync_id: UUID, - ) -> "Future[BotXMethodCallback]": ... # noqa: WPS428, E704 + ) -> "Future[BotXMethodCallback]": ... # pragma: no cover - async def stop_callbacks_waiting(self) -> None: ... # noqa: WPS428, E704 + async def stop_callbacks_waiting(self) -> None: ... # pragma: no cover diff --git a/pybotx/bot/handler.py b/pybotx/bot/handler.py index a26efab2..e0b466f3 100644 --- a/pybotx/bot/handler.py +++ b/pybotx/bot/handler.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from functools import partial -from typing import TYPE_CHECKING, Awaitable, Callable, List, Literal, TypeVar, Union +from typing import TYPE_CHECKING, Literal, TypeVar +from collections.abc import Awaitable, Callable from pybotx.models.commands import BotCommand from pybotx.models.message.incoming_message import IncomingMessage @@ -36,23 +37,23 @@ ] IncomingMessageHandlerFunc = HandlerFunc[IncomingMessage] -SystemEventHandlerFunc = Union[ - HandlerFunc[AddedToChatEvent], - HandlerFunc[ChatCreatedEvent], - HandlerFunc[ChatDeletedByUserEvent], - HandlerFunc[DeletedFromChatEvent], - HandlerFunc[LeftFromChatEvent], - HandlerFunc[CTSLoginEvent], - HandlerFunc[CTSLogoutEvent], - HandlerFunc[InternalBotNotificationEvent], - HandlerFunc[SmartAppEvent], - HandlerFunc[EventDeleted], - HandlerFunc[EventEdit], - HandlerFunc[JoinToChatEvent], - HandlerFunc[ConferenceChangedEvent], - HandlerFunc[ConferenceCreatedEvent], - HandlerFunc[ConferenceDeletedEvent], -] +SystemEventHandlerFunc = ( + HandlerFunc[AddedToChatEvent] + | HandlerFunc[ChatCreatedEvent] + | HandlerFunc[ChatDeletedByUserEvent] + | HandlerFunc[DeletedFromChatEvent] + | HandlerFunc[LeftFromChatEvent] + | HandlerFunc[CTSLoginEvent] + | HandlerFunc[CTSLogoutEvent] + | HandlerFunc[InternalBotNotificationEvent] + | HandlerFunc[SmartAppEvent] + | HandlerFunc[EventDeleted] + | HandlerFunc[EventEdit] + | HandlerFunc[JoinToChatEvent] + | HandlerFunc[ConferenceChangedEvent] + | HandlerFunc[ConferenceCreatedEvent] + | HandlerFunc[ConferenceDeletedEvent] +) VisibleFunc = Callable[[StatusRecipient, "Bot"], Awaitable[bool]] @@ -62,10 +63,10 @@ ] -@dataclass +@dataclass(slots=True) class BaseIncomingMessageHandler: handler_func: IncomingMessageHandlerFunc - middlewares: List[Middleware] + middlewares: list[Middleware] async def __call__(self, message: IncomingMessage, bot: "Bot") -> None: handler_func = self.handler_func @@ -78,25 +79,25 @@ async def __call__(self, message: IncomingMessage, bot: "Bot") -> None: await handler_func(message, bot) - def add_middlewares(self, middlewares: List[Middleware]) -> None: + def add_middlewares(self, middlewares: list[Middleware]) -> None: self.middlewares = middlewares + self.middlewares -@dataclass +@dataclass(slots=True) class HiddenCommandHandler(BaseIncomingMessageHandler): # Default should be here, see: https://github.com/python/mypy/issues/6113 visible: Literal[False] = False -@dataclass +@dataclass(slots=True) class VisibleCommandHandler(BaseIncomingMessageHandler): description: str - visible: Union[Literal[True], VisibleFunc] = True + visible: Literal[True] | VisibleFunc = True -@dataclass +@dataclass(slots=True) class DefaultMessageHandler(BaseIncomingMessageHandler): """Just for separate type.""" -CommandHandler = Union[HiddenCommandHandler, VisibleCommandHandler] +CommandHandler = HiddenCommandHandler | VisibleCommandHandler diff --git a/pybotx/bot/handler_collector.py b/pybotx/bot/handler_collector.py index 20230514..3a5f979a 100644 --- a/pybotx/bot/handler_collector.py +++ b/pybotx/bot/handler_collector.py @@ -3,16 +3,9 @@ from typing import ( TYPE_CHECKING, Any, - Callable, - Dict, - List, - Optional, - Sequence, - Set, - Type, - Union, overload, ) +from collections.abc import Callable, Sequence from weakref import WeakSet from pybotx.bot.contextvars import bot_id_var, bot_var, chat_id_var @@ -69,19 +62,19 @@ class HandlerCollector: VALID_COMMAND_NAME_RE = re.compile(r"^\/[^\s\/]+$", flags=re.UNICODE) - def __init__(self, middlewares: Optional[Sequence[Middleware]] = None) -> None: - self._user_commands_handlers: Dict[str, CommandHandler] = {} - self._default_message_handler: Optional[DefaultMessageHandler] = None - self._system_events_handlers: Dict[ - Type[BotCommand], + def __init__(self, middlewares: Sequence[Middleware] | None = None) -> None: + self._user_commands_handlers: dict[str, CommandHandler] = {} + self._default_message_handler: DefaultMessageHandler | None = None + self._system_events_handlers: dict[ + type[BotCommand], SystemEventHandlerFunc, ] = {} - self._sync_smartapp_event_handler: Dict[ - Type[SmartAppEvent], + self._sync_smartapp_event_handler: dict[ + type[SmartAppEvent], SyncSmartAppEventHandlerFunc, ] = {} self._middlewares = optional_sequence_to_list(middlewares) - self._tasks: "WeakSet[asyncio.Task[None]]" = WeakSet() + self._tasks: WeakSet[asyncio.Task[None]] = WeakSet() def include(self, *others: "HandlerCollector") -> None: """Include other `HandlerCollector`.""" @@ -120,7 +113,7 @@ async def handle_bot_command(self, bot_command: BotCommand, bot: "Bot") -> None: elif isinstance( bot_command, - SystemEvent.__args__, # type: ignore [attr-defined] + SystemEvent.__args__, ): event_handler = self._get_system_event_handler_or_none(bot_command) if event_handler: @@ -169,9 +162,9 @@ async def get_bot_menu( def command( self, command_name: str, - visible: Union[bool, VisibleFunc] = True, - description: Optional[str] = None, - middlewares: Optional[Sequence[Middleware]] = None, + visible: bool | VisibleFunc = True, + description: str | None = None, + middlewares: Sequence[Middleware] | None = None, ) -> Callable[[IncomingMessageHandlerFunc], IncomingMessageHandlerFunc]: """Decorate command handler.""" if not self.VALID_COMMAND_NAME_RE.match(command_name): @@ -200,30 +193,27 @@ def decorator( def default_message_handler( self, handler_func: IncomingMessageHandlerFunc, - ) -> IncomingMessageHandlerFunc: ... # noqa: WPS428, E704 + ) -> IncomingMessageHandlerFunc: ... # pragma: no cover @overload def default_message_handler( self, *, - middlewares: Optional[Sequence[Middleware]] = None, - ) -> MessageHandlerDecorator: ... # noqa: WPS428, E704 + middlewares: Sequence[Middleware] | None = None, + ) -> MessageHandlerDecorator: ... # pragma: no cover - def default_message_handler( # noqa: WPS320 + def default_message_handler( self, - handler_func: Optional[IncomingMessageHandlerFunc] = None, + handler_func: IncomingMessageHandlerFunc | None = None, *, - middlewares: Optional[Sequence[Middleware]] = None, - ) -> Union[ - IncomingMessageHandlerFunc, - Callable[[IncomingMessageHandlerFunc], IncomingMessageHandlerFunc], - ]: + middlewares: Sequence[Middleware] | None = None, + ) -> IncomingMessageHandlerFunc | Callable[[IncomingMessageHandlerFunc], IncomingMessageHandlerFunc]: """Decorate fallback messages handler.""" if self._default_message_handler: raise ValueError("Default command handler already registered") def decorator( - handler_func: IncomingMessageHandlerFunc, # noqa: WPS442 + handler_func: IncomingMessageHandlerFunc, ) -> IncomingMessageHandlerFunc: self._default_message_handler = DefaultMessageHandler( handler_func=handler_func, @@ -367,7 +357,7 @@ def sync_smartapp_event( def insert_exception_middleware( self, - exception_handlers: Optional[ExceptionHandlersDict] = None, + exception_handlers: ExceptionHandlersDict | None = None, ) -> None: exception_middleware = ExceptionMiddleware(exception_handlers or {}) self._middlewares.insert(0, exception_middleware.dispatch) @@ -379,7 +369,7 @@ async def wait_active_tasks(self) -> None: return_when=asyncio.ALL_COMPLETED, ) - def _include_collector(self, other: "HandlerCollector") -> None: # noqa: WPS238 + def _include_collector(self, other: "HandlerCollector") -> None: # - Message handlers - command_duplicates = set(self._user_commands_handlers) & set( other._user_commands_handlers, @@ -415,7 +405,7 @@ def _include_collector(self, other: "HandlerCollector") -> None: # noqa: WPS238 self._system_events_handlers.update(other._system_events_handlers) # - Sync smartapp event handler - - sync_events_duplicates: Set[Type[SmartAppEvent]] = set( + sync_events_duplicates: set[type[SmartAppEvent]] = set( self._sync_smartapp_event_handler, ) & set( other._sync_smartapp_event_handler, @@ -430,14 +420,14 @@ def _include_collector(self, other: "HandlerCollector") -> None: # noqa: WPS238 def _get_incoming_message_handler( self, message: IncomingMessage, - ) -> Union[CommandHandler, DefaultMessageHandler, None]: + ) -> CommandHandler | DefaultMessageHandler | None: return self._get_command_handler(message.body) def _get_command_handler( self, command: str, - ) -> Union[CommandHandler, DefaultMessageHandler, None]: - handler: Optional[Union[CommandHandler, DefaultMessageHandler]] = None + ) -> CommandHandler | DefaultMessageHandler | None: + handler: CommandHandler | DefaultMessageHandler | None = None command_name = self._get_command_name(command) if command_name: @@ -456,7 +446,7 @@ def _get_command_handler( def _get_system_event_handler_or_none( self, event: SystemEvent, - ) -> Optional[SystemEventHandlerFunc]: + ) -> SystemEventHandlerFunc | None: event_cls = event.__class__ handler = self._system_events_handlers.get(event_cls) @@ -467,7 +457,7 @@ def _get_system_event_handler_or_none( def _get_sync_smartapp_event_handler_or_none( self, event: SmartAppEvent, - ) -> Optional[SyncSmartAppEventHandlerFunc]: + ) -> SyncSmartAppEventHandlerFunc | None: event_cls = event.__class__ handler = self._sync_smartapp_event_handler.get(event_cls) @@ -475,7 +465,7 @@ def _get_sync_smartapp_event_handler_or_none( return handler - def _get_command_name(self, body: str) -> Optional[str]: + def _get_command_name(self, body: str) -> str | None: if not body: return None @@ -488,9 +478,9 @@ def _get_command_name(self, body: str) -> Optional[str]: def _build_command_handler( self, handler_func: IncomingMessageHandlerFunc, - visible: Union[bool, VisibleFunc], - description: Optional[str], - middlewares: List[Middleware], + visible: bool | VisibleFunc, + description: str | None, + middlewares: list[Middleware], ) -> CommandHandler: if visible is True or callable(visible): if not description: @@ -510,7 +500,7 @@ def _build_command_handler( def _system_event( self, - event_cls_name: Type[BotCommand], + event_cls_name: type[BotCommand], handler_func: SystemEventHandlerFunc, ) -> SystemEventHandlerFunc: if event_cls_name in self._system_events_handlers: @@ -522,7 +512,7 @@ def _system_event( def _sync_smartapp_event( self, - event_cls_name: Type[SmartAppEvent], + event_cls_name: type[SmartAppEvent], handler_func: SyncSmartAppEventHandlerFunc, ) -> SyncSmartAppEventHandlerFunc: if event_cls_name in self._sync_smartapp_event_handler: @@ -550,7 +540,7 @@ def _log_system_event_handler_call( else: logger.info(f"Handler for `{event_cls_name}` not found") - def _log_default_handler_call(self, command_name: Optional[str]) -> None: + def _log_default_handler_call(self, command_name: str | None) -> None: if command_name: logger.info( f"Handler for command `{command_name}` not found, " diff --git a/pybotx/bot/middlewares/exception_middleware.py b/pybotx/bot/middlewares/exception_middleware.py index c7ac4d50..e3a52118 100644 --- a/pybotx/bot/middlewares/exception_middleware.py +++ b/pybotx/bot/middlewares/exception_middleware.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, Awaitable, Callable, Dict, Optional, Type +from typing import TYPE_CHECKING +from collections.abc import Awaitable, Callable from pybotx.bot.handler import IncomingMessageHandlerFunc from pybotx.logger import logger @@ -11,7 +12,7 @@ [IncomingMessage, "Bot", Exception], Awaitable[None], ] -ExceptionHandlersDict = Dict[Type[Exception], ExceptionHandler] +ExceptionHandlersDict = dict[type[Exception], ExceptionHandler] class ExceptionMiddleware: @@ -42,7 +43,7 @@ async def dispatch( error_handler_exc, ) - def _get_exception_handler(self, exc: Exception) -> Optional[ExceptionHandler]: + def _get_exception_handler(self, exc: Exception) -> ExceptionHandler | None: for exc_cls in type(exc).mro(): handler = self._exception_handlers.get(exc_cls) if handler: diff --git a/pybotx/bot/testing.py b/pybotx/bot/testing.py index 28512358..bc875772 100644 --- a/pybotx/bot/testing.py +++ b/pybotx/bot/testing.py @@ -1,5 +1,5 @@ from contextlib import asynccontextmanager -from typing import AsyncGenerator +from collections.abc import AsyncGenerator from pybotx.bot.bot import Bot diff --git a/pybotx/client/authorized_botx_method.py b/pybotx/client/authorized_botx_method.py index a8c78592..275130a2 100644 --- a/pybotx/client/authorized_botx_method.py +++ b/pybotx/client/authorized_botx_method.py @@ -1,8 +1,11 @@ from contextlib import asynccontextmanager -from typing import Any, AsyncGenerator, Dict +from typing import Any +from collections.abc import AsyncGenerator +import warnings import httpx +from pybotx.auth import BotXAuthVersion from pybotx.client.botx_method import BotXMethod, response_exception_thrower from pybotx.client.exceptions.common import InvalidBotAccountError from pybotx.client.get_token import get_token @@ -10,6 +13,7 @@ class AuthorizedBotXMethod(BotXMethod): status_handlers = {401: response_exception_thrower(InvalidBotAccountError)} + _legacy_auth_warned: bool = False async def _botx_method_call( self, @@ -37,14 +41,29 @@ async def _botx_method_stream( ) as response: yield response - async def _add_authorization_headers(self, headers: Dict[str, Any]) -> None: - token = self._bot_accounts_storage.get_token_or_none(self._bot_id) - if not token: - token = await get_token( - self._bot_id, - self._httpx_client, - self._bot_accounts_storage, - ) - self._bot_accounts_storage.set_token(self._bot_id, token) + async def _add_authorization_headers(self, headers: dict[str, Any]) -> None: + auth_version = self._bot_accounts_storage.get_auth_version() + if auth_version == BotXAuthVersion.V2: + token = self._bot_accounts_storage.build_jwt_v2(self._bot_id) + elif auth_version == BotXAuthVersion.V1: + if not self._legacy_auth_warned: + warnings.warn( + "BotX auth v1 is deprecated; use auth_version=BotXAuthVersion.V2", + DeprecationWarning, + stacklevel=2, + ) + self._legacy_auth_warned = True + token_or_none = self._bot_accounts_storage.get_token_or_none(self._bot_id) + if token_or_none is None: + token = await get_token( + self._bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + self._bot_accounts_storage.set_token(self._bot_id, token) + else: + token = token_or_none + else: + raise NotImplementedError(f"Unsupported auth version: {auth_version}") headers.update({"Authorization": f"Bearer {token}"}) diff --git a/pybotx/client/bots_api/bot_catalog.py b/pybotx/client/bots_api/bot_catalog.py index b9610b66..7ea40d4b 100644 --- a/pybotx/client/bots_api/bot_catalog.py +++ b/pybotx/client/bots_api/bot_catalog.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Literal, Optional, Tuple +from typing import Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -23,20 +23,20 @@ class BotXAPIBotItem(VerifiedPayloadBaseModel): user_huid: UUID name: str description: str - avatar: Optional[str] = None + avatar: str | None = None enabled: bool class BotXAPIBotsListResult(VerifiedPayloadBaseModel): generated_at: datetime - bots: List[BotXAPIBotItem] + bots: list[BotXAPIBotItem] class BotXAPIBotsListResponsePayload(VerifiedPayloadBaseModel): result: BotXAPIBotsListResult status: Literal["ok"] - def to_domain(self) -> Tuple[List[BotsListItem], datetime]: + def to_domain(self) -> tuple[list[BotsListItem], datetime]: bots_list = [ BotsListItem( id=bot.user_huid, diff --git a/pybotx/client/botx_method.py b/pybotx/client/botx_method.py index 34a8f2c1..4dc473cf 100644 --- a/pybotx/client/botx_method.py +++ b/pybotx/client/botx_method.py @@ -3,15 +3,10 @@ from json.decoder import JSONDecodeError from typing import ( Any, - AsyncGenerator, - Awaitable, - Callable, - Mapping, NoReturn, - Optional, - Type, TypeVar, ) +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping from uuid import UUID import httpx @@ -45,8 +40,8 @@ def response_exception_thrower( - exc: Type[BaseClientError], - comment: Optional[str] = None, + exc: type[BaseClientError], + comment: str | None = None, ) -> StatusHandler: def factory(response: httpx.Response) -> NoReturn: raise exc.from_response(response, comment) @@ -55,8 +50,8 @@ def factory(response: httpx.Response) -> NoReturn: def callback_exception_thrower( - exc: Type[BaseClientError], - comment: Optional[str] = None, + exc: type[BaseClientError], + comment: str | None = None, ) -> CallbackExceptionHandler: # noqa: F821 def factory(callback: BotAPIMethodFailedCallback) -> NoReturn: raise exc.from_callback(callback, comment) @@ -73,7 +68,7 @@ def __init__( sender_bot_id: UUID, httpx_client: httpx.AsyncClient, bot_accounts_storage: BotAccountsStorage, - callbacks_manager: Optional[CallbackManager] = None, + callbacks_manager: CallbackManager | None = None, ) -> None: self._bot_id = sender_bot_id self._httpx_client = httpx_client @@ -92,7 +87,7 @@ def _build_url(self, path: str) -> str: def _verify_and_extract_api_model( self, - model_cls: Type[TBotXAPIModel], + model_cls: type[TBotXAPIModel], response: httpx.Response, ) -> TBotXAPIModel: try: @@ -152,13 +147,14 @@ async def _process_callback( self, sync_id: UUID, wait_callback: bool, - callback_timeout: Optional[float], + callback_timeout: float | None, default_callback_timeout: float, - ) -> Optional[BotXMethodCallback]: + ) -> BotXMethodCallback | None: assert self._callbacks_manager is not None, ( "CallbackManager hasn't been passed to this method" ) + self._callbacks_manager.register_expected_callback(sync_id) await self._callbacks_manager.create_botx_method_callback(sync_id) if callback_timeout is None: diff --git a/pybotx/client/chats_api/add_admin.py b/pybotx/client/chats_api/add_admin.py index f8074264..354ead6f 100644 --- a/pybotx/client/chats_api/add_admin.py +++ b/pybotx/client/chats_api/add_admin.py @@ -1,4 +1,4 @@ -from typing import List, Literal, NoReturn +from typing import Literal, NoReturn from uuid import UUID import httpx @@ -16,13 +16,13 @@ class BotXAPIAddAdminRequestPayload(UnverifiedPayloadBaseModel): group_chat_id: UUID - user_huids: List[UUID] + user_huids: list[UUID] @classmethod def from_domain( cls, chat_id: UUID, - huids: List[UUID], + huids: list[UUID], ) -> "BotXAPIAddAdminRequestPayload": return cls(group_chat_id=chat_id, user_huids=huids) diff --git a/pybotx/client/chats_api/add_user.py b/pybotx/client/chats_api/add_user.py index 31f930d9..8853306b 100644 --- a/pybotx/client/chats_api/add_user.py +++ b/pybotx/client/chats_api/add_user.py @@ -1,4 +1,4 @@ -from typing import List, Literal +from typing import Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -9,13 +9,13 @@ class BotXAPIAddUserRequestPayload(UnverifiedPayloadBaseModel): group_chat_id: UUID - user_huids: List[UUID] + user_huids: list[UUID] @classmethod def from_domain( cls, chat_id: UUID, - huids: List[UUID], + huids: list[UUID], ) -> "BotXAPIAddUserRequestPayload": return cls(group_chat_id=chat_id, user_huids=huids) diff --git a/pybotx/client/chats_api/chat_info.py b/pybotx/client/chats_api/chat_info.py index b4d9e47f..aa71b78e 100644 --- a/pybotx/client/chats_api/chat_info.py +++ b/pybotx/client/chats_api/chat_info.py @@ -1,5 +1,5 @@ from datetime import datetime as dt -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Literal from uuid import UUID from pydantic import ConfigDict, ValidationError, field_validator @@ -26,7 +26,7 @@ class BotXAPIChatInfoRequestPayload(UnverifiedPayloadBaseModel): def from_domain(cls, chat_id: UUID) -> "BotXAPIChatInfoRequestPayload": return cls(group_chat_id=chat_id) - def as_query_params(self) -> Dict[str, Any]: + def as_query_params(self) -> dict[str, Any]: return self.model_dump(mode="json") @@ -40,11 +40,11 @@ class BotXAPIChatInfoMember(VerifiedPayloadBaseModel): class BotXAPIChatInfoResult(VerifiedPayloadBaseModel): chat_type: APIChatTypes - creator: Optional[UUID] - description: Optional[str] = None + creator: UUID | None + description: str | None = None group_chat_id: UUID inserted_at: dt - members: List[Union[BotXAPIChatInfoMember, Dict[str, Any]]] = [] + members: list[BotXAPIChatInfoMember | dict[str, Any]] = [] name: str shared_history: bool @@ -52,16 +52,16 @@ class BotXAPIChatInfoResult(VerifiedPayloadBaseModel): @staticmethod def validate_members( - items: List[Union[BotXAPIChatInfoMember, Dict[str, Any]]], + items: list[BotXAPIChatInfoMember | dict[str, Any]], info: Any, - ) -> List[Union[BotXAPIChatInfoMember, Dict[str, Any]]]: + ) -> list[BotXAPIChatInfoMember | dict[str, Any]]: """ Публичный helper для парсинга списка участников: - dict → BotXAPIChatInfoMember - уже готовый BotXAPIChatInfoMember остаётся как есть - всё остальное логируется и пропускается """ - parsed: List[Union[BotXAPIChatInfoMember, Dict[str, Any]]] = [] + parsed: list[BotXAPIChatInfoMember | dict[str, Any]] = [] for item in items: if isinstance(item, dict): try: @@ -81,9 +81,9 @@ def validate_members( @classmethod def _validate_members_field( cls, - value: List[Union[BotXAPIChatInfoMember, Dict[str, Any]]], + value: list[BotXAPIChatInfoMember | dict[str, Any]], info: Any, - ) -> List[Union[BotXAPIChatInfoMember, Dict[str, Any]]]: + ) -> list[BotXAPIChatInfoMember | dict[str, Any]]: return cls.validate_members(value, info) diff --git a/pybotx/client/chats_api/create_chat.py b/pybotx/client/chats_api/create_chat.py index 7bc1cc37..876b89ec 100644 --- a/pybotx/client/chats_api/create_chat.py +++ b/pybotx/client/chats_api/create_chat.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Literal, Optional, Set, Union +from typing import Any, Literal from uuid import UUID from pydantic import ( @@ -30,21 +30,21 @@ class BotXAPICreateChatRequestPayload(UnverifiedPayloadBaseModel): ) name: str = Field(..., min_length=1) - description: Optional[str] = None - chat_type: Union[APIChatTypes, ChatTypes] - members: List[UUID] + description: str | None = None + chat_type: APIChatTypes | ChatTypes + members: list[UUID] shared_history: Missing[bool] - avatar: Optional[str] = None + avatar: str | None = None @model_validator(mode="before") - def _convert_chat_type(cls, values: Dict[str, Any]) -> Dict[str, Any]: - ct = values.get("chat_type") - if isinstance(ct, ChatTypes): - values["chat_type"] = convert_chat_type_from_domain(ct) + def _convert_chat_type(cls, values: dict[str, Any]) -> dict[str, Any]: + chat_type = values.get("chat_type") + if isinstance(chat_type, ChatTypes): + values["chat_type"] = convert_chat_type_from_domain(chat_type) return values @field_validator("avatar") - def _validate_avatar(cls, v: Optional[str]) -> Optional[str]: + def _validate_avatar(cls, v: str | None) -> str | None: if v is None: return None if not v.startswith("data:"): @@ -56,8 +56,10 @@ def _validate_avatar(cls, v: Optional[str]) -> Optional[str]: return v @field_serializer("chat_type") - def _serialize_chat_type(self, v: APIChatTypes) -> str: - return v.value.lower() + def _serialize_chat_type(self, v: APIChatTypes | ChatTypes) -> str: + if isinstance(v, ChatTypes): + v = convert_chat_type_from_domain(v) + return v.value class BotXAPIChatIdResult(VerifiedPayloadBaseModel): @@ -90,7 +92,7 @@ async def execute( """ url = self._build_url("/api/v3/botx/chats/create") - exclude: Set[str] = ( + exclude: set[str] = ( {"shared_history"} if payload.shared_history is Undefined else set() ) diff --git a/pybotx/client/chats_api/create_chat_link.py b/pybotx/client/chats_api/create_chat_link.py new file mode 100644 index 00000000..568d07a3 --- /dev/null +++ b/pybotx/client/chats_api/create_chat_link.py @@ -0,0 +1,116 @@ +from typing import Literal, NoReturn +from uuid import UUID + +import httpx + +from pybotx.client.authorized_botx_method import AuthorizedBotXMethod +from pybotx.client.botx_method import response_exception_thrower +from pybotx.client.exceptions.chats import ( + ChatLinkCreationError, + ChatLinkCreationProhibitedError, +) +from pybotx.client.exceptions.common import ChatNotFoundError +from pybotx.client.exceptions.http import InvalidBotXStatusCodeError +from pybotx.missing import Missing, Undefined +from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from pybotx.models.chats import ChatLink +from pybotx.models.enums import ChatLinkTypes + + +class BotXAPIChatLinkParams(UnverifiedPayloadBaseModel): + link_type: ChatLinkTypes + access_code: Missing[str | None] + link_ttl: Missing[int | None] + + @classmethod + def from_domain( + cls, + link_type: ChatLinkTypes, + access_code: Missing[str | None], + link_ttl: Missing[int | None], + ) -> "BotXAPIChatLinkParams": + return cls( + link_type=link_type, + access_code=access_code, + link_ttl=link_ttl, + ) + + +class BotXAPICreateChatLinkRequestPayload(UnverifiedPayloadBaseModel): + chat_id: UUID + link: BotXAPIChatLinkParams + + @classmethod + def from_domain( + cls, + chat_id: UUID, + link_type: ChatLinkTypes, + access_code: Missing[str | None] = Undefined, + link_ttl: Missing[int | None] = Undefined, + ) -> "BotXAPICreateChatLinkRequestPayload": + return cls( + chat_id=chat_id, + link=BotXAPIChatLinkParams.from_domain( + link_type=link_type, + access_code=access_code, + link_ttl=link_ttl, + ), + ) + + +class BotXAPIChatLinkResult(VerifiedPayloadBaseModel): + url: str + link_type: ChatLinkTypes + access_code: str | None + link_ttl: int | None + + def to_domain(self) -> ChatLink: + return ChatLink( + url=self.url, + link_type=self.link_type, + access_code=self.access_code, + link_ttl=self.link_ttl, + ) + + +class BotXAPICreateChatLinkResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIChatLinkResult + + def to_domain(self) -> ChatLink: + return self.result.to_domain() + + +def server_error_handler(response: httpx.Response) -> NoReturn: + reason = response.json().get("reason") + + if reason == "error_from_messaging_service": + raise ChatLinkCreationError.from_response(response) + + raise InvalidBotXStatusCodeError(response) + + +class CreateChatLinkMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 403: response_exception_thrower(ChatLinkCreationProhibitedError), + 404: response_exception_thrower(ChatNotFoundError), + 500: server_error_handler, + } + + async def execute( + self, + payload: BotXAPICreateChatLinkRequestPayload, + ) -> BotXAPICreateChatLinkResponsePayload: + path = "/api/v3/botx/chats/create_link" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPICreateChatLinkResponsePayload, + response, + ) diff --git a/pybotx/client/chats_api/list_chats.py b/pybotx/client/chats_api/list_chats.py index c692ff2d..9952218a 100644 --- a/pybotx/client/chats_api/list_chats.py +++ b/pybotx/client/chats_api/list_chats.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -14,8 +14,8 @@ class BotXAPIListChatResult(VerifiedPayloadBaseModel): group_chat_id: UUID chat_type: APIChatTypes name: str - description: Optional[str] = None - members: List[UUID] + description: str | None = None + members: list[UUID] inserted_at: datetime updated_at: datetime shared_history: bool @@ -23,13 +23,13 @@ class BotXAPIListChatResult(VerifiedPayloadBaseModel): class BotXAPIListChatResponsePayload(VerifiedPayloadBaseModel): status: Literal["ok"] - result: List[Union[BotXAPIListChatResult, Dict[str, Any]]] + result: list[BotXAPIListChatResult | dict[str, Any]] @staticmethod def validate_result( - value: List[Union[BotXAPIListChatResult, Dict[str, Any]]], info: Any - ) -> List[Union[BotXAPIListChatResult, Dict[str, Any]]]: - parsed: List[Union[BotXAPIListChatResult, Dict[str, Any]]] = [] + value: list[BotXAPIListChatResult | dict[str, Any]], info: Any + ) -> list[BotXAPIListChatResult | dict[str, Any]]: + parsed: list[BotXAPIListChatResult | dict[str, Any]] = [] for item in value: if isinstance(item, dict): try: @@ -43,12 +43,12 @@ def validate_result( @field_validator("result", mode="before") @classmethod def _validate_result_field( - cls, value: List[Union[BotXAPIListChatResult, Dict[str, Any]]], info: Any - ) -> List[Union[BotXAPIListChatResult, Dict[str, Any]]]: + cls, value: list[BotXAPIListChatResult | dict[str, Any]], info: Any + ) -> list[BotXAPIListChatResult | dict[str, Any]]: # Pydantic-валидатор: просто делегируем статическому методу return cls.validate_result(value, info) - def to_domain(self) -> List[ChatListItem]: + def to_domain(self) -> list[ChatListItem]: chats_list = [ ChatListItem( chat_id=chat_item.group_chat_id, diff --git a/pybotx/client/chats_api/personal_chat.py b/pybotx/client/chats_api/personal_chat.py index 40d33fce..e2796c6a 100644 --- a/pybotx/client/chats_api/personal_chat.py +++ b/pybotx/client/chats_api/personal_chat.py @@ -1,5 +1,5 @@ from datetime import datetime as dt -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Literal from uuid import UUID from pydantic import ConfigDict, ValidationError, field_validator, Field @@ -28,7 +28,7 @@ class BotXAPIPersonalChatRequestPayload(UnverifiedPayloadBaseModel): def from_domain(cls, user_huid: UUID) -> "BotXAPIPersonalChatRequestPayload": return cls(user_huid=user_huid) - def as_query_params(self) -> Dict[str, Any]: + def as_query_params(self) -> dict[str, Any]: return self.model_dump(mode="json") @@ -39,40 +39,41 @@ class BotXAPIPersonalChatMember(VerifiedPayloadBaseModel): user_huid: UUID user_kind: APIUserKinds - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="ignore") class BotXAPIPersonalChatResult(VerifiedPayloadBaseModel): """Результат API-ответа по персональному чату.""" chat_type: APIChatTypes - creator: Optional[UUID] - description: Optional[str] = None + creator: UUID | None = None + description: str | None = None group_chat_id: UUID inserted_at: dt - members: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]] = Field( - default_factory=list + updated_at: dt | None = None + members: list[BotXAPIPersonalChatMember | dict[str, Any] | UUID] = Field( + default_factory=list, ) name: str shared_history: bool - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="ignore") @field_validator("members", mode="before") @classmethod def validate_members( cls, - value: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]], + value: list[BotXAPIPersonalChatMember | dict[str, Any] | UUID | str], info: Any, - ) -> List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]]: + ) -> list[BotXAPIPersonalChatMember | dict[str, Any] | UUID]: return cls._parse_members(value) @staticmethod def _parse_members( - members_data: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]], - ) -> List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]]: + members_data: list[BotXAPIPersonalChatMember | dict[str, Any] | UUID | str], + ) -> list[BotXAPIPersonalChatMember | dict[str, Any] | UUID]: # Явная аннотация решает проблему инвариантности List в mypy - parsed: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]] = [] + parsed: list[BotXAPIPersonalChatMember | dict[str, Any] | UUID] = [] for item in members_data: if isinstance(item, dict): try: @@ -82,6 +83,13 @@ def _parse_members( parsed.append(item) elif isinstance(item, BotXAPIPersonalChatMember): parsed.append(item) + elif isinstance(item, UUID): + parsed.append(item) + elif isinstance(item, str): + try: + parsed.append(UUID(item)) + except ValueError: + logger.warning("Unknown member type: %s", item) else: logger.warning("Unknown member type: %s", item) return parsed @@ -96,7 +104,13 @@ class BotXAPIPersonalChatResponsePayload(VerifiedPayloadBaseModel): model_config = ConfigDict(extra="forbid") def to_domain(self) -> ChatInfo: - members: List[ChatInfoMember] = [] + if any( + not isinstance(member, BotXAPIPersonalChatMember) + for member in self.result.members + ): + logger.warning("Unsupported user type skipped in members list") + + members: list[ChatInfoMember] = [] for member in self.result.members: if isinstance(member, BotXAPIPersonalChatMember): try: @@ -109,10 +123,6 @@ def to_domain(self) -> ChatInfo: ) except Exception as exc: logger.warning("Failed to convert member kind: %s", exc) - else: - logger.warning( - "Unsupported user type skipped in members list: %s", member - ) return ChatInfo( chat_type=convert_chat_type_to_domain(self.result.chat_type), diff --git a/pybotx/client/chats_api/remove_user.py b/pybotx/client/chats_api/remove_user.py index f9c2c370..baf7561f 100644 --- a/pybotx/client/chats_api/remove_user.py +++ b/pybotx/client/chats_api/remove_user.py @@ -1,4 +1,4 @@ -from typing import List, Literal +from typing import Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -9,13 +9,13 @@ class BotXAPIRemoveUserRequestPayload(UnverifiedPayloadBaseModel): group_chat_id: UUID - user_huids: List[UUID] + user_huids: list[UUID] @classmethod def from_domain( cls, chat_id: UUID, - huids: List[UUID], + huids: list[UUID], ) -> "BotXAPIRemoveUserRequestPayload": return cls(group_chat_id=chat_id, user_huids=huids) diff --git a/pybotx/client/events_api/edit_event.py b/pybotx/client/events_api/edit_event.py index 6f734d41..e5e176e7 100644 --- a/pybotx/client/events_api/edit_event.py +++ b/pybotx/client/events_api/edit_event.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Literal, Union +from typing import Any, Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -31,11 +31,11 @@ class BotXAPIEditEventOpts(UnverifiedPayloadBaseModel): class BotXAPIEditEvent(UnverifiedPayloadBaseModel): body: Missing[str] - metadata: Missing[Dict[str, Any]] + metadata: Missing[dict[str, Any]] opts: Missing[BotXAPIEditEventOpts] bubble: Missing[BotXAPIMarkup] keyboard: Missing[BotXAPIMarkup] - mentions: Missing[List[BotXAPIMention]] + mentions: Missing[list[BotXAPIMention]] class BotXAPIEditEventRequestPayload(UnverifiedPayloadBaseModel): @@ -49,10 +49,10 @@ def from_domain( cls, sync_id: UUID, body: Missing[str], - metadata: Missing[Dict[str, Any]], + metadata: Missing[dict[str, Any]], bubbles: Missing[BubbleMarkup], keyboard: Missing[KeyboardMarkup], - file: Missing[Union[IncomingFileAttachment, OutgoingAttachment, None]], + file: Missing[IncomingFileAttachment | OutgoingAttachment | None], markup_auto_adjust: Missing[bool], ) -> "BotXAPIEditEventRequestPayload": api_file: MissingOptional[BotXAPIAttachment] = Undefined @@ -61,7 +61,7 @@ def from_domain( elif file is None: api_file = None - mentions: Missing[List[BotXAPIMention]] = Undefined + mentions: Missing[list[BotXAPIMention]] = Undefined if isinstance(body, str): body, mentions = find_and_replace_embed_mentions(body) diff --git a/pybotx/client/events_api/message_status_event.py b/pybotx/client/events_api/message_status_event.py index 15104da0..63544993 100644 --- a/pybotx/client/events_api/message_status_event.py +++ b/pybotx/client/events_api/message_status_event.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from datetime import datetime -from typing import List, Literal +from typing import Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -18,13 +18,13 @@ def from_domain(cls, sync_id: UUID) -> "BotXAPIMessageStatusRequestPayload": return cls(sync_id=sync_id) -@dataclass +@dataclass(slots=True) class BotXAPIMessageStatusReadUser: user_huid: UUID read_at: datetime -@dataclass +@dataclass(slots=True) class BotXAPIMessageStatusReceivedUser: user_huid: UUID received_at: datetime @@ -32,9 +32,9 @@ class BotXAPIMessageStatusReceivedUser: class BotXAPIMessageStatusResult(VerifiedPayloadBaseModel): group_chat_id: UUID - sent_to: List[UUID] - read_by: List[BotXAPIMessageStatusReadUser] - received_by: List[BotXAPIMessageStatusReceivedUser] + sent_to: list[UUID] + read_by: list[BotXAPIMessageStatusReadUser] + received_by: list[BotXAPIMessageStatusReceivedUser] class BotXAPIMessageStatusResponsePayload(VerifiedPayloadBaseModel): diff --git a/pybotx/client/events_api/reply_event.py b/pybotx/client/events_api/reply_event.py index 1d75d208..2a15731d 100644 --- a/pybotx/client/events_api/reply_event.py +++ b/pybotx/client/events_api/reply_event.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Literal, Union +from typing import Any, Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -29,11 +29,11 @@ class BotXAPIReplyEventMessageOpts(UnverifiedPayloadBaseModel): class BotXAPIReplyEvent(UnverifiedPayloadBaseModel): status: Literal["ok"] body: str - metadata: Missing[Dict[str, Any]] + metadata: Missing[dict[str, Any]] opts: Missing[BotXAPIReplyEventMessageOpts] bubble: Missing[BotXAPIMarkup] keyboard: Missing[BotXAPIMarkup] - mentions: Missing[List[BotXAPIMention]] + mentions: Missing[list[BotXAPIMention]] class BotXAPIReplyEventNestedOpts(UnverifiedPayloadBaseModel): @@ -58,10 +58,10 @@ def from_domain( cls, sync_id: UUID, body: str, - metadata: Missing[Dict[str, Any]], + metadata: Missing[dict[str, Any]], bubbles: Missing[BubbleMarkup], keyboard: Missing[KeyboardMarkup], - file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]], + file: Missing[IncomingFileAttachment | OutgoingAttachment], silent_response: Missing[bool], markup_auto_adjust: Missing[bool], stealth_mode: Missing[bool], diff --git a/pybotx/client/exceptions/base.py b/pybotx/client/exceptions/base.py index 17a21c5b..fd73dea1 100644 --- a/pybotx/client/exceptions/base.py +++ b/pybotx/client/exceptions/base.py @@ -1,4 +1,3 @@ -from typing import Optional import httpx @@ -14,7 +13,7 @@ def __init__(self, message: str) -> None: def from_response( cls, response: httpx.Response, - comment: Optional[str] = None, + comment: str | None = None, ) -> "BaseClientError": method = response.request.method url = response.request.url @@ -36,7 +35,7 @@ def from_response( def from_callback( cls, callback: BotAPIMethodFailedCallback, - comment: Optional[str] = None, + comment: str | None = None, ) -> "BaseClientError": message = ( f"BotX method call with sync_id `{callback.sync_id!s}` " diff --git a/pybotx/client/exceptions/chats.py b/pybotx/client/exceptions/chats.py index e0b0480e..059f568b 100644 --- a/pybotx/client/exceptions/chats.py +++ b/pybotx/client/exceptions/chats.py @@ -17,6 +17,14 @@ class ChatCreationError(BaseClientError): """Error while chat creation.""" +class ChatLinkCreationProhibitedError(BaseClientError): + """Bot doesn't have permissions to create chat link.""" + + +class ChatLinkCreationError(BaseClientError): + """Error while chat link creation.""" + + class ThreadAlreadyExistsError(BaseClientError): """Thread is already exists.""" diff --git a/pybotx/client/files_api/download_file.py b/pybotx/client/files_api/download_file.py index 2f15409a..29dfa2a4 100644 --- a/pybotx/client/files_api/download_file.py +++ b/pybotx/client/files_api/download_file.py @@ -22,11 +22,12 @@ def from_domain( cls, chat_id: UUID, file_id: UUID, + is_preview: bool = False, ) -> "BotXAPIDownloadFileRequestPayload": return cls( group_chat_id=chat_id, file_id=file_id, - is_preview=False, + is_preview=is_preview, ) diff --git a/pybotx/client/mertics_api/collect_bot_function.py b/pybotx/client/mertics_api/collect_bot_function.py index e72a8052..caf98f41 100644 --- a/pybotx/client/mertics_api/collect_bot_function.py +++ b/pybotx/client/mertics_api/collect_bot_function.py @@ -1,4 +1,4 @@ -from typing import List, Literal +from typing import Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -7,14 +7,14 @@ class BotXAPICollectBotFunctionRequestPayload(UnverifiedPayloadBaseModel): group_chat_id: UUID - user_huids: List[UUID] + user_huids: list[UUID] bot_function: str @classmethod def from_domain( cls, chat_id: UUID, - huids: List[UUID], + huids: list[UUID], bot_function: str, ) -> "BotXAPICollectBotFunctionRequestPayload": return cls(group_chat_id=chat_id, user_huids=huids, bot_function=bot_function) diff --git a/pybotx/client/notifications_api/direct_notification.py b/pybotx/client/notifications_api/direct_notification.py index ae0ba20a..bbdc0433 100644 --- a/pybotx/client/notifications_api/direct_notification.py +++ b/pybotx/client/notifications_api/direct_notification.py @@ -1,6 +1,8 @@ -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Literal from uuid import UUID +import httpx + from pybotx.client.authorized_botx_method import AuthorizedBotXMethod from pybotx.client.botx_method import callback_exception_thrower from pybotx.client.exceptions.common import ChatNotFoundError @@ -9,6 +11,7 @@ FinalRecipientsListEmptyError, StealthModeDisabledError, ) +from pybotx.client.exceptions.http import InvalidBotXResponsePayloadError from pybotx.constants import MAX_NOTIFICATION_BODY_LENGTH from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel @@ -47,18 +50,18 @@ class BotXAPIDirectNotificationOpts(UnverifiedPayloadBaseModel): class BotXAPIDirectNotification(UnverifiedPayloadBaseModel): status: Literal["ok"] body: str - metadata: Missing[Dict[str, Any]] + metadata: Missing[dict[str, Any]] opts: Missing[BotXAPIDirectNotificationMessageOpts] bubble: Missing[BotXAPIMarkup] keyboard: Missing[BotXAPIMarkup] - mentions: Missing[List[BotXAPIMention]] + mentions: Missing[list[BotXAPIMention]] class BotXAPIDirectNotificationRequestPayload(UnverifiedPayloadBaseModel): group_chat_id: UUID notification: BotXAPIDirectNotification file: Missing[BotXAPIAttachment] - recipients: Missing[List[UUID]] + recipients: Missing[list[UUID]] opts: Missing[BotXAPIDirectNotificationOpts] @classmethod @@ -66,11 +69,11 @@ def from_domain( cls, chat_id: UUID, body: str, - metadata: Missing[Dict[str, Any]], + metadata: Missing[dict[str, Any]], bubbles: Missing[BubbleMarkup], keyboard: Missing[KeyboardMarkup], - file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]], - recipients: Missing[List[UUID]], + file: Missing[IncomingFileAttachment | OutgoingAttachment], + recipients: Missing[list[UUID]], silent_response: Missing[bool], markup_auto_adjust: Missing[bool], stealth_mode: Missing[bool], @@ -126,6 +129,33 @@ def to_domain(self) -> UUID: return self.result.sync_id +class BotXAPIDirectNotificationSyncResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok", "error"] + result: BotXAPISyncIdResult | None = None + reason: str | None = None + errors: list[str] | None = None + error_data: dict[str, Any] | None = None + + +_DIRECT_NOTIFICATION_SYNC_ERROR_MAP = { + "chat_not_found": ChatNotFoundError, + "bot_is_not_a_chat_member": BotIsNotChatMemberError, + "event_recipients_list_is_empty": FinalRecipientsListEmptyError, + "stealth_mode_disabled": StealthModeDisabledError, +} + + +def _raise_direct_notification_sync_error( + response: "httpx.Response", + reason: str | None, +) -> None: + exc_type = _DIRECT_NOTIFICATION_SYNC_ERROR_MAP.get(reason or "") + if exc_type is None: + raise InvalidBotXResponsePayloadError(response) + + raise exc_type.from_response(response) + + class DirectNotificationMethod(AuthorizedBotXMethod): error_callback_handlers = { **AuthorizedBotXMethod.error_callback_handlers, @@ -143,7 +173,7 @@ async def execute( self, payload: BotXAPIDirectNotificationRequestPayload, wait_callback: bool, - callback_timeout: Optional[float], + callback_timeout: float | None, default_callback_timeout: float, ) -> BotXAPIDirectNotificationResponsePayload: path = "/api/v4/botx/notifications/direct" @@ -166,3 +196,31 @@ async def execute( default_callback_timeout, ) return api_model + + +class DirectNotificationSyncMethod(AuthorizedBotXMethod): + async def execute( + self, + payload: BotXAPIDirectNotificationRequestPayload, + ) -> BotXAPIDirectNotificationResponsePayload: + path = "/api/v4/botx/notifications/direct/sync" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + api_model = self._verify_and_extract_api_model( + BotXAPIDirectNotificationSyncResponsePayload, + response, + ) + + if api_model.status == "error": + _raise_direct_notification_sync_error(response, api_model.reason) + + assert api_model.result is not None + return BotXAPIDirectNotificationResponsePayload( + status="ok", + result=api_model.result, + ) diff --git a/pybotx/client/notifications_api/internal_bot_notification.py b/pybotx/client/notifications_api/internal_bot_notification.py index 4afe694f..da7f081a 100644 --- a/pybotx/client/notifications_api/internal_bot_notification.py +++ b/pybotx/client/notifications_api/internal_bot_notification.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -17,17 +17,17 @@ class BotXAPIInternalBotNotificationRequestPayload(UnverifiedPayloadBaseModel): group_chat_id: UUID - data: Dict[str, Any] - opts: Missing[Dict[str, Any]] - recipients: MissingOptional[List[UUID]] + data: dict[str, Any] + opts: Missing[dict[str, Any]] + recipients: MissingOptional[list[UUID]] @classmethod def from_domain( cls, chat_id: UUID, - data: Dict[str, Any], - opts: Missing[Dict[str, Any]], - recipients: MissingOptional[List[UUID]], + data: dict[str, Any], + opts: Missing[dict[str, Any]], + recipients: MissingOptional[list[UUID]], ) -> "BotXAPIInternalBotNotificationRequestPayload": return cls( group_chat_id=chat_id, @@ -70,7 +70,7 @@ async def execute( self, payload: BotXAPIInternalBotNotificationRequestPayload, wait_callback: bool, - callback_timeout: Optional[float], + callback_timeout: float | None, default_callback_timeout: float, ) -> BotXAPIInternalBotNotificationResponsePayload: path = "/api/v4/botx/notifications/internal" diff --git a/pybotx/client/openid_api/refresh_access_token.py b/pybotx/client/openid_api/refresh_access_token.py index 00928d22..8bd14fe0 100644 --- a/pybotx/client/openid_api/refresh_access_token.py +++ b/pybotx/client/openid_api/refresh_access_token.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional +from typing import Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -7,13 +7,13 @@ class BotXAPIRefreshAccessTokenRequestPayload(UnverifiedPayloadBaseModel): user_huid: UUID - ref: Optional[UUID] + ref: UUID | None @classmethod def from_domain( cls, huid: UUID, - ref: Optional[UUID], + ref: UUID | None, ) -> "BotXAPIRefreshAccessTokenRequestPayload": return cls( user_huid=huid, diff --git a/pybotx/client/smartapps_api/smartapp_custom_notification.py b/pybotx/client/smartapps_api/smartapp_custom_notification.py index 7fc3d5a3..bc01d18d 100644 --- a/pybotx/client/smartapps_api/smartapp_custom_notification.py +++ b/pybotx/client/smartapps_api/smartapp_custom_notification.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Literal, Optional +from typing import Any, Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -14,7 +14,7 @@ class BotXAPISmartAppCustomNotificationNestedPayload(UnverifiedPayloadBaseModel) class BotXAPISmartAppCustomNotificationRequestPayload(UnverifiedPayloadBaseModel): group_chat_id: UUID payload: BotXAPISmartAppCustomNotificationNestedPayload - meta: Missing[Dict[str, Any]] + meta: Missing[dict[str, Any]] @classmethod def from_domain( @@ -22,7 +22,7 @@ def from_domain( group_chat_id: UUID, title: str, body: str, - meta: Missing[Dict[str, Any]], + meta: Missing[dict[str, Any]], ) -> "BotXAPISmartAppCustomNotificationRequestPayload": return cls( group_chat_id=group_chat_id, @@ -55,7 +55,7 @@ async def execute( self, payload: BotXAPISmartAppCustomNotificationRequestPayload, wait_callback: bool, - callback_timeout: Optional[float], + callback_timeout: float | None, default_callback_timeout: float, ) -> BotXAPISmartAppCustomNotificationResponsePayload: path = "/api/v4/botx/smartapps/notification" diff --git a/pybotx/client/smartapps_api/smartapp_event.py b/pybotx/client/smartapps_api/smartapp_event.py index e8649ac5..e4239927 100644 --- a/pybotx/client/smartapps_api/smartapp_event.py +++ b/pybotx/client/smartapps_api/smartapp_event.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Literal +from typing import Any, Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -12,10 +12,10 @@ class BotXAPISmartAppEventRequestPayload(UnverifiedPayloadBaseModel): ref: MissingOptional[UUID] smartapp_id: UUID group_chat_id: UUID - data: Dict[str, Any] - opts: Missing[Dict[str, Any]] + data: dict[str, Any] + opts: Missing[dict[str, Any]] smartapp_api_version: int - async_files: Missing[List[APIAsyncFile]] + async_files: Missing[list[APIAsyncFile]] encrypted: bool @classmethod @@ -24,12 +24,12 @@ def from_domain( ref: MissingOptional[UUID], smartapp_id: UUID, chat_id: UUID, - data: Dict[str, Any], - opts: Missing[Dict[str, Any]], - files: Missing[List[File]], + data: dict[str, Any], + opts: Missing[dict[str, Any]], + files: Missing[list[File]], encrypted: bool, ) -> "BotXAPISmartAppEventRequestPayload": - api_async_files: Missing[List[APIAsyncFile]] = Undefined + api_async_files: Missing[list[APIAsyncFile]] = Undefined if files: api_async_files = [convert_async_file_from_domain(file) for file in files] diff --git a/pybotx/client/smartapps_api/smartapp_manifest.py b/pybotx/client/smartapps_api/smartapp_manifest.py index 2d0961a8..1efe0623 100644 --- a/pybotx/client/smartapps_api/smartapp_manifest.py +++ b/pybotx/client/smartapps_api/smartapp_manifest.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional +from typing import Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -23,14 +23,14 @@ class SmartappManifestAuroraParams(VerifiedPayloadBaseModel): class SmartappManifestWebParams(VerifiedPayloadBaseModel): default_layout: WebLayoutChoices = WebLayoutChoices.minimal expanded_layout: WebLayoutChoices = WebLayoutChoices.half - allowed_layouts: Optional[list[WebLayoutChoices]] = None + allowed_layouts: list[WebLayoutChoices] | None = None always_pinned: bool = False class SmartappManifestUnreadCounterParams(VerifiedPayloadBaseModel): - user_huid: List[UUID] = Field(default_factory=list) - group_chat_id: List[UUID] = Field(default_factory=list) - app_id: List[str] = Field(default_factory=list) + user_huid: list[UUID] = Field(default_factory=list) + group_chat_id: list[UUID] = Field(default_factory=list) + app_id: list[str] = Field(default_factory=list) class SmartappManifest(VerifiedPayloadBaseModel): diff --git a/pybotx/client/smartapps_api/smartapp_notification.py b/pybotx/client/smartapps_api/smartapp_notification.py index d0ae2d04..005df2ba 100644 --- a/pybotx/client/smartapps_api/smartapp_notification.py +++ b/pybotx/client/smartapps_api/smartapp_notification.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Literal +from typing import Any, Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -11,8 +11,8 @@ class BotXAPISmartAppNotificationRequestPayload(UnverifiedPayloadBaseModel): group_chat_id: UUID smartapp_counter: int body: Missing[str] - opts: Missing[Dict[str, Any]] - meta: Missing[Dict[str, Any]] + opts: Missing[dict[str, Any]] + meta: Missing[dict[str, Any]] smartapp_api_version: int @classmethod @@ -21,8 +21,8 @@ def from_domain( chat_id: UUID, smartapp_counter: int, body: Missing[str], - opts: Missing[Dict[str, Any]], - meta: Missing[Dict[str, Any]], + opts: Missing[dict[str, Any]], + meta: Missing[dict[str, Any]], ) -> "BotXAPISmartAppNotificationRequestPayload": return cls( group_chat_id=chat_id, diff --git a/pybotx/client/smartapps_api/smartapp_unread_counter.py b/pybotx/client/smartapps_api/smartapp_unread_counter.py index 0874bf9d..103e9a8c 100644 --- a/pybotx/client/smartapps_api/smartapp_unread_counter.py +++ b/pybotx/client/smartapps_api/smartapp_unread_counter.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional +from typing import Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -42,7 +42,7 @@ async def execute( self, payload: BotXAPISmartAppUnreadCounterRequestPayload, wait_callback: bool, - callback_timeout: Optional[float], + callback_timeout: float | None, default_callback_timeout: float, ) -> BotXAPISmartAppUnreadCounterResponsePayload: path = "/api/v4/botx/smartapps/unread_counter" diff --git a/pybotx/client/smartapps_api/smartapps_list.py b/pybotx/client/smartapps_api/smartapps_list.py index 0097f4be..8e290c55 100644 --- a/pybotx/client/smartapps_api/smartapps_list.py +++ b/pybotx/client/smartapps_api/smartapps_list.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional, Tuple +from typing import Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -23,20 +23,20 @@ class BotXAPISmartAppEntity(VerifiedPayloadBaseModel): enabled: bool id: UUID name: str - avatar: Optional[str] = None - avatar_preview: Optional[str] = None + avatar: str | None = None + avatar_preview: str | None = None class BotXAPISmartAppsListResult(VerifiedPayloadBaseModel): phonebook_version: int - smartapps: List[BotXAPISmartAppEntity] + smartapps: list[BotXAPISmartAppEntity] class BotXAPISmartAppsListResponsePayload(VerifiedPayloadBaseModel): result: BotXAPISmartAppsListResult status: Literal["ok"] - def to_domain(self) -> Tuple[List[SmartApp], int]: + def to_domain(self) -> tuple[list[SmartApp], int]: smartapps_list = [ SmartApp( app_id=smartapp.app_id, diff --git a/pybotx/client/stickers_api/edit_sticker_pack.py b/pybotx/client/stickers_api/edit_sticker_pack.py index f3174f94..b951fd2a 100644 --- a/pybotx/client/stickers_api/edit_sticker_pack.py +++ b/pybotx/client/stickers_api/edit_sticker_pack.py @@ -1,4 +1,3 @@ -from typing import List, Optional from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -12,7 +11,7 @@ class BotXAPIEditStickerPackRequestPayload(UnverifiedPayloadBaseModel): sticker_pack_id: UUID name: str preview: UUID - stickers_order: Optional[List[UUID]] + stickers_order: list[UUID] | None @classmethod def from_domain( @@ -20,7 +19,7 @@ def from_domain( sticker_pack_id: UUID, name: str, preview: UUID, - stickers_order: Optional[List[UUID]], + stickers_order: list[UUID] | None, ) -> "BotXAPIEditStickerPackRequestPayload": return cls( sticker_pack_id=sticker_pack_id, diff --git a/pybotx/client/stickers_api/get_sticker_packs.py b/pybotx/client/stickers_api/get_sticker_packs.py index e56b0a16..1ff2f7da 100644 --- a/pybotx/client/stickers_api/get_sticker_packs.py +++ b/pybotx/client/stickers_api/get_sticker_packs.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional +from typing import Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -10,20 +10,20 @@ class BotXAPIGetStickerPacksRequestPayload(UnverifiedPayloadBaseModel): user_huid: UUID limit: int - after: Optional[str] + after: str | None @classmethod def from_domain( cls, huid: UUID, limit: int, - after: Optional[str], + after: str | None, ) -> "BotXAPIGetStickerPacksRequestPayload": return cls(user_huid=huid, limit=limit, after=after if after else Undefined) class BotXAPIGetPaginationResult(VerifiedPayloadBaseModel): - after: Optional[str] + after: str | None class BotXAPIGetStickerPackResult(VerifiedPayloadBaseModel): @@ -31,11 +31,11 @@ class BotXAPIGetStickerPackResult(VerifiedPayloadBaseModel): name: str public: bool stickers_count: int - stickers_order: Optional[List[UUID]] + stickers_order: list[UUID] | None class BotXAPIGetStickerPacksResult(VerifiedPayloadBaseModel): - packs: List[BotXAPIGetStickerPackResult] + packs: list[BotXAPIGetStickerPackResult] pagination: BotXAPIGetPaginationResult diff --git a/pybotx/client/stickers_api/sticker_pack.py b/pybotx/client/stickers_api/sticker_pack.py index df874f1e..770a28d7 100644 --- a/pybotx/client/stickers_api/sticker_pack.py +++ b/pybotx/client/stickers_api/sticker_pack.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional +from typing import Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -15,8 +15,8 @@ class BotXAPIGetStickerPackResult(VerifiedPayloadBaseModel): id: UUID name: str public: bool - stickers_order: Optional[List[UUID]] - stickers: List[BotXAPIGetStickerResult] + stickers_order: list[UUID] | None + stickers: list[BotXAPIGetStickerResult] class BotXAPIGetStickerPackResponsePayload(VerifiedPayloadBaseModel): diff --git a/pybotx/client/users_api/search_user_by_email.py b/pybotx/client/users_api/search_user_by_email.py index 35385689..ad11a589 100644 --- a/pybotx/client/users_api/search_user_by_email.py +++ b/pybotx/client/users_api/search_user_by_email.py @@ -1,7 +1,14 @@ +import warnings + from pybotx.client.authorized_botx_method import AuthorizedBotXMethod from pybotx.client.botx_method import response_exception_thrower +from pybotx.client.exceptions.http import InvalidBotXResponsePayloadError from pybotx.client.exceptions.users import UserNotFoundError -from pybotx.client.users_api.user_from_search import BotXAPISearchUserResponsePayload +from pybotx.client.users_api.user_from_search import ( + BotXAPISearchUserByEmailsResponsePayload, + BotXAPISearchUserResponsePayload, +) +from pybotx.logger import logger from pybotx.models.api_base import UnverifiedPayloadBaseModel @@ -18,11 +25,21 @@ class SearchUserByEmailMethod(AuthorizedBotXMethod): **AuthorizedBotXMethod.status_handlers, 404: response_exception_thrower(UserNotFoundError), } + _legacy_get_warned: bool = False async def execute( self, payload: BotXAPISearchUserByEmailRequestPayload, ) -> BotXAPISearchUserResponsePayload: + if not type(self)._legacy_get_warned: + warnings.warn( + "GET /api/v3/botx/users/by_email is deprecated; " + "use POST /api/v3/botx/users/by_email instead", + DeprecationWarning, + stacklevel=2, + ) + type(self)._legacy_get_warned = True + path = "/api/v3/botx/users/by_email" response = await self._botx_method_call( @@ -35,3 +52,46 @@ async def execute( BotXAPISearchUserResponsePayload, response, ) + + +class SearchUserByEmailPostMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(UserNotFoundError), + } + + async def execute( + self, + payload: BotXAPISearchUserByEmailRequestPayload, + ) -> BotXAPISearchUserResponsePayload: + path = "/api/v3/botx/users/by_email" + + email = payload.email + request_json = {"emails": [email]} + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=request_json, + ) + + try: + list_payload = self._verify_and_extract_api_model( + BotXAPISearchUserByEmailsResponsePayload, + response, + ) + except InvalidBotXResponsePayloadError as exc: + raise exc + + if not list_payload.result: + raise UserNotFoundError("User not found") + + if len(list_payload.result) > 1: + logger.warning( + "Search by email returned multiple users; taking the first result" + ) + + return BotXAPISearchUserResponsePayload( + status="ok", + result=list_payload.result[0], + ) diff --git a/pybotx/client/users_api/search_user_by_emails.py b/pybotx/client/users_api/search_user_by_emails.py index e85dd2cf..8981c355 100644 --- a/pybotx/client/users_api/search_user_by_emails.py +++ b/pybotx/client/users_api/search_user_by_emails.py @@ -1,4 +1,3 @@ -from typing import List from pybotx.client.authorized_botx_method import AuthorizedBotXMethod from pybotx.client.users_api.user_from_search import ( @@ -8,12 +7,12 @@ class BotXAPISearchUserByEmailsRequestPayload(UnverifiedPayloadBaseModel): - emails: List[str] + emails: list[str] @classmethod def from_domain( cls, - emails: List[str], + emails: list[str], ) -> "BotXAPISearchUserByEmailsRequestPayload": return cls(emails=emails) diff --git a/pybotx/client/users_api/update_user_profile.py b/pybotx/client/users_api/update_user_profile.py index e43b2989..5f638f57 100644 --- a/pybotx/client/users_api/update_user_profile.py +++ b/pybotx/client/users_api/update_user_profile.py @@ -1,4 +1,4 @@ -from typing import Literal, Union +from typing import Literal from uuid import UUID from pybotx.client.authorized_botx_method import AuthorizedBotXMethod @@ -29,7 +29,7 @@ class BotXAPIUpdateUserProfileRequestPayload(UnverifiedPayloadBaseModel): def from_domain( cls, user_huid: UUID, - avatar: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined, + avatar: Missing[IncomingFileAttachment | OutgoingAttachment] = Undefined, name: Missing[str] = Undefined, public_name: Missing[str] = Undefined, company: Missing[str] = Undefined, diff --git a/pybotx/client/users_api/user_from_csv.py b/pybotx/client/users_api/user_from_csv.py index d06f12f2..275b9645 100644 --- a/pybotx/client/users_api/user_from_csv.py +++ b/pybotx/client/users_api/user_from_csv.py @@ -1,4 +1,3 @@ -from typing import Optional, Union from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -16,25 +15,25 @@ class BotXAPIUserFromCSVResult(VerifiedPayloadBaseModel): huid: UUID = Field(alias="HUID") ad_login: str = Field(alias="AD Login") ad_domain: str = Field(alias="Domain") - email: Optional[str] = Field(alias="AD E-mail") + email: str | None = Field(alias="AD E-mail") name: str = Field(alias="Name") - sync_source: Union[APISyncSourceTypes, str] = Field(alias="Sync source") + sync_source: APISyncSourceTypes | str = Field(alias="Sync source") active: bool = Field(alias="Active") user_kind: APIUserKinds = Field(alias="Kind") - company: Optional[str] = Field(alias="Company") - department: Optional[str] = Field(alias="Department") - position: Optional[str] = Field(alias="Position") - avatar: Optional[str] = Field(alias="Avatar") - avatar_preview: Optional[str] = Field(alias="Avatar preview") - office: Optional[str] = Field(alias="Office") - manager: Optional[str] = Field(alias="Manager") - manager_huid: Optional[UUID] = Field(alias="Manager HUID") - description: Optional[str] = Field(alias="Description") - phone: Optional[str] = Field(alias="Phone") - other_phone: Optional[str] = Field(alias="Other phone") - ip_phone: Optional[str] = Field(alias="IP phone") - other_ip_phone: Optional[str] = Field(alias="Other IP phone") - personnel_number: Optional[str] = Field(alias="Personnel number") + company: str | None = Field(alias="Company") + department: str | None = Field(alias="Department") + position: str | None = Field(alias="Position") + avatar: str | None = Field(alias="Avatar") + avatar_preview: str | None = Field(alias="Avatar preview") + office: str | None = Field(alias="Office") + manager: str | None = Field(alias="Manager") + manager_huid: UUID | None = Field(alias="Manager HUID") + description: str | None = Field(alias="Description") + phone: str | None = Field(alias="Phone") + other_phone: str | None = Field(alias="Other phone") + ip_phone: str | None = Field(alias="IP phone") + other_ip_phone: str | None = Field(alias="Other IP phone") + personnel_number: str | None = Field(alias="Personnel number") @field_validator( "email", @@ -55,7 +54,7 @@ class BotXAPIUserFromCSVResult(VerifiedPayloadBaseModel): mode="before", ) @classmethod - def replace_empty_string_with_none(cls, field_value: str) -> Optional[str]: + def replace_empty_string_with_none(cls, field_value: str) -> str | None: if field_value == "": return None diff --git a/pybotx/client/users_api/user_from_search.py b/pybotx/client/users_api/user_from_search.py index f74f2504..92d257c5 100644 --- a/pybotx/client/users_api/user_from_search.py +++ b/pybotx/client/users_api/user_from_search.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Literal, Optional +from typing import Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -10,27 +10,27 @@ class BotXAPISearchUserResult(VerifiedPayloadBaseModel): user_huid: UUID - ad_login: Optional[str] = None - ad_domain: Optional[str] = None + ad_login: str | None = None + ad_domain: str | None = None name: str - company: Optional[str] = None - company_position: Optional[str] = None - department: Optional[str] = None - emails: List[str] = Field(default_factory=list) - other_id: Optional[str] = None + company: str | None = None + company_position: str | None = None + department: str | None = None + emails: list[str] = Field(default_factory=list) + other_id: str | None = None user_kind: APIUserKinds - active: Optional[bool] = None - description: Optional[str] = None - ip_phone: Optional[str] = None - manager: Optional[str] = None - office: Optional[str] = None - other_ip_phone: Optional[str] = None - other_phone: Optional[str] = None - public_name: Optional[str] = None - cts_id: Optional[UUID] = None - rts_id: Optional[UUID] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + active: bool | None = None + description: str | None = None + ip_phone: str | None = None + manager: str | None = None + office: str | None = None + other_ip_phone: str | None = None + other_phone: str | None = None + public_name: str | None = None + cts_id: UUID | None = None + rts_id: UUID | None = None + created_at: datetime | None = None + updated_at: datetime | None = None class BotXAPISearchUserResponsePayload(VerifiedPayloadBaseModel): @@ -66,9 +66,9 @@ def to_domain(self) -> UserFromSearch: class BotXAPISearchUserByEmailsResponsePayload(VerifiedPayloadBaseModel): status: Literal["ok"] - result: List[BotXAPISearchUserResult] + result: list[BotXAPISearchUserResult] - def to_domain(self) -> List[UserFromSearch]: + def to_domain(self) -> list[UserFromSearch]: return [ UserFromSearch( huid=user.user_huid, diff --git a/pybotx/constants.py b/pybotx/constants.py index 6e95c04a..6f499cf0 100644 --- a/pybotx/constants.py +++ b/pybotx/constants.py @@ -1,7 +1,4 @@ -try: - from typing import Final -except ImportError: - from typing_extensions import Final # type: ignore +from typing import Final CHUNK_SIZE: Final = 1024 * 1024 # 1Mb BOT_API_VERSION: Final = 4 diff --git a/pybotx/converters.py b/pybotx/converters.py index 3a9147a9..60799b01 100644 --- a/pybotx/converters.py +++ b/pybotx/converters.py @@ -1,9 +1,10 @@ -from typing import List, Optional, Sequence, TypeVar +from typing import TypeVar +from collections.abc import Sequence TItem = TypeVar("TItem") def optional_sequence_to_list( - optional_sequence: Optional[Sequence[TItem]], -) -> List[TItem]: + optional_sequence: Sequence[TItem] | None, +) -> list[TItem]: return list(optional_sequence or []) diff --git a/pybotx/logger.py b/pybotx/logger.py index 3a12f95f..ba7b419a 100644 --- a/pybotx/logger.py +++ b/pybotx/logger.py @@ -1,6 +1,6 @@ import json from copy import deepcopy -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any from loguru import logger as _logger @@ -27,7 +27,7 @@ def trim_file_data_in_outgoing_json(json_body: Any) -> Any: return json_body -def trim_file_data_in_incoming_json(json_body: Dict[str, Any]) -> Dict[str, Any]: +def trim_file_data_in_incoming_json(json_body: dict[str, Any]) -> dict[str, Any]: if json_body.get("attachments"): # Max one attach per-message # Link and Location doesn't have content @@ -41,7 +41,7 @@ def trim_file_data_in_incoming_json(json_body: Dict[str, Any]) -> Dict[str, Any] return json_body -def log_incoming_request(request: Dict[str, Any], *, message: str = "") -> None: +def log_incoming_request(request: dict[str, Any], *, message: str = "") -> None: logger.opt(lazy=True).debug( message + "{command}", command=lambda: pformat_jsonable_obj( diff --git a/pybotx/missing.py b/pybotx/missing.py index 6b8c5970..f7b0f2f6 100644 --- a/pybotx/missing.py +++ b/pybotx/missing.py @@ -1,10 +1,10 @@ -from typing import Any, List, Literal, TypeVar, Union +from typing import Any, Literal, TypeAlias, TypeVar class _UndefinedType: """For fields that can be skipped.""" - _instances: List["_UndefinedType"] = [] + _instances: list["_UndefinedType"] = [] def __new__(cls, *args: Any) -> "_UndefinedType": if not cls._instances: @@ -21,5 +21,5 @@ def __repr__(self) -> str: RequiredType = TypeVar("RequiredType") Undefined = _UndefinedType() -Missing = Union[RequiredType, _UndefinedType] -MissingOptional = Union[RequiredType, None, _UndefinedType] +Missing: TypeAlias = RequiredType | _UndefinedType +MissingOptional: TypeAlias = RequiredType | None | _UndefinedType diff --git a/pybotx/models/api_base.py b/pybotx/models/api_base.py index a4e0fbe9..8214c44f 100644 --- a/pybotx/models/api_base.py +++ b/pybotx/models/api_base.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, List, Optional, Set, Union, cast +from typing import Any, cast from pybotx.missing import Undefined from pydantic import BaseModel, ConfigDict @@ -7,8 +7,8 @@ def _remove_undefined( - origin_obj: Union[Dict[str, Any], List[Any]], -) -> Union[Dict[str, Any], List[Any]]: + origin_obj: dict[str, Any] | list[Any], +) -> dict[str, Any] | list[Any]: if isinstance(origin_obj, dict): new_dict = {} @@ -49,9 +49,9 @@ def json(self) -> str: # type: ignore[override] clean_dict = _remove_undefined(self.model_dump()) return json.dumps(clean_dict, default=to_jsonable_python, ensure_ascii=False) - def jsonable_dict(self) -> Dict[str, Any]: + def jsonable_dict(self) -> dict[str, Any]: return cast( - Dict[str, Any], + dict[str, Any], json.loads(self.json()), ) @@ -63,7 +63,7 @@ class VerifiedPayloadBaseModel(PayloadBaseModel): class UnverifiedPayloadBaseModel(PayloadBaseModel): def __init__( self, - _fields_set: Optional[Set[str]] = None, + _fields_set: set[str] | None = None, **kwargs: Any, ) -> None: model = self.__class__.model_construct(_fields_set=_fields_set, **kwargs) diff --git a/pybotx/models/async_files.py b/pybotx/models/async_files.py index edb9fc5e..922e7c42 100644 --- a/pybotx/models/async_files.py +++ b/pybotx/models/async_files.py @@ -1,12 +1,14 @@ from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import AsyncGenerator, Literal, Union, cast +from typing import Literal, cast +from collections.abc import AsyncGenerator from uuid import UUID from aiofiles.tempfile import SpooledTemporaryFile from pybotx.bot.contextvars import bot_id_var, bot_var, chat_id_var from pybotx.constants import CHUNK_SIZE +from pybotx.missing import MissingOptional, Undefined from pybotx.models.api_base import VerifiedPayloadBaseModel from pydantic import ConfigDict from pybotx.models.enums import ( @@ -17,7 +19,7 @@ ) -@dataclass +@dataclass(slots=True) class AsyncFileBase: type: AttachmentTypes filename: str @@ -29,9 +31,27 @@ class AsyncFileBase: _file_url: str _file_mimetype: str _file_hash: str + file_preview: str | None = None + file_preview_height: int | None = None + file_preview_width: int | None = None + file_encryption_algo: str | None = None + chunk_size: int | None = None + caption: str | None = None + + @property + def file_url(self) -> str: + return self._file_url + + @property + def file_mimetype(self) -> str: + return self._file_mimetype + + @property + def file_hash(self) -> str: + return self._file_hash @asynccontextmanager - async def open(self) -> AsyncGenerator[SpooledTemporaryFile, None]: + async def open(self, *, is_preview: bool = False) -> AsyncGenerator[SpooledTemporaryFile, None]: bot = bot_var.get() async with SpooledTemporaryFile(max_size=CHUNK_SIZE) as tmp_file: @@ -40,31 +60,32 @@ async def open(self) -> AsyncGenerator[SpooledTemporaryFile, None]: chat_id=chat_id_var.get(), file_id=self._file_id, async_buffer=tmp_file, + is_preview=is_preview, ) yield tmp_file -@dataclass +@dataclass(slots=True) class Image(AsyncFileBase): type: Literal[AttachmentTypes.IMAGE] -@dataclass +@dataclass(slots=True) class Video(AsyncFileBase): type: Literal[AttachmentTypes.VIDEO] - duration: int + duration: int = 0 -@dataclass +@dataclass(slots=True) class Document(AsyncFileBase): type: Literal[AttachmentTypes.DOCUMENT] -@dataclass +@dataclass(slots=True) class Voice(AsyncFileBase): type: Literal[AttachmentTypes.VOICE] - duration: int + duration: int = 0 class APIAsyncFileBase(VerifiedPayloadBaseModel): @@ -75,8 +96,14 @@ class APIAsyncFileBase(VerifiedPayloadBaseModel): file_name: str file_size: int file_hash: str + file_preview: MissingOptional[str] = Undefined + file_preview_height: MissingOptional[int] = Undefined + file_preview_width: MissingOptional[int] = Undefined + file_encryption_algo: MissingOptional[str] = Undefined + chunk_size: MissingOptional[int] = Undefined + caption: MissingOptional[str] = Undefined - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) class ApiAsyncFileImage(APIAsyncFileBase): @@ -97,14 +124,24 @@ class ApiAsyncFileVoice(APIAsyncFileBase): duration: int -APIAsyncFile = Union[ - ApiAsyncFileImage, - ApiAsyncFileVideo, - ApiAsyncFileDocument, - ApiAsyncFileVoice, -] +APIAsyncFile = ( + ApiAsyncFileImage + | ApiAsyncFileVideo + | ApiAsyncFileDocument + | ApiAsyncFileVoice +) + +File = Image | Video | Document | Voice + + +def _to_optional(value: MissingOptional[str | int]) -> str | int | None: + if value is Undefined: + return None + return cast(str | int | None, value) + -File = Union[Image, Video, Document, Voice] +def _to_missing(value: str | int | None) -> MissingOptional[str | int]: + return Undefined if value is None else value def convert_async_file_from_domain(file: File) -> APIAsyncFile: @@ -121,6 +158,12 @@ def convert_async_file_from_domain(file: File) -> APIAsyncFile: file=file._file_url, file_mime_type=file._file_mimetype, file_hash=file._file_hash, + file_preview=_to_missing(file.file_preview), + file_preview_height=_to_missing(file.file_preview_height), + file_preview_width=_to_missing(file.file_preview_width), + file_encryption_algo=_to_missing(file.file_encryption_algo), + chunk_size=_to_missing(file.chunk_size), + caption=_to_missing(file.caption), ) if attachment_type == APIAttachmentTypes.VIDEO: @@ -135,6 +178,12 @@ def convert_async_file_from_domain(file: File) -> APIAsyncFile: file=file._file_url, file_mime_type=file._file_mimetype, file_hash=file._file_hash, + file_preview=_to_missing(file.file_preview), + file_preview_height=_to_missing(file.file_preview_height), + file_preview_width=_to_missing(file.file_preview_width), + file_encryption_algo=_to_missing(file.file_encryption_algo), + chunk_size=_to_missing(file.chunk_size), + caption=_to_missing(file.caption), ) if attachment_type == APIAttachmentTypes.DOCUMENT: @@ -148,6 +197,12 @@ def convert_async_file_from_domain(file: File) -> APIAsyncFile: file=file._file_url, file_mime_type=file._file_mimetype, file_hash=file._file_hash, + file_preview=_to_missing(file.file_preview), + file_preview_height=_to_missing(file.file_preview_height), + file_preview_width=_to_missing(file.file_preview_width), + file_encryption_algo=_to_missing(file.file_encryption_algo), + chunk_size=_to_missing(file.chunk_size), + caption=_to_missing(file.caption), ) if attachment_type == APIAttachmentTypes.VOICE: @@ -162,6 +217,12 @@ def convert_async_file_from_domain(file: File) -> APIAsyncFile: file=file._file_url, file_mime_type=file._file_mimetype, file_hash=file._file_hash, + file_preview=_to_missing(file.file_preview), + file_preview_height=_to_missing(file.file_preview_height), + file_preview_width=_to_missing(file.file_preview_width), + file_encryption_algo=_to_missing(file.file_encryption_algo), + chunk_size=_to_missing(file.chunk_size), + caption=_to_missing(file.caption), ) raise NotImplementedError(f"Unsupported attachment type: {attachment_type}") @@ -182,6 +243,21 @@ def convert_async_file_to_domain(async_file: APIAsyncFile) -> File: _file_mimetype=async_file.file_mime_type, _file_url=async_file.file, _file_hash=async_file.file_hash, + file_preview=cast(str | None, _to_optional(async_file.file_preview)), + file_preview_height=cast( + int | None, + _to_optional(async_file.file_preview_height), + ), + file_preview_width=cast( + int | None, + _to_optional(async_file.file_preview_width), + ), + file_encryption_algo=cast( + str | None, + _to_optional(async_file.file_encryption_algo), + ), + chunk_size=cast(int | None, _to_optional(async_file.chunk_size)), + caption=cast(str | None, _to_optional(async_file.caption)), ) if attachment_type == AttachmentTypes.VIDEO: @@ -197,6 +273,21 @@ def convert_async_file_to_domain(async_file: APIAsyncFile) -> File: _file_mimetype=async_file.file_mime_type, _file_url=async_file.file, _file_hash=async_file.file_hash, + file_preview=cast(str | None, _to_optional(async_file.file_preview)), + file_preview_height=cast( + int | None, + _to_optional(async_file.file_preview_height), + ), + file_preview_width=cast( + int | None, + _to_optional(async_file.file_preview_width), + ), + file_encryption_algo=cast( + str | None, + _to_optional(async_file.file_encryption_algo), + ), + chunk_size=cast(int | None, _to_optional(async_file.chunk_size)), + caption=cast(str | None, _to_optional(async_file.caption)), ) if attachment_type == AttachmentTypes.DOCUMENT: @@ -211,6 +302,21 @@ def convert_async_file_to_domain(async_file: APIAsyncFile) -> File: _file_mimetype=async_file.file_mime_type, _file_url=async_file.file, _file_hash=async_file.file_hash, + file_preview=cast(str | None, _to_optional(async_file.file_preview)), + file_preview_height=cast( + int | None, + _to_optional(async_file.file_preview_height), + ), + file_preview_width=cast( + int | None, + _to_optional(async_file.file_preview_width), + ), + file_encryption_algo=cast( + str | None, + _to_optional(async_file.file_encryption_algo), + ), + chunk_size=cast(int | None, _to_optional(async_file.chunk_size)), + caption=cast(str | None, _to_optional(async_file.caption)), ) if attachment_type == AttachmentTypes.VOICE: @@ -226,6 +332,21 @@ def convert_async_file_to_domain(async_file: APIAsyncFile) -> File: _file_mimetype=async_file.file_mime_type, _file_url=async_file.file, _file_hash=async_file.file_hash, + file_preview=cast(str | None, _to_optional(async_file.file_preview)), + file_preview_height=cast( + int | None, + _to_optional(async_file.file_preview_height), + ), + file_preview_width=cast( + int | None, + _to_optional(async_file.file_preview_width), + ), + file_encryption_algo=cast( + str | None, + _to_optional(async_file.file_encryption_algo), + ), + chunk_size=cast(int | None, _to_optional(async_file.chunk_size)), + caption=cast(str | None, _to_optional(async_file.caption)), ) raise NotImplementedError(f"Unsupported attachment type: {attachment_type}") diff --git a/pybotx/models/attachments.py b/pybotx/models/attachments.py index 2f7d6365..9882eca3 100644 --- a/pybotx/models/attachments.py +++ b/pybotx/models/attachments.py @@ -2,7 +2,8 @@ from contextlib import asynccontextmanager from dataclasses import dataclass from types import MappingProxyType -from typing import AsyncGenerator, Literal, Union, cast +from typing import Literal, TypeGuard +from collections.abc import AsyncGenerator from uuid import UUID from aiofiles.tempfile import SpooledTemporaryFile @@ -10,15 +11,11 @@ from pybotx.async_buffer import AsyncBufferReadable from pybotx.constants import CHUNK_SIZE from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel -from pybotx.models.enums import ( - APIAttachmentTypes, - AttachmentTypes, - convert_attachment_type_to_domain, -) +from pybotx.models.enums import APIAttachmentTypes, AttachmentTypes from pybotx.models.stickers import Sticker -@dataclass +@dataclass(slots=True) class FileAttachmentBase: type: AttachmentTypes filename: str @@ -37,31 +34,31 @@ async def open(self) -> AsyncGenerator[SpooledTemporaryFile, None]: yield tmp_file -@dataclass +@dataclass(slots=True) class AttachmentImage(FileAttachmentBase): type: Literal[AttachmentTypes.IMAGE] -@dataclass +@dataclass(slots=True) class AttachmentVideo(FileAttachmentBase): type: Literal[AttachmentTypes.VIDEO] duration: int -@dataclass +@dataclass(slots=True) class AttachmentDocument(FileAttachmentBase): type: Literal[AttachmentTypes.DOCUMENT] -@dataclass +@dataclass(slots=True) class AttachmentVoice(FileAttachmentBase): type: Literal[AttachmentTypes.VOICE] duration: int -@dataclass +@dataclass(slots=True) class Location: name: str address: str @@ -69,12 +66,12 @@ class Location: longitude: str -@dataclass +@dataclass(slots=True) class Contact: name: str -@dataclass +@dataclass(slots=True) class Link: url: str title: str @@ -82,15 +79,15 @@ class Link: text: str -IncomingFileAttachment = Union[ - AttachmentImage, - AttachmentVideo, - AttachmentDocument, - AttachmentVoice, -] +IncomingFileAttachment = ( + AttachmentImage + | AttachmentVideo + | AttachmentDocument + | AttachmentVoice +) -@dataclass +@dataclass(slots=True) class OutgoingAttachment: content: bytes filename: str @@ -152,8 +149,8 @@ class BotAPIAttachmentVoice(VerifiedPayloadBaseModel): class BotAPIAttachmentLocationData(VerifiedPayloadBaseModel): location_name: str location_address: str - location_lat: Union[str, float] - location_lng: Union[str, float] + location_lat: str | float + location_lng: str | float class BotAPIAttachmentLocation(VerifiedPayloadBaseModel): @@ -193,147 +190,189 @@ class BotAPIAttachmentLink(VerifiedPayloadBaseModel): data: BotAPIAttachmentLinkData -BotAPIAttachment = Union[ - BotAPIAttachmentVideo, - BotAPIAttachmentImage, - BotAPIAttachmentDocument, - BotAPIAttachmentVoice, - BotAPIAttachmentLocation, - BotAPIAttachmentContact, - BotAPIAttachmentLink, - BotAPIAttachmentSticker, -] +BotAPIAttachment = ( + BotAPIAttachmentVideo + | BotAPIAttachmentImage + | BotAPIAttachmentDocument + | BotAPIAttachmentVoice + | BotAPIAttachmentLocation + | BotAPIAttachmentContact + | BotAPIAttachmentLink + | BotAPIAttachmentSticker +) -IncomingAttachment = Union[ - IncomingFileAttachment, - Location, - Contact, - Link, - Sticker, -] +IncomingAttachment = ( + IncomingFileAttachment + | Location + | Contact + | Link + | Sticker +) -def convert_api_attachment_to_domain( - api_attachment: BotAPIAttachment, - message_body: str, -) -> IncomingAttachment: - attachment_type = convert_attachment_type_to_domain(api_attachment.type) +def _is_api_image_attachment( + attachment: BotAPIAttachment, +) -> TypeGuard[BotAPIAttachmentImage]: + return attachment.type == APIAttachmentTypes.IMAGE - if attachment_type == AttachmentTypes.IMAGE: - attachment_type = cast( # type: ignore[redundant-cast] - Literal[AttachmentTypes.IMAGE], - attachment_type, - ) - api_attachment = cast(BotAPIAttachmentImage, api_attachment) - content = decode_rfc2397(api_attachment.data.content) - - return AttachmentImage( - type=attachment_type, - filename=api_attachment.data.file_name, - size=len(content), - is_async_file=False, - content=content, - ) - if attachment_type == AttachmentTypes.VIDEO: - attachment_type = cast( # type: ignore[redundant-cast] - Literal[AttachmentTypes.VIDEO], - attachment_type, - ) - api_attachment = cast(BotAPIAttachmentVideo, api_attachment) - content = decode_rfc2397(api_attachment.data.content) - - return AttachmentVideo( - type=attachment_type, - filename=api_attachment.data.file_name, - size=len(content), - is_async_file=False, - content=content, - duration=api_attachment.data.duration, - ) +def _is_api_video_attachment( + attachment: BotAPIAttachment, +) -> TypeGuard[BotAPIAttachmentVideo]: + return attachment.type == APIAttachmentTypes.VIDEO - if attachment_type == AttachmentTypes.DOCUMENT: - attachment_type = cast( # type: ignore[redundant-cast] - Literal[AttachmentTypes.DOCUMENT], - attachment_type, - ) - api_attachment = cast(BotAPIAttachmentDocument, api_attachment) - content = decode_rfc2397(api_attachment.data.content) - - return AttachmentDocument( - type=attachment_type, - filename=api_attachment.data.file_name, - size=len(content), - is_async_file=False, - content=content, - ) - if attachment_type == AttachmentTypes.VOICE: - attachment_type = cast( # type: ignore[redundant-cast] - Literal[AttachmentTypes.VOICE], - attachment_type, - ) - api_attachment = cast(BotAPIAttachmentVoice, api_attachment) - content = decode_rfc2397(api_attachment.data.content) - attachment_extension = get_attachment_extension_from_encoded_content( - api_attachment.data.content, - ) +def _is_api_document_attachment( + attachment: BotAPIAttachment, +) -> TypeGuard[BotAPIAttachmentDocument]: + return attachment.type == APIAttachmentTypes.DOCUMENT - return AttachmentVoice( - type=attachment_type, - filename=f"record.{attachment_extension}", - size=len(content), - is_async_file=False, - content=content, - duration=api_attachment.data.duration, - ) - if attachment_type == AttachmentTypes.LOCATION: - attachment_type = cast( # type: ignore[redundant-cast] - Literal[AttachmentTypes.LOCATION], - attachment_type, - ) - api_attachment = cast(BotAPIAttachmentLocation, api_attachment) +def _is_api_voice_attachment( + attachment: BotAPIAttachment, +) -> TypeGuard[BotAPIAttachmentVoice]: + return attachment.type == APIAttachmentTypes.VOICE - return Location( - name=api_attachment.data.location_name, - address=api_attachment.data.location_address, - latitude=str(api_attachment.data.location_lat), - longitude=str(api_attachment.data.location_lng), - ) - if attachment_type == AttachmentTypes.CONTACT: - attachment_type = cast( # type: ignore[redundant-cast] - Literal[AttachmentTypes.CONTACT], - attachment_type, - ) - api_attachment = cast(BotAPIAttachmentContact, api_attachment) +def _is_api_location_attachment( + attachment: BotAPIAttachment, +) -> TypeGuard[BotAPIAttachmentLocation]: + return attachment.type == APIAttachmentTypes.LOCATION - return Contact( - name=api_attachment.data.contact_name, - ) - if attachment_type == AttachmentTypes.LINK: - api_attachment = cast(BotAPIAttachmentLink, api_attachment) +def _is_api_contact_attachment( + attachment: BotAPIAttachment, +) -> TypeGuard[BotAPIAttachmentContact]: + return attachment.type == APIAttachmentTypes.CONTACT - return Link( - url=api_attachment.data.url, - title=api_attachment.data.url_title, - preview=api_attachment.data.url_preview, - text=api_attachment.data.url_text, - ) - if attachment_type == AttachmentTypes.STICKER: - api_attachment = cast(BotAPIAttachmentSticker, api_attachment) +def _is_api_link_attachment( + attachment: BotAPIAttachment, +) -> TypeGuard[BotAPIAttachmentLink]: + return attachment.type == APIAttachmentTypes.LINK - return Sticker( - id=api_attachment.data.id, - image_link=api_attachment.data.link, - pack_id=api_attachment.data.pack, - emoji=message_body, - ) - raise NotImplementedError(f"Unsupported attachment type: {attachment_type}") +def _is_api_sticker_attachment( + attachment: BotAPIAttachment, +) -> TypeGuard[BotAPIAttachmentSticker]: + return attachment.type == APIAttachmentTypes.STICKER + + +def convert_api_attachment_to_domain( + api_attachment: BotAPIAttachment, + message_body: str, +) -> IncomingAttachment: + match api_attachment.type: + case APIAttachmentTypes.IMAGE: + if not _is_api_image_attachment(api_attachment): + raise NotImplementedError( + f"Unsupported attachment type: {api_attachment.type}", + ) + content = decode_rfc2397(api_attachment.data.content) + + return AttachmentImage( + type=AttachmentTypes.IMAGE, + filename=api_attachment.data.file_name, + size=len(content), + is_async_file=False, + content=content, + ) + case APIAttachmentTypes.VIDEO: + if not _is_api_video_attachment(api_attachment): + raise NotImplementedError( + f"Unsupported attachment type: {api_attachment.type}", + ) + content = decode_rfc2397(api_attachment.data.content) + + return AttachmentVideo( + type=AttachmentTypes.VIDEO, + filename=api_attachment.data.file_name, + size=len(content), + is_async_file=False, + content=content, + duration=api_attachment.data.duration, + ) + case APIAttachmentTypes.DOCUMENT: + if not _is_api_document_attachment(api_attachment): + raise NotImplementedError( + f"Unsupported attachment type: {api_attachment.type}", + ) + content = decode_rfc2397(api_attachment.data.content) + + return AttachmentDocument( + type=AttachmentTypes.DOCUMENT, + filename=api_attachment.data.file_name, + size=len(content), + is_async_file=False, + content=content, + ) + case APIAttachmentTypes.VOICE: + if not _is_api_voice_attachment(api_attachment): + raise NotImplementedError( + f"Unsupported attachment type: {api_attachment.type}", + ) + content = decode_rfc2397(api_attachment.data.content) + attachment_extension = get_attachment_extension_from_encoded_content( + api_attachment.data.content, + ) + + return AttachmentVoice( + type=AttachmentTypes.VOICE, + filename=f"record.{attachment_extension}", + size=len(content), + is_async_file=False, + content=content, + duration=api_attachment.data.duration, + ) + case APIAttachmentTypes.LOCATION: + if not _is_api_location_attachment(api_attachment): + raise NotImplementedError( + f"Unsupported attachment type: {api_attachment.type}", + ) + + return Location( + name=api_attachment.data.location_name, + address=api_attachment.data.location_address, + latitude=str(api_attachment.data.location_lat), + longitude=str(api_attachment.data.location_lng), + ) + case APIAttachmentTypes.CONTACT: + if not _is_api_contact_attachment(api_attachment): + raise NotImplementedError( + f"Unsupported attachment type: {api_attachment.type}", + ) + + return Contact( + name=api_attachment.data.contact_name, + ) + case APIAttachmentTypes.LINK: + if not _is_api_link_attachment(api_attachment): + raise NotImplementedError( + f"Unsupported attachment type: {api_attachment.type}", + ) + + return Link( + url=api_attachment.data.url, + title=api_attachment.data.url_title, + preview=api_attachment.data.url_preview, + text=api_attachment.data.url_text, + ) + case APIAttachmentTypes.STICKER: + if not _is_api_sticker_attachment(api_attachment): + raise NotImplementedError( + f"Unsupported attachment type: {api_attachment.type}", + ) + + return Sticker( + id=api_attachment.data.id, + image_link=api_attachment.data.link, + pack_id=api_attachment.data.pack, + emoji=message_body, + ) + case _: + raise NotImplementedError( + f"Unsupported attachment type: {api_attachment.type}", + ) def get_attachment_extension_from_encoded_content( @@ -469,7 +508,7 @@ class BotXAPIAttachment(UnverifiedPayloadBaseModel): @classmethod def from_file_attachment( cls, - attachment: Union[IncomingFileAttachment, OutgoingAttachment], + attachment: IncomingFileAttachment | OutgoingAttachment, ) -> "BotXAPIAttachment": assert attachment.content is not None diff --git a/pybotx/models/base_command.py b/pybotx/models/base_command.py index d710f50a..5476ad7c 100644 --- a/pybotx/models/base_command.py +++ b/pybotx/models/base_command.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Literal, Optional, Union +from typing import Any, Literal from uuid import UUID from pybotx.bot.api.exceptions import ( @@ -21,14 +21,14 @@ class BotAPICommandPayload(VerifiedPayloadBaseModel): body: str command_type: Literal[BotAPICommandTypes.USER] - data: Dict[str, Any] - metadata: Dict[str, Any] + data: dict[str, Any] + metadata: dict[str, Any] class BotAPIDeviceMeta(VerifiedPayloadBaseModel): - pushes: Optional[bool] = None - timezone: Optional[str] = None - permissions: Optional[Dict[str, Any]] = None + pushes: bool | None = None + timezone: str | None = None + permissions: dict[str, Any] | None = None class BaseBotAPIContext(VerifiedPayloadBaseModel): @@ -37,28 +37,28 @@ class BaseBotAPIContext(VerifiedPayloadBaseModel): class BotAPIUserContext(BaseBotAPIContext): user_huid: UUID - user_udid: Optional[UUID] = None - ad_domain: Optional[str] = None - ad_login: Optional[str] = None - username: Optional[str] = None - is_admin: Optional[bool] = None - is_creator: Optional[bool] = None + user_udid: UUID | None = None + ad_domain: str | None = None + ad_login: str | None = None + username: str | None = None + is_admin: bool | None = None + is_creator: bool | None = None class BotAPIChatContext(BaseBotAPIContext): group_chat_id: UUID - chat_type: Union[APIChatTypes, str] + chat_type: APIChatTypes | str class BotAPIDeviceContext(BaseBotAPIContext): - app_version: Optional[str] = None - platform: Optional[BotAPIClientPlatforms] = None - platform_package_id: Optional[str] = None - device: Optional[str] = None - device_meta: Optional[BotAPIDeviceMeta] = None - device_software: Optional[str] = None - manufacturer: Optional[str] = None - locale: Optional[str] = None + app_version: str | None = None + platform: BotAPIClientPlatforms | None = None + platform_package_id: str | None = None + device: str | None = None + device_meta: BotAPIDeviceMeta | None = None + device_software: str | None = None + manufacturer: str | None = None + locale: str | None = None class BotAPIBaseCommand(VerifiedPayloadBaseModel): @@ -87,7 +87,7 @@ def find_unknown_system_event(cls, body: str) -> str: return body -@dataclass +@dataclass(slots=True) class BotCommandBase: bot: BotAccount - raw_command: Optional[Dict[str, Any]] + raw_command: dict[str, Any] | None diff --git a/pybotx/models/bot_account.py b/pybotx/models/bot_account.py index c58f110a..66ca28de 100644 --- a/pybotx/models/bot_account.py +++ b/pybotx/models/bot_account.py @@ -1,16 +1,15 @@ from dataclasses import dataclass -from typing import Optional from urllib.parse import urlparse from uuid import UUID from pydantic import AnyHttpUrl, BaseModel, ConfigDict -@dataclass +@dataclass(slots=True) class BotAccount: id: UUID - host: Optional[str] + host: str | None class BotAccountWithSecret(BaseModel): diff --git a/pybotx/models/bot_catalog.py b/pybotx/models/bot_catalog.py index 0a13de2d..e486d24b 100644 --- a/pybotx/models/bot_catalog.py +++ b/pybotx/models/bot_catalog.py @@ -1,9 +1,8 @@ from dataclasses import dataclass -from typing import Optional from uuid import UUID -@dataclass +@dataclass(slots=True) class BotsListItem: """Bot from list of bots. @@ -18,5 +17,5 @@ class BotsListItem: id: UUID name: str description: str - avatar: Optional[str] + avatar: str | None enabled: bool diff --git a/pybotx/models/bot_sender.py b/pybotx/models/bot_sender.py index 5709c7ad..c8ba3995 100644 --- a/pybotx/models/bot_sender.py +++ b/pybotx/models/bot_sender.py @@ -1,10 +1,9 @@ from dataclasses import dataclass -from typing import Optional from uuid import UUID -@dataclass +@dataclass(slots=True) class BotSender: huid: UUID - is_chat_admin: Optional[bool] - is_chat_creator: Optional[bool] + is_chat_admin: bool | None + is_chat_creator: bool | None diff --git a/pybotx/models/chats.py b/pybotx/models/chats.py index 817879cc..92143faf 100644 --- a/pybotx/models/chats.py +++ b/pybotx/models/chats.py @@ -1,18 +1,17 @@ from dataclasses import dataclass from datetime import datetime -from typing import List, Optional from uuid import UUID -from pybotx.models.enums import ChatTypes, IncomingChatTypes, UserKinds +from pybotx.models.enums import ChatLinkTypes, ChatTypes, IncomingChatTypes, UserKinds -@dataclass +@dataclass(slots=True) class Chat: id: UUID type: IncomingChatTypes -@dataclass +@dataclass(slots=True) class ChatListItem: """Chat from list. @@ -30,14 +29,14 @@ class ChatListItem: chat_id: UUID chat_type: ChatTypes name: str - description: Optional[str] - members: List[UUID] + description: str | None + members: list[UUID] created_at: datetime updated_at: datetime shared_history: bool -@dataclass +@dataclass(slots=True) class ChatInfoMember: """Chat member. @@ -52,7 +51,7 @@ class ChatInfoMember: kind: UserKinds -@dataclass +@dataclass(slots=True) class ChatInfo: """Chat information. @@ -68,10 +67,20 @@ class ChatInfo: """ chat_type: ChatTypes - creator_id: Optional[UUID] - description: Optional[str] + creator_id: UUID | None + description: str | None chat_id: UUID created_at: datetime - members: List[ChatInfoMember] + members: list[ChatInfoMember] name: str shared_history: bool + + +@dataclass(slots=True) +class ChatLink: + """Chat invite link.""" + + url: str + link_type: ChatLinkTypes + access_code: str | None + link_ttl: int | None diff --git a/pybotx/models/commands.py b/pybotx/models/commands.py index 7eaadbe6..7fdb06ff 100644 --- a/pybotx/models/commands.py +++ b/pybotx/models/commands.py @@ -1,11 +1,10 @@ -from typing import List, Union from pybotx.models.message.incoming_message import ( BotAPIIncomingMessage, IncomingMessage, ) -__all__: List[str] = [ +__all__: list[str] = [ "BotAPIIncomingMessage", "IncomingMessage", "BotAPISystemEvent", @@ -60,41 +59,41 @@ ) # Sorted by frequency of occurrence to speedup validation -BotAPISystemEvent = Union[ - BotAPISmartAppEvent, - BotAPIInternalBotNotification, - BotAPIChatCreated, - BotAPIChatDeletedByUser, - BotAPIAddedToChat, - BotAPIDeletedFromChat, - BotAPILeftFromChat, - BotAPICTSLogin, - BotAPICTSLogout, - BotAPIEventDeleted, - BotAPIEventEdit, - BotAPIJoinToChat, - BotAPIConferenceChanged, - BotAPIConferenceCreated, - BotAPIConferenceDeleted, -] -BotAPICommand = Union[BotAPIIncomingMessage, BotAPISystemEvent] +BotAPISystemEvent = ( + BotAPISmartAppEvent + | BotAPIInternalBotNotification + | BotAPIChatCreated + | BotAPIChatDeletedByUser + | BotAPIAddedToChat + | BotAPIDeletedFromChat + | BotAPILeftFromChat + | BotAPICTSLogin + | BotAPICTSLogout + | BotAPIEventDeleted + | BotAPIEventEdit + | BotAPIJoinToChat + | BotAPIConferenceChanged + | BotAPIConferenceCreated + | BotAPIConferenceDeleted +) +BotAPICommand = BotAPIIncomingMessage | BotAPISystemEvent # Just sorted as above, no real profits -SystemEvent = Union[ - SmartAppEvent, - InternalBotNotificationEvent, - ChatCreatedEvent, - ChatDeletedByUserEvent, - AddedToChatEvent, - DeletedFromChatEvent, - LeftFromChatEvent, - CTSLoginEvent, - CTSLogoutEvent, - EventDeleted, - EventEdit, - JoinToChatEvent, - ConferenceChangedEvent, - ConferenceCreatedEvent, - ConferenceDeletedEvent, -] -BotCommand = Union[IncomingMessage, SystemEvent] +SystemEvent = ( + SmartAppEvent + | InternalBotNotificationEvent + | ChatCreatedEvent + | ChatDeletedByUserEvent + | AddedToChatEvent + | DeletedFromChatEvent + | LeftFromChatEvent + | CTSLoginEvent + | CTSLogoutEvent + | EventDeleted + | EventEdit + | JoinToChatEvent + | ConferenceChangedEvent + | ConferenceCreatedEvent + | ConferenceDeletedEvent +) +BotCommand = IncomingMessage | SystemEvent diff --git a/pybotx/models/enums.py b/pybotx/models/enums.py index aa5b04da..b0f3de99 100644 --- a/pybotx/models/enums.py +++ b/pybotx/models/enums.py @@ -1,5 +1,5 @@ from enum import Enum, auto -from typing import Literal, Optional, Union, overload +from typing import Literal, overload class AutoName(Enum): @@ -79,8 +79,8 @@ class ConferenceLinkTypes(AutoName): UNSUPPORTED = Literal["UNSUPPORTED"] -IncomingChatTypes = Union[ChatTypes, UNSUPPORTED] -IncomingSyncSourceTypes = Union[SyncSourceTypes, UNSUPPORTED] +IncomingChatTypes = ChatTypes | UNSUPPORTED +IncomingSyncSourceTypes = SyncSourceTypes | UNSUPPORTED class StrEnum(str, Enum): # (pydantic needs this inheritance) @@ -90,8 +90,16 @@ class StrEnum(str, Enum): # (pydantic needs this inheritance) # TODO: Use plain enums after migrating to Pydantic 2.0 +class ChatLinkTypes(StrEnum): + PUBLIC = "public" + TRUSTS = "trusts" + CORPORATE = "corporate" + SERVER = "server" + + class APIChatTypes(Enum): CHAT = "chat" + NOTES = "notes" GROUP_CHAT = "group_chat" CHANNEL = "channel" THREAD = "thread" @@ -318,20 +326,21 @@ def convert_chat_type_to_domain( # pragma: no cover @overload def convert_chat_type_to_domain( # pragma: no cover chat_type: str, -) -> UNSUPPORTED: ... +) -> IncomingChatTypes: ... def convert_chat_type_to_domain( - chat_type: Union[APIChatTypes, str], + chat_type: APIChatTypes | str, ) -> IncomingChatTypes: chat_types_mapping = { APIChatTypes.CHAT: ChatTypes.PERSONAL_CHAT, + APIChatTypes.NOTES: ChatTypes.PERSONAL_CHAT, APIChatTypes.GROUP_CHAT: ChatTypes.GROUP_CHAT, APIChatTypes.CHANNEL: ChatTypes.CHANNEL, APIChatTypes.THREAD: ChatTypes.THREAD, } - converted_type: Optional[IncomingChatTypes] + converted_type: IncomingChatTypes | None try: converted_type = chat_types_mapping.get(APIChatTypes(chat_type)) except ValueError: @@ -356,7 +365,7 @@ def convert_sync_source_type_to_domain( # pragma: no cover def convert_sync_source_type_to_domain( - sync_type: Union[APISyncSourceTypes, str], + sync_type: APISyncSourceTypes | str, ) -> IncomingSyncSourceTypes: sync_source_types_mapping = { APISyncSourceTypes.AD: SyncSourceTypes.AD, @@ -366,7 +375,7 @@ def convert_sync_source_type_to_domain( APISyncSourceTypes.BOTX: SyncSourceTypes.BOTX, } - converted_type: Optional[IncomingSyncSourceTypes] + converted_type: IncomingSyncSourceTypes | None try: converted_type = sync_source_types_mapping.get(APISyncSourceTypes(sync_type)) except ValueError: diff --git a/pybotx/models/message/edit_message.py b/pybotx/models/message/edit_message.py index 46615399..896dba03 100644 --- a/pybotx/models/message/edit_message.py +++ b/pybotx/models/message/edit_message.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Union +from typing import Any from uuid import UUID from pybotx.missing import Missing, Undefined @@ -7,13 +7,13 @@ from pybotx.models.message.markup import BubbleMarkup, KeyboardMarkup -@dataclass +@dataclass(slots=True) class EditMessage: bot_id: UUID sync_id: UUID body: Missing[str] = Undefined - metadata: Missing[Dict[str, Any]] = Undefined + metadata: Missing[dict[str, Any]] = Undefined bubbles: Missing[BubbleMarkup] = Undefined keyboard: Missing[KeyboardMarkup] = Undefined - file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined + file: Missing[IncomingFileAttachment | OutgoingAttachment] = Undefined markup_auto_adjust: Missing[bool] = Undefined diff --git a/pybotx/models/message/forward.py b/pybotx/models/message/forward.py index ce4a676a..b581e71d 100644 --- a/pybotx/models/message/forward.py +++ b/pybotx/models/message/forward.py @@ -7,7 +7,7 @@ from pybotx.models.enums import APIChatTypes, BotAPIEntityTypes, ChatTypes -@dataclass +@dataclass(slots=True) class Forward: chat_id: UUID author_id: UUID diff --git a/pybotx/models/message/incoming_message.py b/pybotx/models/message/incoming_message.py index 2f16604b..3e5d10f4 100644 --- a/pybotx/models/message/incoming_message.py +++ b/pybotx/models/message/incoming_message.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from types import SimpleNamespace -from typing import Any, Dict, List, Optional, Tuple, Union, cast +from typing import Any, TypeGuard, cast from uuid import UUID from pybotx.logger import logger @@ -44,33 +44,33 @@ from pydantic import Field, ValidationError, field_validator, TypeAdapter -@dataclass +@dataclass(slots=True) class UserDevice: - manufacturer: Optional[str] - device_name: Optional[str] - os: Optional[str] - pushes: Optional[bool] - timezone: Optional[str] - permissions: Optional[Dict[str, Any]] - platform: Optional[ClientPlatforms] - platform_package_id: Optional[str] - app_version: Optional[str] - locale: Optional[str] - - -@dataclass + manufacturer: str | None + device_name: str | None + os: str | None + pushes: bool | None + timezone: str | None + permissions: dict[str, Any] | None + platform: ClientPlatforms | None + platform_package_id: str | None + app_version: str | None + locale: str | None + + +@dataclass(slots=True) class UserSender: huid: UUID - udid: Optional[UUID] - ad_login: Optional[str] - ad_domain: Optional[str] - username: Optional[str] - is_chat_admin: Optional[bool] - is_chat_creator: Optional[bool] + udid: UUID | None + ad_login: str | None + ad_domain: str | None + username: str | None + is_chat_admin: bool | None + is_chat_creator: bool | None device: UserDevice @property - def upn(self) -> Optional[str]: + def upn(self) -> str | None: # https://docs.microsoft.com/en-us/windows/win32/secauthn/user-name-formats if not (self.ad_login and self.ad_domain): return None @@ -78,23 +78,23 @@ def upn(self) -> Optional[str]: return f"{self.ad_login}@{self.ad_domain}" -@dataclass +@dataclass(slots=True) class IncomingMessage(BotCommandBase): sync_id: UUID - source_sync_id: Optional[UUID] + source_sync_id: UUID | None body: str - data: Dict[str, Any] - metadata: Dict[str, Any] + data: dict[str, Any] + metadata: dict[str, Any] sender: UserSender chat: Chat mentions: MentionList = field(default_factory=MentionList) - forward: Optional[Forward] = None - reply: Optional[Reply] = None - file: Optional[IncomingFileAttachment] = None - location: Optional[Location] = None - contact: Optional[Contact] = None - link: Optional[Link] = None - sticker: Optional[Sticker] = None + forward: Forward | None = None + reply: Reply | None = None + file: IncomingFileAttachment | None = None + location: Location | None = None + contact: Contact | None = None + link: Link | None = None + sticker: Sticker | None = None state: SimpleNamespace = field(default_factory=SimpleNamespace) @@ -108,76 +108,96 @@ def argument(self) -> str: return self.body[command_len:].strip() @property - def arguments(self) -> Tuple[str, ...]: + def arguments(self) -> tuple[str, ...]: return tuple(arg.strip() for arg in self.argument.split()) -BotAPIEntity = Union[BotAPIMention, BotAPIForward, BotAPIReply] -Entity = Union[Mention, Forward, Reply] +BotAPIEntity = BotAPIMention | BotAPIForward | BotAPIReply +Entity = Mention | Forward | Reply def _convert_bot_api_mention_to_domain(api_mention_data: BotAPIMentionData) -> Mention: mention_data = cast(BotAPINestedMentionData, api_mention_data.mention_data) - if api_mention_data.mention_type == BotAPIMentionTypes.USER: - return MentionBuilder.user( - entity_id=mention_data.entity_id, - name=mention_data.name, - ) - - if api_mention_data.mention_type == BotAPIMentionTypes.CHAT: - return MentionBuilder.chat( - entity_id=mention_data.entity_id, - name=mention_data.name, - ) - - if api_mention_data.mention_type == BotAPIMentionTypes.CONTACT: - return MentionBuilder.contact( - entity_id=mention_data.entity_id, - name=mention_data.name, - ) - - if api_mention_data.mention_type == BotAPIMentionTypes.CHANNEL: - return MentionBuilder.channel( - entity_id=mention_data.entity_id, - name=mention_data.name, - ) - - if api_mention_data.mention_type == BotAPIMentionTypes.ALL: - return MentionBuilder.all() - - raise NotImplementedError( - f"Unsupported mention type: {api_mention_data.mention_type}", - ) + match api_mention_data.mention_type: + case BotAPIMentionTypes.USER: + return MentionBuilder.user( + entity_id=mention_data.entity_id, + name=mention_data.name, + ) + case BotAPIMentionTypes.CHAT: + return MentionBuilder.chat( + entity_id=mention_data.entity_id, + name=mention_data.name, + ) + case BotAPIMentionTypes.CONTACT: + return MentionBuilder.contact( + entity_id=mention_data.entity_id, + name=mention_data.name, + ) + case BotAPIMentionTypes.CHANNEL: + return MentionBuilder.channel( + entity_id=mention_data.entity_id, + name=mention_data.name, + ) + case BotAPIMentionTypes.ALL: + return MentionBuilder.all() + case _: + raise NotImplementedError( + f"Unsupported mention type: {api_mention_data.mention_type}", + ) + + +def _is_bot_api_mention(entity: BotAPIEntity) -> TypeGuard[BotAPIMention]: + return entity.type == BotAPIEntityTypes.MENTION + + +def _is_bot_api_forward(entity: BotAPIEntity) -> TypeGuard[BotAPIForward]: + return entity.type == BotAPIEntityTypes.FORWARD + + +def _is_bot_api_reply(entity: BotAPIEntity) -> TypeGuard[BotAPIReply]: + return entity.type == BotAPIEntityTypes.REPLY def convert_bot_api_entity_to_domain(api_entity: BotAPIEntity) -> Entity: - if api_entity.type == BotAPIEntityTypes.MENTION: - return _convert_bot_api_mention_to_domain(api_entity.data) - - if api_entity.type == BotAPIEntityTypes.FORWARD: - return Forward( - chat_id=api_entity.data.group_chat_id, - author_id=api_entity.data.sender_huid, - sync_id=api_entity.data.source_sync_id, - chat_name=api_entity.data.source_chat_name, - forward_type=convert_chat_type_to_domain(api_entity.data.forward_type), - inserted_at=api_entity.data.source_inserted_at, - ) - - if api_entity.type == BotAPIEntityTypes.REPLY: - mentions = MentionList() - for api_mention_data in api_entity.data.mentions: - mentions.append(_convert_bot_api_mention_to_domain(api_mention_data)) - - return Reply( - author_id=api_entity.data.sender, - sync_id=api_entity.data.source_sync_id, - body=api_entity.data.body, - mentions=mentions, - ) + match api_entity.type: + case BotAPIEntityTypes.MENTION: + if not _is_bot_api_mention(api_entity): + raise NotImplementedError( + f"Unsupported entity type: {api_entity.type}", + ) + return _convert_bot_api_mention_to_domain(api_entity.data) + case BotAPIEntityTypes.FORWARD: + if not _is_bot_api_forward(api_entity): + raise NotImplementedError( + f"Unsupported entity type: {api_entity.type}", + ) + return Forward( + chat_id=api_entity.data.group_chat_id, + author_id=api_entity.data.sender_huid, + sync_id=api_entity.data.source_sync_id, + chat_name=api_entity.data.source_chat_name, + forward_type=convert_chat_type_to_domain(api_entity.data.forward_type), + inserted_at=api_entity.data.source_inserted_at, + ) + case BotAPIEntityTypes.REPLY: + if not _is_bot_api_reply(api_entity): + raise NotImplementedError( + f"Unsupported entity type: {api_entity.type}", + ) + mentions = MentionList() + for api_mention_data in api_entity.data.mentions: + mentions.append(_convert_bot_api_mention_to_domain(api_mention_data)) - raise NotImplementedError(f"Unsupported entity type: {api_entity.type}") + return Reply( + author_id=api_entity.data.sender, + sync_id=api_entity.data.source_sync_id, + body=api_entity.data.body, + mentions=mentions, + ) + case _: + raise NotImplementedError(f"Unsupported entity type: {api_entity.type}") class BotAPIIncomingMessageContext( @@ -192,16 +212,16 @@ class BotAPIIncomingMessage(BotAPIBaseCommand): payload: BotAPICommandPayload = Field(..., alias="command") sender: BotAPIIncomingMessageContext = Field(..., alias="from") - source_sync_id: Optional[UUID] - attachments: List[Union[BotAPIAttachment, Dict[str, Any]]] - entities: List[Union[BotAPIEntity, Dict[str, Any]]] + source_sync_id: UUID | None + attachments: list[BotAPIAttachment | dict[str, Any]] + entities: list[BotAPIEntity | dict[str, Any]] @staticmethod - def validate_items(value: List[Union[Dict[str, Any], Any]], info: Any) -> List[Any]: + def validate_items(value: list[dict[str, Any] | Any], info: Any) -> list[Any]: item_model = ( BotAPIAttachment if info.field_name == "attachments" else BotAPIEntity ) - parsed: List[Any] = [] + parsed: list[Any] = [] for item in value: if isinstance(item, dict): try: @@ -213,12 +233,12 @@ def validate_items(value: List[Union[Dict[str, Any], Any]], info: Any) -> List[A @field_validator("attachments", "entities", mode="before") @classmethod def _validate_items_field( - cls, value: List[Union[Dict[str, Any], Any]], info: Any - ) -> List[Any]: + cls, value: list[dict[str, Any] | Any], info: Any + ) -> list[Any]: # Pydantic-валидатор: просто делегируем статическому методу return cls.validate_items(value, info) - def to_domain(self, raw_command: Dict[str, Any]) -> IncomingMessage: + def to_domain(self, raw_command: dict[str, Any]) -> IncomingMessage: if self.sender.device_meta: pushes = self.sender.device_meta.pushes timezone = self.sender.device_meta.timezone @@ -259,11 +279,11 @@ def to_domain(self, raw_command: Dict[str, Any]) -> IncomingMessage: type=convert_chat_type_to_domain(self.sender.chat_type), ) - file: Optional[IncomingFileAttachment] = None - location: Optional[Location] = None - contact: Optional[Contact] = None - link: Optional[Link] = None - sticker: Optional[Sticker] = None + file: IncomingFileAttachment | None = None + location: Location | None = None + contact: Contact | None = None + link: Link | None = None + sticker: Sticker | None = None if self.attachments: # Always one attachment per-message @@ -288,8 +308,8 @@ def to_domain(self, raw_command: Dict[str, Any]) -> IncomingMessage: raise NotImplementedError mentions: MentionList = MentionList() - forward: Optional[Forward] = None - reply: Optional[Reply] = None + forward: Forward | None = None + reply: Reply | None = None for entity in self.entities: if isinstance(entity, dict): logger.warning("Received unknown entity type") @@ -297,7 +317,7 @@ def to_domain(self, raw_command: Dict[str, Any]) -> IncomingMessage: entity_domain = convert_bot_api_entity_to_domain(entity) if isinstance( entity_domain, - Mention.__args__, # type: ignore [attr-defined] + Mention.__args__, ): mentions.append(entity_domain) elif isinstance(entity_domain, Forward): diff --git a/pybotx/models/message/markup.py b/pybotx/models/message/markup.py index a32d10ce..587049dd 100644 --- a/pybotx/models/message/markup.py +++ b/pybotx/models/message/markup.py @@ -1,7 +1,8 @@ import json from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, Iterator, List, Literal, Optional, Union, cast +from typing import Any, Literal, cast +from collections.abc import Iterator from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel, _remove_undefined @@ -15,7 +16,7 @@ class ButtonTextAlign(Enum): RIGHT = "right" -@dataclass +@dataclass(slots=True) class Button: """ Button object. @@ -40,7 +41,7 @@ class Button: label: str command: Missing[str] = Undefined - data: Dict[str, Any] = field(default_factory=dict) + data: dict[str, Any] = field(default_factory=dict) text_color: Missing[str] = Undefined background_color: Missing[str] = Undefined align: ButtonTextAlign = ButtonTextAlign.CENTER @@ -56,11 +57,11 @@ def __post_init__(self) -> None: raise ValueError("Either 'command' or 'link' must be provided") -ButtonRow = List[Button] +ButtonRow = list[Button] class BaseMarkup: - def __init__(self, buttons: Optional[List[ButtonRow]] = None) -> None: + def __init__(self, buttons: list[ButtonRow] | None = None) -> None: self._buttons = buttons or [] def __iter__(self) -> Iterator[ButtonRow]: @@ -99,7 +100,7 @@ def add_button( self, label: str, command: Missing[str] = Undefined, - data: Optional[Dict[str, Any]] = None, + data: dict[str, Any] | None = None, text_color: Missing[str] = Undefined, background_color: Missing[str] = Undefined, align: ButtonTextAlign = ButtonTextAlign.CENTER, @@ -161,7 +162,7 @@ class KeyboardMarkup(BaseMarkup): """Class for managing keyboard message buttons.""" -Markup = Union[BubbleMarkup, KeyboardMarkup] +Markup = BubbleMarkup | KeyboardMarkup class BotXAPIButtonOptions(UnverifiedPayloadBaseModel): @@ -179,17 +180,17 @@ class BotXAPIButtonOptions(UnverifiedPayloadBaseModel): class BotXAPIButton(UnverifiedPayloadBaseModel): command: str label: str - data: Dict[str, Any] + data: dict[str, Any] opts: BotXAPIButtonOptions -class BotXAPIMarkup(RootModel[List[List[BotXAPIButton]]]): +class BotXAPIMarkup(RootModel[list[list[BotXAPIButton]]]): def json(self) -> str: # type: ignore[override] clean_dict = _remove_undefined(self.model_dump()) return json.dumps(clean_dict, default=to_jsonable_python, ensure_ascii=False) - def jsonable_dict(self) -> List[List[Dict[str, Any]]]: - return cast(List[List[Dict[str, Any]]], json.loads(self.json())) + def jsonable_dict(self) -> list[list[dict[str, Any]]]: + return cast(list[list[dict[str, Any]]], json.loads(self.json())) def api_button_from_domain(button: Button) -> BotXAPIButton: diff --git a/pybotx/models/message/mentions.py b/pybotx/models/message/mentions.py index ec8359ce..d11d5914 100644 --- a/pybotx/models/message/mentions.py +++ b/pybotx/models/message/mentions.py @@ -1,6 +1,6 @@ import re from dataclasses import dataclass -from typing import Dict, List, Literal, Optional, Tuple, Union +from typing import Literal from uuid import UUID, uuid4 from pybotx.missing import Missing, Undefined @@ -16,21 +16,21 @@ def build_embed_mention( mention_type: MentionTypes, - entity_id: Optional[UUID] = None, - name: Optional[str] = None, + entity_id: UUID | None = None, + name: str | None = None, ) -> str: name = name or "" entity_id_str = "" if entity_id is None else str(entity_id) return f"{mention_type.value}:{entity_id_str}:{name}" -@dataclass +@dataclass(slots=True) class BaseTargetMention: entity_id: UUID - name: Optional[str] + name: str | None -@dataclass +@dataclass(slots=True) class MentionUser(BaseTargetMention): type: Literal[MentionTypes.USER] @@ -38,7 +38,7 @@ def __str__(self) -> str: return build_embed_mention(self.type, self.entity_id, self.name) -@dataclass +@dataclass(slots=True) class MentionContact(BaseTargetMention): type: Literal[MentionTypes.CONTACT] @@ -46,7 +46,7 @@ def __str__(self) -> str: return build_embed_mention(self.type, self.entity_id, self.name) -@dataclass +@dataclass(slots=True) class MentionChat(BaseTargetMention): type: Literal[MentionTypes.CHAT] @@ -54,7 +54,7 @@ def __str__(self) -> str: return build_embed_mention(self.type, self.entity_id, self.name) -@dataclass +@dataclass(slots=True) class MentionChannel(BaseTargetMention): type: Literal[MentionTypes.CHANNEL] @@ -62,7 +62,7 @@ def __str__(self) -> str: return build_embed_mention(self.type, self.entity_id, self.name) -@dataclass +@dataclass(slots=True) class MentionAll: type: Literal[MentionTypes.ALL] @@ -70,18 +70,18 @@ def __str__(self) -> str: return build_embed_mention(self.type) -Mention = Union[ - MentionUser, - MentionContact, - MentionChat, - MentionChannel, - MentionAll, -] +Mention = ( + MentionUser + | MentionContact + | MentionChat + | MentionChannel + | MentionAll +) class MentionBuilder: @classmethod - def user(cls, entity_id: UUID, name: Optional[str] = None) -> MentionUser: + def user(cls, entity_id: UUID, name: str | None = None) -> MentionUser: return MentionUser( type=MentionTypes.USER, entity_id=entity_id, @@ -92,7 +92,7 @@ def user(cls, entity_id: UUID, name: Optional[str] = None) -> MentionUser: def contact( cls, entity_id: UUID, - name: Optional[str] = None, + name: str | None = None, ) -> MentionContact: return MentionContact( type=MentionTypes.CONTACT, @@ -101,7 +101,7 @@ def contact( ) @classmethod - def chat(cls, entity_id: UUID, name: Optional[str] = None) -> MentionChat: + def chat(cls, entity_id: UUID, name: str | None = None) -> MentionChat: return MentionChat( type=MentionTypes.CHAT, entity_id=entity_id, @@ -112,7 +112,7 @@ def chat(cls, entity_id: UUID, name: Optional[str] = None) -> MentionChat: def channel( cls, entity_id: UUID, - name: Optional[str] = None, + name: str | None = None, ) -> MentionChannel: return MentionChannel( type=MentionTypes.CHANNEL, @@ -127,21 +127,21 @@ def all(cls) -> MentionAll: ) -class MentionList(List[Mention]): +class MentionList(list[Mention]): @property - def contacts(self) -> List[MentionContact]: + def contacts(self) -> list[MentionContact]: return [mention for mention in self if isinstance(mention, MentionContact)] @property - def chats(self) -> List[MentionChat]: + def chats(self) -> list[MentionChat]: return [mention for mention in self if isinstance(mention, MentionChat)] @property - def channels(self) -> List[MentionChannel]: + def channels(self) -> list[MentionChannel]: return [mention for mention in self if isinstance(mention, MentionChannel)] @property - def users(self) -> List[MentionUser]: + def users(self) -> list[MentionUser]: return [mention for mention in self if isinstance(mention, MentionUser)] @property @@ -164,23 +164,23 @@ class BotAPINestedGroupMentionData(VerifiedPayloadBaseModel): name: str -BotAPINestedMentionData = Union[ - BotAPINestedPersonalMentionData, - BotAPINestedGroupMentionData, -] +BotAPINestedMentionData = ( + BotAPINestedPersonalMentionData + | BotAPINestedGroupMentionData +) class BotAPIMentionData(VerifiedPayloadBaseModel): mention_type: BotAPIMentionTypes mention_id: UUID - mention_data: Optional[BotAPINestedMentionData] + mention_data: BotAPINestedMentionData | None @field_validator("mention_data", mode="before") @classmethod def validate_mention_data( cls, - mention_data: Dict[str, str], - ) -> Optional[Dict[str, str]]: + mention_data: dict[str, str], + ) -> dict[str, str] | None: # Mention data can be an empty dict if not mention_data: return None @@ -247,17 +247,17 @@ def to_botx_embed_mention_format(self) -> str: return f"@{{mention:{self.mention_id}}}" -BotXAPIMention = Union[ - BotXAPIUserMention, - BotXAPIContactMention, - BotXAPIChatMention, - BotXAPIChannelMention, - BotXAPIAllMention, -] +BotXAPIMention = ( + BotXAPIUserMention + | BotXAPIContactMention + | BotXAPIChatMention + | BotXAPIChannelMention + | BotXAPIAllMention +) def build_botx_api_embed_mention( - mention_dict: Dict[str, str], + mention_dict: dict[str, str], ) -> BotXAPIMention: mention_type = MentionTypes(mention_dict["mention_type"]) mentioned_entity_id = mention_dict["mentioned_entity_id"] @@ -324,7 +324,7 @@ def build_botx_api_embed_mention( ) -def find_and_replace_embed_mentions(body: str) -> Tuple[str, List[BotXAPIMention]]: +def find_and_replace_embed_mentions(body: str) -> tuple[str, list[BotXAPIMention]]: mentions = [] for match in EMBED_MENTION_RE.finditer(body): diff --git a/pybotx/models/message/message_status.py b/pybotx/models/message/message_status.py index def98483..10a81b3d 100644 --- a/pybotx/models/message/message_status.py +++ b/pybotx/models/message/message_status.py @@ -1,12 +1,11 @@ from dataclasses import dataclass from datetime import datetime -from typing import Dict, List from uuid import UUID -@dataclass +@dataclass(slots=True) class MessageStatus: group_chat_id: UUID - sent_to: List[UUID] - read_by: Dict[UUID, datetime] - received_by: Dict[UUID, datetime] + sent_to: list[UUID] + read_by: dict[UUID, datetime] + received_by: dict[UUID, datetime] diff --git a/pybotx/models/message/outgoing_message.py b/pybotx/models/message/outgoing_message.py index cd79e6e7..446a6009 100644 --- a/pybotx/models/message/outgoing_message.py +++ b/pybotx/models/message/outgoing_message.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Union +from typing import Any from uuid import UUID from pybotx.missing import Missing, Undefined @@ -7,18 +7,18 @@ from pybotx.models.message.markup import BubbleMarkup, KeyboardMarkup -@dataclass +@dataclass(slots=True) class OutgoingMessage: bot_id: UUID chat_id: UUID body: str - metadata: Missing[Dict[str, Any]] = Undefined + metadata: Missing[dict[str, Any]] = Undefined bubbles: Missing[BubbleMarkup] = Undefined keyboard: Missing[KeyboardMarkup] = Undefined - file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined + file: Missing[IncomingFileAttachment | OutgoingAttachment] = Undefined silent_response: Missing[bool] = Undefined markup_auto_adjust: Missing[bool] = Undefined - recipients: Missing[List[UUID]] = Undefined + recipients: Missing[list[UUID]] = Undefined stealth_mode: Missing[bool] = Undefined send_push: Missing[bool] = Undefined ignore_mute: Missing[bool] = Undefined diff --git a/pybotx/models/message/reply.py b/pybotx/models/message/reply.py index 338348f6..393a0a12 100644 --- a/pybotx/models/message/reply.py +++ b/pybotx/models/message/reply.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Literal +from typing import Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -7,7 +7,7 @@ from pybotx.models.message.mentions import BotAPIMentionData, MentionList -@dataclass +@dataclass(slots=True) class Reply: author_id: UUID sync_id: UUID @@ -19,7 +19,7 @@ class BotAPIReplyData(VerifiedPayloadBaseModel): source_sync_id: UUID sender: UUID body: str - mentions: List[BotAPIMentionData] + mentions: list[BotAPIMentionData] # Ignoring attachments cause they don't have content diff --git a/pybotx/models/message/reply_message.py b/pybotx/models/message/reply_message.py index 2d6a344d..592188d8 100644 --- a/pybotx/models/message/reply_message.py +++ b/pybotx/models/message/reply_message.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Union +from typing import Any from uuid import UUID from pybotx.missing import Missing, Undefined @@ -7,15 +7,15 @@ from pybotx.models.message.markup import BubbleMarkup, KeyboardMarkup -@dataclass +@dataclass(slots=True) class ReplyMessage: bot_id: UUID sync_id: UUID body: str - metadata: Missing[Dict[str, Any]] = Undefined + metadata: Missing[dict[str, Any]] = Undefined bubbles: Missing[BubbleMarkup] = Undefined keyboard: Missing[KeyboardMarkup] = Undefined - file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined + file: Missing[IncomingFileAttachment | OutgoingAttachment] = Undefined silent_response: Missing[bool] = Undefined markup_auto_adjust: Missing[bool] = Undefined stealth_mode: Missing[bool] = Undefined diff --git a/pybotx/models/method_callbacks.py b/pybotx/models/method_callbacks.py index aab7c090..0fd63ae5 100644 --- a/pybotx/models/method_callbacks.py +++ b/pybotx/models/method_callbacks.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Literal, Union +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -7,15 +7,15 @@ class BotAPIMethodSuccessfulCallback(VerifiedPayloadBaseModel): sync_id: UUID status: Literal["ok"] - result: Dict[str, Any] + result: dict[str, Any] class BotAPIMethodFailedCallback(VerifiedPayloadBaseModel): sync_id: UUID status: Literal["error"] reason: str - errors: List[str] - error_data: Dict[str, Any] + errors: list[str] + error_data: dict[str, Any] -BotXMethodCallback = Union[BotAPIMethodSuccessfulCallback, BotAPIMethodFailedCallback] +BotXMethodCallback = BotAPIMethodSuccessfulCallback | BotAPIMethodFailedCallback diff --git a/pybotx/models/smartapps.py b/pybotx/models/smartapps.py index 75b10cc0..ccce54ac 100644 --- a/pybotx/models/smartapps.py +++ b/pybotx/models/smartapps.py @@ -1,9 +1,8 @@ from dataclasses import dataclass -from typing import Optional from uuid import UUID -@dataclass +@dataclass(slots=True) class SmartApp: """SmartApp from list of SmartApps. @@ -20,5 +19,5 @@ class SmartApp: enabled: bool id: UUID name: str - avatar: Optional[str] = None - avatar_preview: Optional[str] = None + avatar: str | None = None + avatar_preview: str | None = None diff --git a/pybotx/models/status.py b/pybotx/models/status.py index e4e37022..5e2cf08a 100644 --- a/pybotx/models/status.py +++ b/pybotx/models/status.py @@ -1,5 +1,5 @@ from dataclasses import asdict, dataclass -from typing import Any, Dict, List, Literal, NewType, Optional, Union +from typing import Any, Literal, NewType from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -11,16 +11,16 @@ from pybotx.models.message.incoming_message import IncomingMessage from pydantic import field_validator -BotMenu = NewType("BotMenu", Dict[str, str]) +BotMenu = NewType("BotMenu", dict[str, str]) -@dataclass +@dataclass(slots=True) class StatusRecipient: bot_id: UUID huid: UUID - ad_login: Optional[str] - ad_domain: Optional[str] - is_admin: Optional[bool] + ad_login: str | None + ad_domain: str | None + is_admin: bool | None chat_type: IncomingChatTypes @classmethod @@ -41,17 +41,17 @@ def from_incoming_message( class BotAPIStatusRecipient(VerifiedPayloadBaseModel): bot_id: UUID user_huid: UUID - ad_login: Optional[str] = None - ad_domain: Optional[str] = None - is_admin: Optional[bool] = None - chat_type: Union[APIChatTypes, str] + ad_login: str | None = None + ad_domain: str | None = None + is_admin: bool | None = None + chat_type: APIChatTypes | str @field_validator("ad_login", "ad_domain", "is_admin", mode="before") @classmethod def replace_empty_string( cls, - field_value: Union[str, bool], - ) -> Union[str, bool, None]: + field_value: str | bool, + ) -> str | bool | None: if field_value == "": return None @@ -68,30 +68,30 @@ def to_domain(self) -> StatusRecipient: ) -@dataclass +@dataclass(slots=True) class BotAPIBotMenuItem: description: str body: str name: str -BotAPIBotMenu = List[BotAPIBotMenuItem] +BotAPIBotMenu = list[BotAPIBotMenuItem] -@dataclass +@dataclass(slots=True) class BotAPIStatusResult: commands: BotAPIBotMenu enabled: Literal[True] = True - status_message: Optional[str] = None + status_message: str | None = None -@dataclass +@dataclass(slots=True) class BotAPIStatus: result: BotAPIStatusResult status: Literal["ok"] = "ok" -def build_bot_status_response(bot_menu: BotMenu) -> Dict[str, Any]: +def build_bot_status_response(bot_menu: BotMenu) -> dict[str, Any]: commands = [ BotAPIBotMenuItem(body=command, name=command, description=description) for command, description in bot_menu.items() diff --git a/pybotx/models/stickers.py b/pybotx/models/stickers.py index 48de8de8..d424d92d 100644 --- a/pybotx/models/stickers.py +++ b/pybotx/models/stickers.py @@ -1,12 +1,11 @@ from dataclasses import dataclass -from typing import List, Optional from uuid import UUID from pybotx.async_buffer import AsyncBufferWritable from pybotx.bot.contextvars import bot_var -@dataclass +@dataclass(slots=True) class Sticker: """Sticker from sticker pack. @@ -37,7 +36,7 @@ async def download( await async_buffer.seek(0) -@dataclass +@dataclass(slots=True) class StickerPack: """Sticker pack. @@ -52,10 +51,10 @@ class StickerPack: id: UUID name: str is_public: bool - stickers: List[Sticker] + stickers: list[Sticker] -@dataclass +@dataclass(slots=True) class StickerPackFromList: """Sticker pack from list. @@ -72,10 +71,10 @@ class StickerPackFromList: name: str is_public: bool stickers_count: int - sticker_ids: Optional[List[UUID]] # Can be omitted in result + sticker_ids: list[UUID] | None # Can be omitted in result -@dataclass +@dataclass(slots=True) class StickerPackPage: """Sticker pack page. @@ -85,5 +84,5 @@ class StickerPackPage: """ - sticker_packs: List[StickerPackFromList] - after: Optional[str] + sticker_packs: list[StickerPackFromList] + after: str | None diff --git a/pybotx/models/sync_smartapp_event.py b/pybotx/models/sync_smartapp_event.py index ea012c5b..b4d519c6 100644 --- a/pybotx/models/sync_smartapp_event.py +++ b/pybotx/models/sync_smartapp_event.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, List, Optional, Union +from typing import Any from uuid import UUID from pybotx.missing import Missing, Undefined @@ -23,13 +23,13 @@ class BotAPISyncSmartAppSender(VerifiedPayloadBaseModel): user_huid: UUID - udid: Optional[UUID] - platform: Optional[BotAPIClientPlatforms] + udid: UUID | None + platform: BotAPIClientPlatforms | None class BotAPISyncSmartAppPayload(VerifiedPayloadBaseModel): - data: Dict[str, Any] - files: List[APIAsyncFile] + data: dict[str, Any] + files: list[APIAsyncFile] class BotAPISyncSmartAppEvent(VerifiedPayloadBaseModel): @@ -39,7 +39,7 @@ class BotAPISyncSmartAppEvent(VerifiedPayloadBaseModel): method: str payload: BotAPISyncSmartAppPayload - def to_domain(self, raw_smartapp_event: Dict[str, Any]) -> SmartAppEvent: + def to_domain(self, raw_smartapp_event: dict[str, Any]) -> SmartAppEvent: platform = ( convert_client_platform_to_domain(self.sender_info.platform) if self.sender_info.platform @@ -93,15 +93,15 @@ def to_domain(self, raw_smartapp_event: Dict[str, Any]) -> SmartAppEvent: class BotAPISyncSmartAppEventResultResponse(UnverifiedPayloadBaseModel): data: Any - files: List[APIAsyncFile] + files: list[APIAsyncFile] @classmethod def from_domain( cls, data: Any, - files: Missing[List[File]] = Undefined, + files: Missing[list[File]] = Undefined, ) -> "BotAPISyncSmartAppEventResultResponse": - api_async_files: List[APIAsyncFile] = [] + api_async_files: list[APIAsyncFile] = [] if files: api_async_files = [convert_async_file_from_domain(file) for file in files] @@ -110,7 +110,7 @@ def from_domain( files=api_async_files, ) - def jsonable_dict(self) -> Dict[str, Any]: + def jsonable_dict(self) -> dict[str, Any]: return { "status": "ok", "result": json.loads(self.json()), @@ -119,15 +119,15 @@ def jsonable_dict(self) -> Dict[str, Any]: class BotAPISyncSmartAppEventErrorResponse(UnverifiedPayloadBaseModel): reason: str - errors: List[Any] - error_data: Dict[str, Any] + errors: list[Any] + error_data: dict[str, Any] @classmethod def from_domain( cls, reason: Missing[str] = Undefined, - errors: Missing[List[Any]] = Undefined, - error_data: Missing[Dict[str, Any]] = Undefined, + errors: Missing[list[Any]] = Undefined, + error_data: Missing[dict[str, Any]] = Undefined, ) -> "BotAPISyncSmartAppEventErrorResponse": return cls( reason="smartapp_error" if reason is Undefined else reason, @@ -135,14 +135,14 @@ def from_domain( error_data={} if error_data is Undefined else error_data, ) - def jsonable_dict(self) -> Dict[str, Any]: + def jsonable_dict(self) -> dict[str, Any]: return { "status": "error", **json.loads(self.json()), } -BotAPISyncSmartAppEventResponse = Union[ - BotAPISyncSmartAppEventResultResponse, - BotAPISyncSmartAppEventErrorResponse, -] +BotAPISyncSmartAppEventResponse = ( + BotAPISyncSmartAppEventResultResponse + | BotAPISyncSmartAppEventErrorResponse +) diff --git a/pybotx/models/system_events/added_to_chat.py b/pybotx/models/system_events/added_to_chat.py index 770c7742..b775697d 100644 --- a/pybotx/models/system_events/added_to_chat.py +++ b/pybotx/models/system_events/added_to_chat.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Literal +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -15,7 +15,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class AddedToChatEvent(BotCommandBase): """Event `system:added_to_chat`. @@ -23,12 +23,12 @@ class AddedToChatEvent(BotCommandBase): huids: List of added to chat user huids. """ - huids: List[UUID] + huids: list[UUID] chat: Chat class BotAPIAddedToChatData(VerifiedPayloadBaseModel): - added_members: List[UUID] + added_members: list[UUID] class BotAPIAddedToChatPayload(BotAPIBaseSystemEventPayload): @@ -40,7 +40,7 @@ class BotAPIAddedToChat(BotAPIBaseCommand): payload: BotAPIAddedToChatPayload = Field(..., alias="command") sender: BotAPIChatContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> AddedToChatEvent: + def to_domain(self, raw_command: dict[str, Any]) -> AddedToChatEvent: return AddedToChatEvent( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/system_events/chat_created.py b/pybotx/models/system_events/chat_created.py index 8791b96e..afc55b1f 100644 --- a/pybotx/models/system_events/chat_created.py +++ b/pybotx/models/system_events/chat_created.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -22,7 +22,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class ChatCreatedMember: """ChatCreatedEvent member. @@ -35,11 +35,11 @@ class ChatCreatedMember: is_admin: bool huid: UUID - username: Optional[str] + username: str | None kind: UserKinds -@dataclass +@dataclass(slots=True) class ChatCreatedEvent(BotCommandBase): """Event `system:chat_created`. @@ -57,13 +57,13 @@ class ChatCreatedEvent(BotCommandBase): sync_id: UUID chat_name: str creator_id: UUID - members: List[ChatCreatedMember] + members: list[ChatCreatedMember] class BotAPIChatMember(VerifiedPayloadBaseModel): is_admin: bool = Field(..., alias="admin") huid: UUID - name: Optional[str] + name: str | None user_kind: APIUserKinds @@ -71,7 +71,7 @@ class BotAPIChatCreatedData(VerifiedPayloadBaseModel): chat_type: APIChatTypes creator: UUID group_chat_id: UUID - members: List[BotAPIChatMember] + members: list[BotAPIChatMember] name: str @@ -84,7 +84,7 @@ class BotAPIChatCreated(BotAPIBaseCommand): payload: BotAPIChatCreatedPayload = Field(..., alias="command") sender: BotAPIChatContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> ChatCreatedEvent: + def to_domain(self, raw_command: dict[str, Any]) -> ChatCreatedEvent: members = [ ChatCreatedMember( is_admin=member.is_admin, diff --git a/pybotx/models/system_events/chat_deleted_by_user.py b/pybotx/models/system_events/chat_deleted_by_user.py index 8ce451ef..f34442e0 100644 --- a/pybotx/models/system_events/chat_deleted_by_user.py +++ b/pybotx/models/system_events/chat_deleted_by_user.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Literal +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -14,7 +14,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class ChatDeletedByUserEvent(BotCommandBase): """Event `system:chat_deleted_by_user`. @@ -43,7 +43,7 @@ class BotAPIChatDeletedByUser(BotAPIBaseCommand): payload: BotAPIChatDeletedByUserPayload = Field(..., alias="command") sender: BaseBotAPIContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> ChatDeletedByUserEvent: + def to_domain(self, raw_command: dict[str, Any]) -> ChatDeletedByUserEvent: return ChatDeletedByUserEvent( sync_id=self.sync_id, bot=BotAccount( diff --git a/pybotx/models/system_events/conference_changed.py b/pybotx/models/system_events/conference_changed.py index d124a662..786486b4 100644 --- a/pybotx/models/system_events/conference_changed.py +++ b/pybotx/models/system_events/conference_changed.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -20,7 +20,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class ConferenceChangedEvent(BotCommandBase): """Event `system:conference_changed`. @@ -42,17 +42,17 @@ class ConferenceChangedEvent(BotCommandBase): start_at: end conference. """ - access_code: Optional[str] - actor: Optional[UUID] - added_users: List[UUID] - admins: List[UUID] + access_code: str | None + actor: UUID | None + added_users: list[UUID] + admins: list[UUID] call_id: UUID - deleted_users: List[UUID] - end_at: Optional[datetime] + deleted_users: list[UUID] + end_at: datetime | None link: str link_id: UUID link_type: ConferenceLinkTypes - members: List[UUID] + members: list[UUID] name: str operation: str sip_number: int @@ -60,17 +60,17 @@ class ConferenceChangedEvent(BotCommandBase): class BotAPIConferenceChangedData(VerifiedPayloadBaseModel): - access_code: Optional[str] - actor: Optional[UUID] - added_users: List[UUID] - admins: List[UUID] + access_code: str | None + actor: UUID | None + added_users: list[UUID] + admins: list[UUID] call_id: UUID - deleted_users: List[UUID] - end_at: Optional[datetime] + deleted_users: list[UUID] + end_at: datetime | None link: str link_id: UUID link_type: BotAPIConferenceLinkTypes - members: List[UUID] + members: list[UUID] name: str operation: str sip_number: int @@ -86,7 +86,7 @@ class BotAPIConferenceChanged(BotAPIBaseCommand): payload: BotAPIConferenceChangedPayload = Field(..., alias="command") sender: BaseBotAPIContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> ConferenceChangedEvent: + def to_domain(self, raw_command: dict[str, Any]) -> ConferenceChangedEvent: return ConferenceChangedEvent( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/system_events/conference_created.py b/pybotx/models/system_events/conference_created.py index 56f88932..b666a677 100644 --- a/pybotx/models/system_events/conference_created.py +++ b/pybotx/models/system_events/conference_created.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Literal +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -14,7 +14,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class ConferenceCreatedEvent(BotCommandBase): """Event `system:conference_created`. @@ -38,7 +38,7 @@ class BotAPIConferenceCreated(BotAPIBaseCommand): payload: BotAPIConferenceCreatedPayload = Field(..., alias="command") sender: BaseBotAPIContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> ConferenceCreatedEvent: + def to_domain(self, raw_command: dict[str, Any]) -> ConferenceCreatedEvent: return ConferenceCreatedEvent( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/system_events/conference_deleted.py b/pybotx/models/system_events/conference_deleted.py index a2d19556..c6c2d9e3 100644 --- a/pybotx/models/system_events/conference_deleted.py +++ b/pybotx/models/system_events/conference_deleted.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Literal +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -14,7 +14,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class ConferenceDeletedEvent(BotCommandBase): """Event `system:conference_deleted`. @@ -38,7 +38,7 @@ class BotAPIConferenceDeleted(BotAPIBaseCommand): payload: BotAPIConferenceDeletedPayload = Field(..., alias="command") sender: BaseBotAPIContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> ConferenceDeletedEvent: + def to_domain(self, raw_command: dict[str, Any]) -> ConferenceDeletedEvent: return ConferenceDeletedEvent( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/system_events/cts_login.py b/pybotx/models/system_events/cts_login.py index 114540d1..f128098f 100644 --- a/pybotx/models/system_events/cts_login.py +++ b/pybotx/models/system_events/cts_login.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Literal +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -14,7 +14,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class CTSLoginEvent(BotCommandBase): """Event `system:cts_login`. @@ -38,7 +38,7 @@ class BotAPICTSLogin(BotAPIBaseCommand): payload: BotAPICTSLoginPayload = Field(..., alias="command") sender: BaseBotAPIContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> CTSLoginEvent: + def to_domain(self, raw_command: dict[str, Any]) -> CTSLoginEvent: return CTSLoginEvent( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/system_events/cts_logout.py b/pybotx/models/system_events/cts_logout.py index f136094d..8935cfee 100644 --- a/pybotx/models/system_events/cts_logout.py +++ b/pybotx/models/system_events/cts_logout.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Literal +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -14,7 +14,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class CTSLogoutEvent(BotCommandBase): """Event `system:cts_logout`. @@ -38,7 +38,7 @@ class BotAPICTSLogout(BotAPIBaseCommand): payload: BotAPICTSLogoutPayload = Field(..., alias="command") sender: BaseBotAPIContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> CTSLogoutEvent: + def to_domain(self, raw_command: dict[str, Any]) -> CTSLogoutEvent: return CTSLogoutEvent( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/system_events/deleted_from_chat.py b/pybotx/models/system_events/deleted_from_chat.py index 2f754404..416da3fa 100644 --- a/pybotx/models/system_events/deleted_from_chat.py +++ b/pybotx/models/system_events/deleted_from_chat.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Literal +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -18,7 +18,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class DeletedFromChatEvent(BotCommandBase): """Event `system:deleted_from_chat`. @@ -27,12 +27,12 @@ class DeletedFromChatEvent(BotCommandBase): chat_id: Chat where the user was deleted from. """ - huids: List[UUID] + huids: list[UUID] chat: Chat class BotAPIDeletedFromChatData(VerifiedPayloadBaseModel): - deleted_members: List[UUID] + deleted_members: list[UUID] class BotAPIDeletedFromChatPayload(VerifiedPayloadBaseModel): @@ -45,7 +45,7 @@ class BotAPIDeletedFromChat(BotAPIBaseCommand): payload: BotAPIDeletedFromChatPayload = Field(..., alias="command") sender: BotAPIChatContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> DeletedFromChatEvent: + def to_domain(self, raw_command: dict[str, Any]) -> DeletedFromChatEvent: return DeletedFromChatEvent( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/system_events/event_delete.py b/pybotx/models/system_events/event_delete.py index cd0311d3..97430527 100644 --- a/pybotx/models/system_events/event_delete.py +++ b/pybotx/models/system_events/event_delete.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal from uuid import UUID from pydantic import Field @@ -16,7 +16,7 @@ from pybotx.models.enums import BotAPISystemEventTypes -@dataclass +@dataclass(slots=True) class EventDeleted(BotCommandBase): """Event `system:event_deleted`. @@ -29,15 +29,15 @@ class EventDeleted(BotCommandBase): deleted_at: datetime group_chat_id: UUID - sync_ids: List[UUID] - meta: Optional[Dict[str, Any]] + sync_ids: list[UUID] + meta: dict[str, Any] | None class BotAPIEventDeletedData(VerifiedPayloadBaseModel): deleted_at: datetime group_chat_id: UUID - sync_ids: List[UUID] - meta: Optional[Dict[str, Any]] + sync_ids: list[UUID] + meta: dict[str, Any] | None class BotAPIEventDeletedPayload(BotAPIBaseSystemEventPayload): @@ -49,7 +49,7 @@ class BotAPIEventDeleted(BotAPIBaseCommand): payload: BotAPIEventDeletedPayload = Field(..., alias="command") bot: BaseBotAPIContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> EventDeleted: + def to_domain(self, raw_command: dict[str, Any]) -> EventDeleted: return EventDeleted( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/system_events/event_edit.py b/pybotx/models/system_events/event_edit.py index fd04ba24..204d10ca 100644 --- a/pybotx/models/system_events/event_edit.py +++ b/pybotx/models/system_events/event_edit.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -24,7 +24,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class EventEdit(BotCommandBase): """Event `system:event_edit`. @@ -37,16 +37,16 @@ class EventEdit(BotCommandBase): entities: Entities from updated message. """ - body: Optional[str] + body: str | None sync_id: UUID chat_id: UUID huid: UUID - attachments: List[IncomingAttachment] - entities: List[Entity] + attachments: list[IncomingAttachment] + entities: list[Entity] class BotAPIEventEditData(VerifiedPayloadBaseModel): - body: Optional[str] + body: str | None class BotAPIEventEditPayload(BotAPIBaseSystemEventPayload): @@ -63,10 +63,10 @@ class BotAPIBotContext(BotAPIUserContext): class BotAPIEventEdit(BotAPIBaseCommand): payload: BotAPIEventEditPayload = Field(..., alias="command") sender: BotAPIBotContext = Field(..., alias="from") - attachments: List[BotAPIAttachment] - entities: List[BotAPIEntity] + attachments: list[BotAPIAttachment] + entities: list[BotAPIEntity] - def to_domain(self, raw_command: Dict[str, Any]) -> EventEdit: + def to_domain(self, raw_command: dict[str, Any]) -> EventEdit: return EventEdit( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/system_events/internal_bot_notification.py b/pybotx/models/system_events/internal_bot_notification.py index 84cc1eab..29637d6e 100644 --- a/pybotx/models/system_events/internal_bot_notification.py +++ b/pybotx/models/system_events/internal_bot_notification.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Literal +from typing import Any, Literal from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( @@ -16,7 +16,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class InternalBotNotificationEvent(BotCommandBase): """Event `system:internal_bot_notification`. @@ -25,15 +25,15 @@ class InternalBotNotificationEvent(BotCommandBase): opts: request options. """ - data: Dict[str, Any] - opts: Dict[str, Any] + data: dict[str, Any] + opts: dict[str, Any] chat: Chat sender: BotSender class BotAPIInternalBotNotificationData(VerifiedPayloadBaseModel): - data: Dict[str, Any] - opts: Dict[str, Any] + data: dict[str, Any] + opts: dict[str, Any] class BotAPIInternalBotNotificationPayload(BotAPIBaseSystemEventPayload): @@ -49,7 +49,7 @@ class BotAPIInternalBotNotification(BotAPIBaseCommand): payload: BotAPIInternalBotNotificationPayload = Field(..., alias="command") sender: BotAPIBotContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> InternalBotNotificationEvent: + def to_domain(self, raw_command: dict[str, Any]) -> InternalBotNotificationEvent: return InternalBotNotificationEvent( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/system_events/left_from_chat.py b/pybotx/models/system_events/left_from_chat.py index dd32afb4..261501f7 100644 --- a/pybotx/models/system_events/left_from_chat.py +++ b/pybotx/models/system_events/left_from_chat.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Literal +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -15,7 +15,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class LeftFromChatEvent(BotCommandBase): """Event `system:left_from_chat`. @@ -23,12 +23,12 @@ class LeftFromChatEvent(BotCommandBase): huids: List of left from chat user huids. """ - huids: List[UUID] + huids: list[UUID] chat: Chat class BotAPILeftFromChatData(VerifiedPayloadBaseModel): - left_members: List[UUID] + left_members: list[UUID] class BotAPILeftFromChatPayload(BotAPIBaseSystemEventPayload): @@ -40,7 +40,7 @@ class BotAPILeftFromChat(BotAPIBaseCommand): payload: BotAPILeftFromChatPayload = Field(..., alias="command") sender: BotAPIChatContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> LeftFromChatEvent: + def to_domain(self, raw_command: dict[str, Any]) -> LeftFromChatEvent: return LeftFromChatEvent( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/system_events/smartapp_event.py b/pybotx/models/system_events/smartapp_event.py index c7e8d5e2..be09b935 100644 --- a/pybotx/models/system_events/smartapp_event.py +++ b/pybotx/models/system_events/smartapp_event.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -23,7 +23,7 @@ from pydantic import Field -@dataclass +@dataclass(slots=True) class SmartAppEvent(BotCommandBase): """Event `system:smartapp_event`. @@ -36,12 +36,12 @@ class SmartAppEvent(BotCommandBase): sender: Event sender. """ - ref: Optional[UUID] + ref: UUID | None smartapp_id: UUID - data: Dict[str, Any] - opts: Optional[Dict[str, Any]] - smartapp_api_version: Optional[int] - files: List[File] + data: dict[str, Any] + opts: dict[str, Any] | None + smartapp_api_version: int | None + files: list[File] chat: Chat sender: UserSender @@ -49,8 +49,8 @@ class SmartAppEvent(BotCommandBase): class BotAPISmartAppData(VerifiedPayloadBaseModel): ref: UUID smartapp_id: UUID - data: Dict[str, Any] - opts: Dict[str, Any] + data: dict[str, Any] + opts: dict[str, Any] smartapp_api_version: int @@ -58,7 +58,7 @@ class BotAPISmartAppPayload(VerifiedPayloadBaseModel): body: Literal[BotAPISystemEventTypes.SMARTAPP_EVENT] command_type: Literal[BotAPICommandTypes.SYSTEM] data: BotAPISmartAppData - metadata: Dict[str, Any] + metadata: dict[str, Any] class BotAPISmartAppEventContext( @@ -72,9 +72,9 @@ class BotAPISmartAppEventContext( class BotAPISmartAppEvent(BotAPIBaseCommand): payload: BotAPISmartAppPayload = Field(..., alias="command") sender: BotAPISmartAppEventContext = Field(..., alias="from") - async_files: List[APIAsyncFile] + async_files: list[APIAsyncFile] - def to_domain(self, raw_command: Dict[str, Any]) -> SmartAppEvent: + def to_domain(self, raw_command: dict[str, Any]) -> SmartAppEvent: device = UserDevice( manufacturer=self.sender.manufacturer, device_name=self.sender.device, diff --git a/pybotx/models/system_events/user_joined_to_chat.py b/pybotx/models/system_events/user_joined_to_chat.py index ece7ad0b..5aa881ea 100644 --- a/pybotx/models/system_events/user_joined_to_chat.py +++ b/pybotx/models/system_events/user_joined_to_chat.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, List, Literal +from typing import Any, Literal from uuid import UUID from pybotx.models.api_base import VerifiedPayloadBaseModel @@ -15,7 +15,7 @@ from pydantic import Field, ConfigDict -@dataclass +@dataclass(slots=True) class JoinToChatEvent(BotCommandBase): """Domain model for user joined to chat event. @@ -29,7 +29,7 @@ class JoinToChatEvent(BotCommandBase): chat: The chat that users joined. """ - huids: List[UUID] + huids: list[UUID] chat: Chat @@ -43,7 +43,7 @@ class BotAPIJoinToChatData(VerifiedPayloadBaseModel): added_members: List of UUIDs of users who joined the chat. """ - added_members: List[UUID] + added_members: list[UUID] class BotAPIJoinToChatPayload(BotAPIBaseSystemEventPayload): @@ -75,7 +75,7 @@ class BotAPIJoinToChat(BotAPIBaseCommand): payload: BotAPIJoinToChatPayload = Field(..., alias="command") sender: BotAPIChatContext = Field(..., alias="from") - def to_domain(self, raw_command: Dict[str, Any]) -> JoinToChatEvent: + def to_domain(self, raw_command: dict[str, Any]) -> JoinToChatEvent: return JoinToChatEvent( bot=BotAccount( id=self.bot_id, diff --git a/pybotx/models/users.py b/pybotx/models/users.py index 567a31c2..79bb736e 100644 --- a/pybotx/models/users.py +++ b/pybotx/models/users.py @@ -1,12 +1,11 @@ from dataclasses import dataclass from datetime import datetime -from typing import List, Optional from uuid import UUID from pybotx.models.enums import IncomingSyncSourceTypes, UserKinds -@dataclass +@dataclass(slots=True) class UserFromSearch: """User from search. @@ -36,30 +35,30 @@ class UserFromSearch: """ huid: UUID - ad_login: Optional[str] - ad_domain: Optional[str] + ad_login: str | None + ad_domain: str | None username: str - company: Optional[str] - company_position: Optional[str] - department: Optional[str] - emails: List[str] - other_id: Optional[str] + company: str | None + company_position: str | None + department: str | None + emails: list[str] + other_id: str | None user_kind: UserKinds - active: Optional[bool] = None - description: Optional[str] = None - ip_phone: Optional[str] = None - manager: Optional[str] = None - office: Optional[str] = None - other_ip_phone: Optional[str] = None - other_phone: Optional[str] = None - public_name: Optional[str] = None - cts_id: Optional[UUID] = None - rts_id: Optional[UUID] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + active: bool | None = None + description: str | None = None + ip_phone: str | None = None + manager: str | None = None + office: str | None = None + other_ip_phone: str | None = None + other_phone: str | None = None + public_name: str | None = None + cts_id: UUID | None = None + rts_id: UUID | None = None + created_at: datetime | None = None + updated_at: datetime | None = None -@dataclass +@dataclass(slots=True) class UserFromCSV: """User from a list of a CTS users. @@ -94,18 +93,18 @@ class UserFromCSV: sync_source: IncomingSyncSourceTypes active: bool user_kind: UserKinds - email: Optional[str] = None - company: Optional[str] = None - department: Optional[str] = None - position: Optional[str] = None - avatar: Optional[str] = None - avatar_preview: Optional[str] = None - office: Optional[str] = None - manager: Optional[str] = None - manager_huid: Optional[UUID] = None - description: Optional[str] = None - phone: Optional[str] = None - other_phone: Optional[str] = None - ip_phone: Optional[str] = None - other_ip_phone: Optional[str] = None - personnel_number: Optional[str] = None + email: str | None = None + company: str | None = None + department: str | None = None + position: str | None = None + avatar: str | None = None + avatar_preview: str | None = None + office: str | None = None + manager: str | None = None + manager_huid: UUID | None = None + description: str | None = None + phone: str | None = None + other_phone: str | None = None + ip_phone: str | None = None + other_ip_phone: str | None = None + personnel_number: str | None = None diff --git a/pyproject.toml b/pyproject.toml index 22e01d4f..9f6dbde6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pybotx" -version = "0.76.0a1" +version = "0.76.0a2" description = "A python library for interacting with eXpress BotX API" authors = [ "Sidnev Nikolay ", @@ -13,37 +13,39 @@ repository = "https://github.com/ExpressApp/pybotx" [tool.poetry.dependencies] -python = ">=3.9,<3.14" +python = ">=3.10,<4.0" -aiofiles = ">=0.7.0,<=24.1.0" -httpx = "^0.28.0" +aiofiles = "^25.1.0" +httpx = "^0.28.1" # The v1.0.3 cause some troubles with no-wait callbacks functionality. # It will be fixed in the next versions. # https://github.com/encode/httpcore/pull/880 -httpcore = "1.0.9" -loguru = ">=0.6.0,<0.7.0" -pydantic = ">=2.8.2,<3.0" -aiocsv = ">=1.2.3,<=1.4.0" -pyjwt = ">=2.0.0,<3.0.0" -mypy-extensions = ">=0.2.0,<=1.1.0" +httpcore = "^1.0.9" +loguru = "^0.7.3" +pydantic = "^2.12.5" +aiocsv = "^1.4.0" +pyjwt = "^2.11.0" +mypy-extensions = "^1.1.0" [tool.poetry.group.dev.dependencies] -mypy = "1.15.0" -typing-extensions = ">=3.7.4,<5.0.0" -bandit = "1.8.3" # https://github.com/PyCQA/bandit/issues/837 +mypy = "^1.19.1" +bandit = "^1.9.3" # https://github.com/PyCQA/bandit/issues/837 -pytest = "8.3.5" -pytest-asyncio = "0.26.0" -pytest-cov = "6.1.1" -requests = "2.32.3" -respx = "0.22.0" -factory-boy = ">=3.3.3,<=4.0.0" -deepdiff = "^8.5.0,<=9.0.0" +pytest = "^9.0.2" +pytest-asyncio = "^1.3.0" +pytest-cov = "^7.0.0" +pytest-timeout = "^2.4.0" +pytest-xdist = "^3.8.0" +hypothesis = "^6.151.5" +requests = "^2.32.5" +respx = "^0.22.0" +factory-boy = "^3.3.3" +deepdiff = "^8.6.1" -fastapi = "0.115.12 " -starlette = "0.46.2" # TODO: Drop dependency after updating end-to-end test -uvicorn = "0.34.2" -ruff = "0.12.3" +fastapi = "^0.128.1" +starlette = ">=0.40,<0.51" # TODO: Drop dependency after updating end-to-end test +uvicorn = "^0.40.0" +ruff = "^0.15.0" [build-system] requires = ["poetry>=1.2.0"] @@ -56,20 +58,26 @@ testpaths = ["tests"] addopts = [ "--strict-markers", "--tb=short", + "-n", + "auto", + "--dist=loadscope", "--cov=pybotx", "--cov-report=term-missing", "--cov-branch", "--no-cov-on-fail", "--cov-fail-under=100", + "--timeout=10", + "--timeout-method=thread", ] markers = [ "wip: Work in progress", "mock_authorization: Mock authorization", + "allow_network: Allow real network access in a test", ] filterwarnings = [ "ignore:Pydantic serializer warnings:UserWarning", ] [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 88 diff --git a/tests/client/chats_api/test_add_admin.py b/tests/client/chats_api/test_add_admin.py index 5dd29767..91766dec 100644 --- a/tests/client/chats_api/test_add_admin.py +++ b/tests/client/chats_api/test_add_admin.py @@ -1,21 +1,19 @@ from http import HTTPStatus +from typing import Any +from collections.abc import Sequence from uuid import UUID -import httpx import pytest from respx.router import MockRouter from pybotx import ( - Bot, - BotAccountWithSecret, CantUpdatePersonalChatError, ChatNotFoundError, - HandlerCollector, InvalidBotXStatusCodeError, InvalidUsersListError, PermissionDeniedError, - lifespan_wrapper, ) +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -23,215 +21,88 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v3/botx/chats/add_admin" -async def test__promote_to_chat_admins__unexpected_bad_request_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/add_admin", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.BAD_REQUEST, - json={ - "status": "error", - "reason": "some_reason", - "errors": [], - "error_data": {}, - }, - ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(InvalidBotXStatusCodeError) as exc: - await bot.promote_to_chat_admins( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], - ) - - # - Assert - - assert "some_reason" in str(exc.value) - assert endpoint.called +REQUEST = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, +) -async def test__promote_to_chat_admins__cant_update_personal_chat_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/add_admin", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( +@pytest.mark.parametrize( + ("response_status", "response_json", "expected_exc", "expected_fragments"), + [ + ( HTTPStatus.BAD_REQUEST, - json={ - "status": "error", - "reason": "chat_members_not_modifiable", - "errors": [], - "error_data": {}, - }, + error_payload("some_reason"), + InvalidBotXStatusCodeError, + ("some_reason",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(CantUpdatePersonalChatError) as exc: - await bot.promote_to_chat_admins( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], - ) - - # - Assert - - assert "chat_members_not_modifiable" in str(exc.value) - assert "Personal chat couldn't have admins" in str(exc.value) - assert endpoint.called - - -async def test__promote_to_chat_admins__invalid_users_list_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/add_admin", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( + ( + HTTPStatus.BAD_REQUEST, + error_payload("chat_members_not_modifiable"), + CantUpdatePersonalChatError, + ("chat_members_not_modifiable", "Personal chat couldn't have admins"), + ), + ( HTTPStatus.BAD_REQUEST, - json={ - "status": "error", - "reason": "admins_not_changed", - "errors": ["Admins have not changed"], - "error_data": { + error_payload( + "admins_not_changed", + errors=["Admins have not changed"], + error_data={ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", }, - }, + ), + InvalidUsersListError, + ("admins_not_changed", "Specified users are already admins or missing from chat"), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(InvalidUsersListError) as exc: - await bot.promote_to_chat_admins( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], - ) - - # - Assert - - assert "admins_not_changed" in str(exc.value) - assert "Specified users are already admins or missing from chat" in str(exc.value) - assert endpoint.called - - -async def test__promote_to_chat_admins__permission_denied_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/add_admin", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( + ( HTTPStatus.FORBIDDEN, - json={ - "status": "error", - "reason": "no_permission_for_operation", - "errors": ["Sender is not chat admin"], - "error_data": { + error_payload( + "no_permission_for_operation", + errors=["Sender is not chat admin"], + error_data={ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", "sender": "a465f0f3-1354-491c-8f11-f400164295cb", }, - }, + ), + PermissionDeniedError, + ("no_permission_for_operation",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(PermissionDeniedError) as exc: - await bot.promote_to_chat_admins( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], - ) - - # - Assert - - assert "no_permission_for_operation" in str(exc.value) - assert endpoint.called - - -async def test__promote_to_chat_admins__chat_not_found_error_raised( + ( + HTTPStatus.NOT_FOUND, + error_payload( + "chat_not_found", + errors=["Chat not found"], + error_data={ + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + ), + ChatNotFoundError, + ("chat_not_found",), + ), + ], +) +async def test__promote_to_chat_admins__error_response( + response_status: int, + response_json: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/add_admin", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "chat_not_found", - "errors": ["Chat not found"], - "error_data": { - "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", - }, - }, - ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx(respx_mock, host, REQUEST, response_json, response_status) # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(ChatNotFoundError) as exc: + async with bot_factory() as bot: + with pytest.raises(expected_exc) as exc: await bot.promote_to_chat_admins( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), @@ -239,7 +110,8 @@ async def test__promote_to_chat_admins__chat_not_found_error_raised( ) # - Assert - - assert "chat_not_found" in str(exc.value) + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called @@ -247,27 +119,19 @@ async def test__promote_to_chat_admins__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/add_admin", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={"status": "ok", "result": True}, - ), + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload(True), + HTTPStatus.OK, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: await bot.promote_to_chat_admins( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), diff --git a/tests/client/chats_api/test_add_user.py b/tests/client/chats_api/test_add_user.py index bb7848f3..876d55a7 100644 --- a/tests/client/chats_api/test_add_user.py +++ b/tests/client/chats_api/test_add_user.py @@ -1,18 +1,13 @@ from http import HTTPStatus +from typing import Any +from collections.abc import Sequence from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - ChatNotFoundError, - HandlerCollector, - PermissionDeniedError, - lifespan_wrapper, -) +from pybotx import ChatNotFoundError, PermissionDeniedError +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -20,85 +15,64 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v3/botx/chats/add_user" -async def test__add_users_to_chat__chat_not_found_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/add_user", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( +REQUEST = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, +) + + +@pytest.mark.parametrize( + ("response_status", "response_json", "expected_exc", "expected_fragments"), + [ + ( HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "chat_not_found", - "errors": ["Chat not found"], - "error_data": { + error_payload( + "chat_not_found", + errors=["Chat not found"], + error_data={ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", }, - }, + ), + ChatNotFoundError, + ("chat_not_found",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(ChatNotFoundError) as exc: - await bot.add_users_to_chat( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], - ) - - # - Assert - - assert "chat_not_found" in str(exc.value) - assert endpoint.called - - -async def test__add_users_to_chat__permission_denied_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/add_user", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( + ( HTTPStatus.FORBIDDEN, - json={ - "status": "error", - "reason": "no_permission_for_operation", - "errors": ["Sender is not chat admin"], - "error_data": { + error_payload( + "no_permission_for_operation", + errors=["Sender is not chat admin"], + error_data={ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", "sender": "a465f0f3-1354-491c-8f11-f400164295cb", }, - }, + ), + PermissionDeniedError, + ("no_permission_for_operation",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + ], +) +async def test__add_users_to_chat__error_response( + response_status: int, + response_json: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + # - Arrange - + endpoint = mock_botx(respx_mock, host, REQUEST, response_json, response_status) # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(PermissionDeniedError) as exc: + async with bot_factory() as bot: + with pytest.raises(expected_exc) as exc: await bot.add_users_to_chat( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), @@ -106,7 +80,8 @@ async def test__add_users_to_chat__permission_denied_error_raised( ) # - Assert - - assert "no_permission_for_operation" in str(exc.value) + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called @@ -114,27 +89,19 @@ async def test__add_users_to_chat__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/add_user", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={"status": "ok", "result": True}, - ), + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload(True), + HTTPStatus.OK, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: await bot.add_users_to_chat( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), diff --git a/tests/client/chats_api/test_chat_info.py b/tests/client/chats_api/test_chat_info.py index 258c3eaa..c1a54a46 100644 --- a/tests/client/chats_api/test_chat_info.py +++ b/tests/client/chats_api/test_chat_info.py @@ -1,23 +1,20 @@ from datetime import datetime as dt from http import HTTPStatus -from typing import Callable +from typing import Any +from collections.abc import Callable, Sequence from uuid import UUID -import httpx import pytest from respx.router import MockRouter from pybotx import ( - Bot, - BotAccountWithSecret, ChatInfo, ChatInfoMember, ChatNotFoundError, ChatTypes, - HandlerCollector, UserKinds, - lifespan_wrapper, ) +from tests.testkit import BotXRequest, assert_deep_equal, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -25,45 +22,56 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v3/botx/chats/info" -async def test__chat_info__chat_not_found_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/chats/info", - headers={"Authorization": "Bearer token"}, - params={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, - ).mock( - return_value=httpx.Response( +REQUEST = BotXRequest( + method="GET", + path=ENDPOINT, + params={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, +) + + +@pytest.mark.parametrize( + ("response_status", "response_json", "expected_exc", "expected_fragments"), + [ + ( HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "chat_not_found", - "errors": [], - "error_data": { + error_payload( + "chat_not_found", + error_data={ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", "error_description": "Chat with id dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4 not found", }, - }, + ), + ChatNotFoundError, + ("chat_not_found",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + ], +) +async def test__chat_info__error_response( + response_status: int, + response_json: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + # - Arrange - + endpoint = mock_botx(respx_mock, host, REQUEST, response_json, response_status) # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(ChatNotFoundError) as exc: + async with bot_factory() as bot: + with pytest.raises(expected_exc) as exc: await bot.chat_info( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), ) # - Assert - - assert "chat_not_found" in str(exc.value) + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called @@ -72,73 +80,134 @@ async def test__chat_info__succeed( host: str, bot_id: UUID, datetime_formatter: Callable[[str], dt], - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/chats/info", - headers={"Authorization": "Bearer token"}, - params={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": { - "chat_type": "group_chat", - "creator": "6fafda2c-6505-57a5-a088-25ea5d1d0364", - "description": None, - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "inserted_at": "2019-08-29T11:22:48.358586Z", - "members": [ - { - "admin": True, - "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", - "user_kind": "user", - }, - { - "admin": False, - "user_huid": "705df263-6bfd-536a-9d51-13524afaab5c", - "user_kind": "botx", - }, - ], - "name": "Group Chat Example", - "shared_history": False, - }, + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload( + { + "chat_type": "group_chat", + "creator": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "description": None, + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "inserted_at": "2019-08-29T11:22:48.358586Z", + "members": [ + { + "admin": True, + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "user_kind": "user", + }, + { + "admin": False, + "user_huid": "705df263-6bfd-536a-9d51-13524afaab5c", + "user_kind": "botx", + }, + ], + "name": "Group Chat Example", + "shared_history": False, }, ), + HTTPStatus.OK, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + # - Act - + async with bot_factory() as bot: + chat_info = await bot.chat_info( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert_deep_equal( + chat_info, + ChatInfo( + chat_type=ChatTypes.GROUP_CHAT, + creator_id=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + description=None, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), + members=[ + ChatInfoMember( + is_admin=True, + huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + kind=UserKinds.RTS_USER, + ), + ChatInfoMember( + is_admin=False, + huid=UUID("705df263-6bfd-536a-9d51-13524afaab5c"), + kind=UserKinds.BOT, + ), + ], + name="Group Chat Example", + shared_history=False, + ), + ) + + assert endpoint.called + + +async def test__chat_info__notes_chat_type_mapped_to_personal_chat( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + datetime_formatter: Callable[[str], dt], + bot_factory: Any, +) -> None: + # - Arrange - + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload( + { + "chat_type": "notes", + "creator": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "description": None, + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "inserted_at": "2019-08-29T11:22:48.358586Z", + "members": [ + { + "admin": True, + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "user_kind": "user", + }, + ], + "name": "Saved messages", + "shared_history": False, + }, + ), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: chat_info = await bot.chat_info( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), ) # - Assert - - assert chat_info == ChatInfo( - chat_type=ChatTypes.GROUP_CHAT, - creator_id=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), - description=None, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), - members=[ - ChatInfoMember( - is_admin=True, - huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), - kind=UserKinds.RTS_USER, - ), - ChatInfoMember( - is_admin=False, - huid=UUID("705df263-6bfd-536a-9d51-13524afaab5c"), - kind=UserKinds.BOT, - ), - ], - name="Group Chat Example", - shared_history=False, + assert_deep_equal( + chat_info, + ChatInfo( + chat_type=ChatTypes.PERSONAL_CHAT, + creator_id=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + description=None, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), + members=[ + ChatInfoMember( + is_admin=True, + huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + kind=UserKinds.RTS_USER, + ), + ], + name="Saved messages", + shared_history=False, + ), ) assert endpoint.called @@ -149,79 +218,76 @@ async def test__chat_info__skipped_members( host: str, bot_id: UUID, datetime_formatter: Callable[[str], dt], - bot_account: BotAccountWithSecret, loguru_caplog: pytest.LogCaptureFixture, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/chats/info", - headers={"Authorization": "Bearer token"}, - params={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": { - "chat_type": "group_chat", - "creator": "6fafda2c-6505-57a5-a088-25ea5d1d0364", - "description": None, - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "inserted_at": "2019-08-29T11:22:48.358586Z", - "members": [ - { - "admin": True, - "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", - "user_kind": "user", - }, - { - "admin": False, - "user_huid": "705df263-6bfd-536a-9d51-13524afaab5c", - "user_kind": "botx", - }, - { - "admin": False, - "user_huid": "0843a8a8-6d56-4ce6-92aa-13dc36bd9ede", - "user_kind": "unsupported_user_type", - }, - ], - "name": "Group Chat Example", - "shared_history": False, - }, + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload( + { + "chat_type": "group_chat", + "creator": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "description": None, + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "inserted_at": "2019-08-29T11:22:48.358586Z", + "members": [ + { + "admin": True, + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "user_kind": "user", + }, + { + "admin": False, + "user_huid": "705df263-6bfd-536a-9d51-13524afaab5c", + "user_kind": "botx", + }, + { + "admin": False, + "user_huid": "0843a8a8-6d56-4ce6-92aa-13dc36bd9ede", + "user_kind": "unsupported_user_type", + }, + ], + "name": "Group Chat Example", + "shared_history": False, }, ), + HTTPStatus.OK, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: chat_info = await bot.chat_info( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), ) # - Assert - - assert chat_info == ChatInfo( - chat_type=ChatTypes.GROUP_CHAT, - creator_id=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), - description=None, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), - members=[ - ChatInfoMember( - is_admin=True, - huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), - kind=UserKinds.RTS_USER, - ), - ChatInfoMember( - is_admin=False, - huid=UUID("705df263-6bfd-536a-9d51-13524afaab5c"), - kind=UserKinds.BOT, - ), - ], - name="Group Chat Example", - shared_history=False, + assert_deep_equal( + chat_info, + ChatInfo( + chat_type=ChatTypes.GROUP_CHAT, + creator_id=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + description=None, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), + members=[ + ChatInfoMember( + is_admin=True, + huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + kind=UserKinds.RTS_USER, + ), + ChatInfoMember( + is_admin=False, + huid=UUID("705df263-6bfd-536a-9d51-13524afaab5c"), + kind=UserKinds.BOT, + ), + ], + name="Group Chat Example", + shared_history=False, + ), ) assert "One or more unsupported user types skipped" in loguru_caplog.text assert endpoint.called @@ -232,76 +298,77 @@ async def test__open_channel_info__succeed( host: str, bot_id: UUID, datetime_formatter: Callable[[str], dt], - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/chats/info", - headers={"Authorization": "Bearer token"}, - params={"group_chat_id": "e53d5080-68f7-5050-bb4f-005efd375612"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": { - "chat_type": "channel", - "creator": None, - "description": None, - "group_chat_id": "e53d5080-68f7-5050-bb4f-005efd375612", - "inserted_at": "2023-10-26T07:49:53.821672Z", - "members": [ - { - "admin": True, - "server_id": "a619fcfa-a19b-5256-a592-9b0e75ca0896", - "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", - "user_kind": "cts_user", - }, - { - "admin": False, - "user_huid": "705df263-6bfd-536a-9d51-13524afaab5c", - "server_id": "a619fcfa-a19b-5256-a592-9b0e75ca0896", - "user_kind": "botx", - }, - ], - "name": "Open Channel Example", - "shared_history": False, - "updated_at": "2023-10-26T08:09:30.721566Z", - }, + endpoint = mock_botx( + respx_mock, + host, + BotXRequest( + method="GET", + path=ENDPOINT, + params={"group_chat_id": "e53d5080-68f7-5050-bb4f-005efd375612"}, + ), + ok_payload( + { + "chat_type": "channel", + "creator": None, + "description": None, + "group_chat_id": "e53d5080-68f7-5050-bb4f-005efd375612", + "inserted_at": "2023-10-26T07:49:53.821672Z", + "members": [ + { + "admin": True, + "server_id": "a619fcfa-a19b-5256-a592-9b0e75ca0896", + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "user_kind": "cts_user", + }, + { + "admin": False, + "user_huid": "705df263-6bfd-536a-9d51-13524afaab5c", + "server_id": "a619fcfa-a19b-5256-a592-9b0e75ca0896", + "user_kind": "botx", + }, + ], + "name": "Open Channel Example", + "shared_history": False, + "updated_at": "2023-10-26T08:09:30.721566Z", }, ), + HTTPStatus.OK, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: chat_info = await bot.chat_info( bot_id=bot_id, chat_id=UUID("e53d5080-68f7-5050-bb4f-005efd375612"), ) # - Assert - - assert chat_info == ChatInfo( - chat_type=ChatTypes.CHANNEL, - creator_id=None, - description=None, - chat_id=UUID("e53d5080-68f7-5050-bb4f-005efd375612"), - created_at=datetime_formatter("2023-10-26T07:49:53.821672Z"), - members=[ - ChatInfoMember( - is_admin=True, - huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), - kind=UserKinds.CTS_USER, - ), - ChatInfoMember( - is_admin=False, - huid=UUID("705df263-6bfd-536a-9d51-13524afaab5c"), - kind=UserKinds.BOT, - ), - ], - name="Open Channel Example", - shared_history=False, + assert_deep_equal( + chat_info, + ChatInfo( + chat_type=ChatTypes.CHANNEL, + creator_id=None, + description=None, + chat_id=UUID("e53d5080-68f7-5050-bb4f-005efd375612"), + created_at=datetime_formatter("2023-10-26T07:49:53.821672Z"), + members=[ + ChatInfoMember( + is_admin=True, + huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + kind=UserKinds.CTS_USER, + ), + ChatInfoMember( + is_admin=False, + huid=UUID("705df263-6bfd-536a-9d51-13524afaab5c"), + kind=UserKinds.BOT, + ), + ], + name="Open Channel Example", + shared_history=False, + ), ) assert endpoint.called diff --git a/tests/client/chats_api/test_create_chat.py b/tests/client/chats_api/test_create_chat.py index 4abca1d9..7c73fa01 100644 --- a/tests/client/chats_api/test_create_chat.py +++ b/tests/client/chats_api/test_create_chat.py @@ -1,63 +1,77 @@ from http import HTTPStatus +from typing import Any +from collections.abc import Sequence from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - ChatCreationError, - ChatCreationProhibitedError, - ChatTypes, - HandlerCollector, - lifespan_wrapper, -) +from pybotx import ChatCreationError, ChatCreationProhibitedError, ChatTypes +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload pytestmark = [ - pytest.mark.asyncio, pytest.mark.mock_authorization, pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v3/botx/chats/create" + +REQUEST_BASE = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "name": "Test chat name", + "description": None, + "chat_type": "group_chat", + "members": [], + "avatar": None, + }, +) + -async def test__create_chat__bot_have_no_permissions_raised( +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("response_status", "response_json", "expected_exc", "expected_fragments"), + [ + ( + HTTPStatus.FORBIDDEN, + error_payload( + "chat_creation_is_prohibited", + errors=["This bot is not allowed to create chats"], + error_data={ + "bot_id": "a465f0f3-1354-491c-8f11-f400164295cb", + }, + ), + ChatCreationProhibitedError, + ("chat_creation_is_prohibited",), + ), + ( + HTTPStatus.UNPROCESSABLE_ENTITY, + error_payload( + "|specified reason|", + errors=["|specified errors|"], + ), + ChatCreationError, + ("specified reason",), + ), + ], +) +async def test__create_chat__error_response( + response_status: int, + response_json: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/create", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "name": "Test chat name", - "description": None, - "chat_type": "group_chat", - "members": [], - "avatar": None, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.FORBIDDEN, - json={ - "status": "error", - "reason": "chat_creation_is_prohibited", - "errors": ["This bot is not allowed to create chats"], - "error_data": { - "bot_id": "a465f0f3-1354-491c-8f11-f400164295cb", - }, - }, - ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx(respx_mock, host, REQUEST_BASE, response_json, response_status) # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(ChatCreationProhibitedError) as exc: + async with bot_factory() as bot: + with pytest.raises(expected_exc) as exc: await bot.create_chat( bot_id=bot_id, name="Test chat name", @@ -66,95 +80,92 @@ async def test__create_chat__bot_have_no_permissions_raised( ) # - Assert - + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called - assert "chat_creation_is_prohibited" in str(exc.value) -async def test__create_chat__botx_error_raised( +@pytest.mark.asyncio +async def test__create_chat__maximum_filled_succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/create", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="POST", + path=ENDPOINT, json={ "name": "Test chat name", - "description": None, + "description": "Test description", "chat_type": "group_chat", - "members": [], + "members": ["2fc83441-366a-49ba-81fc-6c39f065bb58"], + "shared_history": True, "avatar": None, }, - ).mock( - return_value=httpx.Response( - HTTPStatus.UNPROCESSABLE_ENTITY, - json={ - "status": "error", - "reason": "|specified reason|", - "errors": ["|specified errors|"], - "error_data": {}, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(ChatCreationError) as exc: - await bot.create_chat( - bot_id=bot_id, - name="Test chat name", - chat_type=ChatTypes.GROUP_CHAT, - huids=[], - ) + async with bot_factory() as bot: + created_chat_id = await bot.create_chat( + bot_id=bot_id, + name="Test chat name", + chat_type=ChatTypes.GROUP_CHAT, + huids=[UUID("2fc83441-366a-49ba-81fc-6c39f065bb58")], + description="Test description", + shared_history=True, + ) # - Assert - + assert created_chat_id == UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa") assert endpoint.called - assert "specified reason" in str(exc.value) -async def test__create_chat__maximum_filled_succeed( +@pytest.mark.asyncio +async def test__create_chat__with_valid_avatar_succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/create", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + valid_avatar = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + + request = BotXRequest( + method="POST", + path=ENDPOINT, json={ "name": "Test chat name", - "description": "Test description", + "description": None, "chat_type": "group_chat", - "members": ["2fc83441-366a-49ba-81fc-6c39f065bb58"], - "shared_history": True, - "avatar": None, + "members": [], + "avatar": valid_avatar, }, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": {"chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: created_chat_id = await bot.create_chat( bot_id=bot_id, name="Test chat name", chat_type=ChatTypes.GROUP_CHAT, - huids=[UUID("2fc83441-366a-49ba-81fc-6c39f065bb58")], - description="Test description", - shared_history=True, + huids=[], + avatar=valid_avatar, ) # - Assert - @@ -209,7 +220,7 @@ def test__create_chat_payload__convert_chat_type_validator() -> None: result = BotXAPICreateChatRequestPayload._convert_chat_type(values) # type: ignore[operator] assert result["chat_type"] == APIChatTypes.GROUP_CHAT - # Test with non-ChatTypes value (should remain unchanged) + # Test with APIChatTypes value (should remain unchanged) values = {"chat_type": APIChatTypes.CHAT} # type: ignore[dict-item] result = BotXAPICreateChatRequestPayload._convert_chat_type(values) # type: ignore[operator] assert result["chat_type"] == APIChatTypes.CHAT @@ -220,47 +231,32 @@ def test__create_chat_payload__convert_chat_type_validator() -> None: assert result == {"name": "test"} -async def test__create_chat__with_valid_avatar_succeed( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - valid_avatar = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/create", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "name": "Test chat name", - "description": None, - "chat_type": "group_chat", - "members": [], - "avatar": valid_avatar, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": {"chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, - }, - ), +def test__create_chat_payload__serialize_chat_type_from_domain() -> None: + """Test that chat_type serialization converts ChatTypes to API value.""" + from pybotx.client.chats_api.create_chat import BotXAPICreateChatRequestPayload + from pybotx.models.enums import ChatTypes, APIChatTypes + from pybotx.missing import Undefined + + payload = BotXAPICreateChatRequestPayload( + name="Test chat name", + description=None, + chat_type=ChatTypes.PERSONAL_CHAT, + members=[], + shared_history=Undefined, + avatar=None, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + dumped = payload.model_dump(mode="json", exclude={"shared_history"}) + assert dumped["chat_type"] == "chat" - # - Act - - async with lifespan_wrapper(built_bot) as bot: - created_chat_id = await bot.create_chat( - bot_id=bot_id, - name="Test chat name", - chat_type=ChatTypes.GROUP_CHAT, - huids=[], - avatar=valid_avatar, - ) + payload_api = BotXAPICreateChatRequestPayload( + name="Test chat name", + description=None, + chat_type=APIChatTypes.GROUP_CHAT, + members=[], + shared_history=Undefined, + avatar=None, + ) - # - Assert - - assert created_chat_id == UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa") - assert endpoint.called + dumped_api = payload_api.model_dump(mode="json", exclude={"shared_history"}) + assert dumped_api["chat_type"] == "group_chat" diff --git a/tests/client/chats_api/test_create_chat_link.py b/tests/client/chats_api/test_create_chat_link.py new file mode 100644 index 00000000..bf9b15a4 --- /dev/null +++ b/tests/client/chats_api/test_create_chat_link.py @@ -0,0 +1,221 @@ +from http import HTTPStatus +from typing import Any +from uuid import UUID + +import pytest +from respx.router import MockRouter + +from pybotx import ( + ChatLink, + ChatLinkCreationError, + ChatLinkCreationProhibitedError, + ChatLinkTypes, + ChatNotFoundError, + InvalidBotXStatusCodeError, +) +from tests.testkit import ( + BotXRequest, + assert_deep_equal, + error_payload, + mock_botx, + ok_payload, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + +ENDPOINT = "/api/v3/botx/chats/create_link" + + +@pytest.fixture +def chat_id() -> str: + return "f102c2a6-bae5-5ade-9ace-10e5bd96102d" + + +async def test__create_chat_link__succeed( + respx_mock: MockRouter, + host: str, + chat_id: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + # - Arrange - + request = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "chat_id": chat_id, + "link": { + "link_type": "public", + "access_code": "1234", + "link_ttl": 3600, + }, + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload( + { + "url": "https://xlnk.ms/ASjalqtRVgGZQrtFCfJI8w", + "link_type": "public", + "access_code": "1234", + "link_ttl": 3600, + }, + ), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + chat_link = await bot.create_chat_link( + bot_id=bot_id, + chat_id=UUID(chat_id), + link_type=ChatLinkTypes.PUBLIC, + access_code="1234", + link_ttl=3600, + ) + + # - Assert - + assert_deep_equal( + chat_link, + ChatLink( + url="https://xlnk.ms/ASjalqtRVgGZQrtFCfJI8w", + link_type=ChatLinkTypes.PUBLIC, + access_code="1234", + link_ttl=3600, + ), + ) + assert endpoint.called + + +@pytest.mark.parametrize( + ("return_json", "response_status", "expected_exc_type"), + ( + ( + { + "status": "error", + "reason": "chat_not_found", + "errors": [], + "error_data": {}, + }, + HTTPStatus.NOT_FOUND, + ChatNotFoundError, + ), + ( + { + "status": "error", + "reason": "no_permission_for_operation", + "errors": [], + "error_data": {}, + }, + HTTPStatus.FORBIDDEN, + ChatLinkCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "error_from_messaging_service", + "errors": [], + "error_data": { + "error_description": "Messaging service returns error", + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + "reason": "some_error", + }, + }, + HTTPStatus.INTERNAL_SERVER_ERROR, + ChatLinkCreationError, + ), + ), +) +async def test__create_chat_link__error_response( + return_json: dict[str, Any], + response_status: int, + expected_exc_type: type[Exception], + respx_mock: MockRouter, + host: str, + chat_id: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + # - Arrange - + request = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "chat_id": chat_id, + "link": { + "link_type": "public", + "access_code": "1234", + "link_ttl": 3600, + }, + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + return_json, + response_status, + ) + + # - Act - + async with bot_factory() as bot: + with pytest.raises(expected_exc_type): + await bot.create_chat_link( + bot_id=bot_id, + chat_id=UUID(chat_id), + link_type=ChatLinkTypes.PUBLIC, + access_code="1234", + link_ttl=3600, + ) + + # - Assert - + assert endpoint.called + + +async def test__create_chat_link__unknown_server_error_reason( + respx_mock: MockRouter, + host: str, + chat_id: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + # - Arrange - + request = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "chat_id": chat_id, + "link": { + "link_type": "public", + "access_code": "1234", + "link_ttl": 3600, + }, + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + error_payload("unknown_reason"), + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + # - Act - + async with bot_factory() as bot: + with pytest.raises(InvalidBotXStatusCodeError): + await bot.create_chat_link( + bot_id=bot_id, + chat_id=UUID(chat_id), + link_type=ChatLinkTypes.PUBLIC, + access_code="1234", + link_ttl=3600, + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/chats_api/test_create_thread.py b/tests/client/chats_api/test_create_thread.py index 5c719596..3e2b6eb8 100644 --- a/tests/client/chats_api/test_create_thread.py +++ b/tests/client/chats_api/test_create_thread.py @@ -1,23 +1,17 @@ -from collections.abc import Callable from http import HTTPStatus from typing import Any from uuid import UUID -import httpx import pytest -from respx import Route from respx.router import MockRouter from pybotx import ( - Bot, - BotAccountWithSecret, EventNotFoundError, - HandlerCollector, ThreadAlreadyExistsError, ThreadCreationError, ThreadCreationProhibitedError, - lifespan_wrapper, ) +from tests.testkit import BotXRequest, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -25,7 +19,7 @@ pytest.mark.usefixtures("respx_mock"), ] -ENDPOINT = "api/v3/botx/chats/create_thread" +ENDPOINT = "/api/v3/botx/chats/create_thread" @pytest.fixture @@ -33,44 +27,25 @@ def sync_id() -> str: return "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" -@pytest.fixture -def create_mocked_endpoint( +async def test__create_thread__succeed( respx_mock: MockRouter, host: str, sync_id: str, -) -> Callable[[dict[str, Any], int], Route]: - def mocked_endpoint(json_response: dict[str, Any], status_code: int) -> Route: - return respx_mock.post( - f"https://{host}/{ENDPOINT}", - headers={ - "Authorization": "Bearer token", - "Content-Type": "application/json", - }, - json={"sync_id": sync_id}, - ).mock(return_value=httpx.Response(status_code, json=json_response)) - - return mocked_endpoint - - -async def test__create_thread__succeed( - create_mocked_endpoint: Callable[[dict[str, Any], int], Route], - sync_id: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = create_mocked_endpoint( - { - "status": "ok", - "result": {"thread_id": sync_id}, - }, + request = BotXRequest(method="POST", path=ENDPOINT, json={"sync_id": sync_id}) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"thread_id": sync_id}), HTTPStatus.OK, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: created_thread_id = await bot.create_thread( bot_id=bot_id, sync_id=UUID(sync_id), @@ -225,20 +200,21 @@ async def test__create_thread__succeed( ), ) async def test__create_thread__botx_error_raised( - create_mocked_endpoint: Callable[[dict[str, Any], int], Route], + respx_mock: MockRouter, + host: str, sync_id: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, return_json: dict[str, Any], response_status: int, expected_exc_type: type[BaseException], ) -> None: # - Arrange - - endpoint = create_mocked_endpoint(return_json, response_status) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + request = BotXRequest(method="POST", path=ENDPOINT, json={"sync_id": sync_id}) + endpoint = mock_botx(respx_mock, host, request, return_json, response_status) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: with pytest.raises(expected_exc_type) as exc: await bot.create_thread( bot_id=bot_id, diff --git a/tests/client/chats_api/test_disable_stealth.py b/tests/client/chats_api/test_disable_stealth.py index cdf452bb..73cb4da7 100644 --- a/tests/client/chats_api/test_disable_stealth.py +++ b/tests/client/chats_api/test_disable_stealth.py @@ -1,18 +1,13 @@ from http import HTTPStatus +from typing import Any +from collections.abc import Sequence from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - ChatNotFoundError, - HandlerCollector, - PermissionDeniedError, - lifespan_wrapper, -) +from pybotx import ChatNotFoundError, PermissionDeniedError +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -20,85 +15,69 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v3/botx/chats/stealth_disable" -async def test__disable_stealth__permission_denied_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/stealth_disable", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, - ).mock( - return_value=httpx.Response( +REQUEST = BotXRequest( + method="POST", + path=ENDPOINT, + json={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, +) + + +@pytest.mark.parametrize( + ("response_status", "response_json", "expected_exc", "expected_fragments"), + [ + ( HTTPStatus.FORBIDDEN, - json={ - "status": "error", - "reason": "no_permission_for_operation", - "errors": ["Sender is not chat admin"], - "error_data": { + error_payload( + "no_permission_for_operation", + errors=["Sender is not chat admin"], + error_data={ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", "sender": "a465f0f3-1354-491c-8f11-f400164295cb", }, - }, + ), + PermissionDeniedError, + ("no_permission_for_operation",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(PermissionDeniedError) as exc: - await bot.disable_stealth( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - ) - - # - Assert - - assert "no_permission_for_operation" in str(exc.value) - assert endpoint.called - - -async def test__disable_stealth__chat_not_found_raised( + ( + HTTPStatus.NOT_FOUND, + error_payload( + "chat_not_found", + errors=["Chat not found"], + error_data={ + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + ), + ChatNotFoundError, + ("chat_not_found",), + ), + ], +) +async def test__disable_stealth__error_response( + response_status: int, + response_json: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/stealth_disable", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "chat_not_found", - "errors": ["Chat not found"], - "error_data": { - "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", - }, - }, - ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx(respx_mock, host, REQUEST, response_json, response_status) # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(ChatNotFoundError) as exc: + async with bot_factory() as bot: + with pytest.raises(expected_exc) as exc: await bot.disable_stealth( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), ) # - Assert - - assert "chat_not_found" in str(exc.value) + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called @@ -106,24 +85,13 @@ async def test__disable_stealth__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/stealth_disable", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={"status": "ok", "result": True}, - ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx(respx_mock, host, REQUEST, ok_payload(True), HTTPStatus.OK) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: await bot.disable_stealth( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), diff --git a/tests/client/chats_api/test_list_chats.py b/tests/client/chats_api/test_list_chats.py index c64f4224..6952b766 100644 --- a/tests/client/chats_api/test_list_chats.py +++ b/tests/client/chats_api/test_list_chats.py @@ -1,20 +1,14 @@ from datetime import datetime from http import HTTPStatus -from typing import Callable +from typing import Any +from collections.abc import Callable from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - ChatListItem, - ChatTypes, - HandlerCollector, - lifespan_wrapper, -) +from pybotx import ChatListItem, ChatTypes +from tests.testkit import BotXRequest, assert_deep_equal, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -22,64 +16,69 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v3/botx/chats/list" + +REQUEST = BotXRequest( + method="GET", + path=ENDPOINT, +) + async def test__list_chats__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, datetime_formatter: Callable[[str], datetime], + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/chats/list", - headers={"Authorization": "Bearer token"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": [ - { - "group_chat_id": "740cf331-d833-5250-b5a5-5b5cbc697ff5", - "chat_type": "group_chat", - "name": "Chat Name", - "description": "Desc", - "members": [ - "6fafda2c-6505-57a5-a088-25ea5d1d0364", - "705df263-6bfd-536a-9d51-13524afaab5c", - ], - "inserted_at": "2019-08-29T11:22:48.358586Z", - "updated_at": "2019-08-30T21:02:10.453786Z", - "shared_history": False, - }, - ], - }, + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload( + [ + { + "group_chat_id": "740cf331-d833-5250-b5a5-5b5cbc697ff5", + "chat_type": "group_chat", + "name": "Chat Name", + "description": "Desc", + "members": [ + "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "705df263-6bfd-536a-9d51-13524afaab5c", + ], + "inserted_at": "2019-08-29T11:22:48.358586Z", + "updated_at": "2019-08-30T21:02:10.453786Z", + "shared_history": False, + }, + ], ), + HTTPStatus.OK, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: chats = await bot.list_chats(bot_id=bot_id) # - Assert - - assert chats == [ - ChatListItem( - chat_id=UUID("740cf331-d833-5250-b5a5-5b5cbc697ff5"), - chat_type=ChatTypes.GROUP_CHAT, - name="Chat Name", - description="Desc", - members=[ - UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), - UUID("705df263-6bfd-536a-9d51-13524afaab5c"), - ], - created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), - updated_at=datetime_formatter("2019-08-30T21:02:10.453786Z"), - shared_history=False, - ), - ] + assert_deep_equal( + chats, + [ + ChatListItem( + chat_id=UUID("740cf331-d833-5250-b5a5-5b5cbc697ff5"), + chat_type=ChatTypes.GROUP_CHAT, + name="Chat Name", + description="Desc", + members=[ + UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + UUID("705df263-6bfd-536a-9d51-13524afaab5c"), + ], + created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), + updated_at=datetime_formatter("2019-08-30T21:02:10.453786Z"), + shared_history=False, + ), + ], + ) assert endpoint.called @@ -87,72 +86,70 @@ async def test__list_chats__unsupported_chats_types( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, datetime_formatter: Callable[[str], datetime], loguru_caplog: pytest.LogCaptureFixture, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/chats/list", - headers={"Authorization": "Bearer token"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": [ - { - "group_chat_id": "740cf331-d833-5250-b5a5-5b5cbc697ff5", - "chat_type": "group_chat", - "name": "Chat Name", - "description": "Desc", - "members": [ - "6fafda2c-6505-57a5-a088-25ea5d1d0364", - "705df263-6bfd-536a-9d51-13524afaab5c", - ], - "inserted_at": "2019-08-29T11:22:48.358586Z", - "updated_at": "2019-08-30T21:02:10.453786Z", - "shared_history": False, - }, - { - "group_chat_id": "c7faf797-5470-4d18-9b1c-379bb8b24d48", - "chat_type": "unsupported_chat_type", - "name": "Chat Name", - "description": "Desc", - "members": [ - "0a2d036d-2257-4ffa-9cbc-e5bb8afe4d08", - "b54ace7a-a041-4dc0-ad83-1d5f8d635654", - ], - "inserted_at": "2019-08-29T11:22:48.358586Z", - "updated_at": "2019-08-30T21:02:10.453786Z", - "shared_history": False, - }, - ], - }, + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload( + [ + { + "group_chat_id": "740cf331-d833-5250-b5a5-5b5cbc697ff5", + "chat_type": "group_chat", + "name": "Chat Name", + "description": "Desc", + "members": [ + "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "705df263-6bfd-536a-9d51-13524afaab5c", + ], + "inserted_at": "2019-08-29T11:22:48.358586Z", + "updated_at": "2019-08-30T21:02:10.453786Z", + "shared_history": False, + }, + { + "group_chat_id": "c7faf797-5470-4d18-9b1c-379bb8b24d48", + "chat_type": "unsupported_chat_type", + "name": "Chat Name", + "description": "Desc", + "members": [ + "0a2d036d-2257-4ffa-9cbc-e5bb8afe4d08", + "b54ace7a-a041-4dc0-ad83-1d5f8d635654", + ], + "inserted_at": "2019-08-29T11:22:48.358586Z", + "updated_at": "2019-08-30T21:02:10.453786Z", + "shared_history": False, + }, + ], ), + HTTPStatus.OK, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: chats = await bot.list_chats(bot_id=bot_id) # - Assert - assert "One or more unsupported chat types skipped" in loguru_caplog.text - assert chats == [ - ChatListItem( - chat_id=UUID("740cf331-d833-5250-b5a5-5b5cbc697ff5"), - chat_type=ChatTypes.GROUP_CHAT, - name="Chat Name", - description="Desc", - members=[ - UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), - UUID("705df263-6bfd-536a-9d51-13524afaab5c"), - ], - created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), - updated_at=datetime_formatter("2019-08-30T21:02:10.453786Z"), - shared_history=False, - ), - ] + assert_deep_equal( + chats, + [ + ChatListItem( + chat_id=UUID("740cf331-d833-5250-b5a5-5b5cbc697ff5"), + chat_type=ChatTypes.GROUP_CHAT, + name="Chat Name", + description="Desc", + members=[ + UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + UUID("705df263-6bfd-536a-9d51-13524afaab5c"), + ], + created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), + updated_at=datetime_formatter("2019-08-30T21:02:10.453786Z"), + shared_history=False, + ), + ], + ) assert endpoint.called diff --git a/tests/client/chats_api/test_personal_chat.py b/tests/client/chats_api/test_personal_chat.py index 99449007..6d6a70c0 100644 --- a/tests/client/chats_api/test_personal_chat.py +++ b/tests/client/chats_api/test_personal_chat.py @@ -2,25 +2,19 @@ from datetime import datetime as dt from http import HTTPStatus -from typing import Callable, Any +from typing import Any +from collections.abc import Callable, Sequence from uuid import UUID -import httpx import pytest -from deepdiff import DeepDiff from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - ChatNotFoundError, - HandlerCollector, - lifespan_wrapper, -) +from pybotx import ChatNotFoundError from tests.client.chats_api.factories import ( APIPersonalChatResponseFactory, ChatInfoFactory, ) +from tests.testkit import BotXRequest, assert_deep_equal, error_payload, mock_botx pytestmark = [ pytest.mark.asyncio, @@ -28,45 +22,55 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v1/botx/chats/personal" + -async def test__personal_chat__chat_not_found_error_raised( +@pytest.mark.parametrize( + ("response_status", "response_json", "expected_exc", "expected_fragments"), + [ + ( + HTTPStatus.NOT_FOUND, + error_payload( + "chat_not_found", + error_data={ + "user_huid": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + "error_description": "Chat with user dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4 not found", + }, + ), + ChatNotFoundError, + ("chat_not_found",), + ), + ], +) +async def test__personal_chat__error_response( + response_status: int, + response_json: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v1/botx/chats/personal", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path=ENDPOINT, params={"user_huid": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "chat_not_found", - "errors": [], - "error_data": { - "user_huid": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", - "error_description": "Chat with user dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4 not found", - }, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx(respx_mock, host, request, response_json, response_status) # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(ChatNotFoundError) as exc: + async with bot_factory() as bot: + with pytest.raises(expected_exc) as exc: await bot.personal_chat( bot_id=bot_id, user_huid=UUID("dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4"), ) # - Assert - - assert "chat_not_found" in str(exc.value) + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called @@ -75,26 +79,20 @@ async def test__personal_chat__succeed( host: str, bot_id: UUID, datetime_formatter: Callable[[str], dt], - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - api_response: Any = APIPersonalChatResponseFactory() # type: ignore[no-untyped-call] - endpoint = respx_mock.get( - f"https://{host}/api/v1/botx/chats/personal", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path=ENDPOINT, params={"user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json=api_response, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx(respx_mock, host, request, api_response, HTTPStatus.OK) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: chat_info = await bot.personal_chat( bot_id=bot_id, user_huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), @@ -105,9 +103,7 @@ async def test__personal_chat__succeed( created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), ) # type: ignore[no-untyped-call] - diff = DeepDiff(chat_info, expected_chat_info) - assert diff == {}, diff - + assert_deep_equal(chat_info, expected_chat_info) assert endpoint.called @@ -116,8 +112,8 @@ async def test__personal_chat__skipped_members( host: str, bot_id: UUID, datetime_formatter: Callable[[str], dt], - bot_account: BotAccountWithSecret, loguru_caplog: pytest.LogCaptureFixture, + bot_factory: Any, ) -> None: # - Arrange - api_response: Any = APIPersonalChatResponseFactory() # type: ignore[no-untyped-call] @@ -130,21 +126,15 @@ async def test__personal_chat__skipped_members( } ) - endpoint = respx_mock.get( - f"https://{host}/api/v1/botx/chats/personal", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path=ENDPOINT, params={"user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json=api_response, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx(respx_mock, host, request, api_response, HTTPStatus.OK) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: chat_info = await bot.personal_chat( bot_id=bot_id, user_huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), @@ -155,8 +145,6 @@ async def test__personal_chat__skipped_members( created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), ) # type: ignore[no-untyped-call] - diff = DeepDiff(chat_info, expected_chat_info) - assert diff == {}, diff - + assert_deep_equal(chat_info, expected_chat_info) assert "Unsupported user type skipped in members list" in loguru_caplog.text assert endpoint.called diff --git a/tests/client/chats_api/test_personal_chat_additional.py b/tests/client/chats_api/test_personal_chat_additional.py index 54254027..e727ae07 100644 --- a/tests/client/chats_api/test_personal_chat_additional.py +++ b/tests/client/chats_api/test_personal_chat_additional.py @@ -1,6 +1,6 @@ import uuid from datetime import datetime -from typing import Any, Dict +from typing import Any from uuid import UUID @@ -12,6 +12,7 @@ BotXAPIPersonalChatResponsePayload, ) from pybotx.models.enums import APIUserKinds, APIChatTypes +from pybotx.models.enums import ChatTypes def test_request_payload_as_query_params_returns_string_uuid() -> None: @@ -27,7 +28,7 @@ def test_parse_members_various_types() -> None: """Проверяем все ветки _parse_members: dict → модель, готовый экземпляр и неизвестный тип.""" uid = uuid.uuid4() - dict_valid: Dict[str, Any] = { + dict_valid: dict[str, Any] = { "admin": True, "user_huid": str(uid), "user_kind": APIUserKinds.USER.value, @@ -46,6 +47,13 @@ def test_parse_members_various_types() -> None: assert parsed[1] is member_instance +def test_parse_members_accepts_uuid_and_skips_invalid_string() -> None: + uid = uuid.uuid4() + parsed = BotXAPIPersonalChatResult._parse_members([uid, "not-a-uuid"]) + + assert parsed == [uid] + + def test_to_domain_handles_conversion_error(monkeypatch: Any) -> None: """Если convert_user_kind_to_domain падает — to_domain игнорирует участника.""" uid = UUID("00000000-0000-0000-0000-000000000001") @@ -81,7 +89,7 @@ def fake_convert(kind: Any) -> None: def test_to_domain_skips_unsupported_member_type() -> None: """Если в result.members передан не-BotXAPIPersonalChatMember — пропускаем.""" uid = UUID("00000000-0000-0000-0000-000000000002") - unsupported: Dict[str, Any] = {"foo": "bar"} + unsupported: dict[str, Any] = {"foo": "bar"} result = BotXAPIPersonalChatResult( chat_type=APIChatTypes.CHAT, creator=None, @@ -97,3 +105,29 @@ def test_to_domain_skips_unsupported_member_type() -> None: chat_info = payload.to_domain() assert chat_info.members == [] assert chat_info.chat_id == uid + + +def test_personal_chat_response_accepts_uuid_members_and_updated_at() -> None: + payload = BotXAPIPersonalChatResponsePayload.model_validate( + { + "status": "ok", + "result": { + "name": "botx personal chat", + "description": "", + "members": [ + "5ba1081e-bd29-524d-81b8-59e18d81a2bc", + "043a8472-0ec8-5f35-a5a4-3f3ef3ae4aa9", + ], + "updated_at": "2025-10-28T12:20:20.755183Z", + "group_chat_id": "5f9b8c6c-b3e1-0d78-241c-66df7e2fe815", + "chat_type": "chat", + "shared_history": False, + "inserted_at": "2025-03-18T17:26:09.804178Z", + }, + } + ) + + chat_info = payload.to_domain() + + assert chat_info.chat_id == UUID("5f9b8c6c-b3e1-0d78-241c-66df7e2fe815") + assert chat_info.chat_type == ChatTypes.PERSONAL_CHAT diff --git a/tests/client/chats_api/test_pin_message.py b/tests/client/chats_api/test_pin_message.py index 9c8b2b95..f0d7a8f8 100644 --- a/tests/client/chats_api/test_pin_message.py +++ b/tests/client/chats_api/test_pin_message.py @@ -1,18 +1,13 @@ from http import HTTPStatus +from typing import Any +from collections.abc import Sequence from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - ChatNotFoundError, - HandlerCollector, - PermissionDeniedError, - lifespan_wrapper, -) +from pybotx import ChatNotFoundError, PermissionDeniedError +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -20,87 +15,64 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v3/botx/chats/pin_message" -async def test__pin_message__permission_denied_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/pin_message", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - }, - ).mock( - return_value=httpx.Response( +REQUEST = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + }, +) + + +@pytest.mark.parametrize( + ("response_status", "response_json", "expected_exc", "expected_fragments"), + [ + ( HTTPStatus.FORBIDDEN, - json={ - "error_data": { + error_payload( + "no_permission_for_operation", + error_data={ "bot_id": "f9e1c958-bf81-564e-bff2-a2943869af15", "error_description": "Bot doesn't have permission for this operation in current chat", "group_chat_id": "5680c26a-07a5-5b40-a6ff-f5e7e68fed25", }, - "errors": [], - "reason": "no_permission_for_operation", - "status": "error", - }, + ), + PermissionDeniedError, + ("no_permission_for_operation",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(PermissionDeniedError) as exc: - await bot.pin_message( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - sync_id=UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"), - ) - - # - Assert - - assert "no_permission_for_operation" in str(exc.value) - assert endpoint.called - - -async def test__pin_message__chat_not_found_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/pin_message", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - }, - ).mock( - return_value=httpx.Response( + ( HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "chat_not_found", - "errors": [], - "error_data": { + error_payload( + "chat_not_found", + error_data={ "error_description": "Chat with specified id not found", "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", }, - }, + ), + ChatNotFoundError, + ("chat_not_found",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + ], +) +async def test__pin_message__error_response( + response_status: int, + response_json: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + # - Arrange - + endpoint = mock_botx(respx_mock, host, REQUEST, response_json, response_status) # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(ChatNotFoundError) as exc: + async with bot_factory() as bot: + with pytest.raises(expected_exc) as exc: await bot.pin_message( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), @@ -108,7 +80,8 @@ async def test__pin_message__chat_not_found_error_raised( ) # - Assert - - assert "chat_not_found" in str(exc.value) + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called @@ -116,27 +89,19 @@ async def test__pin_message__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/pin_message", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={"status": "ok", "result": "message_pinned"}, - ), + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload("message_pinned"), + HTTPStatus.OK, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: await bot.pin_message( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), diff --git a/tests/client/chats_api/test_remove_user.py b/tests/client/chats_api/test_remove_user.py index 64bff220..da11379e 100644 --- a/tests/client/chats_api/test_remove_user.py +++ b/tests/client/chats_api/test_remove_user.py @@ -1,18 +1,13 @@ from http import HTTPStatus +from typing import Any +from collections.abc import Sequence from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - ChatNotFoundError, - HandlerCollector, - PermissionDeniedError, - lifespan_wrapper, -) +from pybotx import ChatNotFoundError, PermissionDeniedError +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -20,85 +15,64 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v3/botx/chats/remove_user" -async def test__remove_users_from_chat__permission_denied_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/remove_user", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( +REQUEST = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, +) + + +@pytest.mark.parametrize( + ("response_status", "response_json", "expected_exc", "expected_fragments"), + [ + ( + HTTPStatus.NOT_FOUND, + error_payload( + "chat_not_found", + errors=["Chat not found"], + error_data={ + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + ), + ChatNotFoundError, + ("chat_not_found",), + ), + ( HTTPStatus.FORBIDDEN, - json={ - "status": "error", - "reason": "no_permission_for_operation", - "errors": ["Sender is not chat admin"], - "error_data": { + error_payload( + "no_permission_for_operation", + errors=["Sender is not chat admin"], + error_data={ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", "sender": "a465f0f3-1354-491c-8f11-f400164295cb", }, - }, + ), + PermissionDeniedError, + ("no_permission_for_operation",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(PermissionDeniedError) as exc: - await bot.remove_users_from_chat( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], - ) - - # - Assert - - assert "no_permission_for_operation" in str(exc.value) - assert endpoint.called - - -async def test__remove_users_from_chat__chat_not_found_error_raised( + ], +) +async def test__remove_users_from_chat__error_response( + response_status: int, + response_json: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/remove_user", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "chat_not_found", - "errors": ["Chat not found"], - "error_data": { - "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", - }, - }, - ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx(respx_mock, host, REQUEST, response_json, response_status) # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(ChatNotFoundError) as exc: + async with bot_factory() as bot: + with pytest.raises(expected_exc) as exc: await bot.remove_users_from_chat( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), @@ -106,7 +80,8 @@ async def test__remove_users_from_chat__chat_not_found_error_raised( ) # - Assert - - assert "chat_not_found" in str(exc.value) + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called @@ -114,27 +89,19 @@ async def test__remove_users_from_chat__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/remove_user", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={"status": "ok", "result": True}, - ), + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload(True), + HTTPStatus.OK, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: await bot.remove_users_from_chat( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), diff --git a/tests/client/chats_api/test_set_stealth.py b/tests/client/chats_api/test_set_stealth.py index 21f373df..7b35edea 100644 --- a/tests/client/chats_api/test_set_stealth.py +++ b/tests/client/chats_api/test_set_stealth.py @@ -1,18 +1,13 @@ from http import HTTPStatus +from typing import Any +from collections.abc import Sequence from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - ChatNotFoundError, - HandlerCollector, - PermissionDeniedError, - lifespan_wrapper, -) +from pybotx import ChatNotFoundError, PermissionDeniedError +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -20,89 +15,82 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v3/botx/chats/stealth_set" -async def test__enable_stealth__permission_denied_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/stealth_set", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - }, - ).mock( - return_value=httpx.Response( +REQUEST_BASE = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + }, +) + +REQUEST_FULL = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "disable_web": True, + "burn_in": 100, + "expire_in": 1000, + }, +) + + +@pytest.mark.parametrize( + ("response_status", "response_json", "expected_exc", "expected_fragments"), + [ + ( HTTPStatus.FORBIDDEN, - json={ - "status": "error", - "reason": "no_permission_for_operation", - "errors": ["Sender is not chat admin"], - "error_data": { + error_payload( + "no_permission_for_operation", + errors=["Sender is not chat admin"], + error_data={ "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", "sender": "a465f0f3-1354-491c-8f11-f400164295cb", }, - }, + ), + PermissionDeniedError, + ("no_permission_for_operation",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(PermissionDeniedError) as exc: - await bot.enable_stealth( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - ) - - # - Assert - - assert "no_permission_for_operation" in str(exc.value) - assert endpoint.called - - -async def test__enable_stealth__chat_not_found_raised( + ( + HTTPStatus.NOT_FOUND, + error_payload( + "chat_not_found", + errors=["Chat not found"], + error_data={ + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + ), + ChatNotFoundError, + ("chat_not_found",), + ), + ], +) +async def test__enable_stealth__error_response( + response_status: int, + response_json: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/stealth_set", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "chat_not_found", - "errors": ["Chat not found"], - "error_data": { - "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", - }, - }, - ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx(respx_mock, host, REQUEST_BASE, response_json, response_status) # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(ChatNotFoundError) as exc: + async with bot_factory() as bot: + with pytest.raises(expected_exc) as exc: await bot.enable_stealth( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), ) # - Assert - - assert "chat_not_found" in str(exc.value) + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called @@ -110,32 +98,13 @@ async def test__enable_stealth__maximum_filled_succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/stealth_set", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "disable_web": True, - "burn_in": 100, - "expire_in": 1000, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": True, - }, - ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx(respx_mock, host, REQUEST_FULL, ok_payload(True), HTTPStatus.OK) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: await bot.enable_stealth( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), diff --git a/tests/client/chats_api/test_unpin_message.py b/tests/client/chats_api/test_unpin_message.py index fbf9983f..5e52d287 100644 --- a/tests/client/chats_api/test_unpin_message.py +++ b/tests/client/chats_api/test_unpin_message.py @@ -1,18 +1,13 @@ from http import HTTPStatus +from typing import Any +from collections.abc import Sequence from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - ChatNotFoundError, - HandlerCollector, - PermissionDeniedError, - lifespan_wrapper, -) +from pybotx import ChatNotFoundError, PermissionDeniedError +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -20,91 +15,71 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v3/botx/chats/unpin_message" -async def test__unpin_message__permission_denied_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/unpin_message", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - }, - ).mock( - return_value=httpx.Response( +REQUEST = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + }, +) + + +@pytest.mark.parametrize( + ("response_status", "response_json", "expected_exc", "expected_fragments"), + [ + ( HTTPStatus.FORBIDDEN, - json={ - "error_data": { + error_payload( + "no_permission_for_operation", + error_data={ "bot_id": "f9e1c958-bf81-564e-bff2-a2943869af15", "error_description": "Bot doesn't have permission for this operation in current chat", "group_chat_id": "5680c26a-07a5-5b40-a6ff-f5e7e68fed25", }, - "errors": [], - "reason": "no_permission_for_operation", - "status": "error", - }, + ), + PermissionDeniedError, + ("no_permission_for_operation",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(PermissionDeniedError) as exc: - await bot.unpin_message( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - ) - - # - Assert - - assert "no_permission_for_operation" in str(exc.value) - assert endpoint.called - - -async def test__unpin_message__chat_not_found_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/unpin_message", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - }, - ).mock( - return_value=httpx.Response( + ( HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "chat_not_found", - "errors": [], - "error_data": { + error_payload( + "chat_not_found", + error_data={ "error_description": "Chat with specified id not found", "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", }, - }, + ), + ChatNotFoundError, + ("chat_not_found",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + ], +) +async def test__unpin_message__error_response( + response_status: int, + response_json: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + # - Arrange - + endpoint = mock_botx(respx_mock, host, REQUEST, response_json, response_status) # - Act - - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(ChatNotFoundError) as exc: + async with bot_factory() as bot: + with pytest.raises(expected_exc) as exc: await bot.unpin_message( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), ) # - Assert - - assert "chat_not_found" in str(exc.value) + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called @@ -112,26 +87,19 @@ async def test__unpin_message__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/chats/unpin_message", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={"status": "ok", "result": "message_unpinned"}, - ), + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload("message_unpinned"), + HTTPStatus.OK, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: await bot.unpin_message( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), diff --git a/tests/client/events_api/test_message_status_event.py b/tests/client/events_api/test_message_status_event.py index daba82e6..22328bf2 100644 --- a/tests/client/events_api/test_message_status_event.py +++ b/tests/client/events_api/test_message_status_event.py @@ -1,6 +1,6 @@ from datetime import datetime from http import HTTPStatus -from typing import Callable +from collections.abc import Callable from uuid import UUID import httpx diff --git a/tests/client/files_api/test_download_file.py b/tests/client/files_api/test_download_file.py index b5050d61..535738aa 100644 --- a/tests/client/files_api/test_download_file.py +++ b/tests/client/files_api/test_download_file.py @@ -37,6 +37,7 @@ async def test__download_file__unexpected_not_found_error_raised( params={ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + "is_preview": False, }, headers={"Authorization": "Bearer token"}, ).mock( @@ -84,6 +85,7 @@ async def test__download_file__file_metadata_not_found_error_raised( params={ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + "is_preview": False, }, headers={"Authorization": "Bearer token"}, ).mock( @@ -132,6 +134,7 @@ async def test__download_file__file_deleted_error_raised( params={ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + "is_preview": False, }, headers={"Authorization": "Bearer token"}, ).mock( @@ -179,6 +182,7 @@ async def test__download_file__chat_not_found_error_raised( params={ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + "is_preview": False, }, headers={"Authorization": "Bearer token"}, ).mock( @@ -226,6 +230,7 @@ async def test__download_file__succeed( params={ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + "is_preview": False, }, headers={"Authorization": "Bearer token"}, ).mock( diff --git a/tests/client/files_api/test_upload_file.py b/tests/client/files_api/test_upload_file.py index 8d7d97d8..f1c2dc1e 100644 --- a/tests/client/files_api/test_upload_file.py +++ b/tests/client/files_api/test_upload_file.py @@ -114,7 +114,7 @@ async def test__download_file__succeed( # - Act - async with lifespan_wrapper(built_bot) as bot: - await bot.upload_file( + uploaded_file = await bot.upload_file( bot_id=bot_id, chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), async_buffer=async_buffer, @@ -122,4 +122,10 @@ async def test__download_file__succeed( ) # - Assert - + assert uploaded_file.file_preview == "https://link.to/preview" + assert uploaded_file.file_preview_height == 300 + assert uploaded_file.file_preview_width == 300 + assert uploaded_file.file_encryption_algo == "stream" + assert uploaded_file.chunk_size == 2097152 + assert uploaded_file.caption == "текст" assert endpoint.called diff --git a/tests/client/notifications_api/test_direct_notification.py b/tests/client/notifications_api/test_direct_notification.py index f9003c10..88f7ae99 100644 --- a/tests/client/notifications_api/test_direct_notification.py +++ b/tests/client/notifications_api/test_direct_notification.py @@ -1,9 +1,9 @@ import asyncio from http import HTTPStatus -from typing import Any, Callable, Dict +from typing import Any +from collections.abc import Callable, Sequence from uuid import UUID -import httpx import pytest from aiofiles.tempfile import NamedTemporaryFile from respx.router import MockRouter @@ -11,7 +11,6 @@ from pybotx import ( AnswerDestinationLookupError, Bot, - BotAccountWithSecret, BotIsNotChatMemberError, BubbleMarkup, ChatNotFoundError, @@ -24,8 +23,8 @@ OutgoingMessage, StealthModeDisabledError, UnknownBotAccountError, - lifespan_wrapper, ) +from tests.testkit import BotXRequest, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -33,20 +32,33 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v4/botx/notifications/direct" +CHAT_ID = "054af49e-5e18-4dca-ad73-4f96b6de63fa" +SYNC_ID = "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" + +BASE_REQUEST = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "group_chat_id": CHAT_ID, + "notification": {"status": "ok", "body": "Hi!"}, + }, +) + async def test__send__succeed( respx_mock: MockRouter, host: str, - bot_account: BotAccountWithSecret, bot_id: UUID, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="POST", + path=ENDPOINT, json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "group_chat_id": CHAT_ID, "notification": { "opts": { "silent_response": True, @@ -89,20 +101,19 @@ async def test__send__succeed( }, }, }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, ) payload = api_incoming_message_factory( bot_id=bot_id, host=host, - group_chat_id="054af49e-5e18-4dca-ad73-4f96b6de63fa", + group_chat_id=CHAT_ID, ) bubbles = BubbleMarkup() @@ -127,7 +138,7 @@ async def test__send__succeed( outgoing_message = OutgoingMessage( bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), body="Hi!", metadata={"foo": "bar"}, bubbles=bubbles, @@ -145,10 +156,8 @@ async def test__send__succeed( async def hello_handler(message: IncomingMessage, bot: Bot) -> None: await bot.send(message=outgoing_message) - built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory(collectors=[collector]) as bot: bot.async_execute_raw_bot_command(payload, verify_request=False) await asyncio.sleep(0) # Return control to event loop @@ -156,7 +165,7 @@ async def hello_handler(message: IncomingMessage, bot: Bot) -> None: await bot.set_raw_botx_method_result( { "status": "ok", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "sync_id": SYNC_ID, "result": {}, }, verify_request=False, @@ -167,15 +176,12 @@ async def hello_handler(message: IncomingMessage, bot: Bot) -> None: async def test__answer_message__no_incoming_message_error_raised( - host: str, - bot_account: BotAccountWithSecret, - bot_id: UUID, + bot_factory: Any, ) -> None: # - Arrange - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: with pytest.raises(AnswerDestinationLookupError) as exc: await bot.answer_message("Hi!") @@ -186,16 +192,16 @@ async def test__answer_message__no_incoming_message_error_raised( async def test__answer_message__succeed( respx_mock: MockRouter, host: str, - bot_account: BotAccountWithSecret, bot_id: UUID, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="POST", + path=ENDPOINT, json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "group_chat_id": CHAT_ID, "notification": { "status": "ok", "body": "Hi!", @@ -226,20 +232,19 @@ async def test__answer_message__succeed( "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=", }, }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, ) payload = api_incoming_message_factory( bot_id=bot_id, host=host, - group_chat_id="054af49e-5e18-4dca-ad73-4f96b6de63fa", + group_chat_id=CHAT_ID, ) bubbles = BubbleMarkup() @@ -272,10 +277,8 @@ async def hello_handler(message: IncomingMessage, bot: Bot) -> None: file=file, ) - built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory(collectors=[collector]) as bot: bot.async_execute_raw_bot_command(payload, verify_request=False) await asyncio.sleep(0) # Return control to event loop @@ -283,7 +286,7 @@ async def hello_handler(message: IncomingMessage, bot: Bot) -> None: await bot.set_raw_botx_method_result( { "status": "ok", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "sync_id": SYNC_ID, "result": {}, }, verify_request=False, @@ -296,246 +299,103 @@ async def hello_handler(message: IncomingMessage, bot: Bot) -> None: async def test__send_message__unknown_bot_account_error_raised( respx_mock: MockRouter, host: str, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - unknown_bot_id = UUID("51550ccc-dfd1-4d22-9b6f-a330145192b0") - direct_notification_endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", + endpoint = mock_botx( + respx_mock, + host, + BASE_REQUEST, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: with pytest.raises(UnknownBotAccountError) as exc: await bot.send_message( body="Hi!", bot_id=unknown_bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), ) # - Assert - - assert not direct_notification_endpoint.called assert str(unknown_bot_id) in str(exc.value) + assert not endpoint.called -async def test__send_message__chat_not_found_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "notification": {"status": "ok", "body": "Hi!"}, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, +@pytest.mark.parametrize( + ("reason", "error_data", "expected_exc", "expected_fragments"), + [ + ( + "chat_not_found", + { + "group_chat_id": CHAT_ID, + "error_description": "Chat with specified id not found", }, + ChatNotFoundError, + ("chat_not_found",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - task = asyncio.create_task( - bot.send_message( - body="Hi!", - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - ), - ) - - await asyncio.sleep(0) # Return control to event loop - - await bot.set_raw_botx_method_result( + ( + "bot_is_not_a_chat_member", { - "status": "error", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - "reason": "chat_not_found", - "errors": [], - "error_data": { - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "error_description": "Chat with specified id not found", - }, - }, - verify_request=False, - ) - - # - Assert - - with pytest.raises(ChatNotFoundError) as exc: - await task - - assert "chat_not_found" in str(exc.value) - assert endpoint.called - - -async def test__send_message__bot_is_not_a_chat_member_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "notification": {"status": "ok", "body": "Hi!"}, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + "group_chat_id": CHAT_ID, + "bot_id": "b165f00f-3154-412c-7f11-c120164257da", + "error_description": "Bot is not a chat member", }, + BotIsNotChatMemberError, + ("bot_is_not_a_chat_member",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - task = asyncio.create_task( - bot.send_message( - body="Hi!", - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - ), - ) - - await asyncio.sleep(0) # Return control to event loop - - await bot.set_raw_botx_method_result( + ( + "event_recipients_list_is_empty", { - "status": "error", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - "reason": "bot_is_not_a_chat_member", - "errors": [], - "error_data": { - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "bot_id": "b165f00f-3154-412c-7f11-c120164257da", - "error_description": "Bot is not a chat member", - }, - }, - verify_request=False, - ) - - # - Assert - - with pytest.raises(BotIsNotChatMemberError) as exc: - await task - - assert "bot_is_not_a_chat_member" in str(exc.value) - assert endpoint.called - - -async def test__send_message__event_recipients_list_is_empty_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "notification": {"status": "ok", "body": "Hi!"}, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + "group_chat_id": CHAT_ID, + "bot_id": "b165f00f-3154-412c-7f11-c120164257da", + "recipients_param": ["b165f00f-3154-412c-7f11-c120164257da"], + "error_description": "Event recipients list is empty", }, + FinalRecipientsListEmptyError, + ("event_recipients_list_is_empty",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - task = asyncio.create_task( - bot.send_message( - body="Hi!", - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - ), - ) - - await asyncio.sleep(0) # Return control to event loop - - await bot.set_raw_botx_method_result( + ( + "stealth_mode_disabled", { - "status": "error", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - "reason": "event_recipients_list_is_empty", - "errors": [], - "error_data": { - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "bot_id": "b165f00f-3154-412c-7f11-c120164257da", - "recipients_param": ["b165f00f-3154-412c-7f11-c120164257da"], - "error_description": "Event recipients list is empty", - }, + "group_chat_id": CHAT_ID, + "bot_id": "b165f00f-3154-412c-7f11-c120164257da", + "error_description": "Stealth mode disabled in specified chat", }, - verify_request=False, - ) - - # - Assert - - with pytest.raises(FinalRecipientsListEmptyError) as exc: - await task - - assert "event_recipients_list_is_empty" in str(exc.value) - assert endpoint.called - - -async def test__send_message__stealth_mode_disabled_error_raised( + StealthModeDisabledError, + ("stealth_mode_disabled",), + ), + ], +) +async def test__send_message__callback_error_raised( + reason: str, + error_data: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "notification": {"status": "ok", "body": "Hi!"}, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + endpoint = mock_botx( + respx_mock, + host, + BASE_REQUEST, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: task = asyncio.create_task( bot.send_message( body="Hi!", bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), ), ) @@ -544,23 +404,20 @@ async def test__send_message__stealth_mode_disabled_error_raised( await bot.set_raw_botx_method_result( { "status": "error", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - "reason": "stealth_mode_disabled", + "sync_id": SYNC_ID, + "reason": reason, "errors": [], - "error_data": { - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "bot_id": "b165f00f-3154-412c-7f11-c120164257da", - "error_description": "Stealth mode disabled in specified chat", - }, + "error_data": error_data, }, verify_request=False, ) # - Assert - - with pytest.raises(StealthModeDisabledError) as exc: + with pytest.raises(expected_exc) as exc: await task - assert "stealth_mode_disabled" in str(exc.value) + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called @@ -568,35 +425,24 @@ async def test__send_message__miminally_filled_succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "notification": {"status": "ok", "body": "Hi!"}, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + endpoint = mock_botx( + respx_mock, + host, + BASE_REQUEST, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: task = asyncio.create_task( bot.send_message( body="Hi!", bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), ), ) @@ -605,14 +451,14 @@ async def test__send_message__miminally_filled_succeed( await bot.set_raw_botx_method_result( { "status": "ok", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "sync_id": SYNC_ID, "result": {}, }, verify_request=False, ) # - Assert - - assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert (await task) == UUID(SYNC_ID) assert endpoint.called @@ -620,8 +466,8 @@ async def test__send_message__maximum_filled_succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, monkeypatch: pytest.MonkeyPatch, + bot_factory: Any, ) -> None: # - Arrange - monkeypatch.setattr( @@ -632,11 +478,11 @@ async def test__send_message__maximum_filled_succeed( body = f"Hi, {MentionBuilder.user(UUID('8f3abcc8-ba00-4c89-88e0-b786beb8ec24'))}!" formatted_body = "Hi, @{mention:f3e176d5-ff46-4b18-b260-25008338c06e}!" - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="POST", + path=ENDPOINT, json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "group_chat_id": CHAT_ID, "notification": { "opts": { "silent_response": True, @@ -701,17 +547,15 @@ async def test__send_message__maximum_filled_succeed( }, }, }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, + ) async with NamedTemporaryFile("wb+") as async_buffer: await async_buffer.write(b"Hello, world!\n") @@ -742,12 +586,12 @@ async def test__send_message__maximum_filled_succeed( ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: task = asyncio.create_task( bot.send_message( body=body, bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), metadata={"foo": "bar"}, bubbles=bubbles, keyboard=keyboard, @@ -766,14 +610,14 @@ async def test__send_message__maximum_filled_succeed( await bot.set_raw_botx_method_result( { "status": "ok", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "sync_id": SYNC_ID, "result": {}, }, verify_request=False, ) # - Assert - - assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert (await task) == UUID(SYNC_ID) assert endpoint.called @@ -781,8 +625,8 @@ async def test__send_message__all_mentions_types_succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, monkeypatch: pytest.MonkeyPatch, + bot_factory: Any, ) -> None: # - Arrange - monkeypatch.setattr( @@ -818,11 +662,11 @@ async def test__send_message__all_mentions_types_succeed( "I will notify you with @{mention:f3e176d5-ff46-4b18-b260-25008338c06e}, so you won't miss it." ) - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="POST", + path=ENDPOINT, json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "group_chat_id": CHAT_ID, "notification": { "status": "ok", "body": formatted_body, @@ -863,25 +707,23 @@ async def test__send_message__all_mentions_types_succeed( ], }, }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: task = asyncio.create_task( bot.send_message( body=body, bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), ), ) @@ -890,14 +732,14 @@ async def test__send_message__all_mentions_types_succeed( await bot.set_raw_botx_method_result( { "status": "ok", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "sync_id": SYNC_ID, "result": {}, }, verify_request=False, ) # - Assert - - assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert (await task) == UUID(SYNC_ID) assert endpoint.called @@ -905,32 +747,33 @@ async def test__send_message__message_body_max_length_error_raised( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - too_long_body = "1" * 4097 - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + request = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "group_chat_id": CHAT_ID, + "notification": {"status": "ok", "body": too_long_body}, + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: with pytest.raises(ValueError) as exc: await bot.send_message( body=too_long_body, bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), ) # - Assert - @@ -942,31 +785,32 @@ async def test__send_message__message_body_max_length_succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: max_long_body = "1" * 4096 - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + request = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "group_chat_id": CHAT_ID, + "notification": {"status": "ok", "body": max_long_body}, + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: task = asyncio.create_task( bot.send_message( body=max_long_body, bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), ), ) @@ -975,12 +819,12 @@ async def test__send_message__message_body_max_length_succeed( await bot.set_raw_botx_method_result( { "status": "ok", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "sync_id": SYNC_ID, "result": {}, }, verify_request=False, ) # - Assert - - assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert (await task) == UUID(SYNC_ID) assert endpoint.called diff --git a/tests/client/notifications_api/test_direct_notification_sync.py b/tests/client/notifications_api/test_direct_notification_sync.py new file mode 100644 index 00000000..43d86814 --- /dev/null +++ b/tests/client/notifications_api/test_direct_notification_sync.py @@ -0,0 +1,132 @@ +from http import HTTPStatus +from typing import Any +from uuid import UUID + +import pytest +from respx.router import MockRouter + +from pybotx import ( + BotIsNotChatMemberError, + ChatNotFoundError, + FinalRecipientsListEmptyError, + StealthModeDisabledError, +) +from pybotx.client.exceptions.http import InvalidBotXResponsePayloadError +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + +ENDPOINT = "/api/v4/botx/notifications/direct/sync" + +REQUEST = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": {"status": "ok", "body": "Hi!"}, + }, +) + + +async def test__send_message_sync__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + # - Arrange - + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload({"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + sync_id = await bot.send_message_sync( + body="Hi!", + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert sync_id == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called + + +@pytest.mark.parametrize( + ("reason", "exc_type"), + [ + ("chat_not_found", ChatNotFoundError), + ("bot_is_not_a_chat_member", BotIsNotChatMemberError), + ("event_recipients_list_is_empty", FinalRecipientsListEmptyError), + ("stealth_mode_disabled", StealthModeDisabledError), + ], +) +async def test__send_message_sync__known_error_reason_raised( + reason: str, + exc_type: type[Exception], + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + # - Arrange - + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + error_payload( + reason, + error_data={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + }, + ), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + with pytest.raises(exc_type): + await bot.send_message_sync( + body="Hi!", + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert endpoint.called + + +async def test__send_message_sync__unknown_error_reason_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + # - Arrange - + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + error_payload("unknown_reason"), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + with pytest.raises(InvalidBotXResponsePayloadError): + await bot.send_message_sync( + body="Hi!", + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/notifications_api/test_internal_bot_notification.py b/tests/client/notifications_api/test_internal_bot_notification.py index b6f4acc0..534684b8 100644 --- a/tests/client/notifications_api/test_internal_bot_notification.py +++ b/tests/client/notifications_api/test_internal_bot_notification.py @@ -1,21 +1,19 @@ import asyncio from http import HTTPStatus +from typing import Any +from collections.abc import Sequence from uuid import UUID -import httpx import pytest from respx.router import MockRouter from pybotx import ( - Bot, - BotAccountWithSecret, BotIsNotChatMemberError, ChatNotFoundError, FinalRecipientsListEmptyError, - HandlerCollector, RateLimitReachedError, - lifespan_wrapper, ) +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -23,39 +21,40 @@ pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v4/botx/notifications/internal" + +REQUEST = BotXRequest( + method="POST", + path=ENDPOINT, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "data": {"foo": "bar"}, + }, +) + async def test__send_internal_bot_notification__rate_limit_reached_error_raised( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/internal", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "data": {"foo": "bar"}, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.TOO_MANY_REQUESTS, - json={ - "status": "error", - "reason": "too_many_requests", - "errors": [], - "error_data": { - "bot_id": "b165f00f-3154-412c-7f11-c120164257da", - }, + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + error_payload( + "too_many_requests", + error_data={ + "bot_id": "b165f00f-3154-412c-7f11-c120164257da", }, ), + HTTPStatus.TOO_MANY_REQUESTS, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: with pytest.raises(RateLimitReachedError) as exc: await bot.send_internal_bot_notification( bot_id=bot_id, @@ -68,155 +67,64 @@ async def test__send_internal_bot_notification__rate_limit_reached_error_raised( assert endpoint.called -async def test__send_internal_bot_notification__chat_not_found_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/internal", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "data": {"foo": "bar"}, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, +@pytest.mark.parametrize( + ("reason", "error_data", "expected_exc", "expected_fragments"), + [ + ( + "chat_not_found", + { + "group_chat_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "error_description": ( + "Chat with id 21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3 not found" + ), }, + ChatNotFoundError, + ("chat_not_found",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - task = asyncio.create_task( - bot.send_internal_bot_notification( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - data={"foo": "bar"}, - ), - ) - await asyncio.sleep(0) # Return control to event loop - - await bot.set_raw_botx_method_result( + ( + "bot_is_not_a_chat_member", { - "status": "error", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - "reason": "chat_not_found", - "errors": [], - "error_data": { - "group_chat_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - "error_description": ( - "Chat with id 21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3 not found" - ), - }, - }, - verify_request=False, - ) - - with pytest.raises(ChatNotFoundError) as exc: - await task - - # - Assert - - assert "chat_not_found" in str(exc.value) - assert endpoint.called - - -async def test__send_internal_bot_notification__bot_is_not_chat_member_error_raised( - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/internal", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "data": {"foo": "bar"}, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "bot_id": "00000000-0000-0000-0000-000000000000", + "error_description": "Bot is not a chat member", }, + BotIsNotChatMemberError, + ("bot_is_not_a_chat_member",), ), - ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - task = asyncio.create_task( - bot.send_internal_bot_notification( - bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), - data={"foo": "bar"}, - ), - ) - await asyncio.sleep(0) # Return control to event loop - - await bot.set_raw_botx_method_result( + ( + "event_recipients_list_is_empty", { - "status": "error", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - "reason": "bot_is_not_a_chat_member", - "errors": [], - "error_data": { - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "bot_id": str(bot_id), - "error_description": "Bot is not a chat member", - }, + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "bot_id": "00000000-0000-0000-0000-000000000000", + "recipients_param": ["b165f00f-3154-412c-7f11-c120164257da"], + "error_description": "Event recipients list is empty", }, - verify_request=False, - ) - - with pytest.raises(BotIsNotChatMemberError) as exc: - await task - - # - Assert - - assert "bot_is_not_a_chat_member" in str(exc.value) - assert endpoint.called - - -async def test__send_internal_bot_notification__final_recipients_list_empty_error_raised( + FinalRecipientsListEmptyError, + ("event_recipients_list_is_empty",), + ), + ], +) +async def test__send_internal_bot_notification__callback_error_raised( + reason: str, + error_data: dict[str, Any], + expected_exc: type[Exception], + expected_fragments: Sequence[str], respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/internal", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "data": {"foo": "bar"}, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload({"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}), + HTTPStatus.ACCEPTED, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: task = asyncio.create_task( bot.send_internal_bot_notification( bot_id=bot_id, @@ -230,23 +138,19 @@ async def test__send_internal_bot_notification__final_recipients_list_empty_erro { "status": "error", "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - "reason": "event_recipients_list_is_empty", + "reason": reason, "errors": [], - "error_data": { - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "bot_id": str(bot_id), - "recipients_param": ["b165f00f-3154-412c-7f11-c120164257da"], - "error_description": "Event recipients list is empty", - }, + "error_data": error_data, }, verify_request=False, ) - with pytest.raises(FinalRecipientsListEmptyError) as exc: + # - Assert - + with pytest.raises(expected_exc) as exc: await task - # - Assert - - assert "event_recipients_list_is_empty" in str(exc.value) + for fragment in expected_fragments: + assert fragment in str(exc.value) assert endpoint.called @@ -254,30 +158,19 @@ async def test__send_internal_bot_notification__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/internal", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", - "data": {"foo": "bar"}, - }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload({"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}), + HTTPStatus.ACCEPTED, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: task = asyncio.create_task( bot.send_internal_bot_notification( bot_id=bot_id, diff --git a/tests/client/notifications_api/test_markup.py b/tests/client/notifications_api/test_markup.py index 2ecc2cec..2361b3d3 100644 --- a/tests/client/notifications_api/test_markup.py +++ b/tests/client/notifications_api/test_markup.py @@ -1,41 +1,38 @@ import asyncio from http import HTTPStatus +from typing import Any from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - BubbleMarkup, - Button, - HandlerCollector, - KeyboardMarkup, - lifespan_wrapper, -) +from pybotx import BubbleMarkup, Button, KeyboardMarkup from pybotx.models.message.markup import ButtonTextAlign +from tests.testkit import BotXRequest, mock_botx, ok_payload pytestmark = [ pytest.mark.mock_authorization, pytest.mark.usefixtures("respx_mock"), ] +ENDPOINT = "/api/v4/botx/notifications/direct" +CHAT_ID = "054af49e-5e18-4dca-ad73-4f96b6de63fa" +SYNC_ID = "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" + @pytest.mark.asyncio async def test__markup__defaults_filled( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="POST", + path=ENDPOINT, json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "group_chat_id": CHAT_ID, "notification": { "status": "ok", "body": "Hi!", @@ -61,14 +58,13 @@ async def test__markup__defaults_filled( ], }, }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, ) bubbles = BubbleMarkup() @@ -83,15 +79,13 @@ async def test__markup__defaults_filled( label="Keyboard button", ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: task = asyncio.create_task( bot.send_message( body="Hi!", bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), bubbles=bubbles, keyboard=keyboard, ), @@ -102,14 +96,14 @@ async def test__markup__defaults_filled( await bot.set_raw_botx_method_result( { "status": "ok", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "sync_id": SYNC_ID, "result": {}, }, verify_request=False, ) # - Assert - - assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert (await task) == UUID(SYNC_ID) assert endpoint.called @@ -118,14 +112,14 @@ async def test__markup__correctly_built( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="POST", + path=ENDPOINT, json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "group_chat_id": CHAT_ID, "notification": { "status": "ok", "body": "Hi!", @@ -169,14 +163,13 @@ async def test__markup__correctly_built( ], }, }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, ) bubbles = BubbleMarkup() @@ -206,15 +199,13 @@ async def test__markup__correctly_built( ) bubbles.add_row([button_4, button_5]) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: task = asyncio.create_task( bot.send_message( body="Hi!", bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), bubbles=bubbles, ), ) @@ -224,14 +215,14 @@ async def test__markup__correctly_built( await bot.set_raw_botx_method_result( { "status": "ok", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "sync_id": SYNC_ID, "result": {}, }, verify_request=False, ) # - Assert - - assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert (await task) == UUID(SYNC_ID) assert endpoint.called @@ -240,14 +231,14 @@ async def test__markup__color_and_align( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="POST", + path=ENDPOINT, json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "group_chat_id": CHAT_ID, "notification": { "body": "Buttons styles:", "bubble": [ @@ -306,14 +297,13 @@ async def test__markup__color_and_align( "status": "ok", }, }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, ) bubbles = BubbleMarkup() @@ -346,15 +336,13 @@ async def test__markup__color_and_align( align=ButtonTextAlign.LEFT, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: task = asyncio.create_task( bot.send_message( body="Buttons styles:", bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), bubbles=bubbles, keyboard=keyboard, ), @@ -365,14 +353,14 @@ async def test__markup__color_and_align( await bot.set_raw_botx_method_result( { "status": "ok", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "sync_id": SYNC_ID, "result": {}, }, verify_request=False, ) # - Assert - - assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert (await task) == UUID(SYNC_ID) assert endpoint.called @@ -381,14 +369,14 @@ async def test__markup__link( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/api/v4/botx/notifications/direct", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="POST", + path=ENDPOINT, json={ - "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "group_chat_id": CHAT_ID, "notification": { "body": "Buttons links:", "bubble": [ @@ -422,14 +410,13 @@ async def test__markup__link( "status": "ok", }, }, - ).mock( - return_value=httpx.Response( - HTTPStatus.ACCEPTED, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload({"sync_id": SYNC_ID}), + HTTPStatus.ACCEPTED, ) bubbles = BubbleMarkup() @@ -446,15 +433,13 @@ async def test__markup__link( link="https://example.com", ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: task = asyncio.create_task( bot.send_message( body="Buttons links:", bot_id=bot_id, - chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_id=UUID(CHAT_ID), bubbles=bubbles, keyboard=keyboard, ), @@ -465,14 +450,14 @@ async def test__markup__link( await bot.set_raw_botx_method_result( { "status": "ok", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "sync_id": SYNC_ID, "result": {}, }, verify_request=False, ) # - Assert - - assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert (await task) == UUID(SYNC_ID) assert endpoint.called diff --git a/tests/client/test_authorized_botx_method.py b/tests/client/test_authorized_botx_method.py index e8053341..51706eae 100644 --- a/tests/client/test_authorized_botx_method.py +++ b/tests/client/test_authorized_botx_method.py @@ -2,10 +2,11 @@ from uuid import UUID import httpx +import jwt import pytest from respx.router import MockRouter -from pybotx import BotAccountWithSecret, InvalidBotAccountError +from pybotx import BotAccountWithSecret, BotXAuthVersion from pybotx.bot.bot_accounts_storage import BotAccountsStorage from pybotx.client.authorized_botx_method import AuthorizedBotXMethod from tests.client.test_botx_method import ( @@ -39,7 +40,7 @@ async def execute( ] -async def test__authorized_botx_method__unauthorized( +async def test__authorized_botx_method__v2_succeed( httpx_client: httpx.AsyncClient, respx_mock: MockRouter, host: str, @@ -61,71 +62,38 @@ async def test__authorized_botx_method__unauthorized( ), ) - foo_bar_endpoint = respx_mock.post( - f"https://{host}/foo/bar", - json={"baz": 1}, - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - ).mock( - return_value=httpx.Response(HTTPStatus.UNAUTHORIZED), - ) - - method = FooBarMethod( - bot_id, - httpx_client, - BotAccountsStorage([bot_account]), - ) - payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) - - # - Act - - with pytest.raises(InvalidBotAccountError) as exc: - await method.execute(payload) - - # - Assert - - assert "failed with code 401" in str(exc.value) - assert token_endpoint.called - assert foo_bar_endpoint.called - - -async def test__authorized_botx_method__succeed( - httpx_client: httpx.AsyncClient, - respx_mock: MockRouter, - host: str, - bot_id: UUID, - bot_signature: str, - bot_account: BotAccountWithSecret, -) -> None: - # - Arrange - - token_endpoint = respx_mock.get( - f"https://{host}/api/v2/botx/bots/{bot_id}/token", - params={"signature": bot_signature}, - ).mock( - return_value=httpx.Response( + def responder(request: httpx.Request) -> httpx.Response: + authorization = request.headers.get("Authorization") + assert authorization is not None + token = authorization.split()[-1] + payload = jwt.decode( + jwt=token, + key=bot_account.secret_key, + algorithms=["HS256"], + options={"verify_aud": False, "verify_iss": False}, + ) + assert payload["iss"] == str(bot_id) + assert payload["aud"] == host + assert payload["version"] == 2 + assert payload["exp"] - payload["iat"] == 60 + assert payload["nbf"] == payload["iat"] + return httpx.Response( HTTPStatus.OK, json={ "status": "ok", - "result": "token", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, }, - ), - ) + ) foo_bar_endpoint = respx_mock.post( f"https://{host}/foo/bar", json={"baz": 1}, - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), - ) + ).mock(side_effect=responder) method = FooBarMethod( bot_id, httpx_client, - BotAccountsStorage([bot_account]), + BotAccountsStorage([bot_account], auth_version=BotXAuthVersion.V2), ) payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) @@ -134,43 +102,5 @@ async def test__authorized_botx_method__succeed( # - Assert - assert botx_api_foo_bar.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") - assert token_endpoint.called assert foo_bar_endpoint.called - - -async def test__authorized_botx_method__with_prepared_token( - httpx_client: httpx.AsyncClient, - respx_mock: MockRouter, - host: str, - bot_id: UUID, - prepared_bot_accounts_storage: BotAccountsStorage, -) -> None: - # - Arrange - - endpoint = respx_mock.post( - f"https://{host}/foo/bar", - json={"baz": 1}, - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, - }, - ), - ) - - method = FooBarMethod( - bot_id, - httpx_client, - prepared_bot_accounts_storage, - ) - - payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) - - # - Act - - botx_api_foo_bar = await method.execute(payload) - - # - Assert - - assert botx_api_foo_bar.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") - assert endpoint.called + assert not token_endpoint.called diff --git a/tests/client/test_botx_method_callback.py b/tests/client/test_botx_method_callback.py index 3b609646..629f4a2f 100644 --- a/tests/client/test_botx_method_callback.py +++ b/tests/client/test_botx_method_callback.py @@ -4,7 +4,6 @@ import time import types from http import HTTPStatus -from typing import Optional from uuid import UUID import httpx @@ -35,6 +34,18 @@ ) +class SlowCreateCallbackRepo(CallbackMemoryRepo): + def __init__(self, started: asyncio.Event, proceed: asyncio.Event) -> None: + super().__init__() + self._started = started + self._proceed = proceed + + async def create_botx_method_callback(self, sync_id: UUID) -> None: + self._started.set() + await self._proceed.wait() + await super().create_botx_method_callback(sync_id) + + class FooBarError(BaseClientError): """Test exception.""" @@ -51,7 +62,7 @@ async def execute( self, payload: BotXAPIFooBarRequestPayload, wait_callback: bool, - callback_timeout: Optional[float], + callback_timeout: float | None, default_callback_timeout: float, ) -> BotXAPIFooBarResponsePayload: path = "/foo/bar" @@ -81,7 +92,7 @@ async def call_foo_bar( bot_id: UUID, baz: int, wait_callback: bool = True, - callback_timeout: Optional[float] = None, + callback_timeout: float | None = None, ) -> UUID: method = FooBarCallbackMethod( bot_id, @@ -109,35 +120,126 @@ async def call_foo_bar( async def test__botx_method_callback__callback_not_found( bot_account: BotAccountWithSecret, + loguru_caplog: pytest.LogCaptureFixture, ) -> None: # - Arrange - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) # - Act - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(BotXMethodCallbackNotFoundError) as exc: - await bot.set_raw_botx_method_result( - { - "status": "error", - "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", - "reason": "chat_not_found", - "errors": [], - "error_data": { - "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c", - "error_description": ( - "Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found" - ), - }, + await bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "chat_not_found", + "errors": [], + "error_data": { + "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c", + "error_description": ( + "Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found" + ), }, - verify_request=False, - ) + }, + verify_request=False, + ) + + # - Assert - + assert "received without a registered handler" in loguru_caplog.text + + +async def test__botx_method_callback__orphan_callback_expires( + bot_account: BotAccountWithSecret, + loguru_caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + import pybotx.bot.callbacks.callback_manager as callback_manager_module + + monkeypatch.setattr(callback_manager_module, "ORPHAN_CALLBACK_TTL_SECONDS", 0.01) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + payload = { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + } + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.set_raw_botx_method_result(payload, verify_request=False) + await bot.set_raw_botx_method_result(payload, verify_request=False) + + await asyncio.sleep(0.05) + + bot._callbacks_manager.mark_callback_expired( + UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + ) + + # - Assert - + assert "received without a registered handler and expired" in loguru_caplog.text + + +async def test__botx_method_callback__pending_limit_drops_orphan( + bot_account: BotAccountWithSecret, + loguru_caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + import pybotx.bot.callbacks.callback_manager as callback_manager_module + + monkeypatch.setattr(callback_manager_module, "ORPHAN_PENDING_CALLBACKS_LIMIT", 1) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + payload_1 = { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + } + payload_2 = { + "status": "ok", + "sync_id": "d4d3d774-1f90-4b53-9b92-7f3867dbb2f8", + "result": {}, + } + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.set_raw_botx_method_result(payload_1, verify_request=False) + await bot.set_raw_botx_method_result(payload_2, verify_request=False) # - Assert - - assert "Callback `21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3` doesn't exist" in str( - exc.value, + assert "Pending callbacks limit reached; dropping orphan callback" in ( + loguru_caplog.text ) +async def test__botx_method_callback__orphan_alarm_already_exists( + bot_account: BotAccountWithSecret, + loguru_caplog: pytest.LogCaptureFixture, +) -> None: + # - Arrange - + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + payload = { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + } + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.set_raw_botx_method_result(payload, verify_request=False) + + bot._callbacks_manager._pending_callbacks.pop( + UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"), + None, + ) + + await bot.set_raw_botx_method_result(payload, verify_request=False) + + # - Assert - + assert "received without a registered handler; buffering" in loguru_caplog.text + + async def test__botx_method_callback__error_callback_error_handler_called( respx_mock: MockRouter, host: str, @@ -502,6 +604,60 @@ async def test__botx_method_callback__callback_successful_received_with_custom_r assert endpoint.called +async def test__botx_method_callback__callback_received_before_repo_create( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + started = asyncio.Event() + proceed = asyncio.Event() + built_bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + callback_repo=SlowCreateCallbackRepo(started, proceed), + ) + built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.call_foo_bar(bot_id, baz=1), + ) + + await started.wait() + + await bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + verify_request=False, + ) + + proceed.set() + assert await task == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + + # - Assert - + assert endpoint.called + + async def test__botx_method_callback__bot_wait_callback_before_its_receiving( respx_mock: MockRouter, httpx_client: httpx.AsyncClient, diff --git a/tests/client/test_botx_method_undefined_cleaned.py b/tests/client/test_botx_method_undefined_cleaned.py index 345c53dc..4a64a061 100644 --- a/tests/client/test_botx_method_undefined_cleaned.py +++ b/tests/client/test_botx_method_undefined_cleaned.py @@ -1,5 +1,5 @@ from http import HTTPStatus -from typing import Any, Dict, Literal +from typing import Any, Literal from uuid import UUID import httpx @@ -14,10 +14,10 @@ class BotXAPIFooBarRequestPayload(UnverifiedPayloadBaseModel): - baz: Dict[str, Any] + baz: dict[str, Any] @classmethod - def from_domain(cls, baz: Dict[str, Any]) -> "BotXAPIFooBarRequestPayload": + def from_domain(cls, baz: dict[str, Any]) -> "BotXAPIFooBarRequestPayload": return cls(baz=baz) diff --git a/tests/client/users_api/conftest.py b/tests/client/users_api/conftest.py index 78279acc..79388de7 100644 --- a/tests/client/users_api/conftest.py +++ b/tests/client/users_api/conftest.py @@ -1,117 +1,6 @@ -from typing import Any, Dict -from uuid import UUID - -import pytest - -from pybotx import UserFromSearch, UserKinds -from tests.client.users_api.convert_to_datetime import convert_to_datetime - - -@pytest.fixture() -def user_from_search_with_data_json() -> Dict[str, Any]: - return { - "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", - "ad_login": "ad_user_login", - "ad_domain": "cts.com", - "name": "Bob", - "company": "Bobs Co", - "company_position": "Director", - "department": "Owners", - "emails": ["ad_user@cts.com"], - "user_kind": "cts_user", - "active": True, - "created_at": "2023-03-26T14:36:08.740618Z", - "cts_id": "e0140f4c-4af2-5a2e-9ad1-5f37fceafbaf", - "description": "Director in Owners dep", - "ip_phone": "1271020", - "manager": "Alice", - "office": "SUN", - "other_ip_phone": "32593", - "other_phone": "1254218", - "public_name": "Bobby", - "rts_id": "f46440a4-d930-58d4-b3f5-8110ab846ee3", - "updated_at": "2023-03-26T14:36:08.740618Z", - } - - -@pytest.fixture -def user_from_search_with_data() -> UserFromSearch: - return UserFromSearch( - huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), - ad_login="ad_user_login", - ad_domain="cts.com", - username="Bob", - company="Bobs Co", - company_position="Director", - department="Owners", - emails=["ad_user@cts.com"], - other_id=None, - user_kind=UserKinds.CTS_USER, - active=True, - created_at=convert_to_datetime("2023-03-26T14:36:08.740618Z"), - cts_id=UUID("e0140f4c-4af2-5a2e-9ad1-5f37fceafbaf"), - description="Director in Owners dep", - ip_phone="1271020", - manager="Alice", - office="SUN", - other_ip_phone="32593", - other_phone="1254218", - public_name="Bobby", - rts_id=UUID("f46440a4-d930-58d4-b3f5-8110ab846ee3"), - updated_at=convert_to_datetime("2023-03-26T14:36:08.740618Z"), - ) - - -@pytest.fixture -def user_from_search_without_data_json() -> Dict[str, Any]: - return { - "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", - "ad_login": "ad_user_login", - "ad_domain": "cts.com", - "name": "Bob", - "company": "Bobs Co", - "company_position": "Director", - "department": "Owners", - "emails": ["ad_user@cts.com"], - "user_kind": "cts_user", - "active": None, - "created_at": None, - "cts_id": None, - "description": None, - "ip_phone": None, - "manager": None, - "office": None, - "other_ip_phone": None, - "other_phone": None, - "public_name": None, - "rts_id": None, - "updated_at": None, - } - - -@pytest.fixture -def user_from_search_without_data() -> UserFromSearch: - return UserFromSearch( - huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), - ad_login="ad_user_login", - ad_domain="cts.com", - username="Bob", - company="Bobs Co", - company_position="Director", - department="Owners", - emails=["ad_user@cts.com"], - other_id=None, - user_kind=UserKinds.CTS_USER, - active=None, - created_at=None, - cts_id=None, - description=None, - ip_phone=None, - manager=None, - office=None, - other_ip_phone=None, - other_phone=None, - public_name=None, - rts_id=None, - updated_at=None, - ) +from tests.fixtures.users_api import ( # noqa: F401 + user_from_search_with_data, + user_from_search_with_data_json, + user_from_search_without_data, + user_from_search_without_data_json, +) diff --git a/tests/client/users_api/test_search_user_by_email_post.py b/tests/client/users_api/test_search_user_by_email_post.py new file mode 100644 index 00000000..9a4f7dcc --- /dev/null +++ b/tests/client/users_api/test_search_user_by_email_post.py @@ -0,0 +1,239 @@ +from http import HTTPStatus +from typing import Any +from uuid import UUID + +import pytest +from respx.router import MockRouter + +from pybotx import UserFromSearch, UserNotFoundError +from pybotx.client.exceptions.http import ( + InvalidBotXResponsePayloadError, + InvalidBotXStatusCodeError, +) +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__search_user_by_email_post__user_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + # - Arrange - + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", + json={"emails": ["ad_user@cts.com"]}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + error_payload("user_not_found"), + HTTPStatus.NOT_FOUND, + ) + + # - Act - + async with bot_factory() as bot: + with pytest.raises(UserNotFoundError) as exc: + await bot.search_user_by_email_post( + bot_id=bot_id, + email="ad_user@cts.com", + ) + + # - Assert - + assert "user_not_found" in str(exc.value) + assert endpoint.called + + +async def test__search_user_by_email_post__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data: UserFromSearch, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + # - Arrange - + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", + json={"emails": ["ad_user@cts.com"]}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload([user_from_search_with_data_json]), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + user = await bot.search_user_by_email_post( + bot_id=bot_id, + email="ad_user@cts.com", + ) + + # - Assert - + assert user == user_from_search_with_data + assert endpoint.called + + +async def test__search_user_by_email_post_without_extra_data__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_without_data: UserFromSearch, + user_from_search_without_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + # - Arrange - + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", + json={"emails": ["ad_user@cts.com"]}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload([user_from_search_without_data_json]), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + user = await bot.search_user_by_email_post( + bot_id=bot_id, + email="ad_user@cts.com", + ) + + # - Assert - + assert user == user_from_search_without_data + assert endpoint.called + + +async def test__search_user_by_email_post__list_response_logs_warning( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, + loguru_caplog: pytest.LogCaptureFixture, +) -> None: + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", + json={"emails": ["ad_user@cts.com"]}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload([user_from_search_with_data_json, user_from_search_with_data_json]), + HTTPStatus.OK, + ) + + async with bot_factory() as bot: + await bot.search_user_by_email_post( + bot_id=bot_id, + email="ad_user@cts.com", + ) + + assert "multiple users" in loguru_caplog.text + assert endpoint.called + + +async def test__search_user_by_email_post__non_400_status_is_not_retried( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", + json={"emails": ["ad_user@cts.com"]}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + error_payload("unexpected_error"), + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + async with bot_factory() as bot: + with pytest.raises(InvalidBotXStatusCodeError): + await bot.search_user_by_email_post( + bot_id=bot_id, + email="ad_user@cts.com", + ) + + assert endpoint.called + + +async def test__search_user_by_email_post__empty_list_raises_user_not_found( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", + json={"emails": ["ad_user@cts.com"]}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload([]), + HTTPStatus.OK, + ) + + async with bot_factory() as bot: + with pytest.raises(UserNotFoundError): + await bot.search_user_by_email_post( + bot_id=bot_id, + email="ad_user@cts.com", + ) + + assert endpoint.called + + +async def test__search_user_by_email_post__invalid_payload_raises_invalid_response( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", + json={"emails": ["ad_user@cts.com"]}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_with_data_json), + HTTPStatus.OK, + ) + + async with bot_factory() as bot: + with pytest.raises(InvalidBotXResponsePayloadError): + await bot.search_user_by_email_post( + bot_id=bot_id, + email="ad_user@cts.com", + ) + + assert endpoint.called diff --git a/tests/client/users_api/test_search_user_by_emails.py b/tests/client/users_api/test_search_user_by_emails.py index f521209a..72d61425 100644 --- a/tests/client/users_api/test_search_user_by_emails.py +++ b/tests/client/users_api/test_search_user_by_emails.py @@ -1,18 +1,12 @@ from http import HTTPStatus -from typing import Any, Dict +from typing import Any from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - HandlerCollector, - UserFromSearch, - lifespan_wrapper, -) +from pybotx import UserFromSearch +from tests.testkit import BotXRequest, assert_deep_equal, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -25,39 +19,35 @@ async def test__search_user_by_email__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, user_from_search_with_data: UserFromSearch, - user_from_search_with_data_json: Dict[str, Any], + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, ) -> None: # - Arrange - user_emails = ["ad_user@cts.com"] - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/users/by_email", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", json={"emails": user_emails}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": [user_from_search_with_data_json], - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload([user_from_search_with_data_json]), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: users = await bot.search_user_by_emails( bot_id=bot_id, emails=user_emails, ) # - Assert - - assert users[0] == user_from_search_with_data - + assert_deep_equal(users, [user_from_search_with_data]) assert endpoint.called @@ -65,37 +55,33 @@ async def test__search_user_by_email_without_data__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, user_from_search_without_data: UserFromSearch, - user_from_search_without_data_json: Dict[str, Any], + user_from_search_without_data_json: dict[str, Any], + bot_factory: Any, ) -> None: # - Arrange - user_emails = ["ad_user@cts.com"] - endpoint = respx_mock.post( - f"https://{host}/api/v3/botx/users/by_email", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", json={"emails": user_emails}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": [user_from_search_without_data_json], - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload([user_from_search_without_data_json]), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: users = await bot.search_user_by_emails( bot_id=bot_id, emails=user_emails, ) # - Assert - - assert users[0] == user_from_search_without_data - + assert_deep_equal(users, [user_from_search_without_data]) assert endpoint.called diff --git a/tests/client/users_api/test_search_user_by_huid.py b/tests/client/users_api/test_search_user_by_huid.py index 7396497b..51a47cc2 100644 --- a/tests/client/users_api/test_search_user_by_huid.py +++ b/tests/client/users_api/test_search_user_by_huid.py @@ -1,19 +1,12 @@ from http import HTTPStatus -from typing import Any, Dict +from typing import Any from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - HandlerCollector, - UserFromSearch, - UserNotFoundError, - lifespan_wrapper, -) +from pybotx import UserFromSearch, UserNotFoundError +from tests.testkit import BotXRequest, assert_deep_equal, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -26,29 +19,24 @@ async def test__search_user_by_huid__user_not_found_error_raised( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/users/by_huid", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_huid", params={"user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "user_not_found", - "errors": [], - "error_data": {}, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + error_payload("user_not_found"), + HTTPStatus.NOT_FOUND, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: with pytest.raises(UserNotFoundError) as exc: await bot.search_user_by_huid( bot_id=bot_id, @@ -64,37 +52,33 @@ async def test__search_user_by_huid__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, user_from_search_with_data: UserFromSearch, - user_from_search_with_data_json: Dict[str, Any], + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/users/by_huid", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_huid", params={"user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": user_from_search_with_data_json, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_with_data_json), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: user = await bot.search_user_by_huid( bot_id=bot_id, huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"), ) # - Assert - - assert user == user_from_search_with_data - + assert_deep_equal(user, user_from_search_with_data) assert endpoint.called @@ -102,35 +86,31 @@ async def test__search_user_by_huid_without_data__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, user_from_search_without_data: UserFromSearch, - user_from_search_without_data_json: Dict[str, Any], + user_from_search_without_data_json: dict[str, Any], + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/users/by_huid", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_huid", params={"user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": user_from_search_without_data_json, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_without_data_json), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: user = await bot.search_user_by_huid( bot_id=bot_id, huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"), ) # - Assert - - assert user == user_from_search_without_data - + assert_deep_equal(user, user_from_search_without_data) assert endpoint.called diff --git a/tests/client/users_api/test_search_user_by_login.py b/tests/client/users_api/test_search_user_by_login.py index 238d692c..75ea05cb 100644 --- a/tests/client/users_api/test_search_user_by_login.py +++ b/tests/client/users_api/test_search_user_by_login.py @@ -1,19 +1,12 @@ from http import HTTPStatus -from typing import Any, Dict +from typing import Any from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - HandlerCollector, - UserFromSearch, - UserNotFoundError, - lifespan_wrapper, -) +from pybotx import UserFromSearch, UserNotFoundError +from tests.testkit import BotXRequest, assert_deep_equal, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -26,29 +19,24 @@ async def test__search_user_by_ad__user_not_found_error_raised( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/users/by_login", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_login", params={"ad_login": "ad_user_login", "ad_domain": "cts.com"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "user_not_found", - "errors": [], - "error_data": {}, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + error_payload("user_not_found"), + HTTPStatus.NOT_FOUND, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: with pytest.raises(UserNotFoundError) as exc: await bot.search_user_by_ad( bot_id=bot_id, @@ -65,29 +53,26 @@ async def test__search_user_by_ad__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, user_from_search_with_data: UserFromSearch, - user_from_search_with_data_json: Dict[str, Any], + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/users/by_login", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_login", params={"ad_login": "ad_user_login", "ad_domain": "cts.com"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": user_from_search_with_data_json, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_with_data_json), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: user = await bot.search_user_by_ad( bot_id=bot_id, ad_login="ad_user_login", @@ -95,8 +80,7 @@ async def test__search_user_by_ad__succeed( ) # - Assert - - assert user == user_from_search_with_data - + assert_deep_equal(user, user_from_search_with_data) assert endpoint.called @@ -104,29 +88,26 @@ async def test__search_user_by_ad_without_data__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, user_from_search_without_data: UserFromSearch, - user_from_search_without_data_json: Dict[str, Any], + user_from_search_without_data_json: dict[str, Any], + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/users/by_login", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_login", params={"ad_login": "ad_user_login", "ad_domain": "cts.com"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": user_from_search_without_data_json, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_without_data_json), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: user = await bot.search_user_by_ad( bot_id=bot_id, ad_login="ad_user_login", @@ -134,6 +115,5 @@ async def test__search_user_by_ad_without_data__succeed( ) # - Assert - - assert user == user_from_search_without_data - + assert_deep_equal(user, user_from_search_without_data) assert endpoint.called diff --git a/tests/client/users_api/test_search_user_by_other_id.py b/tests/client/users_api/test_search_user_by_other_id.py index de58acf1..df8d3465 100644 --- a/tests/client/users_api/test_search_user_by_other_id.py +++ b/tests/client/users_api/test_search_user_by_other_id.py @@ -1,19 +1,12 @@ from http import HTTPStatus -from typing import Any, Dict +from typing import Any from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import ( - Bot, - BotAccountWithSecret, - HandlerCollector, - UserFromSearch, - UserNotFoundError, - lifespan_wrapper, -) +from pybotx import UserFromSearch, UserNotFoundError +from tests.testkit import BotXRequest, assert_deep_equal, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -26,29 +19,24 @@ async def test__search_user_by_other_id__user_not_found_error_raised( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/users/by_other_id", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_other_id", params={"other_id": "some_id"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.NOT_FOUND, - json={ - "status": "error", - "reason": "user_not_found", - "errors": [], - "error_data": {}, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + error_payload("user_not_found"), + HTTPStatus.NOT_FOUND, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: with pytest.raises(UserNotFoundError) as exc: await bot.search_user_by_other_id( bot_id=bot_id, @@ -64,37 +52,33 @@ async def test__search_user_by_other_id__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, user_from_search_with_data: UserFromSearch, - user_from_search_with_data_json: Dict[str, Any], + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/users/by_other_id", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_other_id", params={"other_id": "some_id"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": user_from_search_with_data_json, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_with_data_json), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: user = await bot.search_user_by_other_id( bot_id=bot_id, other_id="some_id", ) # - Assert - - assert user == user_from_search_with_data - + assert_deep_equal(user, user_from_search_with_data) assert endpoint.called @@ -102,35 +86,31 @@ async def test__search_user_by_other_id_without_data__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, user_from_search_without_data: UserFromSearch, - user_from_search_without_data_json: Dict[str, Any], + user_from_search_without_data_json: dict[str, Any], + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.get( - f"https://{host}/api/v3/botx/users/by_other_id", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_other_id", params={"other_id": "some_id"}, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": user_from_search_without_data_json, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_without_data_json), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: user = await bot.search_user_by_other_id( bot_id=bot_id, other_id="some_id", ) # - Assert - - assert user == user_from_search_without_data - + assert_deep_equal(user, user_from_search_without_data) assert endpoint.called diff --git a/tests/client/users_api/test_update_user_profile.py b/tests/client/users_api/test_update_user_profile.py index 47eaf187..da713334 100644 --- a/tests/client/users_api/test_update_user_profile.py +++ b/tests/client/users_api/test_update_user_profile.py @@ -1,15 +1,14 @@ from http import HTTPStatus +from typing import Any from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import Bot, HandlerCollector, lifespan_wrapper from pybotx.client.exceptions.users import InvalidProfileDataError from pybotx.models.attachments import AttachmentImage -from pybotx.models.bot_account import BotAccountWithSecret from pybotx.models.enums import AttachmentTypes +from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload pytestmark = [ pytest.mark.asyncio, @@ -33,29 +32,26 @@ async def test__update_user_profile__minimal_update_succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.put( - f"https://{host}/api/v3/botx/users/update_profile", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="PUT", + path="/api/v3/botx/users/update_profile", json={ "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", }, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": True, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(True), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: await bot.update_user_profile( bot_id=bot_id, user_huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), @@ -69,13 +65,13 @@ async def test__update_user_profile__maximum_update_succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, avatar: AttachmentImage, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.put( - f"https://{host}/api/v3/botx/users/update_profile", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="PUT", + path="/api/v3/botx/users/update_profile", json={ "avatar": "data:image/png;base64,SGVsbG8sIHdvcmxkIQ==", "company": "Doge Co", @@ -88,20 +84,17 @@ async def test__update_user_profile__maximum_update_succeed( "public_name": "Johny B.", "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", }, - ).mock( - return_value=httpx.Response( - HTTPStatus.OK, - json={ - "status": "ok", - "result": True, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(True), + HTTPStatus.OK, + ) # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: await bot.update_user_profile( bot_id=bot_id, user_huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), @@ -124,12 +117,12 @@ async def test__update_user_profile__invalid_profile_data_error( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: # - Arrange - - endpoint = respx_mock.put( - f"https://{host}/api/v3/botx/users/update_profile", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + request = BotXRequest( + method="PUT", + path="/api/v3/botx/users/update_profile", json={ "company": "Doge Co", "company_position": "Chief", @@ -141,26 +134,24 @@ async def test__update_user_profile__invalid_profile_data_error( "public_name": "Johny B.", "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", }, - ).mock( - return_value=httpx.Response( - HTTPStatus.BAD_REQUEST, - json={ - "status": "error", - "reason": "invalid_profile", - "errors": [], - "error_data": { - "errors": {"field": "invalid"}, - "error_description": "Invalid profile data", - "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", - }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + error_payload( + "invalid_profile", + error_data={ + "errors": {"field": "invalid"}, + "error_description": "Invalid profile data", + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", }, ), + HTTPStatus.BAD_REQUEST, ) - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) - # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: with pytest.raises(InvalidProfileDataError): await bot.update_user_profile( bot_id=bot_id, diff --git a/tests/client/users_api/test_users_as_csv.py b/tests/client/users_api/test_users_as_csv.py index 200b62c0..95315b2c 100644 --- a/tests/client/users_api/test_users_as_csv.py +++ b/tests/client/users_api/test_users_as_csv.py @@ -1,15 +1,14 @@ from http import HTTPStatus +from typing import Any from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import Bot, HandlerCollector, lifespan_wrapper from pybotx.client.exceptions.users import NoUserKindSelectedError -from pybotx.models.bot_account import BotAccountWithSecret from pybotx.models.enums import SyncSourceTypes, UserKinds from pybotx.models.users import UserFromCSV +from tests.testkit import BotXRequest, assert_deep_equal, error_payload, mock_botx pytestmark = [ pytest.mark.asyncio, @@ -22,29 +21,24 @@ async def test__users_as_csv__no_user_kind_selected_error( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: - endpoint = respx_mock.get( - url=f"https://{host}/api/v3/botx/users/users_as_csv", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/users_as_csv", params={"cts_user": False, "unregistered": False, "botx": False}, - ).mock( - return_value=httpx.Response( - status_code=HTTPStatus.BAD_REQUEST, - json={ - "status": "error", - "reason": "no_user_kind_selected", - "errors": [], - "error_data": {}, - }, - ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + endpoint = mock_botx( + respx_mock, + host, + request, + error_payload("no_user_kind_selected"), + HTTPStatus.BAD_REQUEST, + ) # - Act - with pytest.raises(NoUserKindSelectedError) as exc: - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: async with bot.users_as_csv( bot_id=bot_id, cts_user=False, @@ -62,35 +56,38 @@ async def test__users_as_csv__succeed( respx_mock: MockRouter, host: str, bot_id: UUID, - bot_account: BotAccountWithSecret, + bot_factory: Any, ) -> None: - endpoint = respx_mock.get( - url=f"https://{host}/api/v3/botx/users/users_as_csv", - headers={"Authorization": "Bearer token"}, + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/users_as_csv", params={"cts_user": True, "unregistered": True, "botx": False}, - ).mock( - return_value=httpx.Response( - status_code=HTTPStatus.OK, - content=( - b"HUID,AD Login,Domain,AD E-mail,Name,Sync source,Active,Kind,Company,Department,Position,Manager,Manager HUID,Personnel number,Description,IP phone,Other IP phone,Phone,Other phone,Avatar,Office,Avatar preview\n" - b"dbc8934f-d0d7-4a9e-89df-d45c137a851c,test_user_17,cts.example.com,,test_user_17,ad,false,cts_user,Company,Department,Position,Manager John,13a6909c-bce1-4dbf-8359-efb7ef8e5b34,Some number,Description,IP phone,Other IP phone,Phone,Other_phone,Avatar,Office,Avatar_preview\n" - b"13a6909c-bce1-4dbf-8359-efb7ef8e5b34,test_user_18,cts.example.com,,test_user_18,unsupported,true,cts_user,,,,,,,,,,,,,," - ), + ) + endpoint = mock_botx( + respx_mock, + host, + request, + response_json=None, + status=HTTPStatus.OK, + response_content=( + b"HUID,AD Login,Domain,AD E-mail,Name,Sync source,Active,Kind,Company,Department,Position,Manager,Manager HUID,Personnel number,Description,IP phone,Other IP phone,Phone,Other phone,Avatar,Office,Avatar preview\n" + b"dbc8934f-d0d7-4a9e-89df-d45c137a851c,test_user_17,cts.example.com,,test_user_17,ad,false,cts_user,Company,Department,Position,Manager John,13a6909c-bce1-4dbf-8359-efb7ef8e5b34,Some number,Description,IP phone,Other IP phone,Phone,Other_phone,Avatar,Office,Avatar_preview\n" + b"13a6909c-bce1-4dbf-8359-efb7ef8e5b34,test_user_18,cts.example.com,,test_user_18,unsupported,true,cts_user,,,,,,,,,,,,,," ), ) - - built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) users_from_csv = [] # - Act - - async with lifespan_wrapper(built_bot) as bot: + async with bot_factory() as bot: async with bot.users_as_csv(bot_id=bot_id) as users: async for user in users: users_from_csv.append(user) # - Assert - assert endpoint.called - assert users_from_csv == [ + assert_deep_equal( + users_from_csv, + [ UserFromCSV( huid=UUID("dbc8934f-d0d7-4a9e-89df-d45c137a851c"), ad_login="test_user_17", @@ -139,4 +136,5 @@ async def test__users_as_csv__succeed( office=None, avatar_preview=None, ), - ] + ], + ) diff --git a/tests/conftest.py b/tests/conftest.py index b2755b68..94dbb182 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,10 @@ import logging +import socket from datetime import datetime from http import HTTPStatus -from typing import Any, AsyncGenerator, Callable, Dict, Generator, List, Optional +from typing import Any +from contextlib import AbstractAsyncContextManager +from collections.abc import AsyncGenerator, Callable, Generator from unittest.mock import Mock from uuid import UUID, uuid4 @@ -22,11 +25,21 @@ SmartAppEvent, UserDevice, UserSender, + BotXAuthVersion, + lifespan_wrapper, ) from pybotx.bot.bot_accounts_storage import BotAccountsStorage from pybotx.logger import logger from pybotx.models.sync_smartapp_event import BotAPISyncSmartAppEventResultResponse from pydantic import BaseModel +from contextlib import asynccontextmanager + +from tests.fixtures.users_api import ( # noqa: F401 + user_from_search_with_data, + user_from_search_with_data_json, + user_from_search_without_data, + user_from_search_without_data_json, +) @pytest.fixture(autouse=True) @@ -34,12 +47,30 @@ def enable_logger() -> None: logger.enable("pybotx") +@pytest.fixture(autouse=True) +def block_network(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None: + if request.node.get_closest_marker("allow_network"): + return + + def guard(*args: Any, **kwargs: Any) -> None: + raise RuntimeError( + "Network access is disabled during tests. " + "Use @pytest.mark.allow_network to override.", + ) + + monkeypatch.setattr(socket, "create_connection", guard) + monkeypatch.setattr(socket.socket, "connect", guard, raising=True) + + @pytest.fixture def prepared_bot_accounts_storage( bot_id: UUID, bot_account: BotAccountWithSecret, ) -> BotAccountsStorage: - bot_accounts_storage = BotAccountsStorage([bot_account]) + bot_accounts_storage = BotAccountsStorage( + [bot_account], + auth_version=BotXAuthVersion.V1, + ) bot_accounts_storage.set_token(bot_id, "token") return bot_accounts_storage @@ -81,12 +112,25 @@ def bot_account(cts_url: str, bot_id: UUID) -> BotAccountWithSecret: return BotAccountWithSecret( id=bot_id, cts_url=cts_url, - secret_key="bee001", + secret_key="bee001bee001bee001bee001bee001bee001", ) @pytest.fixture -def authorization_token_payload(bot_account: BotAccountWithSecret) -> Dict[str, Any]: +def authorization_token_payload(bot_account: BotAccountWithSecret) -> dict[str, Any]: + return { + "aud": bot_account.host, + "exp": datetime(year=3000, month=1, day=1).timestamp(), + "iat": datetime(year=2000, month=1, day=1).timestamp(), + "iss": str(bot_account.id), + "jti": "2uqpju31h6dgv4f41c005e1i", + "nbf": datetime(year=2000, month=1, day=1).timestamp(), + "version": 2, + } + + +@pytest.fixture +def authorization_token_payload_v1(bot_account: BotAccountWithSecret) -> dict[str, Any]: return { "aud": [str(bot_account.id)], "exp": datetime(year=3000, month=1, day=1).timestamp(), @@ -100,8 +144,8 @@ def authorization_token_payload(bot_account: BotAccountWithSecret) -> Dict[str, @pytest.fixture def authorization_header( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], -) -> Dict[str, str]: + authorization_token_payload: dict[str, Any], +) -> dict[str, str]: token = jwt.encode( payload=authorization_token_payload, key=bot_account.secret_key, @@ -109,19 +153,37 @@ def authorization_header( return {"authorization": f"Bearer {token}"} +@pytest.fixture +def authorization_header_v1( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> dict[str, str]: + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + return {"authorization": f"Bearer {token}"} + + @pytest.fixture def bot_signature() -> str: - return "E050AEEA197E0EF0A6E1653E18B7D41C7FDEC0FCFBA44C44FCCD2A88CEABD130" + return "5393FDE463800BB05C4271111AF68D54A4B5EC03EBE808BC2B1FCB4F91BE2DCF" @pytest.fixture def mock_authorization( respx_mock: MockRouter, + monkeypatch: pytest.MonkeyPatch, host: str, bot_id: UUID, bot_signature: str, ) -> None: """Fixture should be used as a marker.""" + monkeypatch.setattr( + BotAccountsStorage, + "build_jwt_v2", + lambda _self, _bot_id: "token", + ) respx_mock.get( f"https://{host}/api/v2/botx/bots/{bot_id}/token", params={"signature": bot_signature}, @@ -136,8 +198,26 @@ def mock_authorization( ) +@pytest.fixture +def bot_factory( + bot_account: BotAccountWithSecret, +) -> Callable[..., AbstractAsyncContextManager[Bot]]: + @asynccontextmanager + async def factory( + *, + collectors: list[HandlerCollector] | None = None, + **kwargs: Any, + ) -> AsyncGenerator[Bot, None]: + collectors = collectors or [HandlerCollector()] + bot = Bot(collectors=collectors, bot_accounts=[bot_account], **kwargs) + async with lifespan_wrapper(bot) as running_bot: + yield running_bot + + return factory + + @pytest.hookimpl(trylast=True) -def pytest_collection_modifyitems(items: List[pytest.Function]) -> None: +def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: for item in items: if item.get_closest_marker("mock_authorization"): item.fixturenames.append("mock_authorization") @@ -171,20 +251,20 @@ async def async_buffer() -> AsyncGenerator[NamedTemporaryFile, None]: @pytest.fixture -def api_incoming_message_factory() -> Callable[..., Dict[str, Any]]: +def api_incoming_message_factory() -> Callable[..., dict[str, Any]]: def decorator( *, body: str = "/hello", command_type: str = "user", - data: Optional[Dict[str, Any]] = None, - metadata: Optional[Dict[str, Any]] = None, - bot_id: Optional[UUID] = None, - group_chat_id: Optional[UUID] = None, - user_huid: Optional[UUID] = None, - host: Optional[str] = None, - attachment: Optional[Dict[str, Any]] = None, - async_file: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + data: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + bot_id: UUID | None = None, + group_chat_id: UUID | None = None, + user_huid: UUID | None = None, + host: str | None = None, + attachment: dict[str, Any] | None = None, + async_file: dict[str, Any] | None = None, + ) -> dict[str, Any]: return { "bot_id": str(bot_id) if bot_id else "24348246-6791-4ac0-9d86-b948cd6a0e46", "command": { @@ -237,16 +317,16 @@ def decorator( @pytest.fixture -def api_sync_smartapp_event_factory() -> Callable[..., Dict[str, Any]]: +def api_sync_smartapp_event_factory() -> Callable[..., dict[str, Any]]: def decorator( *, - bot_id: Optional[UUID] = None, - group_chat_id: Optional[UUID] = None, - user_huid: Optional[UUID] = None, - async_file: Optional[Dict[str, Any]] = None, - method: Optional[str] = None, - params: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + bot_id: UUID | None = None, + group_chat_id: UUID | None = None, + user_huid: UUID | None = None, + async_file: dict[str, Any] | None = None, + method: str | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: return { "bot_id": str(bot_id) if bot_id else "8dada2c8-67a6-4434-9dec-570d244e78ee", "group_chat_id": ( @@ -281,8 +361,8 @@ def incoming_message_factory( def decorator( *, body: str = "", - ad_login: Optional[str] = None, - ad_domain: Optional[str] = None, + ad_login: str | None = None, + ad_domain: str | None = None, ) -> IncomingMessage: return IncomingMessage( bot=BotAccount( diff --git a/tests/deprecated/test_authorized_botx_method_v1.py b/tests/deprecated/test_authorized_botx_method_v1.py new file mode 100644 index 00000000..a2e08704 --- /dev/null +++ b/tests/deprecated/test_authorized_botx_method_v1.py @@ -0,0 +1,210 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from pybotx import BotAccountWithSecret, BotXAuthVersion, InvalidBotAccountError +from pybotx.bot.bot_accounts_storage import BotAccountsStorage +from tests.client.test_authorized_botx_method import FooBarMethod +from tests.client.test_botx_method import BotXAPIFooBarRequestPayload + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__authorized_botx_method__unauthorized( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_signature: str, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + token_endpoint = respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": "token", + }, + ), + ) + + foo_bar_endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response(HTTPStatus.UNAUTHORIZED), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account], auth_version=BotXAuthVersion.V1), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.warns(DeprecationWarning): + with pytest.raises(InvalidBotAccountError) as exc: + await method.execute(payload) + + # - Assert - + assert "failed with code 401" in str(exc.value) + assert token_endpoint.called + assert foo_bar_endpoint.called + + +async def test__authorized_botx_method__succeed( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_signature: str, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + token_endpoint = respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": "token", + }, + ), + ) + + foo_bar_endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account], auth_version=BotXAuthVersion.V1), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.warns(DeprecationWarning): + botx_api_foo_bar = await method.execute(payload) + + # - Assert - + assert botx_api_foo_bar.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert token_endpoint.called + assert foo_bar_endpoint.called + + +async def test__authorized_botx_method__with_prepared_token( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + prepared_bot_accounts_storage: BotAccountsStorage, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + prepared_bot_accounts_storage, + ) + + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.warns(DeprecationWarning): + botx_api_foo_bar = await method.execute(payload) + + # - Assert - + assert botx_api_foo_bar.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called + + +async def test__authorized_botx_method__legacy_warning_suppressed( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_signature: str, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + token_endpoint = respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": "token", + }, + ), + ) + + foo_bar_endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account], auth_version=BotXAuthVersion.V1), + ) + method._legacy_auth_warned = True + + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + botx_api_foo_bar = await method.execute(payload) + + # - Assert - + assert botx_api_foo_bar.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert token_endpoint.called + assert foo_bar_endpoint.called diff --git a/tests/deprecated/test_lifespan_v1.py b/tests/deprecated/test_lifespan_v1.py new file mode 100644 index 00000000..b84191e2 --- /dev/null +++ b/tests/deprecated/test_lifespan_v1.py @@ -0,0 +1,126 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from pybotx import Bot, BotAccountWithSecret, BotXAuthVersion, HandlerCollector + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__startup__authorize_cant_get_token( + respx_mock: MockRouter, + loguru_caplog: pytest.LogCaptureFixture, + bot_account: BotAccountWithSecret, + host: str, + bot_id: UUID, + bot_signature: str, +) -> None: + # - Arrange - + token_endpoint = respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ).mock( + return_value=httpx.Response( + HTTPStatus.UNAUTHORIZED, + json={ + "status": "error", + }, + ), + ) + + collector = HandlerCollector() + + bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + auth_version=BotXAuthVersion.V1, + ) + + # - Act - + await bot.startup() + + # - Assert - + assert token_endpoint.called + + assert "Can't get token for bot account: " in loguru_caplog.text + assert f"host - {host}, bot_id - {bot_id}" in loguru_caplog.text + + # Cleanup + await bot.shutdown() + + +async def test__startup__can_skip_fetching_tokens( + respx_mock: MockRouter, + bot_account: BotAccountWithSecret, + host: str, + bot_id: UUID, + bot_signature: str, +) -> None: + # - Arrange - + token_endpoint = respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ) + + collector = HandlerCollector() + + bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + auth_version=BotXAuthVersion.V1, + ) + + # - Act - + await bot.startup(fetch_tokens=False) + + # - Assert - + assert not token_endpoint.called + + # Cleanup + await bot.shutdown() + + +async def test__fetch_tokens__succeeds_for_auth_v1( + respx_mock: MockRouter, + bot_account: BotAccountWithSecret, + host: str, + bot_id: UUID, + bot_signature: str, +) -> None: + # - Arrange - + token_endpoint = respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": "token", + }, + ), + ) + + collector = HandlerCollector() + bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + auth_version=BotXAuthVersion.V1, + ) + + # - Act - + await bot.fetch_tokens() + + # - Assert - + assert token_endpoint.called + assert bot._bot_accounts_storage.get_token_or_none(bot_id) == "token" + + # Cleanup + await bot.shutdown() diff --git a/tests/client/users_api/test_search_user_by_email.py b/tests/deprecated/test_search_user_by_email_get.py similarity index 63% rename from tests/client/users_api/test_search_user_by_email.py rename to tests/deprecated/test_search_user_by_email_get.py index fc59f1ef..3cfa873e 100644 --- a/tests/client/users_api/test_search_user_by_email.py +++ b/tests/deprecated/test_search_user_by_email_get.py @@ -1,5 +1,5 @@ from http import HTTPStatus -from typing import Any, Dict +from typing import Any from uuid import UUID import httpx @@ -14,6 +14,7 @@ UserNotFoundError, lifespan_wrapper, ) +from pybotx.client.users_api.search_user_by_email import SearchUserByEmailMethod pytestmark = [ pytest.mark.asyncio, @@ -22,6 +23,11 @@ ] +@pytest.fixture(autouse=True) +def reset_search_user_by_email_deprecation() -> None: + SearchUserByEmailMethod._legacy_get_warned = False + + async def test__search_user_by_email__user_not_found_error_raised( respx_mock: MockRouter, host: str, @@ -49,11 +55,12 @@ async def test__search_user_by_email__user_not_found_error_raised( # - Act - async with lifespan_wrapper(built_bot) as bot: - with pytest.raises(UserNotFoundError) as exc: - await bot.search_user_by_email( - bot_id=bot_id, - email="ad_user@cts.com", - ) + with pytest.warns(DeprecationWarning): + with pytest.raises(UserNotFoundError) as exc: + await bot.search_user_by_email( + bot_id=bot_id, + email="ad_user@cts.com", + ) # - Assert - assert "user_not_found" in str(exc.value) @@ -66,7 +73,7 @@ async def test__search_user_by_email__succeed( bot_id: UUID, bot_account: BotAccountWithSecret, user_from_search_with_data: UserFromSearch, - user_from_search_with_data_json: Dict[str, Any], + user_from_search_with_data_json: dict[str, Any], ) -> None: # - Arrange - endpoint = respx_mock.get( @@ -87,10 +94,11 @@ async def test__search_user_by_email__succeed( # - Act - async with lifespan_wrapper(built_bot) as bot: - user = await bot.search_user_by_email( - bot_id=bot_id, - email="ad_user@cts.com", - ) + with pytest.warns(DeprecationWarning): + user = await bot.search_user_by_email( + bot_id=bot_id, + email="ad_user@cts.com", + ) # - Assert - assert user == user_from_search_with_data @@ -104,7 +112,7 @@ async def test__search_user_by_email_without_extra_data__succeed( bot_id: UUID, bot_account: BotAccountWithSecret, user_from_search_without_data: UserFromSearch, - user_from_search_without_data_json: Dict[str, Any], + user_from_search_without_data_json: dict[str, Any], ) -> None: # - Arrange - endpoint = respx_mock.get( @@ -123,6 +131,46 @@ async def test__search_user_by_email_without_extra_data__succeed( built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.warns(DeprecationWarning): + user = await bot.search_user_by_email( + bot_id=bot_id, + email="ad_user@cts.com", + ) + + # - Assert - + assert user == user_from_search_without_data + + assert endpoint.called + + +async def test__search_user_by_email__legacy_warning_suppressed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + user_from_search_with_data: UserFromSearch, + user_from_search_with_data_json: dict[str, Any], +) -> None: + # - Arrange - + SearchUserByEmailMethod._legacy_get_warned = True + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/users/by_email", + headers={"Authorization": "Bearer token"}, + params={"email": "ad_user@cts.com"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": user_from_search_with_data_json, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + # - Act - async with lifespan_wrapper(built_bot) as bot: user = await bot.search_user_by_email( @@ -131,6 +179,5 @@ async def test__search_user_by_email_without_extra_data__succeed( ) # - Assert - - assert user == user_from_search_without_data - + assert user == user_from_search_with_data assert endpoint.called diff --git a/tests/deprecated/test_verify_request_v1.py b/tests/deprecated/test_verify_request_v1.py new file mode 100644 index 00000000..48bb584d --- /dev/null +++ b/tests/deprecated/test_verify_request_v1.py @@ -0,0 +1,581 @@ +from datetime import datetime +from typing import Any +from collections.abc import Callable, Coroutine +from unittest.mock import AsyncMock, Mock +from uuid import uuid4 + +import jwt +import pytest + +from pybotx import ( + Bot, + BotAccountWithSecret, + BotMenu, + HandlerCollector, + RequestHeadersNotProvidedError, + UnverifiedRequestError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__verify_request__success_attempt( + bot_account: BotAccountWithSecret, + authorization_header_v1: dict[str, str], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act and Assert - + async with lifespan_wrapper(built_bot) as bot: + bot._verify_request(authorization_header_v1) + + +async def test__verify_request__no_authorization_header_v1_provided( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request({}) + + # - Assert - + assert "The authorization token was not provided." in str(exc.value) + + +async def test__verify_request__cannot_decode_token( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act and Assert - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError): + bot._verify_request({"authorization": "test"}) + + +async def test__verify_request__aud_is_not_provided( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + authorization_token_payload_v1.pop("aud") + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request({"authorization": f"Bearer {token}"}) + + # - Assert - + assert "Invalid audience parameter was provided." in str(exc.value) + + +async def test__verify_request__aud_is_not_sequence( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + authorization_token_payload_v1["aud"] = 12345 + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request({"authorization": f"Bearer {token}"}) + + # - Assert - + assert "Invalid audience parameter was provided." in str(exc.value) + + +async def test__verify_request__aud_is_string( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + authorization_token_payload_v1["aud"] = "not-a-list" + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request({"authorization": f"Bearer {token}"}) + + # - Assert - + assert "Invalid audience parameter was provided." in str(exc.value) + + +async def test__verify_request__too_many_aud_values( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + authorization_token_payload_v1["aud"] = [str(bot_account.id), str(uuid4())] + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request({"authorization": f"Bearer {token}"}) + + # - Assert - + assert "Invalid audience parameter was provided." in str(exc.value) + + +async def test__verify_request__unknown_aud_value( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + random_bot_id = uuid4() + authorization_token_payload_v1["aud"] = [str(random_bot_id)] + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request({"authorization": f"Bearer {token}"}) + + # - Assert - + assert f"No bot account with bot_id: `{random_bot_id!s}`" in str(exc.value) + + +async def test__verify_request__invalid_token_secret( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + token = jwt.encode( + payload=authorization_token_payload_v1, + key=str(uuid4()), + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request({"authorization": f"Bearer {token}"}) + + # - Assert - + assert "Signature verification failed" in str(exc.value) + + +async def test__verify_request__expired_signature( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + authorization_token_payload_v1["exp"] = datetime(year=2000, month=1, day=1).timestamp() + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request({"authorization": f"Bearer {token}"}) + + # - Assert - + assert "Signature has expired" in str(exc.value) + + +async def test__verify_request__token_is_not_yet_valid_by_nbf( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + authorization_token_payload_v1["nbf"] = datetime(year=3000, month=1, day=1).timestamp() + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request({"authorization": f"Bearer {token}"}) + + # - Assert - + assert "The token is not yet valid (nbf)" in str(exc.value) + + +async def test__verify_request__token_is_not_yet_valid_by_iat( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + authorization_token_payload_v1["iat"] = datetime(year=3000, month=1, day=1).timestamp() + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request({"authorization": f"Bearer {token}"}) + + # - Assert - + assert "The token is not yet valid (iat)" in str(exc.value) + + +async def test__verify_request__invalid_issuer( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + authorization_token_payload_v1["iss"] = "another.example.com" + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request({"authorization": f"Bearer {token}"}) + + # - Assert - + assert "Invalid issuer" in str(exc.value) + + +async def test__verify_request__trusted_issuers_have_token_issuer( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + token_issuer = "another.example.com" + authorization_token_payload_v1["iss"] = token_issuer + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot._verify_request( + {"authorization": f"Bearer {token}"}, + trusted_issuers={token_issuer}, + ) + + +async def test__verify_request__trusted_issuers_have_not_token_issuer( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + authorization_token_payload_v1["iss"] = "another.example.com" + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request( + {"authorization": f"Bearer {token}"}, + trusted_issuers={"another-another.example.com"}, + ) + + # - Assert - + assert "Invalid issuer" in str(exc.value) + + +async def test__verify_request__token_issuer_is_missed( + bot_account: BotAccountWithSecret, + authorization_token_payload_v1: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + del authorization_token_payload_v1["iss"] + token = jwt.encode( + payload=authorization_token_payload_v1, + key=bot_account.secret_key, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnverifiedRequestError) as exc: + bot._verify_request( + {"authorization": f"Bearer {token}"}, + ) + + # - Assert - + assert 'Token is missing the "iss" claim' in str(exc.value) + + +@pytest.mark.parametrize( + "target_func_name", + ( + "async_execute_raw_bot_command", + "sync_execute_raw_smartapp_event", + "raw_get_status", + "set_raw_botx_method_result", + ), +) +async def test__verify_request__without_headers( + api_incoming_message_factory: Callable[..., dict[str, Any]], + bot_account: BotAccountWithSecret, + target_func_name: str, +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + payload = api_incoming_message_factory() + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(RequestHeadersNotProvidedError) as exc: + target_func = getattr(bot, target_func_name) + result = target_func(payload, verify_request=True) + if isinstance(result, Coroutine): + await result + + # - Assert - + assert "To verify the request you should provide headers." in str(exc.value) + + +async def test__async_execute_raw_bot_command__verify_request__called( + api_incoming_message_factory: Callable[..., dict[str, Any]], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + payload = api_incoming_message_factory() + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot._verify_request = Mock() # type: ignore + bot.async_execute_raw_bot_command( + payload, + verify_request=True, + request_headers={}, + ) + + # - Assert - + bot._verify_request.assert_called() + + +async def test__sync_execute_raw_smartapp_event__verify_request__called( + api_sync_smartapp_event_factory: Callable[..., dict[str, Any]], + collector_with_sync_smartapp_event_handler: HandlerCollector, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + built_bot = Bot( + collectors=[collector_with_sync_smartapp_event_handler], + bot_accounts=[bot_account], + ) + payload = api_sync_smartapp_event_factory(bot_id=bot_account.id) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot._verify_request = Mock() # type: ignore + await bot.sync_execute_raw_smartapp_event( + payload, + verify_request=True, + request_headers={}, + ) + + # - Assert - + bot._verify_request.assert_called() + + +async def test__raw_get_status__verify_request__called( + api_incoming_message_factory: Callable[..., dict[str, Any]], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot._verify_request = Mock() # type: ignore + await bot.raw_get_status( + { + "bot_id": str(bot_account.id), + "chat_type": "chat", + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + }, + verify_request=True, + request_headers={}, + ) + + # - Assert - + bot._verify_request.assert_called() + + +async def test__set_raw_botx_method_result__verify_request__called( + api_incoming_message_factory: Callable[..., dict[str, Any]], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot._verify_request = Mock() # type: ignore + bot._callbacks_manager.set_botx_method_callback_result = ( # type: ignore + AsyncMock() + ) + await bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + verify_request=True, + request_headers={}, + ) + + # - Assert - + bot._verify_request.assert_called() + + +async def test__async_execute_raw_bot_command__verify_request__not_called( + api_incoming_message_factory: Callable[..., dict[str, Any]], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + payload = api_incoming_message_factory() + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot._verify_request = Mock() # type: ignore + bot.async_execute_bot_command = Mock() # type: ignore + bot.async_execute_raw_bot_command(payload, verify_request=False) + + # - Assert - + bot._verify_request.assert_not_called() + bot.async_execute_bot_command.assert_called() + + +async def test__sync_execute_raw_smartapp_event__verify_request__not_called( + api_incoming_message_factory: Callable[..., dict[str, Any]], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + payload = api_incoming_message_factory() + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot._verify_request = Mock() # type: ignore + bot.sync_execute_raw_smartapp_event = Mock() # type: ignore + bot.sync_execute_raw_smartapp_event(payload, verify_request=False) + + # - Assert - + bot._verify_request.assert_not_called() + bot.sync_execute_raw_smartapp_event.assert_called() + + +async def test__raw_get_status__verify_request__not_called( + api_incoming_message_factory: Callable[..., dict[str, Any]], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot._verify_request = Mock() # type: ignore + bot.get_status = AsyncMock(return_value=BotMenu({})) # type: ignore + await bot.raw_get_status( + { + "bot_id": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + "chat_type": "chat", + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + }, + verify_request=False, + ) + + # - Assert - + bot._verify_request.assert_not_called() + bot.get_status.assert_awaited() + + +async def test__set_raw_botx_method_result__verify_request__not_called( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot._verify_request = Mock() # type: ignore + bot._callbacks_manager.set_botx_method_callback_result = ( # type: ignore + AsyncMock() + ) + await bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + verify_request=False, + ) + + # - Assert - + bot._verify_request.assert_not_called() + bot._callbacks_manager.set_botx_method_callback_result.assert_awaited() diff --git a/tests/fixtures/users_api.py b/tests/fixtures/users_api.py new file mode 100644 index 00000000..5196615f --- /dev/null +++ b/tests/fixtures/users_api.py @@ -0,0 +1,117 @@ +from typing import Any +from uuid import UUID + +import pytest + +from pybotx import UserFromSearch, UserKinds +from tests.client.users_api.convert_to_datetime import convert_to_datetime + + +@pytest.fixture() +def user_from_search_with_data_json() -> dict[str, Any]: + return { + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "ad_login": "ad_user_login", + "ad_domain": "cts.com", + "name": "Bob", + "company": "Bobs Co", + "company_position": "Director", + "department": "Owners", + "emails": ["ad_user@cts.com"], + "user_kind": "cts_user", + "active": True, + "created_at": "2023-03-26T14:36:08.740618Z", + "cts_id": "e0140f4c-4af2-5a2e-9ad1-5f37fceafbaf", + "description": "Director in Owners dep", + "ip_phone": "1271020", + "manager": "Alice", + "office": "SUN", + "other_ip_phone": "32593", + "other_phone": "1254218", + "public_name": "Bobby", + "rts_id": "f46440a4-d930-58d4-b3f5-8110ab846ee3", + "updated_at": "2023-03-26T14:36:08.740618Z", + } + + +@pytest.fixture +def user_from_search_with_data() -> UserFromSearch: + return UserFromSearch( + huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + ad_login="ad_user_login", + ad_domain="cts.com", + username="Bob", + company="Bobs Co", + company_position="Director", + department="Owners", + emails=["ad_user@cts.com"], + other_id=None, + user_kind=UserKinds.CTS_USER, + active=True, + created_at=convert_to_datetime("2023-03-26T14:36:08.740618Z"), + cts_id=UUID("e0140f4c-4af2-5a2e-9ad1-5f37fceafbaf"), + description="Director in Owners dep", + ip_phone="1271020", + manager="Alice", + office="SUN", + other_ip_phone="32593", + other_phone="1254218", + public_name="Bobby", + rts_id=UUID("f46440a4-d930-58d4-b3f5-8110ab846ee3"), + updated_at=convert_to_datetime("2023-03-26T14:36:08.740618Z"), + ) + + +@pytest.fixture +def user_from_search_without_data_json() -> dict[str, Any]: + return { + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "ad_login": "ad_user_login", + "ad_domain": "cts.com", + "name": "Bob", + "company": "Bobs Co", + "company_position": "Director", + "department": "Owners", + "emails": ["ad_user@cts.com"], + "user_kind": "cts_user", + "active": None, + "created_at": None, + "cts_id": None, + "description": None, + "ip_phone": None, + "manager": None, + "office": None, + "other_ip_phone": None, + "other_phone": None, + "public_name": None, + "rts_id": None, + "updated_at": None, + } + + +@pytest.fixture +def user_from_search_without_data() -> UserFromSearch: + return UserFromSearch( + huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + ad_login="ad_user_login", + ad_domain="cts.com", + username="Bob", + company="Bobs Co", + company_position="Director", + department="Owners", + emails=["ad_user@cts.com"], + other_id=None, + user_kind=UserKinds.CTS_USER, + active=None, + created_at=None, + cts_id=None, + description=None, + ip_phone=None, + manager=None, + office=None, + other_ip_phone=None, + other_phone=None, + public_name=None, + rts_id=None, + updated_at=None, + ) diff --git a/tests/models/test_api_base_property.py b/tests/models/test_api_base_property.py new file mode 100644 index 00000000..1d4c8594 --- /dev/null +++ b/tests/models/test_api_base_property.py @@ -0,0 +1,59 @@ +from typing import Any + +from deepdiff import DeepDiff +from hypothesis import given, strategies as st + +from pybotx.missing import Undefined +from pybotx.models.api_base import PayloadBaseModel + + +class DummyPayload(PayloadBaseModel): + payload: Any + + +def _contains_undefined(value: Any) -> bool: + if value is Undefined: + return True + if isinstance(value, dict): + return any(_contains_undefined(item) for item in value.values()) + if isinstance(value, list): + return any(_contains_undefined(item) for item in value) + return False + + +JSON_SCALARS = st.one_of( + st.none(), + st.booleans(), + st.integers(), + st.text(), + st.floats(allow_nan=False, allow_infinity=False), +) + +JSON_WITH_UNDEFINED = st.recursive( + st.one_of(JSON_SCALARS, st.just(Undefined)), + lambda children: st.lists(children, max_size=4) + | st.dictionaries(st.text(min_size=1, max_size=10), children, max_size=4), + max_leaves=10, +) + + +def test__payload_jsonable_dict__drops_undefined_values__deepdiff() -> None: + payload = DummyPayload( + payload={ + "a": 1, + "b": Undefined, + "c": {"d": Undefined, "e": 2}, + "f": [Undefined, 3, {"g": Undefined, "h": 4}], + }, + ) + + expected = {"payload": {"a": 1, "c": {"e": 2}, "f": [3, {"h": 4}]}} + diff = DeepDiff(payload.jsonable_dict(), expected, ignore_order=True) + assert diff == {} + + +@given(JSON_WITH_UNDEFINED) +def test__payload_jsonable_dict__no_undefined_values(payload: Any) -> None: + model = DummyPayload(payload=payload) + result = model.jsonable_dict() + assert not _contains_undefined(result) diff --git a/tests/models/test_botx_api_markup.py b/tests/models/test_botx_api_markup.py index 206cd724..850d4639 100644 --- a/tests/models/test_botx_api_markup.py +++ b/tests/models/test_botx_api_markup.py @@ -1,5 +1,5 @@ import json -from typing import Dict, Any, List +from typing import Any from pybotx.models.message.markup import ( @@ -93,7 +93,7 @@ def test_botx_api_markup_jsonable_dict() -> None: jsonable_dict = markup.jsonable_dict() # - Assert - - expected_dict: List[List[Dict[str, Any]]] = [ + expected_dict: list[list[dict[str, Any]]] = [ [ { "command": "/test", diff --git a/tests/models/test_enums.py b/tests/models/test_enums.py index d7d39fb7..3dc6d855 100644 --- a/tests/models/test_enums.py +++ b/tests/models/test_enums.py @@ -1,7 +1,12 @@ import pytest from unittest.mock import Mock -from pybotx.models.enums import convert_chat_type_from_domain, ChatTypes, APIChatTypes +from pybotx.models.enums import ( + APIChatTypes, + ChatTypes, + convert_chat_type_from_domain, + convert_chat_type_to_domain, +) def test__convert_chat_type_from_domain__successful_conversion() -> None: @@ -22,3 +27,8 @@ def test__convert_chat_type_from_domain__unsupported_chat_type_raises_error() -> # - Act & Assert - with pytest.raises(NotImplementedError, match="Unsupported chat type"): convert_chat_type_from_domain(unsupported_chat_type) + + +def test__convert_chat_type_to_domain__notes_maps_to_personal_chat() -> None: + assert convert_chat_type_to_domain(APIChatTypes.NOTES) == ChatTypes.PERSONAL_CHAT + assert convert_chat_type_to_domain("notes") == ChatTypes.PERSONAL_CHAT diff --git a/tests/models/test_enums_property.py b/tests/models/test_enums_property.py new file mode 100644 index 00000000..257353ec --- /dev/null +++ b/tests/models/test_enums_property.py @@ -0,0 +1,134 @@ +from hypothesis import given, strategies as st + +import pytest + +from pybotx.models.enums import ( + APIAttachmentTypes, + APIChatTypes, + APIUserKinds, + APISyncSourceTypes, + AttachmentTypes, + BotAPIClientPlatforms, + BotAPIMentionTypes, + BotAPIConferenceLinkTypes, + ChatTypes, + ClientPlatforms, + ConferenceLinkTypes, + MentionTypes, + SyncSourceTypes, + UserKinds, + convert_attachment_type_from_domain, + convert_attachment_type_to_domain, + convert_chat_type_from_domain, + convert_chat_type_to_domain, + convert_client_platform_to_domain, + convert_conference_link_type_to_domain, + convert_mention_type_from_domain, + convert_sync_source_type_to_domain, + convert_user_kind_to_domain, +) + +API_CHAT_VALUES = [chat_type.value for chat_type in APIChatTypes] +API_SYNC_SOURCE_VALUES = [sync_type.value for sync_type in APISyncSourceTypes] + + +@given(st.sampled_from(list(ChatTypes))) +def test__convert_chat_type_roundtrip__property(chat_type: ChatTypes) -> None: + api_type = convert_chat_type_from_domain(chat_type) + assert convert_chat_type_to_domain(api_type) == chat_type + + +@given(st.sampled_from(list(APIChatTypes))) +def test__convert_chat_type_to_domain__accepts_api_enum( + api_type: APIChatTypes, +) -> None: + assert convert_chat_type_to_domain(api_type) in ChatTypes + + +@given(st.sampled_from(API_CHAT_VALUES)) +def test__convert_chat_type_to_domain__accepts_api_string( + api_value: str, +) -> None: + assert convert_chat_type_to_domain(api_value) in ChatTypes + + +@given(st.text(min_size=1).filter(lambda value: value not in API_CHAT_VALUES)) +def test__convert_chat_type_to_domain__unknown_strings_become_unsupported( + api_value: str, +) -> None: + assert convert_chat_type_to_domain(api_value) == "UNSUPPORTED" + + +@given(st.sampled_from(list(APIAttachmentTypes))) +def test__convert_attachment_type_to_domain__property( + api_type: APIAttachmentTypes, +) -> None: + domain_type = convert_attachment_type_to_domain(api_type) + assert domain_type in AttachmentTypes + if domain_type is AttachmentTypes.STICKER: + with pytest.raises(NotImplementedError): + convert_attachment_type_from_domain(domain_type) + else: + assert convert_attachment_type_from_domain(domain_type) == api_type + + +@given(st.sampled_from(list(AttachmentTypes))) +def test__convert_attachment_type_from_domain__property( + domain_type: AttachmentTypes, +) -> None: + if domain_type is AttachmentTypes.STICKER: + with pytest.raises(NotImplementedError): + convert_attachment_type_from_domain(domain_type) + else: + api_type = convert_attachment_type_from_domain(domain_type) + assert convert_attachment_type_to_domain(api_type) == domain_type + + +@given(st.sampled_from(list(APIUserKinds))) +def test__convert_user_kind_to_domain__property( + api_kind: APIUserKinds, +) -> None: + assert convert_user_kind_to_domain(api_kind) in UserKinds + + +@given(st.sampled_from(list(BotAPIClientPlatforms))) +def test__convert_client_platform_to_domain__property( + api_platform: BotAPIClientPlatforms, +) -> None: + assert convert_client_platform_to_domain(api_platform) in ClientPlatforms + + +@given(st.sampled_from(list(MentionTypes))) +def test__convert_mention_type_from_domain__property( + domain_mention: MentionTypes, +) -> None: + assert convert_mention_type_from_domain(domain_mention) in BotAPIMentionTypes + + +@given(st.sampled_from(list(BotAPIConferenceLinkTypes))) +def test__convert_conference_link_type_to_domain__property( + api_type: BotAPIConferenceLinkTypes, +) -> None: + assert convert_conference_link_type_to_domain(api_type) in ConferenceLinkTypes + + +@given(st.sampled_from(list(APISyncSourceTypes))) +def test__convert_sync_source_type_to_domain__property( + api_type: APISyncSourceTypes, +) -> None: + assert convert_sync_source_type_to_domain(api_type) in SyncSourceTypes + + +@given(st.text(min_size=1)) +def test__convert_sync_source_type_to_domain__unknown_strings_become_unsupported( + api_value: str, +) -> None: + if api_value not in API_SYNC_SOURCE_VALUES: + assert convert_sync_source_type_to_domain(api_value) == "UNSUPPORTED" + + +@given(st.sampled_from(API_SYNC_SOURCE_VALUES)) +def test__convert_sync_source_type_to_domain__accepts_api_string( + api_value: str, +) -> None: + assert convert_sync_source_type_to_domain(api_value) in SyncSourceTypes diff --git a/tests/models/test_incoming_message.py b/tests/models/test_incoming_message.py index 5e9da2c3..0e0386e2 100644 --- a/tests/models/test_incoming_message.py +++ b/tests/models/test_incoming_message.py @@ -1,4 +1,4 @@ -from typing import Callable, Tuple +from collections.abc import Callable import pytest @@ -73,7 +73,7 @@ def test__argument__filled( def test__arguments__not_filled( incoming_message_factory: Callable[..., IncomingMessage], body: str, - argument_answer: Tuple[str, ...], + argument_answer: tuple[str, ...], ) -> None: # - Arrange - message = incoming_message_factory(body=body) @@ -92,7 +92,7 @@ def test__arguments__not_filled( def test__arguments__filled( incoming_message_factory: Callable[..., IncomingMessage], body: str, - argument_answer: Tuple[str, ...], + argument_answer: tuple[str, ...], ) -> None: # - Arrange - message = incoming_message_factory(body=body) diff --git a/tests/models/test_status_recipient.py b/tests/models/test_status_recipient.py index a89c76dc..396f3168 100644 --- a/tests/models/test_status_recipient.py +++ b/tests/models/test_status_recipient.py @@ -1,4 +1,4 @@ -from typing import Callable +from collections.abc import Callable from pybotx import IncomingMessage, StatusRecipient diff --git a/tests/system_events/factories.py b/tests/system_events/factories.py index c508b407..160ba2ae 100644 --- a/tests/system_events/factories.py +++ b/tests/system_events/factories.py @@ -1,57 +1,57 @@ import uuid -from typing import Any, Dict, List, Optional +from typing import Any from factory.base import DictFactory from factory.declarations import SubFactory class DeviceMetaFactory(DictFactory): - permissions: Optional[str] = None - pushes: Optional[str] = None - timezone: Optional[str] = None + permissions: str | None = None + pushes: str | None = None + timezone: str | None = None class FromFactory(DictFactory): - user_huid: Optional[str] = None + user_huid: str | None = None group_chat_id: str = "8dada2c8-67a6-4434-9dec-570d244e78ee" - ad_login: Optional[str] = None - ad_domain: Optional[str] = None - username: Optional[str] = None + ad_login: str | None = None + ad_domain: str | None = None + username: str | None = None chat_type: str = "group_chat" - manufacturer: Optional[str] = None - device: Optional[str] = None - device_software: Optional[str] = None + manufacturer: str | None = None + device: str | None = None + device_software: str | None = None device_meta: Any = SubFactory(DeviceMetaFactory) # type: ignore[no-untyped-call] - platform: Optional[str] = None - platform_package_id: Optional[str] = None - is_admin: Optional[bool] = None - is_creator: Optional[bool] = None - app_version: Optional[str] = None + platform: str | None = None + platform_package_id: str | None = None + is_admin: bool | None = None + is_creator: bool | None = None + app_version: str | None = None locale: str = "en" host: str = "cts.ccteam.ru" class CommandDataFactory(DictFactory): - added_members: List[str] = [uuid.uuid4().hex, uuid.uuid4().hex] + added_members: list[str] = [uuid.uuid4().hex, uuid.uuid4().hex] class CommandFactory(DictFactory): body: str = "system:user_joined_to_chat" command_type: str = "system" data: Any = SubFactory(CommandDataFactory) # type: ignore[no-untyped-call] - metadata: Dict[str, Any] = {} + metadata: dict[str, Any] = {} class BotAPIJoinToChatFactory(DictFactory): sync_id: str = uuid.uuid4().hex command: Any = SubFactory(CommandFactory) # type: ignore[no-untyped-call] - async_files: List[str] = [] - attachments: List[str] = [] - entities: List[str] = [] + async_files: list[str] = [] + attachments: list[str] = [] + entities: list[str] = [] from_: Any = SubFactory(FromFactory) # type: ignore[no-untyped-call] bot_id: str = uuid.uuid4().hex proto_version: int = 4 - source_sync_id: Optional[str] = None + source_sync_id: str | None = None class Meta: rename = {"from_": "from"} diff --git a/tests/system_events/test_added_to_chat.py b/tests/system_events/test_added_to_chat.py index c26a0b82..89fc74d7 100644 --- a/tests/system_events/test_added_to_chat.py +++ b/tests/system_events/test_added_to_chat.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID import pytest @@ -67,7 +66,7 @@ async def test__added_to_chat__succeed( } collector = HandlerCollector() - added_to_chat: Optional[AddedToChatEvent] = None + added_to_chat: AddedToChatEvent | None = None @collector.added_to_chat async def added_to_chat_handler(event: AddedToChatEvent, bot: Bot) -> None: diff --git a/tests/system_events/test_chat_created.py b/tests/system_events/test_chat_created.py index dbc9bef5..a67f0e67 100644 --- a/tests/system_events/test_chat_created.py +++ b/tests/system_events/test_chat_created.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID import pytest @@ -83,7 +82,7 @@ async def test__chat_created__succeed( } collector = HandlerCollector() - chat_created: Optional[ChatCreatedEvent] = None + chat_created: ChatCreatedEvent | None = None @collector.chat_created async def chat_created_handler(event: ChatCreatedEvent, bot: Bot) -> None: diff --git a/tests/system_events/test_chat_deleted_by_user.py b/tests/system_events/test_chat_deleted_by_user.py index 6dcef414..5e3885e2 100644 --- a/tests/system_events/test_chat_deleted_by_user.py +++ b/tests/system_events/test_chat_deleted_by_user.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID import pytest @@ -62,7 +61,7 @@ async def test__chat_deleted_by_user__succeed( } collector = HandlerCollector() - chat_deleted: Optional[ChatDeletedByUserEvent] = None + chat_deleted: ChatDeletedByUserEvent | None = None @collector.chat_deleted_by_user async def chat_deleted_handler(event: ChatDeletedByUserEvent, bot: Bot) -> None: diff --git a/tests/system_events/test_conference_changed.py b/tests/system_events/test_conference_changed.py index 66eea67e..2319b7f6 100644 --- a/tests/system_events/test_conference_changed.py +++ b/tests/system_events/test_conference_changed.py @@ -1,5 +1,6 @@ from datetime import datetime -from typing import Any, Callable, Dict, Optional, cast +from typing import Any, cast +from collections.abc import Callable from uuid import UUID import pytest @@ -29,11 +30,11 @@ async def test__conference_changed_succeed( bot_id: UUID, call_id: UUID, host: str, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], ) -> None: # - Arrange - conference_change_data = cast( - Dict[str, Any], + dict[str, Any], ConferenceChangedDataFactory( call_id=str(call_id), link_type="public", @@ -48,7 +49,7 @@ async def test__conference_changed_succeed( ) collector = HandlerCollector() - conference_changed: Optional[ConferenceChangedEvent] = None + conference_changed: ConferenceChangedEvent | None = None @collector.conference_changed async def conference_changed_handler( diff --git a/tests/system_events/test_conference_created.py b/tests/system_events/test_conference_created.py index 68ced694..f34c6d04 100644 --- a/tests/system_events/test_conference_created.py +++ b/tests/system_events/test_conference_created.py @@ -1,4 +1,5 @@ -from typing import Any, Callable, Dict, Optional +from typing import Any +from collections.abc import Callable from uuid import UUID import pytest @@ -25,7 +26,7 @@ async def test__conference_created_succeed( bot_id: UUID, host: str, call_id: UUID, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], ) -> None: # - Arrange - payload = api_incoming_message_factory( @@ -37,7 +38,7 @@ async def test__conference_created_succeed( ) collector = HandlerCollector() - conference_created: Optional[ConferenceCreatedEvent] = None + conference_created: ConferenceCreatedEvent | None = None @collector.conference_created async def conference_created_handler( diff --git a/tests/system_events/test_conference_deleted.py b/tests/system_events/test_conference_deleted.py index a3764ed6..499b6757 100644 --- a/tests/system_events/test_conference_deleted.py +++ b/tests/system_events/test_conference_deleted.py @@ -1,4 +1,5 @@ -from typing import Any, Callable, Dict, Optional +from typing import Any +from collections.abc import Callable from uuid import UUID import pytest @@ -25,7 +26,7 @@ async def test__conference_deleted_succeed( bot_id: UUID, host: str, call_id: UUID, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], ) -> None: # - Arrange - payload = api_incoming_message_factory( @@ -37,7 +38,7 @@ async def test__conference_deleted_succeed( ) collector = HandlerCollector() - conference_deleted: Optional[ConferenceDeletedEvent] = None + conference_deleted: ConferenceDeletedEvent | None = None @collector.conference_deleted async def conference_deleted_handler( diff --git a/tests/system_events/test_cts_login.py b/tests/system_events/test_cts_login.py index 795daf4b..1c13022b 100644 --- a/tests/system_events/test_cts_login.py +++ b/tests/system_events/test_cts_login.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID import pytest @@ -63,7 +62,7 @@ async def test__cts_login__succeed( } collector = HandlerCollector() - cts_login: Optional[CTSLoginEvent] = None + cts_login: CTSLoginEvent | None = None @collector.cts_login async def cts_login_handler(event: CTSLoginEvent, bot: Bot) -> None: diff --git a/tests/system_events/test_cts_logout.py b/tests/system_events/test_cts_logout.py index dd95c878..8e0ce661 100644 --- a/tests/system_events/test_cts_logout.py +++ b/tests/system_events/test_cts_logout.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID import pytest @@ -63,7 +62,7 @@ async def test__cts_logout__succeed( } collector = HandlerCollector() - cts_logout: Optional[CTSLogoutEvent] = None + cts_logout: CTSLogoutEvent | None = None @collector.cts_logout async def cts_logout_handler(event: CTSLogoutEvent, bot: Bot) -> None: diff --git a/tests/system_events/test_deleted_from_chat.py b/tests/system_events/test_deleted_from_chat.py index c429fdb1..2c44072a 100644 --- a/tests/system_events/test_deleted_from_chat.py +++ b/tests/system_events/test_deleted_from_chat.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID import pytest @@ -66,7 +65,7 @@ async def test__deleted_from_chat__succeed( } collector = HandlerCollector() - deleted_from_chat: Optional[DeletedFromChatEvent] = None + deleted_from_chat: DeletedFromChatEvent | None = None @collector.deleted_from_chat async def deleted_from_chat_handler(event: DeletedFromChatEvent, bot: Bot) -> None: diff --git a/tests/system_events/test_event_delete.py b/tests/system_events/test_event_delete.py index 6405f5da..1f8ea578 100644 --- a/tests/system_events/test_event_delete.py +++ b/tests/system_events/test_event_delete.py @@ -1,5 +1,6 @@ from datetime import datetime -from typing import Any, Callable, Dict, Optional +from typing import Any +from collections.abc import Callable from uuid import UUID import pytest @@ -27,7 +28,7 @@ async def test__event_delete__succeed( bot_id: UUID, host: str, datetime_formatter: Callable[[str], datetime], - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], ) -> None: # - Arrange - event_deleted_data = DeleteEventFactory.create() @@ -41,7 +42,7 @@ async def test__event_delete__succeed( ) collector = HandlerCollector() - event_deleted: Optional[EventDeleted] = None + event_deleted: EventDeleted | None = None @collector.event_deleted async def event_deleted_handler(event: EventDeleted, _: Bot) -> None: diff --git a/tests/system_events/test_event_edit.py b/tests/system_events/test_event_edit.py index 9207a5ef..19dd94a3 100644 --- a/tests/system_events/test_event_edit.py +++ b/tests/system_events/test_event_edit.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID import pytest @@ -84,7 +83,7 @@ async def test__event_edit__succeed( } collector = HandlerCollector() - event_edit: Optional[EventEdit] = None + event_edit: EventEdit | None = None @collector.event_edit async def event_edit_handler(event: EventEdit, _: Bot) -> None: diff --git a/tests/system_events/test_internal_bot_notification.py b/tests/system_events/test_internal_bot_notification.py index ea1bc6d0..6f19dcab 100644 --- a/tests/system_events/test_internal_bot_notification.py +++ b/tests/system_events/test_internal_bot_notification.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID import pytest @@ -69,7 +68,7 @@ async def test__internal_bot_notification__succeed( } collector = HandlerCollector() - internal_bot_notification: Optional[InternalBotNotificationEvent] = None + internal_bot_notification: InternalBotNotificationEvent | None = None @collector.internal_bot_notification async def internal_bot_notification_handler( diff --git a/tests/system_events/test_join_to_chat.py b/tests/system_events/test_join_to_chat.py index 16622787..116daf30 100644 --- a/tests/system_events/test_join_to_chat.py +++ b/tests/system_events/test_join_to_chat.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional, cast +from typing import Any, cast from uuid import UUID import pytest @@ -33,13 +33,13 @@ async def test__join_to_chat__succeed( 3. The registered user_joined_to_chat handler is called with this event """ - payload: Dict[str, Any] = cast( - Dict[str, Any], + payload: dict[str, Any] = cast( + dict[str, Any], BotAPIJoinToChatFactory(bot_id=bot_account.id.hex), # type: ignore[no-untyped-call] ) collector = HandlerCollector() - join_to_chat: Optional[JoinToChatEvent] = None + join_to_chat: JoinToChatEvent | None = None @collector.user_joined_to_chat async def join_to_chat_handler(event: JoinToChatEvent, bot: Bot) -> None: diff --git a/tests/system_events/test_left_from_chat.py b/tests/system_events/test_left_from_chat.py index f9031e1c..2185d543 100644 --- a/tests/system_events/test_left_from_chat.py +++ b/tests/system_events/test_left_from_chat.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID import pytest @@ -66,7 +65,7 @@ async def test__left_from_chat__succeed( } collector = HandlerCollector() - left_from_chat: Optional[LeftFromChatEvent] = None + left_from_chat: LeftFromChatEvent | None = None @collector.left_from_chat async def left_from_chat_handler(event: LeftFromChatEvent, bot: Bot) -> None: diff --git a/tests/system_events/test_smartapp_event.py b/tests/system_events/test_smartapp_event.py index 76057309..2b7232f3 100644 --- a/tests/system_events/test_smartapp_event.py +++ b/tests/system_events/test_smartapp_event.py @@ -1,4 +1,3 @@ -from typing import Optional from uuid import UUID import pytest @@ -92,7 +91,7 @@ async def test__smartapp__succeed( } collector = HandlerCollector() - smartapp: Optional[SmartAppEvent] = None + smartapp: SmartAppEvent | None = None @collector.smartapp_event async def smartapp_handler(event: SmartAppEvent, bot: Bot) -> None: @@ -134,6 +133,11 @@ async def smartapp_handler(event: SmartAppEvent, bot: Bot) -> None: _file_url="https://link.to/file", _file_mimetype="image/png", _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + file_preview="https://link.to/preview", + file_preview_height=300, + file_preview_width=300, + file_encryption_algo="stream", + chunk_size=2097152, ), ], chat=Chat( diff --git a/tests/test_async_buffer.py b/tests/test_async_buffer.py index da2985dc..69225fb0 100644 --- a/tests/test_async_buffer.py +++ b/tests/test_async_buffer.py @@ -1,6 +1,5 @@ import os import pytest -from typing import Optional from pybotx.async_buffer import ( AsyncBufferReadable, @@ -38,7 +37,7 @@ async def write(self, content: bytes) -> int: self._position += len(content) return len(content) - async def read(self, bytes_to_read: Optional[int] = None) -> bytes: + async def read(self, bytes_to_read: int | None = None) -> bytes: if bytes_to_read is None: result = bytes(self._buffer[self._position :]) self._position = len(self._buffer) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index a6bab9f2..a3a87b6d 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -1,5 +1,7 @@ import asyncio -from typing import Any, Callable, Dict, Optional +from types import SimpleNamespace +from typing import Any, cast +from collections.abc import Callable from uuid import UUID import pytest @@ -18,10 +20,12 @@ AttachmentImage, AttachmentVideo, AttachmentVoice, + BotAPIAttachment, Contact, IncomingAttachment, Link, Location, + convert_api_attachment_to_domain, ) pytestmark = [ @@ -35,7 +39,7 @@ async def test__attachment__open( host: str, bot_account: BotAccountWithSecret, bot_id: UUID, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], ) -> None: # - Arrange - payload = api_incoming_message_factory( @@ -51,7 +55,7 @@ async def test__attachment__open( host=host, ) collector = HandlerCollector() - incoming_message: Optional[IncomingMessage] = None + incoming_message: IncomingMessage | None = None @collector.default_message_handler async def default_handler(message: IncomingMessage, bot: Bot) -> None: @@ -153,17 +157,17 @@ async def default_handler(message: IncomingMessage, bot: Bot) -> None: API_AND_DOMAIN_NON_FILE_ATTACHMENTS, ) async def test__async_execute_raw_bot_command__non_file_attachments_types( - api_attachment: Dict[str, Any], + api_attachment: dict[str, Any], domain_attachment: IncomingAttachment, attr_name: str, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, ) -> None: # - Arrange - payload = api_incoming_message_factory(body="😀", attachment=api_attachment) collector = HandlerCollector() - incoming_message: Optional[IncomingMessage] = None + incoming_message: IncomingMessage | None = None @collector.default_message_handler async def default_handler(message: IncomingMessage, bot: Bot) -> None: @@ -275,16 +279,16 @@ async def default_handler(message: IncomingMessage, bot: Bot) -> None: API_AND_DOMAIN_FILE_ATTACHMENTS, ) async def test__async_execute_raw_bot_command__file_attachments_types( - api_attachment: Dict[str, Any], + api_attachment: dict[str, Any], domain_attachment: IncomingAttachment, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, ) -> None: # - Arrange - payload = api_incoming_message_factory(attachment=api_attachment) collector = HandlerCollector() - incoming_message: Optional[IncomingMessage] = None + incoming_message: IncomingMessage | None = None @collector.default_message_handler async def default_handler(message: IncomingMessage, bot: Bot) -> None: @@ -304,8 +308,18 @@ async def default_handler(message: IncomingMessage, bot: Bot) -> None: assert incoming_message.file == domain_attachment +async def test__convert_api_attachment_to_domain__unsupported_type() -> None: + api_attachment = cast( + BotAPIAttachment, + SimpleNamespace(type="unsupported"), + ) + + with pytest.raises(NotImplementedError): + convert_api_attachment_to_domain(api_attachment, "body") + + async def test__async_execute_raw_bot_command__unknown_attachment_type( - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, loguru_caplog: pytest.LogCaptureFixture, ) -> None: @@ -326,7 +340,7 @@ async def test__async_execute_raw_bot_command__unknown_attachment_type( async def test__async_execute_raw_bot_command__empty_attachment( - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, loguru_caplog: pytest.LogCaptureFixture, ) -> None: @@ -341,7 +355,7 @@ async def test__async_execute_raw_bot_command__empty_attachment( payload = api_incoming_message_factory(attachment=empty_attachment) collector = HandlerCollector() - incoming_message: Optional[IncomingMessage] = None + incoming_message: IncomingMessage | None = None @collector.default_message_handler async def default_handler(message: IncomingMessage, bot: Bot) -> None: diff --git a/tests/test_base_command.py b/tests/test_base_command.py index 7e2937e1..959139c7 100644 --- a/tests/test_base_command.py +++ b/tests/test_base_command.py @@ -1,6 +1,7 @@ import json import logging -from typing import Any, Callable, Dict +from typing import Any +from collections.abc import Callable import pytest @@ -105,14 +106,12 @@ async def test__async_execute_raw_bot_command__unknown_system_event() -> None: async def test__async_execute_raw_bot_command__logging_incoming_request( bot_account: BotAccountWithSecret, loguru_caplog: pytest.LogCaptureFixture, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], mock_authorization: None, ) -> None: # - Arrange - payload = api_incoming_message_factory(bot_id=bot_account.id) - log_message = "Got command: {command}".format( - command=json.dumps(payload, sort_keys=True, indent=4, ensure_ascii=False), - ) + log_message = f"Got command: {json.dumps(payload, sort_keys=True, indent=4, ensure_ascii=False)}" built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) # - Act - @@ -127,14 +126,12 @@ async def test__async_execute_raw_bot_command__logging_incoming_request( async def test__async_execute_raw_bot_command__not_logging_incoming_request( bot_account: BotAccountWithSecret, loguru_caplog: pytest.LogCaptureFixture, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], mock_authorization: None, ) -> None: # - Arrange - payload = api_incoming_message_factory(bot_id=bot_account.id) - log_message = "Got command: {command}".format( - command=json.dumps(payload, sort_keys=True, indent=4, ensure_ascii=False), - ) + log_message = f"Got command: {json.dumps(payload, sort_keys=True, indent=4, ensure_ascii=False)}" built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) # - Act - @@ -153,14 +150,12 @@ async def test__async_execute_raw_bot_command__not_logging_incoming_request( async def test__sync_execute_raw_smartapp_event__logging_incoming_request( bot_account: BotAccountWithSecret, loguru_caplog: pytest.LogCaptureFixture, - api_sync_smartapp_event_factory: Callable[..., Dict[str, Any]], + api_sync_smartapp_event_factory: Callable[..., dict[str, Any]], collector_with_sync_smartapp_event_handler: HandlerCollector, ) -> None: # - Arrange - payload = api_sync_smartapp_event_factory(bot_id=bot_account.id) - log_message = "Got sync smartapp event: {command}".format( - command=json.dumps(payload, sort_keys=True, indent=4, ensure_ascii=False), - ) + log_message = f"Got sync smartapp event: {json.dumps(payload, sort_keys=True, indent=4, ensure_ascii=False)}" built_bot = Bot( collectors=[collector_with_sync_smartapp_event_handler], bot_accounts=[bot_account], @@ -178,14 +173,12 @@ async def test__sync_execute_raw_smartapp_event__logging_incoming_request( async def test__sync_execute_raw_smartapp_event__not_logging_incoming_request( bot_account: BotAccountWithSecret, loguru_caplog: pytest.LogCaptureFixture, - api_sync_smartapp_event_factory: Callable[..., Dict[str, Any]], + api_sync_smartapp_event_factory: Callable[..., dict[str, Any]], collector_with_sync_smartapp_event_handler: HandlerCollector, ) -> None: # - Arrange - payload = api_sync_smartapp_event_factory(bot_id=bot_account.id) - log_message = "Got sync smartapp event: {command}".format( - command=json.dumps(payload, sort_keys=True, indent=4, ensure_ascii=False), - ) + log_message = f"Got sync smartapp event: {json.dumps(payload, sort_keys=True, indent=4, ensure_ascii=False)}" built_bot = Bot( collectors=[collector_with_sync_smartapp_event_handler], bot_accounts=[bot_account], @@ -206,7 +199,7 @@ async def test__sync_execute_raw_smartapp_event__not_logging_incoming_request( async def test__sync_execute_raw_smartapp_event__headers_not_provided( bot_account: BotAccountWithSecret, - api_sync_smartapp_event_factory: Callable[..., Dict[str, Any]], + api_sync_smartapp_event_factory: Callable[..., dict[str, Any]], collector_with_sync_smartapp_event_handler: HandlerCollector, ) -> None: # - Arrange - @@ -224,9 +217,9 @@ async def test__sync_execute_raw_smartapp_event__headers_not_provided( async def test__sync_execute_raw_smartapp_event__request_verified( bot_account: BotAccountWithSecret, - api_sync_smartapp_event_factory: Callable[..., Dict[str, Any]], + api_sync_smartapp_event_factory: Callable[..., dict[str, Any]], collector_with_sync_smartapp_event_handler: HandlerCollector, - authorization_header: Dict[str, str], + authorization_header: dict[str, str], ) -> None: # - Arrange - payload = api_sync_smartapp_event_factory(bot_id=bot_account.id) diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 566b98f0..9b69e837 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -1,5 +1,6 @@ from http import HTTPStatus -from typing import Any, Callable, Dict, List, Optional +from typing import Any +from collections.abc import Callable from uuid import UUID import httpx @@ -50,8 +51,8 @@ async def handle_sync_smartapp_event( def bot_factory( - bot_accounts: List[BotAccountWithSecret], - bot_collector: Optional[HandlerCollector] = None, + bot_accounts: list[BotAccountWithSecret], + bot_collector: HandlerCollector | None = None, ) -> Bot: return Bot(collectors=[bot_collector or collector], bot_accounts=bot_accounts) @@ -258,7 +259,7 @@ def test__web_app__bot_command( ), ) - command_payload = { + command_payload: dict[str, Any] = { "bot_id": str(bot_id), "command": { "body": "/debug", @@ -325,7 +326,7 @@ def test__web_app__unknown_bot_response( bot: Bot, ) -> None: # - Arrange - - payload = { + payload: dict[str, Any] = { "bot_id": "c755e147-30a5-45df-b46a-c75aa6089c8f", "command": { "body": "/debug", @@ -465,6 +466,8 @@ def test__web_app__sync_smartapp_event__success(bot: Bot, bot_id: UUID) -> None: "file_size": 349372, "file_hash": "qVSzEUJITWP+TgCvcF3UCzQrBaY3RHqB92CHObz4E70=", "file_mime_type": "application/octet-stream", + "chunk_size": 2097152, + "file_encryption_algo": "stream", "file_id": "a0ec914f-8235-5021-9b8d-05c3cd303536", "type": "document", }, @@ -476,7 +479,7 @@ def test__web_app__sync_smartapp_event__success(bot: Bot, bot_id: UUID) -> None: def test__web_app__sync_smartapp_event__error( bot_id: UUID, bot_account: BotAccountWithSecret, - api_sync_smartapp_event_factory: Callable[..., Dict[str, Any]], + api_sync_smartapp_event_factory: Callable[..., dict[str, Any]], ) -> None: # - Arrange - request_payload = api_sync_smartapp_event_factory(bot_id=bot_id) diff --git a/tests/test_exception_middleware.py b/tests/test_exception_middleware.py index 4c90b99f..1ca20ed5 100644 --- a/tests/test_exception_middleware.py +++ b/tests/test_exception_middleware.py @@ -1,5 +1,5 @@ import asyncio -from typing import Callable +from collections.abc import Callable from unittest.mock import MagicMock, call import pytest diff --git a/tests/test_files.py b/tests/test_files.py index 5fac5c20..aa172e76 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -1,5 +1,6 @@ from http import HTTPStatus -from typing import Any, Callable, Dict, Optional +from typing import Any +from collections.abc import Callable from uuid import UUID import httpx @@ -32,7 +33,7 @@ async def test__async_file__open( host: str, bot_account: BotAccountWithSecret, bot_id: UUID, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], ) -> None: # - Arrange - endpoint = respx_mock.get( @@ -40,6 +41,7 @@ async def test__async_file__open( params={ "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + "is_preview": False, }, headers={"Authorization": "Bearer token"}, ).mock( @@ -112,7 +114,7 @@ async def test__async_file__open( } collector = HandlerCollector() - read_content: Optional[bytes] = None + read_content: bytes | None = None @collector.smartapp_event async def smartapp_event_handler(event: SmartAppEvent, bot: Bot) -> None: @@ -158,6 +160,11 @@ async def smartapp_event_handler(event: SmartAppEvent, bot: Bot) -> None: _file_url="https://link.to/file", _file_mimetype="image/png", _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + file_preview="https://link.to/preview", + file_preview_height=300, + file_preview_width=300, + file_encryption_algo="stream", + chunk_size=2097152, ), ), ( @@ -186,6 +193,11 @@ async def smartapp_event_handler(event: SmartAppEvent, bot: Bot) -> None: _file_url="https://link.to/file", _file_mimetype="video/mp4", _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + file_preview="https://link.to/preview", + file_preview_height=300, + file_preview_width=300, + file_encryption_algo="stream", + chunk_size=2097152, ), ), ( @@ -212,6 +224,11 @@ async def smartapp_event_handler(event: SmartAppEvent, bot: Bot) -> None: _file_url="https://link.to/file", _file_mimetype="plain/text", _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + file_preview="https://link.to/preview", + file_preview_height=300, + file_preview_width=300, + file_encryption_algo="stream", + chunk_size=2097152, ), ), ( @@ -240,6 +257,11 @@ async def smartapp_event_handler(event: SmartAppEvent, bot: Bot) -> None: _file_url="https://link.to/file", _file_mimetype="audio/mp3", _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + file_preview="https://link.to/preview", + file_preview_height=300, + file_preview_width=300, + file_encryption_algo="stream", + chunk_size=2097152, ), ), ) @@ -250,9 +272,9 @@ async def smartapp_event_handler(event: SmartAppEvent, bot: Bot) -> None: API_AND_DOMAIN_FILES, ) async def test__async_execute_raw_bot_command__different_file_types( - api_async_file: Dict[str, Any], + api_async_file: dict[str, Any], domain_async_file: File, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, ) -> None: # - Arrange - @@ -304,7 +326,7 @@ async def test__async_execute_raw_bot_command__different_file_types( } collector = HandlerCollector() - smartapp_event: Optional[SmartAppEvent] = None + smartapp_event: SmartAppEvent | None = None @collector.smartapp_event async def smartapp_event_handler(event: SmartAppEvent, bot: Bot) -> None: @@ -322,3 +344,20 @@ async def smartapp_event_handler(event: SmartAppEvent, bot: Bot) -> None: # - Assert - assert smartapp_event assert smartapp_event.files == [domain_async_file] + + +async def test__async_file_properties_expose_private_fields() -> None: + image = Image( + type=AttachmentTypes.IMAGE, + filename="pass.png", + size=1502345, + is_async_file=True, + _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + _file_url="https://link.to/file", + _file_mimetype="image/png", + _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + ) + + assert image.file_url == "https://link.to/file" + assert image.file_mimetype == "image/png" + assert image.file_hash == "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=" diff --git a/tests/test_handler_collector.py b/tests/test_handler_collector.py index a9d1d48e..88c54e5c 100644 --- a/tests/test_handler_collector.py +++ b/tests/test_handler_collector.py @@ -1,5 +1,6 @@ from copy import deepcopy -from typing import Any, Callable +from typing import Any +from collections.abc import Callable from unittest.mock import Mock import pytest diff --git a/tests/test_incoming_message.py b/tests/test_incoming_message.py index 1a587691..25acdb6d 100644 --- a/tests/test_incoming_message.py +++ b/tests/test_incoming_message.py @@ -1,5 +1,7 @@ from datetime import datetime -from typing import Callable, Optional +from types import SimpleNamespace +from typing import Any, cast +from collections.abc import Callable from uuid import UUID import pytest @@ -23,6 +25,13 @@ lifespan_wrapper, ) from pybotx.models.attachments import AttachmentImage +from pybotx.models.enums import BotAPIMentionTypes +from pybotx.models.message.incoming_message import ( + BotAPIEntity, + _convert_bot_api_mention_to_domain, + convert_bot_api_entity_to_domain, +) +from pybotx.models.message.mentions import BotAPIMentionData pytestmark = [ pytest.mark.asyncio, @@ -35,7 +44,7 @@ async def test__async_execute_raw_bot_command__minimally_filled_incoming_message bot_account: BotAccountWithSecret, ) -> None: # - Arrange - - payload = { + payload: dict[str, Any] = { "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", "command": { "body": "/hello", @@ -72,7 +81,7 @@ async def test__async_execute_raw_bot_command__minimally_filled_incoming_message } collector = HandlerCollector() - incoming_message: Optional[IncomingMessage] = None + incoming_message: IncomingMessage | None = None @collector.default_message_handler async def default_handler(message: IncomingMessage, bot: Bot) -> None: @@ -132,7 +141,7 @@ async def test__async_execute_raw_bot_command__maximum_filled_incoming_message( bot_account: BotAccountWithSecret, ) -> None: # - Arrange - - payload = { + payload: dict[str, Any] = { "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", "command": { "body": "/hello", @@ -230,7 +239,7 @@ async def test__async_execute_raw_bot_command__maximum_filled_incoming_message( } collector = HandlerCollector() - incoming_message: Optional[IncomingMessage] = None + incoming_message: IncomingMessage | None = None @collector.default_message_handler async def default_handler(message: IncomingMessage, bot: Bot) -> None: @@ -325,7 +334,7 @@ async def test__async_execute_raw_bot_command__all_mention_types( bot_account: BotAccountWithSecret, ) -> None: # - Arrange - - payload = { + payload: dict[str, Any] = { "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", "command": { "body": "/hello", @@ -421,7 +430,7 @@ async def test__async_execute_raw_bot_command__all_mention_types( } collector = HandlerCollector() - incoming_message: Optional[IncomingMessage] = None + incoming_message: IncomingMessage | None = None @collector.default_message_handler async def default_handler(message: IncomingMessage, bot: Bot) -> None: @@ -466,7 +475,7 @@ async def test__async_execute_raw_bot_command__unknown_entity_type( loguru_caplog: pytest.LogCaptureFixture, ) -> None: # - Arrange - - payload = { + payload: dict[str, Any] = { "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", "command": { "body": "/hello", @@ -528,7 +537,7 @@ async def test__async_execute_raw_bot_command__unsupported_chat_type_accepted( bot_account: BotAccountWithSecret, ) -> None: # - Arrange - - payload = { + payload: dict[str, Any] = { "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", "command": { "body": "/hello", @@ -565,7 +574,7 @@ async def test__async_execute_raw_bot_command__unsupported_chat_type_accepted( } collector = HandlerCollector() - incoming_message: Optional[IncomingMessage] = None + incoming_message: IncomingMessage | None = None @collector.default_message_handler async def default_handler(message: IncomingMessage, bot: Bot) -> None: @@ -586,3 +595,24 @@ async def default_handler(message: IncomingMessage, bot: Bot) -> None: id=UUID("30dc1980-643a-00ad-37fc-7cc10d74e935"), type="UNSUPPORTED", ) + + +async def test__convert_bot_api_mention_to_domain__unsupported_type() -> None: + api_mention = BotAPIMentionData.model_construct( + mention_type=cast(BotAPIMentionTypes, "unsupported"), + mention_id=UUID("00000000-0000-0000-0000-000000000000"), + mention_data=None, + ) + + with pytest.raises(NotImplementedError): + _convert_bot_api_mention_to_domain(api_mention) + + +async def test__convert_bot_api_entity_to_domain__unsupported_type() -> None: + api_entity = cast( + BotAPIEntity, + SimpleNamespace(type="unsupported"), + ) + + with pytest.raises(NotImplementedError): + convert_bot_api_entity_to_domain(api_entity) diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py index fd3fa974..2c805e6f 100644 --- a/tests/test_lifespan.py +++ b/tests/test_lifespan.py @@ -1,13 +1,16 @@ -from http import HTTPStatus -from typing import Callable +from collections.abc import Callable from unittest.mock import Mock -from uuid import UUID -import httpx import pytest from respx.router import MockRouter -from pybotx import Bot, BotAccountWithSecret, HandlerCollector, IncomingMessage +from pybotx import ( + Bot, + BotAccountWithSecret, + BotXAuthVersion, + HandlerCollector, + IncomingMessage, +) from pybotx.bot.testing import lifespan_wrapper pytestmark = [ @@ -63,66 +66,23 @@ async def handler(message: IncomingMessage, bot: Bot) -> None: correct_handler_trigger.assert_called_once() -async def test__startup__authorize_cant_get_token( +async def test__fetch_tokens__skips_for_auth_v2( respx_mock: MockRouter, - loguru_caplog: pytest.LogCaptureFixture, bot_account: BotAccountWithSecret, - host: str, - bot_id: UUID, - bot_signature: str, ) -> None: # - Arrange - - token_endpoint = respx_mock.get( - f"https://{host}/api/v2/botx/bots/{bot_id}/token", - params={"signature": bot_signature}, - ).mock( - return_value=httpx.Response( - HTTPStatus.UNAUTHORIZED, - json={ - "status": "error", - }, - ), - ) - collector = HandlerCollector() - - bot = Bot(collectors=[collector], bot_accounts=[bot_account]) - - # - Act - - await bot.startup() - - # - Assert - - assert token_endpoint.called - - assert "Can't get token for bot account: " in loguru_caplog.text - assert f"host - {host}, bot_id - {bot_id}" in loguru_caplog.text - - # Cleanup - await bot.shutdown() - - -async def test__startup__can_skip_fetching_tokens( - respx_mock: MockRouter, - bot_account: BotAccountWithSecret, - host: str, - bot_id: UUID, - bot_signature: str, -) -> None: - # - Arrange - - token_endpoint = respx_mock.get( - f"https://{host}/api/v2/botx/bots/{bot_id}/token", - params={"signature": bot_signature}, + bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + auth_version=BotXAuthVersion.V2, ) - collector = HandlerCollector() - - bot = Bot(collectors=[collector], bot_accounts=[bot_account]) - # - Act - - await bot.startup(fetch_tokens=False) + await bot.fetch_tokens() # - Assert - - assert not token_endpoint.called + assert len(respx_mock.calls) == 0 # Cleanup await bot.shutdown() diff --git a/tests/test_logs.py b/tests/test_logs.py index 157ae8da..d6e7f64c 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -1,5 +1,6 @@ from http import HTTPStatus -from typing import Any, Callable, Dict, Optional, cast +from typing import Any, cast +from collections.abc import Callable from uuid import UUID import httpx @@ -25,7 +26,7 @@ async def test__attachment__trimmed_in_incoming_message( bot_account: BotAccountWithSecret, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], loguru_caplog: pytest.LogCaptureFixture, ) -> None: payload = api_incoming_message_factory( @@ -43,7 +44,7 @@ async def test__attachment__trimmed_in_incoming_message( }, ) collector = HandlerCollector() - file_data: Optional[bytes] = None + file_data: bytes | None = None @collector.default_message_handler async def default_handler(message: IncomingMessage, bot: Bot) -> None: diff --git a/tests/test_middlewares.py b/tests/test_middlewares.py index 4c2b42dd..d1bf0d8c 100644 --- a/tests/test_middlewares.py +++ b/tests/test_middlewares.py @@ -1,4 +1,4 @@ -from typing import Callable +from collections.abc import Callable from unittest.mock import Mock import pytest diff --git a/tests/test_personal_chat_ensure.py b/tests/test_personal_chat_ensure.py new file mode 100644 index 00000000..7c7bc157 --- /dev/null +++ b/tests/test_personal_chat_ensure.py @@ -0,0 +1,163 @@ +from datetime import datetime as dt +from http import HTTPStatus +from typing import Any +from collections.abc import Callable +from uuid import UUID + +import pytest +from respx.router import MockRouter + +from pybotx import ChatInfo, ChatInfoMember, ChatTypes, UserKinds +from tests.client.chats_api.factories import APIPersonalChatResponseFactory, ChatInfoFactory +from tests.testkit import BotXRequest, assert_deep_equal, error_payload, mock_botx, ok_payload + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__ensure_personal_chat__returns_existing( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + datetime_formatter: Callable[[str], dt], + bot_factory: Any, +) -> None: + # - Arrange - + api_response: Any = APIPersonalChatResponseFactory() # type: ignore[no-untyped-call] + request = BotXRequest( + method="GET", + path="/api/v1/botx/chats/personal", + params={"user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364"}, + ) + endpoint = mock_botx(respx_mock, host, request, api_response, HTTPStatus.OK) + + # - Act - + async with bot_factory() as bot: + chat_info = await bot.ensure_personal_chat( + bot_id=bot_id, + user_huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + ) + + # - Assert - + expected_chat_info = ChatInfoFactory( + created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), + ) # type: ignore[no-untyped-call] + + assert_deep_equal(chat_info, expected_chat_info) + assert endpoint.called + + +async def test__ensure_personal_chat__creates_when_missing( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + datetime_formatter: Callable[[str], dt], + bot_factory: Any, +) -> None: + # - Arrange - + user_huid = UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364") + created_chat_id = UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa") + chat_name = "Personal Chat" + + personal_request = BotXRequest( + method="GET", + path="/api/v1/botx/chats/personal", + params={"user_huid": str(user_huid)}, + ) + personal_endpoint = mock_botx( + respx_mock, + host, + personal_request, + error_payload( + "chat_not_found", + error_data={ + "user_huid": str(user_huid), + "error_description": "Personal chat with specified user_huid is not found", + }, + ), + HTTPStatus.NOT_FOUND, + ) + + create_request = BotXRequest( + method="POST", + path="/api/v3/botx/chats/create", + json={ + "name": chat_name, + "description": None, + "chat_type": "chat", + "members": [str(user_huid)], + "avatar": None, + }, + ) + create_endpoint = mock_botx( + respx_mock, + host, + create_request, + ok_payload({"chat_id": str(created_chat_id)}), + HTTPStatus.OK, + ) + + info_request = BotXRequest( + method="GET", + path="/api/v3/botx/chats/info", + params={"group_chat_id": str(created_chat_id)}, + ) + info_endpoint = mock_botx( + respx_mock, + host, + info_request, + ok_payload( + { + "chat_type": "chat", + "creator": str(user_huid), + "description": None, + "group_chat_id": str(created_chat_id), + "inserted_at": "2019-08-29T11:22:48.358586Z", + "members": [ + { + "admin": True, + "user_huid": str(user_huid), + "user_kind": "user", + }, + ], + "name": chat_name, + "shared_history": False, + } + ), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + chat_info = await bot.ensure_personal_chat( + bot_id=bot_id, + user_huid=user_huid, + name=chat_name, + ) + + # - Assert - + assert_deep_equal( + chat_info, + ChatInfo( + chat_type=ChatTypes.PERSONAL_CHAT, + creator_id=user_huid, + description=None, + chat_id=created_chat_id, + created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), + members=[ + ChatInfoMember( + is_admin=True, + huid=user_huid, + kind=UserKinds.RTS_USER, + ), + ], + name=chat_name, + shared_history=False, + ), + ) + assert personal_endpoint.called + assert create_endpoint.called + assert info_endpoint.called diff --git a/tests/test_state.py b/tests/test_state.py index 2998263c..b458c384 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,4 +1,4 @@ -from typing import Callable, Optional +from collections.abc import Callable import pytest @@ -59,7 +59,7 @@ async def test__message_state__save_changes_between_middleware_and_handler( bot_account: BotAccountWithSecret, ) -> None: # - Arrange - - incoming_message: Optional[IncomingMessage] = None + incoming_message: IncomingMessage | None = None user_command = incoming_message_factory(body="/command") async def middleware( diff --git a/tests/test_stickers.py b/tests/test_stickers.py index cf682368..63f2c9b7 100644 --- a/tests/test_stickers.py +++ b/tests/test_stickers.py @@ -1,5 +1,6 @@ from http import HTTPStatus -from typing import Any, Callable, Dict +from typing import Any +from collections.abc import Callable from uuid import UUID import httpx @@ -36,7 +37,7 @@ async def test__sticker__download( host: str, bot_account: BotAccountWithSecret, async_buffer: NamedTemporaryFile, - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], ) -> None: # - Arrange - image_link = ( diff --git a/tests/test_verify_request.py b/tests/test_verify_request.py index 9480c088..b06e31ca 100644 --- a/tests/test_verify_request.py +++ b/tests/test_verify_request.py @@ -1,5 +1,6 @@ from datetime import datetime -from typing import Any, Callable, Coroutine, Dict +from typing import Any +from collections.abc import Callable, Coroutine from unittest.mock import AsyncMock, Mock from uuid import uuid4 @@ -25,7 +26,7 @@ async def test__verify_request__success_attempt( bot_account: BotAccountWithSecret, - authorization_header: Dict[str, str], + authorization_header: dict[str, str], ) -> None: # - Arrange - collector = HandlerCollector() @@ -67,7 +68,7 @@ async def test__verify_request__cannot_decode_token( async def test__verify_request__aud_is_not_provided( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], + authorization_token_payload: dict[str, Any], ) -> None: # - Arrange - collector = HandlerCollector() @@ -87,9 +88,9 @@ async def test__verify_request__aud_is_not_provided( assert "Invalid audience parameter was provided." in str(exc.value) -async def test__verify_request__aud_is_not_sequence( +async def test__verify_request__aud_is_not_string( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], + authorization_token_payload: dict[str, Any], ) -> None: # - Arrange - collector = HandlerCollector() @@ -109,14 +110,14 @@ async def test__verify_request__aud_is_not_sequence( assert "Invalid audience parameter was provided." in str(exc.value) -async def test__verify_request__too_many_aud_values( +async def test__verify_request__aud_is_invalid_value( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], + authorization_token_payload: dict[str, Any], ) -> None: # - Arrange - collector = HandlerCollector() built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) - authorization_token_payload["aud"] = [str(bot_account.id), str(uuid4())] + authorization_token_payload["aud"] = "another.example.com" token = jwt.encode( payload=authorization_token_payload, key=bot_account.secret_key, @@ -131,15 +132,33 @@ async def test__verify_request__too_many_aud_values( assert "Invalid audience parameter was provided." in str(exc.value) -async def test__verify_request__unknown_aud_value( +async def test__verify_request__v2_without_version_claim( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], + authorization_token_payload: dict[str, Any], +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + del authorization_token_payload["version"] + token = jwt.encode( + payload=authorization_token_payload, + key=bot_account.secret_key, + ) + + # - Act and Assert - + async with lifespan_wrapper(built_bot) as bot: + bot._verify_request({"authorization": f"Bearer {token}"}) + + +async def test__verify_request__unknown_issuer_value( + bot_account: BotAccountWithSecret, + authorization_token_payload: dict[str, Any], ) -> None: # - Arrange - collector = HandlerCollector() built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) random_bot_id = uuid4() - authorization_token_payload["aud"] = [str(random_bot_id)] + authorization_token_payload["iss"] = str(random_bot_id) token = jwt.encode( payload=authorization_token_payload, key=bot_account.secret_key, @@ -156,7 +175,7 @@ async def test__verify_request__unknown_aud_value( async def test__verify_request__invalid_token_secret( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], + authorization_token_payload: dict[str, Any], ) -> None: # - Arrange - collector = HandlerCollector() @@ -177,7 +196,7 @@ async def test__verify_request__invalid_token_secret( async def test__verify_request__expired_signature( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], + authorization_token_payload: dict[str, Any], ) -> None: # - Arrange - collector = HandlerCollector() @@ -199,7 +218,7 @@ async def test__verify_request__expired_signature( async def test__verify_request__token_is_not_yet_valid_by_nbf( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], + authorization_token_payload: dict[str, Any], ) -> None: # - Arrange - collector = HandlerCollector() @@ -221,7 +240,7 @@ async def test__verify_request__token_is_not_yet_valid_by_nbf( async def test__verify_request__token_is_not_yet_valid_by_iat( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], + authorization_token_payload: dict[str, Any], ) -> None: # - Arrange - collector = HandlerCollector() @@ -243,7 +262,7 @@ async def test__verify_request__token_is_not_yet_valid_by_iat( async def test__verify_request__invalid_issuer( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], + authorization_token_payload: dict[str, Any], ) -> None: # - Arrange - collector = HandlerCollector() @@ -263,48 +282,20 @@ async def test__verify_request__invalid_issuer( assert "Invalid issuer" in str(exc.value) -async def test__verify_request__trusted_issuers_have_token_issuer( +async def test__verify_request__issuer_is_not_string( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], + authorization_token_payload: dict[str, Any], ) -> None: # - Arrange - collector = HandlerCollector() built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) - token_issuer = "another.example.com" - authorization_token_payload["iss"] = token_issuer - token = jwt.encode( - payload=authorization_token_payload, - key=bot_account.secret_key, - ) - - # - Act - - async with lifespan_wrapper(built_bot) as bot: - bot._verify_request( - {"authorization": f"Bearer {token}"}, - trusted_issuers={token_issuer}, - ) - - -async def test__verify_request__trusted_issuers_have_not_token_issuer( - bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], -) -> None: - # - Arrange - - collector = HandlerCollector() - built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) - authorization_token_payload["iss"] = "another.example.com" - token = jwt.encode( - payload=authorization_token_payload, - key=bot_account.secret_key, - ) + token_payload = dict(authorization_token_payload) + token_payload["iss"] = 12345 # - Act - async with lifespan_wrapper(built_bot) as bot: with pytest.raises(UnverifiedRequestError) as exc: - bot._verify_request( - {"authorization": f"Bearer {token}"}, - trusted_issuers={"another-another.example.com"}, - ) + bot._verify_request_v2("token", token_payload, ["HS256"]) # - Assert - assert "Invalid issuer" in str(exc.value) @@ -312,7 +303,7 @@ async def test__verify_request__trusted_issuers_have_not_token_issuer( async def test__verify_request__token_issuer_is_missed( bot_account: BotAccountWithSecret, - authorization_token_payload: Dict[str, Any], + authorization_token_payload: dict[str, Any], ) -> None: # - Arrange - collector = HandlerCollector() @@ -344,7 +335,7 @@ async def test__verify_request__token_issuer_is_missed( ), ) async def test__verify_request__without_headers( - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, target_func_name: str, ) -> None: @@ -366,7 +357,7 @@ async def test__verify_request__without_headers( async def test__async_execute_raw_bot_command__verify_request__called( - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, ) -> None: # - Arrange - @@ -388,7 +379,7 @@ async def test__async_execute_raw_bot_command__verify_request__called( async def test__sync_execute_raw_smartapp_event__verify_request__called( - api_sync_smartapp_event_factory: Callable[..., Dict[str, Any]], + api_sync_smartapp_event_factory: Callable[..., dict[str, Any]], collector_with_sync_smartapp_event_handler: HandlerCollector, bot_account: BotAccountWithSecret, ) -> None: @@ -413,7 +404,7 @@ async def test__sync_execute_raw_smartapp_event__verify_request__called( async def test__raw_get_status__verify_request__called( - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, ) -> None: # - Arrange - @@ -438,7 +429,7 @@ async def test__raw_get_status__verify_request__called( async def test__set_raw_botx_method_result__verify_request__called( - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, ) -> None: # - Arrange - @@ -466,7 +457,7 @@ async def test__set_raw_botx_method_result__verify_request__called( async def test__async_execute_raw_bot_command__verify_request__not_called( - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, ) -> None: # - Arrange - @@ -486,7 +477,7 @@ async def test__async_execute_raw_bot_command__verify_request__not_called( async def test__sync_execute_raw_smartapp_event__verify_request__not_called( - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, ) -> None: # - Arrange - @@ -506,7 +497,7 @@ async def test__sync_execute_raw_smartapp_event__verify_request__not_called( async def test__raw_get_status__verify_request__not_called( - api_incoming_message_factory: Callable[..., Dict[str, Any]], + api_incoming_message_factory: Callable[..., dict[str, Any]], bot_account: BotAccountWithSecret, ) -> None: # - Arrange - diff --git a/tests/testkit/__init__.py b/tests/testkit/__init__.py new file mode 100644 index 00000000..7107552e --- /dev/null +++ b/tests/testkit/__init__.py @@ -0,0 +1,15 @@ +from tests.testkit.botx import ( # noqa: F401 + BotXRequest, + assert_deep_equal, + error_payload, + mock_botx, + ok_payload, +) + +__all__ = ( + "BotXRequest", + "assert_deep_equal", + "error_payload", + "mock_botx", + "ok_payload", +) diff --git a/tests/testkit/botx.py b/tests/testkit/botx.py new file mode 100644 index 00000000..bc87a99b --- /dev/null +++ b/tests/testkit/botx.py @@ -0,0 +1,94 @@ + +from dataclasses import dataclass +from http import HTTPStatus +from typing import Any, cast +from collections.abc import Mapping + +import httpx +from deepdiff import DeepDiff +from respx.router import MockRouter, Route # type: ignore[attr-defined] + + +@dataclass(frozen=True) +class BotXRequest: + method: str + path: str + json: dict[str, Any] | None = None + params: dict[str, Any] | None = None + headers: Mapping[str, str] | None = None + require_auth: bool = True + + +def _default_headers(*, has_json: bool) -> dict[str, str]: + headers = {"Authorization": "Bearer token"} + if has_json: + headers["Content-Type"] = "application/json" + return headers + + +def mock_botx( + respx_mock: MockRouter, + host: str, + request: BotXRequest, + response_json: dict[str, Any] | list[Any] | None, + status: int = HTTPStatus.OK, + response_content: bytes | str | None = None, +) -> Route: + if response_json is not None and response_content is not None: + raise ValueError("Provide either response_json or response_content, not both.") + + headers = request.headers + if headers is None and request.require_auth: + headers = _default_headers(has_json=request.json is not None) + + route_kwargs: dict[str, Any] = {} + if headers is not None: + route_kwargs["headers"] = headers + if request.json is not None: + route_kwargs["json"] = request.json + if request.params is not None: + route_kwargs["params"] = request.params + + route = cast( + Route, + getattr(respx_mock, request.method.lower())( + f"https://{host}{request.path}", + **route_kwargs, + ), + ) + if response_json is not None: + route.mock(return_value=httpx.Response(status, json=response_json)) + return route + if response_content is None: + route.mock(return_value=httpx.Response(status)) + return route + route.mock(return_value=httpx.Response(status, content=response_content)) + return route + + +def ok_payload(result: Any) -> dict[str, Any]: + return {"status": "ok", "result": result} + + +def error_payload( + reason: str, + *, + error_data: dict[str, Any] | None = None, + errors: list[str] | None = None, +) -> dict[str, Any]: + return { + "status": "error", + "reason": reason, + "errors": errors or [], + "error_data": error_data or {}, + } + + +def assert_deep_equal( + actual: Any, + expected: Any, + *, + ignore_order: bool = True, +) -> None: + diff = DeepDiff(actual, expected, ignore_order=ignore_order) + assert diff == {}