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 == {}