diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 74d09617..c8bfd677 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -58,4 +58,4 @@ jobs: - name: Run linters run: | - poetry run ./scripts/docs-lint + poetry run bash ./scripts/docs-lint diff --git a/poetry.lock b/poetry.lock index 64746db8..0e25552a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,19 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. - -[[package]] -name = "add-trailing-comma" -version = "3.1.0" -description = "Automatically add trailing commas to calls and literals" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "add_trailing_comma-3.1.0-py2.py3-none-any.whl", hash = "sha256:160207e2ac414a841a71f4f5095f7350f87af460aab3dfe36cfa037992530e5c"}, - {file = "add_trailing_comma-3.1.0.tar.gz", hash = "sha256:b255319d7ef6dca308b051ffd80fccf98c018879744c7c7e03083b2eee079c45"}, -] - -[package.dependencies] -tokenize-rt = ">=3.0.1" +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiocsv" @@ -62,6 +47,18 @@ files = [ {file = "aiofiles-23.2.1.tar.gz", hash = "sha256:84ec2218d8419404abcb9f0c02df3f34c6e0a68ed41072acfb1cef5cbc29051a"}, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "anyio" version = "4.9.0" @@ -85,54 +82,6 @@ doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", 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)"] -[[package]] -name = "astor" -version = "0.8.1" -description = "Read/rewrite/write Python ASTs" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -groups = ["dev"] -files = [ - {file = "astor-0.8.1-py2.py3-none-any.whl", hash = "sha256:070a54e890cefb5b3739d19f30f5a5ec840ffc9c50ffa7d23cc9fc1a38ebbfc5"}, - {file = "astor-0.8.1.tar.gz", hash = "sha256:6a6effda93f4e1ce9f618779b2dd1d9d84f1e32812c23a29b3fff6fd7f63fa5e"}, -] - -[[package]] -name = "attrs" -version = "25.3.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, - {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, -] - -[package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] - -[[package]] -name = "autoflake" -version = "2.3.1" -description = "Removes unused imports and unused variables" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840"}, - {file = "autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e"}, -] - -[package.dependencies] -pyflakes = ">=3.0.0" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} - [[package]] name = "bandit" version = "1.8.3" @@ -158,53 +107,6 @@ test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] yaml = ["PyYAML"] -[[package]] -name = "black" -version = "25.1.0" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, - {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, - {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, - {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, - {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, - {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, - {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, - {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, - {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, - {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, - {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, - {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, - {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, - {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, - {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, - {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, - {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, - {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, - {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, - {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, - {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, - {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - [[package]] name = "certifi" version = "2025.4.26" @@ -426,18 +328,6 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] -[[package]] -name = "darglint" -version = "1.8.1" -description = "A utility for ensuring Google-style docstrings stay up to date with the source code." -optional = false -python-versions = ">=3.6,<4.0" -groups = ["dev"] -files = [ - {file = "darglint-1.8.1-py3-none-any.whl", hash = "sha256:5ae11c259c17b0701618a20c3da343a3eb98b3bc4b5a83d31cdd94f5ebdced8d"}, - {file = "darglint-1.8.1.tar.gz", hash = "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da"}, -] - [[package]] name = "deepdiff" version = "8.5.0" @@ -462,30 +352,6 @@ 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)"] 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 = "docutils" -version = "0.21.2" -description = "Docutils -- Python Documentation Utilities" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, - {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, -] - -[[package]] -name = "eradicate" -version = "2.3.0" -description = "Removes commented-out code." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "eradicate-2.3.0-py3-none-any.whl", hash = "sha256:2b29b3dd27171f209e4ddd8204b70c02f0682ae95eecb353f10e8d72b149c63e"}, - {file = "eradicate-2.3.0.tar.gz", hash = "sha256:06df115be3b87d0fc1c483db22a2ebb12bcf40585722810d809cc770f5031c37"}, -] - [[package]] name = "exceptiongroup" version = "1.3.0" @@ -560,221 +426,6 @@ typing-extensions = ">=4.8.0" 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)"] -[[package]] -name = "flake8" -version = "7.2.0" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343"}, - {file = "flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.13.0,<2.14.0" -pyflakes = ">=3.3.0,<3.4.0" - -[[package]] -name = "flake8-bandit" -version = "4.1.1" -description = "Automated security testing with bandit and flake8." -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "flake8_bandit-4.1.1-py3-none-any.whl", hash = "sha256:4c8a53eb48f23d4ef1e59293657181a3c989d0077c9952717e98a0eace43e06d"}, - {file = "flake8_bandit-4.1.1.tar.gz", hash = "sha256:068e09287189cbfd7f986e92605adea2067630b75380c6b5733dab7d87f9a84e"}, -] - -[package.dependencies] -bandit = ">=1.7.3" -flake8 = ">=5.0.0" - -[[package]] -name = "flake8-broken-line" -version = "1.0.0" -description = "Flake8 plugin to forbid backslashes for line breaks" -optional = false -python-versions = ">=3.8,<4.0" -groups = ["dev"] -files = [ - {file = "flake8_broken_line-1.0.0-py3-none-any.whl", hash = "sha256:96c964336024a5030dc536a9f6fb02aa679e2d2a6b35b80a558b5136c35832a9"}, - {file = "flake8_broken_line-1.0.0.tar.gz", hash = "sha256:e2c6a17f8d9a129e99c1320fce89b33843e2963871025c4c2bb7b8b8d8732a85"}, -] - -[package.dependencies] -flake8 = ">5" - -[[package]] -name = "flake8-bugbear" -version = "24.12.12" -description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." -optional = false -python-versions = ">=3.8.1" -groups = ["dev"] -files = [ - {file = "flake8_bugbear-24.12.12-py3-none-any.whl", hash = "sha256:1b6967436f65ca22a42e5373aaa6f2d87966ade9aa38d4baf2a1be550767545e"}, - {file = "flake8_bugbear-24.12.12.tar.gz", hash = "sha256:46273cef0a6b6ff48ca2d69e472f41420a42a46e24b2a8972e4f0d6733d12a64"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -flake8 = ">=6.0.0" - -[package.extras] -dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "pytest", "tox"] - -[[package]] -name = "flake8-commas" -version = "2.1.0" -description = "Flake8 lint for trailing commas." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "flake8-commas-2.1.0.tar.gz", hash = "sha256:940441ab8ee544df564ae3b3f49f20462d75d5c7cac2463e0b27436e2050f263"}, - {file = "flake8_commas-2.1.0-py2.py3-none-any.whl", hash = "sha256:ebb96c31e01d0ef1d0685a21f3f0e2f8153a0381430e748bf0bbbb5d5b453d54"}, -] - -[package.dependencies] -flake8 = ">=2" - -[[package]] -name = "flake8-comprehensions" -version = "3.16.0" -description = "A flake8 plugin to help you write better list/set/dict comprehensions." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "flake8_comprehensions-3.16.0-py3-none-any.whl", hash = "sha256:7c1eadc9d22e765f39857798febe7766b4d9c519793c6c149e3e13bf99693f70"}, - {file = "flake8_comprehensions-3.16.0.tar.gz", hash = "sha256:9cbf789905a8f03f9d350fb82b17b264d9a16c7ce3542b2a7b871ef568cafabe"}, -] - -[package.dependencies] -flake8 = ">=3,<3.2 || >3.2" - -[[package]] -name = "flake8-debugger" -version = "4.1.2" -description = "ipdb/pdb statement checker plugin for flake8" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "flake8-debugger-4.1.2.tar.gz", hash = "sha256:52b002560941e36d9bf806fca2523dc7fb8560a295d5f1a6e15ac2ded7a73840"}, - {file = "flake8_debugger-4.1.2-py3-none-any.whl", hash = "sha256:0a5e55aeddcc81da631ad9c8c366e7318998f83ff00985a49e6b3ecf61e571bf"}, -] - -[package.dependencies] -flake8 = ">=3.0" -pycodestyle = "*" - -[[package]] -name = "flake8-docstrings" -version = "1.7.0" -description = "Extension for flake8 which uses pydocstyle to check docstrings" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75"}, - {file = "flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af"}, -] - -[package.dependencies] -flake8 = ">=3" -pydocstyle = ">=2.1" - -[[package]] -name = "flake8-eradicate" -version = "1.5.0" -description = "Flake8 plugin to find commented out code" -optional = false -python-versions = ">=3.8,<4.0" -groups = ["dev"] -files = [ - {file = "flake8_eradicate-1.5.0-py3-none-any.whl", hash = "sha256:18acc922ad7de623f5247c7d5595da068525ec5437dd53b22ec2259b96ce9d22"}, - {file = "flake8_eradicate-1.5.0.tar.gz", hash = "sha256:aee636cb9ecb5594a7cd92d67ad73eb69909e5cc7bd81710cf9d00970f3983a6"}, -] - -[package.dependencies] -attrs = "*" -eradicate = ">=2.0,<3.0" -flake8 = ">5" - -[[package]] -name = "flake8-isort" -version = "6.1.2" -description = "flake8 plugin that integrates isort" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "flake8_isort-6.1.2-py3-none-any.whl", hash = "sha256:549197dedf0273502fb74f04c080beed9e62a7eb70244610413d27052e78bd3b"}, - {file = "flake8_isort-6.1.2.tar.gz", hash = "sha256:9d0452acdf0e1cd6f2d6848e3605e66b54d920e73471fb4744eef0f93df62d5d"}, -] - -[package.dependencies] -flake8 = "*" -isort = ">=5.0.0,<7" - -[package.extras] -test = ["pytest"] - -[[package]] -name = "flake8-quotes" -version = "3.4.0" -description = "Flake8 lint for quotes." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "flake8-quotes-3.4.0.tar.gz", hash = "sha256:aad8492fb710a2d3eabe68c5f86a1428de650c8484127e14c43d0504ba30276c"}, -] - -[package.dependencies] -flake8 = "*" -setuptools = "*" - -[[package]] -name = "flake8-rst-docstrings" -version = "0.3.1" -description = "Python docstring reStructuredText (RST) validator for flake8" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "flake8_rst_docstrings-0.3.1-py3-none-any.whl", hash = "sha256:ed831afca7ee47851e2162d5fa726b823b446fd46085c2164d7979ae5d9a96d7"}, - {file = "flake8_rst_docstrings-0.3.1.tar.gz", hash = "sha256:26dcc1338caf985990677696a8a6a274f73a0c6845b85f567befd3b648db78e2"}, -] - -[package.dependencies] -flake8 = ">=3" -pygments = "*" -restructuredtext_lint = "*" - -[package.extras] -develop = ["build", "twine"] - -[[package]] -name = "flake8-string-format" -version = "0.3.0" -description = "string format checker, plugin for flake8" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "flake8-string-format-0.3.0.tar.gz", hash = "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2"}, - {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, -] - -[package.dependencies] -flake8 = "*" - [[package]] name = "h11" version = "0.16.0" @@ -861,22 +512,6 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] -[[package]] -name = "isort" -version = "6.0.1" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, - {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, -] - -[package.extras] -colors = ["colorama"] -plugins = ["setuptools"] - [[package]] name = "loguru" version = "0.6.0" @@ -921,18 +556,6 @@ 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"] -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1035,18 +658,6 @@ files = [ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - [[package]] name = "pbr" version = "6.1.1" @@ -1062,38 +673,6 @@ files = [ [package.dependencies] setuptools = "*" -[[package]] -name = "pep8-naming" -version = "0.13.3" -description = "Check PEP-8 naming conventions, plugin for flake8" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pep8-naming-0.13.3.tar.gz", hash = "sha256:1705f046dfcd851378aac3be1cd1551c7c1e5ff363bacad707d43007877fa971"}, - {file = "pep8_naming-0.13.3-py3-none-any.whl", hash = "sha256:1a86b8c71a03337c97181917e2b472f0f5e4ccb06844a0d6f0a33522549e7a80"}, -] - -[package.dependencies] -flake8 = ">=5.0.0" - -[[package]] -name = "platformdirs" -version = "4.3.8" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, - {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.14.1)"] - [[package]] name = "pluggy" version = "1.5.0" @@ -1110,114 +689,139 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] -[[package]] -name = "pycodestyle" -version = "2.13.0" -description = "Python style guide checker" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9"}, - {file = "pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae"}, -] - [[package]] name = "pydantic" -version = "1.10.22" -description = "Data validation and settings management using python type hints" +version = "2.11.7" +description = "Data validation using Python type hints" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pydantic-1.10.22-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:57889565ccc1e5b7b73343329bbe6198ebc472e3ee874af2fa1865cfe7048228"}, - {file = "pydantic-1.10.22-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:90729e22426de79bc6a3526b4c45ec4400caf0d4f10d7181ba7f12c01bb3897d"}, - {file = "pydantic-1.10.22-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8684d347f351554ec94fdcb507983d3116dc4577fb8799fed63c65869a2d10"}, - {file = "pydantic-1.10.22-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8dad498ceff2d9ef1d2e2bc6608f5b59b8e1ba2031759b22dfb8c16608e1802"}, - {file = "pydantic-1.10.22-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fac529cc654d4575cf8de191cce354b12ba705f528a0a5c654de6d01f76cd818"}, - {file = "pydantic-1.10.22-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4148232aded8dd1dd13cf910a01b32a763c34bd79a0ab4d1ee66164fcb0b7b9d"}, - {file = "pydantic-1.10.22-cp310-cp310-win_amd64.whl", hash = "sha256:ece68105d9e436db45d8650dc375c760cc85a6793ae019c08769052902dca7db"}, - {file = "pydantic-1.10.22-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e530a8da353f791ad89e701c35787418605d35085f4bdda51b416946070e938"}, - {file = "pydantic-1.10.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:654322b85642e9439d7de4c83cb4084ddd513df7ff8706005dada43b34544946"}, - {file = "pydantic-1.10.22-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8bece75bd1b9fc1c32b57a32831517943b1159ba18b4ba32c0d431d76a120ae"}, - {file = "pydantic-1.10.22-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eccb58767f13c6963dcf96d02cb8723ebb98b16692030803ac075d2439c07b0f"}, - {file = "pydantic-1.10.22-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7778e6200ff8ed5f7052c1516617423d22517ad36cc7a3aedd51428168e3e5e8"}, - {file = "pydantic-1.10.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffe02767d27c39af9ca7dc7cd479c00dda6346bb62ffc89e306f665108317a2"}, - {file = "pydantic-1.10.22-cp311-cp311-win_amd64.whl", hash = "sha256:23bc19c55427091b8e589bc08f635ab90005f2dc99518f1233386f46462c550a"}, - {file = "pydantic-1.10.22-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:92d0f97828a075a71d9efc65cf75db5f149b4d79a38c89648a63d2932894d8c9"}, - {file = "pydantic-1.10.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6af5a2811b6b95b58b829aeac5996d465a5f0c7ed84bd871d603cf8646edf6ff"}, - {file = "pydantic-1.10.22-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cf06d8d40993e79af0ab2102ef5da77b9ddba51248e4cb27f9f3f591fbb096e"}, - {file = "pydantic-1.10.22-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:184b7865b171a6057ad97f4a17fbac81cec29bd103e996e7add3d16b0d95f609"}, - {file = "pydantic-1.10.22-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:923ad861677ab09d89be35d36111156063a7ebb44322cdb7b49266e1adaba4bb"}, - {file = "pydantic-1.10.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:82d9a3da1686443fb854c8d2ab9a473251f8f4cdd11b125522efb4d7c646e7bc"}, - {file = "pydantic-1.10.22-cp312-cp312-win_amd64.whl", hash = "sha256:1612604929af4c602694a7f3338b18039d402eb5ddfbf0db44f1ebfaf07f93e7"}, - {file = "pydantic-1.10.22-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b259dc89c9abcd24bf42f31951fb46c62e904ccf4316393f317abeeecda39978"}, - {file = "pydantic-1.10.22-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9238aa0964d80c0908d2f385e981add58faead4412ca80ef0fa352094c24e46d"}, - {file = "pydantic-1.10.22-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8029f05b04080e3f1a550575a1bca747c0ea4be48e2d551473d47fd768fc1b"}, - {file = "pydantic-1.10.22-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c06918894f119e0431a36c9393bc7cceeb34d1feeb66670ef9b9ca48c073937"}, - {file = "pydantic-1.10.22-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e205311649622ee8fc1ec9089bd2076823797f5cd2c1e3182dc0e12aab835b35"}, - {file = "pydantic-1.10.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:815f0a73d5688d6dd0796a7edb9eca7071bfef961a7b33f91e618822ae7345b7"}, - {file = "pydantic-1.10.22-cp313-cp313-win_amd64.whl", hash = "sha256:9dfce71d42a5cde10e78a469e3d986f656afc245ab1b97c7106036f088dd91f8"}, - {file = "pydantic-1.10.22-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3ecaf8177b06aac5d1f442db1288e3b46d9f05f34fd17fdca3ad34105328b61a"}, - {file = "pydantic-1.10.22-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb36c2de9ea74bd7f66b5481dea8032d399affd1cbfbb9bb7ce539437f1fce62"}, - {file = "pydantic-1.10.22-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6b8d14a256be3b8fff9286d76c532f1a7573fbba5f189305b22471c6679854d"}, - {file = "pydantic-1.10.22-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:1c33269e815db4324e71577174c29c7aa30d1bba51340ce6be976f6f3053a4c6"}, - {file = "pydantic-1.10.22-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:8661b3ab2735b2a9ccca2634738534a795f4a10bae3ab28ec0a10c96baa20182"}, - {file = "pydantic-1.10.22-cp37-cp37m-win_amd64.whl", hash = "sha256:22bdd5fe70d4549995981c55b970f59de5c502d5656b2abdfcd0a25be6f3763e"}, - {file = "pydantic-1.10.22-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e3f33d1358aa4bc2795208cc29ff3118aeaad0ea36f0946788cf7cadeccc166b"}, - {file = "pydantic-1.10.22-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:813f079f9cd136cac621f3f9128a4406eb8abd2ad9fdf916a0731d91c6590017"}, - {file = "pydantic-1.10.22-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab618ab8dca6eac7f0755db25f6aba3c22c40e3463f85a1c08dc93092d917704"}, - {file = "pydantic-1.10.22-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d128e1aaa38db88caca920d5822c98fc06516a09a58b6d3d60fa5ea9099b32cc"}, - {file = "pydantic-1.10.22-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:cc97bbc25def7025e55fc9016080773167cda2aad7294e06a37dda04c7d69ece"}, - {file = "pydantic-1.10.22-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dda5d7157d543b1fa565038cae6e952549d0f90071c839b3740fb77c820fab8"}, - {file = "pydantic-1.10.22-cp38-cp38-win_amd64.whl", hash = "sha256:a093fe44fe518cb445d23119511a71f756f8503139d02fcdd1173f7b76c95ffe"}, - {file = "pydantic-1.10.22-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ec54c89b2568b258bb30d7348ac4d82bec1b58b377fb56a00441e2ac66b24587"}, - {file = "pydantic-1.10.22-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d8f1d1a1532e4f3bcab4e34e8d2197a7def4b67072acd26cfa60e92d75803a48"}, - {file = "pydantic-1.10.22-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ad83ca35508c27eae1005b6b61f369f78aae6d27ead2135ec156a2599910121"}, - {file = "pydantic-1.10.22-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53cdb44b78c420f570ff16b071ea8cd5a477635c6b0efc343c8a91e3029bbf1a"}, - {file = "pydantic-1.10.22-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:16d0a5ae9d98264186ce31acdd7686ec05fd331fab9d68ed777d5cb2d1514e5e"}, - {file = "pydantic-1.10.22-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8aee040e25843f036192b1a1af62117504a209a043aa8db12e190bb86ad7e611"}, - {file = "pydantic-1.10.22-cp39-cp39-win_amd64.whl", hash = "sha256:7f691eec68dbbfca497d3c11b92a3e5987393174cbedf03ec7a4184c35c2def6"}, - {file = "pydantic-1.10.22-py3-none-any.whl", hash = "sha256:343037d608bcbd34df937ac259708bfc83664dadf88afe8516c4f282d7d471a9"}, - {file = "pydantic-1.10.22.tar.gz", hash = "sha256:ee1006cebd43a8e7158fb7190bb8f4e2da9649719bff65d0c287282ec38dec6d"}, + {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, + {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, ] [package.dependencies] -typing-extensions = ">=4.2.0" +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" [package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] -name = "pydocstyle" -version = "6.3.0" -description = "Python docstring style checker" +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" optional = false -python-versions = ">=3.6" -groups = ["dev"] +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, - {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, + {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"}, ] [package.dependencies] -snowballstemmer = ">=2.2.0" - -[package.extras] -toml = ["tomli (>=1.2.3) ; python_version < \"3.11\""] - -[[package]] -name = "pyflakes" -version = "3.3.2" -description = "passive checker of Python programs" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a"}, - {file = "pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"}, -] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pygments" @@ -1414,20 +1018,6 @@ files = [ [package.dependencies] httpx = ">=0.25.0" -[[package]] -name = "restructuredtext-lint" -version = "1.4.0" -description = "reStructuredText linter" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "restructuredtext_lint-1.4.0.tar.gz", hash = "sha256:1b235c0c922341ab6c530390892eb9e92f90b9b75046063e047cacfb0f050c45"}, -] - -[package.dependencies] -docutils = ">=0.11,<1.0" - [[package]] name = "rich" version = "14.0.0" @@ -1448,6 +1038,34 @@ typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.1 [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "ruff" +version = "0.12.3" +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"}, +] + [[package]] name = "setuptools" version = "80.4.0" @@ -1481,18 +1099,6 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] -[[package]] -name = "snowballstemmer" -version = "3.0.1" -description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" -groups = ["dev"] -files = [ - {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, - {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, -] - [[package]] name = "starlette" version = "0.46.2" @@ -1527,18 +1133,6 @@ files = [ [package.dependencies] pbr = ">=2.0.0" -[[package]] -name = "tokenize-rt" -version = "6.1.0" -description = "A wrapper around the stdlib `tokenize` which roundtrips." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "tokenize_rt-6.1.0-py2.py3-none-any.whl", hash = "sha256:d706141cdec4aa5f358945abe36b911b8cbdc844545da99e811250c0cee9b6fc"}, - {file = "tokenize_rt-6.1.0.tar.gz", hash = "sha256:e8ee836616c0877ab7c7b54776d2fefcc3bde714449a206762425ae114b53c86"}, -] - [[package]] name = "tomli" version = "2.2.1" @@ -1594,6 +1188,21 @@ files = [ {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] +[[package]] +name = "typing-inspection" +version = "0.4.1" +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"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + [[package]] name = "tzdata" version = "2025.2" @@ -1644,40 +1253,6 @@ 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)"] -[[package]] -name = "wemake-python-styleguide" -version = "0.19.2" -description = "The strictest and most opinionated python linter ever" -optional = false -python-versions = "<4.0,>=3.9" -groups = ["dev"] -files = [ - {file = "wemake_python_styleguide-0.19.2-py3-none-any.whl", hash = "sha256:d53205dbb629755026d853d15fb3ca03ebb2717c97de4198b5676b9bdc0663bd"}, - {file = "wemake_python_styleguide-0.19.2.tar.gz", hash = "sha256:850fe70e6d525fd37ac51778e552a121a489f1bd057184de96ffd74a09aef414"}, -] - -[package.dependencies] -astor = ">=0.8,<0.9" -attrs = "*" -darglint = ">=1.2,<2.0" -flake8 = ">=7.0,<8.0" -flake8-bandit = ">=4.1,<5.0" -flake8-broken-line = ">=1.0,<2.0" -flake8-bugbear = ">=24.2,<25.0" -flake8-commas = ">=2.0,<3.0" -flake8-comprehensions = ">=3.1,<4.0" -flake8-debugger = ">=4.0,<5.0" -flake8-docstrings = ">=1.3,<2.0" -flake8-eradicate = ">=1.5,<2.0" -flake8-isort = ">=6.0,<7.0" -flake8-quotes = ">=3.0,<4.0" -flake8-rst-docstrings = ">=0.3,<0.4" -flake8-string-format = ">=0.3,<0.4" -pep8-naming = ">=0.13,<0.14" -pygments = ">=2.4,<3.0" -setuptools = "*" -typing_extensions = ">=4.0,<5.0" - [[package]] name = "win32-setctime" version = "1.2.0" @@ -1697,4 +1272,4 @@ 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 = "78fb399feb9a1fab9b80489c8630431c8b7be1e939d110d0eb04bd79debf29b3" +content-hash = "a8cf8ae3d3681d747a0416ede87c2c3b4108d6d7989bbc296a19f457c1f220ef" diff --git a/pybotx/__init__.py b/pybotx/__init__.py index e14e9439..456feb01 100644 --- a/pybotx/__init__.py +++ b/pybotx/__init__.py @@ -39,6 +39,8 @@ ChatCreationError, ChatCreationProhibitedError, InvalidUsersListError, + ThreadCreationError, + ThreadCreationProhibitedError, ) from pybotx.client.exceptions.common import ( ChatNotFoundError, @@ -260,6 +262,9 @@ "SyncSmartAppEventHandlerFunc", "SyncSmartAppEventHandlerNotFoundError", "SyncSourceTypes", + "ThreadCreationError", + "ThreadCreationEventNotFoundError", + "ThreadCreationProhibitedError", "UnknownBotAccountError", "UnknownSystemEventError", "UnsupportedBotAPIVersionError", diff --git a/pybotx/async_buffer.py b/pybotx/async_buffer.py index ab965560..1dc4aa13 100644 --- a/pybotx/async_buffer.py +++ b/pybotx/async_buffer.py @@ -5,7 +5,7 @@ try: from typing import Protocol except ImportError: - from typing_extensions import Protocol # type: ignore # noqa: WPS440 + from typing_extensions import Protocol # type: ignore class AsyncBufferBase(Protocol): @@ -13,14 +13,14 @@ async def seek( self, cursor: int, whence: int = os.SEEK_SET, - ) -> int: ... + ) -> int: ... # pragma: no cover - async def tell(self) -> int: ... + async def tell(self) -> int: ... # pragma: no cover class AsyncBufferWritable(AsyncBufferBase): @abc.abstractmethod - async def write(self, content: bytes) -> int: ... + async def write(self, content: bytes) -> int: ... # pragma: no cover class AsyncBufferReadable(AsyncBufferBase): @@ -28,7 +28,7 @@ class AsyncBufferReadable(AsyncBufferBase): async def read( self, bytes_to_read: Optional[int] = None, - ) -> bytes: ... + ) -> bytes: ... # pragma: no cover async def get_file_size(async_buffer: AsyncBufferReadable) -> int: diff --git a/pybotx/bot/bot.py b/pybotx/bot/bot.py index 0cd21e17..206724fd 100644 --- a/pybotx/bot/bot.py +++ b/pybotx/bot/bot.py @@ -1,17 +1,13 @@ from asyncio import Task +from collections.abc import AsyncIterable, AsyncIterator, Iterator, Mapping, Sequence from contextlib import asynccontextmanager from datetime import datetime from types import SimpleNamespace from typing import ( Any, - AsyncIterable, - AsyncIterator, Dict, - Iterator, List, - Mapping, Optional, - Sequence, Set, Tuple, Union, @@ -23,11 +19,11 @@ import jwt from aiocsv.readers import AsyncDictReader from aiofiles.tempfile import NamedTemporaryFile, TemporaryDirectory -from pydantic import ValidationError, parse_obj_as from pybotx.async_buffer import AsyncBufferReadable, AsyncBufferWritable from pybotx.bot.bot_accounts_storage import BotAccountsStorage from pybotx.bot.callbacks.callback_manager import CallbackManager +from pydantic import TypeAdapter from pybotx.bot.callbacks.callback_memory_repo import CallbackMemoryRepo from pybotx.bot.callbacks.callback_repo_proto import CallbackRepoProto from pybotx.bot.contextvars import bot_id_var, chat_id_var @@ -53,10 +49,18 @@ BotXAPIChatInfoRequestPayload, ChatInfoMethod, ) +from pybotx.client.chats_api.personal_chat import ( + BotXAPIPersonalChatRequestPayload, + PersonalChatMethod, +) from pybotx.client.chats_api.create_chat import ( BotXAPICreateChatRequestPayload, CreateChatMethod, ) +from pybotx.client.chats_api.create_thread import ( + BotXAPICreateThreadRequestPayload, + CreateThreadMethod, +) from pybotx.client.chats_api.disable_stealth import ( BotXAPIDisableStealthRequestPayload, DisableStealthMethod, @@ -234,8 +238,12 @@ from pybotx.models.bot_account import BotAccountWithSecret from pybotx.models.bot_catalog import BotsListItem from pybotx.models.chats import ChatInfo, ChatListItem -from pybotx.models.commands import BotAPICommand, BotCommand -from pybotx.models.enums import ChatTypes +from pybotx.models.commands import ( + BotAPISystemEvent, + BotAPIIncomingMessage, + BotCommand, +) +from pybotx.models.enums import BotAPICommandTypes, 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 @@ -256,6 +264,7 @@ ) from pybotx.models.system_events.smartapp_event import SmartAppEvent from pybotx.models.users import UserFromCSV, UserFromSearch +from pydantic import ValidationError MissingOptionalAttachment = MissingOptional[ Union[IncomingFileAttachment, OutgoingAttachment] @@ -312,11 +321,13 @@ def async_execute_raw_bot_command( self._verify_request(request_headers, trusted_issuers=trusted_issuers) try: - bot_api_command: BotAPICommand = parse_obj_as( - # Same ignore as in pydantic - BotAPICommand, # type: ignore[arg-type] - raw_bot_command, - ) + command_type = raw_bot_command.get("command", {}).get("command_type") + if command_type == BotAPICommandTypes.USER: + bot_api_command = BotAPIIncomingMessage.model_validate(raw_bot_command) + else: + bot_api_command = TypeAdapter(BotAPISystemEvent).validate_python( + raw_bot_command + ) except ValidationError as validation_exc: raise ValueError("Bot command validation error") from validation_exc @@ -350,9 +361,8 @@ async def sync_execute_raw_smartapp_event( self._verify_request(request_headers, trusted_issuers=trusted_issuers) try: - bot_api_smartapp_event: BotAPISyncSmartAppEvent = parse_obj_as( - BotAPISyncSmartAppEvent, - raw_smartapp_event, + bot_api_smartapp_event = BotAPISyncSmartAppEvent.model_validate( + raw_smartapp_event ) except ValidationError as validation_exc: raise ValueError( @@ -388,7 +398,9 @@ async def raw_get_status( self._verify_request(request_headers, trusted_issuers=trusted_issuers) try: - bot_api_status_recipient = BotAPIStatusRecipient.parse_obj(query_params) + bot_api_status_recipient = BotAPIStatusRecipient.model_validate( + query_params + ) except ValidationError as exc: raise ValueError("Status request validation error") from exc @@ -415,9 +427,7 @@ async def set_raw_botx_method_result( if verify_request: self._verify_request(request_headers, trusted_issuers=trusted_issuers) - callback: BotXMethodCallback = parse_obj_as( - # Same ignore as in pydantic - BotXMethodCallback, # type: ignore[arg-type] + callback: BotXMethodCallback = TypeAdapter(BotXMethodCallback).validate_python( raw_botx_method_result, ) @@ -548,7 +558,7 @@ async def answer_message( :return: Notification sync_id. """ - try: # noqa: WPS229 + try: bot_id = bot_id_var.get() chat_id = chat_id_var.get() except LookupError as exc: @@ -1007,6 +1017,29 @@ async def chat_info( return botx_api_chat_info.to_domain() + async def personal_chat( + self, + *, + bot_id: UUID, + user_huid: UUID, + ) -> ChatInfo: + """Get personal chat between bot and user. + + :param bot_id: Bot which should perform the request. + :param user_huid: User identifier. + + :return: Chat information. + """ + + method = PersonalChatMethod( + bot_id, self._httpx_client, self._bot_accounts_storage + ) + + payload = BotXAPIPersonalChatRequestPayload.from_domain(user_huid=user_huid) + botx_api_personal_chat = await method.execute(payload) + + return botx_api_personal_chat.to_domain() + async def add_users_to_chat( self, *, @@ -1145,6 +1178,7 @@ async def create_chat( huids: List[UUID], description: Optional[str] = None, shared_history: Missing[bool] = Undefined, + avatar: Optional[str] = None, ) -> UUID: """Create chat. @@ -1155,6 +1189,7 @@ async def create_chat( :param description: Chat description. :param shared_history: (BotX default: False) Open old chat history for new added users. + :param avatar: Chat avatar in data URL format (RFC 2397). :return: Created chat uuid. """ @@ -1165,17 +1200,39 @@ async def create_chat( self._bot_accounts_storage, ) - payload = BotXAPICreateChatRequestPayload.from_domain( + payload = BotXAPICreateChatRequestPayload( name=name, chat_type=chat_type, - huids=huids, + members=huids, shared_history=shared_history, description=description, + avatar=avatar, ) botx_api_chat_id = await method.execute(payload) return botx_api_chat_id.to_domain() + async def create_thread(self, bot_id: UUID, sync_id: UUID) -> UUID: + """ + Create thread. + + :param bot_id: Bot which should perform the request. + :param sync_id: Message for which thread should be created + + :return: Created thread uuid. + """ + + method = CreateThreadMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + + payload = BotXAPICreateThreadRequestPayload.from_domain(sync_id=sync_id) + botx_api_thread_id = await method.execute(payload) + + return botx_api_thread_id.to_domain() + async def pin_message( self, *, @@ -2066,7 +2123,7 @@ async def collect_metric( ) await method.execute(payload) - def _verify_request( # noqa: WPS231, WPS238 + def _verify_request( self, headers: Optional[Mapping[str, str]], *, diff --git a/pybotx/bot/bot_accounts_storage.py b/pybotx/bot/bot_accounts_storage.py index 5fcff38a..c722b921 100644 --- a/pybotx/bot/bot_accounts_storage.py +++ b/pybotx/bot/bot_accounts_storage.py @@ -25,7 +25,7 @@ def iter_bot_accounts(self) -> Iterator[BotAccountWithSecret]: def get_cts_url(self, bot_id: UUID) -> str: bot_account = self.get_bot_account(bot_id) - return bot_account.cts_url + return str(bot_account.cts_url) def set_token(self, bot_id: UUID, token: str) -> None: self._auth_tokens[bot_id] = token diff --git a/pybotx/bot/handler_collector.py b/pybotx/bot/handler_collector.py index 3d07d146..cdffa6fd 100644 --- a/pybotx/bot/handler_collector.py +++ b/pybotx/bot/handler_collector.py @@ -119,7 +119,7 @@ async def handle_bot_command(self, bot_command: BotCommand, bot: "Bot") -> None: elif isinstance( bot_command, - SystemEvent.__args__, # type: ignore [attr-defined] # noqa: WPS609 + SystemEvent.__args__, # type: ignore [attr-defined] ): event_handler = self._get_system_event_handler_or_none(bot_command) if event_handler: diff --git a/pybotx/bot/middlewares/exception_middleware.py b/pybotx/bot/middlewares/exception_middleware.py index 53317780..c7ac4d50 100644 --- a/pybotx/bot/middlewares/exception_middleware.py +++ b/pybotx/bot/middlewares/exception_middleware.py @@ -33,7 +33,7 @@ async def dispatch( if exception_handler is None: raise message_handler_exc - try: # noqa: WPS505 + try: await exception_handler(message, bot, message_handler_exc) except Exception as error_handler_exc: exc_name = type(message_handler_exc).__name__ diff --git a/pybotx/client/botx_method.py b/pybotx/client/botx_method.py index 33302768..34a8f2c1 100644 --- a/pybotx/client/botx_method.py +++ b/pybotx/client/botx_method.py @@ -16,7 +16,6 @@ import httpx from mypy_extensions import Arg -from pydantic import ValidationError, parse_obj_as from pybotx.bot.bot_accounts_storage import BotAccountsStorage from pybotx.bot.callbacks.callback_manager import CallbackManager @@ -32,6 +31,7 @@ BotAPIMethodFailedCallback, BotXMethodCallback, ) +from pydantic import ValidationError StatusHandler = Callable[[Arg(httpx.Response, "response")], NoReturn] # noqa: F821 StatusHandlers = Mapping[int, StatusHandler] @@ -106,7 +106,7 @@ def _verify_and_extract_api_model( ) try: - api_model = parse_obj_as(model_cls, raw_model) + api_model = model_cls.model_validate(raw_model) except ValidationError as validation_exc: raise InvalidBotXResponsePayloadError(response) from validation_exc @@ -155,9 +155,9 @@ async def _process_callback( callback_timeout: Optional[float], default_callback_timeout: float, ) -> Optional[BotXMethodCallback]: - assert ( - self._callbacks_manager is not None - ), "CallbackManager hasn't been passed to this method" + assert self._callbacks_manager is not None, ( + "CallbackManager hasn't been passed to this method" + ) await self._callbacks_manager.create_botx_method_callback(sync_id) diff --git a/pybotx/client/chats_api/chat_info.py b/pybotx/client/chats_api/chat_info.py index e01ede10..b4d9e47f 100644 --- a/pybotx/client/chats_api/chat_info.py +++ b/pybotx/client/chats_api/chat_info.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List, Literal, Optional, Union from uuid import UUID +from pydantic import ConfigDict, ValidationError, field_validator from pybotx.client.authorized_botx_method import AuthorizedBotXMethod from pybotx.client.botx_method import response_exception_thrower from pybotx.client.exceptions.common import ChatNotFoundError @@ -19,16 +20,23 @@ class BotXAPIChatInfoRequestPayload(UnverifiedPayloadBaseModel): group_chat_id: UUID + model_config = ConfigDict(extra="forbid") + @classmethod def from_domain(cls, chat_id: UUID) -> "BotXAPIChatInfoRequestPayload": return cls(group_chat_id=chat_id) + def as_query_params(self) -> Dict[str, Any]: + return self.model_dump(mode="json") + class BotXAPIChatInfoMember(VerifiedPayloadBaseModel): admin: bool user_huid: UUID user_kind: APIUserKinds + model_config = ConfigDict(extra="ignore") + class BotXAPIChatInfoResult(VerifiedPayloadBaseModel): chat_type: APIChatTypes @@ -36,15 +44,55 @@ class BotXAPIChatInfoResult(VerifiedPayloadBaseModel): description: Optional[str] = None group_chat_id: UUID inserted_at: dt - members: List[Union[BotXAPIChatInfoMember, Dict[str, Any]]] # noqa: WPS234 + members: List[Union[BotXAPIChatInfoMember, Dict[str, Any]]] = [] name: str shared_history: bool + model_config = ConfigDict(extra="ignore") + + @staticmethod + def validate_members( + items: List[Union[BotXAPIChatInfoMember, Dict[str, Any]]], + info: Any, + ) -> List[Union[BotXAPIChatInfoMember, Dict[str, Any]]]: + """ + Публичный helper для парсинга списка участников: + - dict → BotXAPIChatInfoMember + - уже готовый BotXAPIChatInfoMember остаётся как есть + - всё остальное логируется и пропускается + """ + parsed: List[Union[BotXAPIChatInfoMember, Dict[str, Any]]] = [] + for item in items: + if isinstance(item, dict): + try: + parsed.append(BotXAPIChatInfoMember.model_validate(item)) + except ValidationError: + # Сохраняем оригинал, чтобы downstream-логика могла + # увидеть и обработать «неожиданную» структуру + parsed.append(item) + logger.warning("Unsupported member structure encountered: %s", item) + elif isinstance(item, BotXAPIChatInfoMember): + parsed.append(item) + else: + logger.warning("Unknown member type: %s", item) # pragma: no cover + return parsed + + @field_validator("members", mode="before") + @classmethod + def _validate_members_field( + cls, + value: List[Union[BotXAPIChatInfoMember, Dict[str, Any]]], + info: Any, + ) -> List[Union[BotXAPIChatInfoMember, Dict[str, Any]]]: + return cls.validate_members(value, info) + class BotXAPIChatInfoResponsePayload(VerifiedPayloadBaseModel): status: Literal["ok"] result: BotXAPIChatInfoResult + model_config = ConfigDict(extra="ignore") + def to_domain(self) -> ChatInfo: if any(isinstance(member, dict) for member in self.result.members): logger.warning("One or more unsupported user types skipped") @@ -86,7 +134,7 @@ async def execute( response = await self._botx_method_call( "GET", self._build_url(path), - params=payload.jsonable_dict(), + params=payload.as_query_params(), ) return self._verify_and_extract_api_model( diff --git a/pybotx/client/chats_api/create_chat.py b/pybotx/client/chats_api/create_chat.py index 9da8f761..7bc1cc37 100644 --- a/pybotx/client/chats_api/create_chat.py +++ b/pybotx/client/chats_api/create_chat.py @@ -1,47 +1,72 @@ -from typing import List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional, Set, Union from uuid import UUID +from pydantic import ( + Field, + ConfigDict, + field_serializer, + field_validator, + model_validator, +) + from pybotx.client.authorized_botx_method import AuthorizedBotXMethod from pybotx.client.botx_method import response_exception_thrower from pybotx.client.exceptions.chats import ( ChatCreationError, ChatCreationProhibitedError, ) -from pybotx.missing import Missing +from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from pybotx.models.attachments import decode_rfc2397 from pybotx.models.enums import APIChatTypes, ChatTypes, convert_chat_type_from_domain class BotXAPICreateChatRequestPayload(UnverifiedPayloadBaseModel): - name: str - description: Optional[str] - chat_type: APIChatTypes + model_config = ConfigDict( + validate_assignment=True, + frozen=True, + str_strip_whitespace=True, + use_enum_values=True, + ) + + name: str = Field(..., min_length=1) + description: Optional[str] = None + chat_type: Union[APIChatTypes, ChatTypes] members: List[UUID] shared_history: Missing[bool] + avatar: Optional[str] = None - @classmethod - def from_domain( - cls, - name: str, - chat_type: ChatTypes, - huids: List[UUID], - shared_history: Missing[bool], - description: Optional[str] = None, - ) -> "BotXAPICreateChatRequestPayload": - return cls( - name=name, - chat_type=convert_chat_type_from_domain(chat_type), - members=huids, - description=description, - shared_history=shared_history, - ) + @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) + return values + + @field_validator("avatar") + def _validate_avatar(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + if not v.startswith("data:"): + raise ValueError("Avatar must be a data URL (RFC2397)") + try: + decode_rfc2397(v) + except Exception as e: + raise ValueError(f"Invalid data URL format: {e}") + return v + + @field_serializer("chat_type") + def _serialize_chat_type(self, v: APIChatTypes) -> str: + return v.value.lower() class BotXAPIChatIdResult(VerifiedPayloadBaseModel): + model_config = ConfigDict(frozen=True) chat_id: UUID class BotXAPICreateChatResponsePayload(VerifiedPayloadBaseModel): + model_config = ConfigDict(frozen=True) status: Literal["ok"] result: BotXAPIChatIdResult @@ -60,12 +85,21 @@ async def execute( self, payload: BotXAPICreateChatRequestPayload, ) -> BotXAPICreateChatResponsePayload: - path = "/api/v3/botx/chats/create" + """ + Создаёт чат через BotX API. + """ + url = self._build_url("/api/v3/botx/chats/create") + + exclude: Set[str] = ( + {"shared_history"} if payload.shared_history is Undefined else set() + ) + + body = payload.model_dump(mode="json", exclude=exclude) response = await self._botx_method_call( "POST", - self._build_url(path), - json=payload.jsonable_dict(), + url, + json=body, ) return self._verify_and_extract_api_model( diff --git a/pybotx/client/chats_api/create_thread.py b/pybotx/client/chats_api/create_thread.py new file mode 100644 index 00000000..f7370b41 --- /dev/null +++ b/pybotx/client/chats_api/create_thread.py @@ -0,0 +1,57 @@ +from typing import Literal +from uuid import UUID + +from pybotx.client.authorized_botx_method import AuthorizedBotXMethod +from pybotx.client.botx_method import response_exception_thrower +from pybotx.client.exceptions.chats import ( + ThreadCreationError, + ThreadCreationProhibitedError, +) +from pybotx.client.exceptions.event import EventNotFoundError +from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPICreateThreadRequestPayload(UnverifiedPayloadBaseModel): + sync_id: UUID + + @classmethod + def from_domain(cls, sync_id: UUID) -> "BotXAPICreateThreadRequestPayload": + return cls(sync_id=sync_id) + + +class BotXAPIThreadIdResult(VerifiedPayloadBaseModel): + thread_id: UUID + + +class BotXAPICreateThreadResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIThreadIdResult + + def to_domain(self) -> UUID: + return self.result.thread_id + + +class CreateThreadMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 403: response_exception_thrower(ThreadCreationProhibitedError), + 404: response_exception_thrower(EventNotFoundError), + 422: response_exception_thrower(ThreadCreationError), + } + + async def execute( + self, + payload: BotXAPICreateThreadRequestPayload, + ) -> BotXAPICreateThreadResponsePayload: + path = "/api/v3/botx/chats/create_thread" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPICreateThreadResponsePayload, + response, + ) diff --git a/pybotx/client/chats_api/list_chats.py b/pybotx/client/chats_api/list_chats.py index ccd878f7..c692ff2d 100644 --- a/pybotx/client/chats_api/list_chats.py +++ b/pybotx/client/chats_api/list_chats.py @@ -5,6 +5,7 @@ from pybotx.client.authorized_botx_method import AuthorizedBotXMethod from pybotx.logger import logger from pybotx.models.api_base import VerifiedPayloadBaseModel +from pydantic import ValidationError, field_validator from pybotx.models.chats import ChatListItem from pybotx.models.enums import APIChatTypes, convert_chat_type_to_domain @@ -22,7 +23,30 @@ class BotXAPIListChatResult(VerifiedPayloadBaseModel): class BotXAPIListChatResponsePayload(VerifiedPayloadBaseModel): status: Literal["ok"] - result: List[Union[BotXAPIListChatResult, Dict[str, Any]]] # noqa: WPS234 + result: List[Union[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]]] = [] + for item in value: + if isinstance(item, dict): + try: + parsed.append(BotXAPIListChatResult.model_validate(item)) + except ValidationError: + parsed.append(item) + else: + parsed.append(item) + return parsed + + @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]]]: + # Pydantic-валидатор: просто делегируем статическому методу + return cls.validate_result(value, info) def to_domain(self) -> List[ChatListItem]: chats_list = [ diff --git a/pybotx/client/chats_api/personal_chat.py b/pybotx/client/chats_api/personal_chat.py new file mode 100644 index 00000000..40d33fce --- /dev/null +++ b/pybotx/client/chats_api/personal_chat.py @@ -0,0 +1,152 @@ +from datetime import datetime as dt +from typing import Any, Dict, List, Literal, Optional, Union +from uuid import UUID + +from pydantic import ConfigDict, ValidationError, field_validator, Field +from pybotx.client.authorized_botx_method import AuthorizedBotXMethod +from pybotx.client.botx_method import response_exception_thrower +from pybotx.client.exceptions.common import ChatNotFoundError +from pybotx.logger import logger +from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from pybotx.models.chats import ChatInfo, ChatInfoMember +from pybotx.models.enums import ( + APIChatTypes, + APIUserKinds, + convert_chat_type_to_domain, + convert_user_kind_to_domain, +) + + +class BotXAPIPersonalChatRequestPayload(UnverifiedPayloadBaseModel): + """Payload запроса на получение персонального чата по HUID пользователя.""" + + user_huid: UUID + + model_config = ConfigDict(extra="forbid") + + @classmethod + def from_domain(cls, user_huid: UUID) -> "BotXAPIPersonalChatRequestPayload": + return cls(user_huid=user_huid) + + def as_query_params(self) -> Dict[str, Any]: + return self.model_dump(mode="json") + + +class BotXAPIPersonalChatMember(VerifiedPayloadBaseModel): + """Информация об участнике чата.""" + + admin: bool + user_huid: UUID + user_kind: APIUserKinds + + model_config = ConfigDict(extra="forbid") + + +class BotXAPIPersonalChatResult(VerifiedPayloadBaseModel): + """Результат API-ответа по персональному чату.""" + + chat_type: APIChatTypes + creator: Optional[UUID] + description: Optional[str] = None + group_chat_id: UUID + inserted_at: dt + members: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]] = Field( + default_factory=list + ) + name: str + shared_history: bool + + model_config = ConfigDict(extra="forbid") + + @field_validator("members", mode="before") + @classmethod + def validate_members( + cls, + value: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]], + info: Any, + ) -> List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]]: + return cls._parse_members(value) + + @staticmethod + def _parse_members( + members_data: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]], + ) -> List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]]: + # Явная аннотация решает проблему инвариантности List в mypy + parsed: List[Union[BotXAPIPersonalChatMember, Dict[str, Any]]] = [] + for item in members_data: + if isinstance(item, dict): + try: + parsed.append(BotXAPIPersonalChatMember.model_validate(item)) + except ValidationError: + logger.warning("Unsupported member structure encountered: %s", item) + parsed.append(item) + elif isinstance(item, BotXAPIPersonalChatMember): + parsed.append(item) + else: + logger.warning("Unknown member type: %s", item) + return parsed + + +class BotXAPIPersonalChatResponsePayload(VerifiedPayloadBaseModel): + """Обработанный payload успешного ответа API по персональному чату.""" + + status: Literal["ok"] + result: BotXAPIPersonalChatResult + + model_config = ConfigDict(extra="forbid") + + def to_domain(self) -> ChatInfo: + members: List[ChatInfoMember] = [] + for member in self.result.members: + if isinstance(member, BotXAPIPersonalChatMember): + try: + members.append( + ChatInfoMember( + is_admin=member.admin, + huid=member.user_huid, + kind=convert_user_kind_to_domain(member.user_kind), + ) + ) + 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), + creator_id=self.result.creator, + description=self.result.description, + chat_id=self.result.group_chat_id, + created_at=self.result.inserted_at, + members=members, + name=self.result.name, + shared_history=self.result.shared_history, + ) + + +class PersonalChatMethod(AuthorizedBotXMethod): + """Метод получения информации о персональном чате по HUID пользователя.""" + + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(ChatNotFoundError), + } + + async def execute( + self, + payload: BotXAPIPersonalChatRequestPayload, + ) -> BotXAPIPersonalChatResponsePayload: + path = "/api/v1/botx/chats/personal" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + params=payload.as_query_params(), + ) + + return self._verify_and_extract_api_model( + BotXAPIPersonalChatResponsePayload, + response, + ) diff --git a/pybotx/client/events_api/edit_event.py b/pybotx/client/events_api/edit_event.py index 4f4b3b21..6f734d41 100644 --- a/pybotx/client/events_api/edit_event.py +++ b/pybotx/client/events_api/edit_event.py @@ -42,6 +42,7 @@ class BotXAPIEditEventRequestPayload(UnverifiedPayloadBaseModel): sync_id: UUID payload: BotXAPIEditEvent file: Missing[BotXAPIAttachment] + opts: BotXAPIEditEventOpts @classmethod def from_domain( diff --git a/pybotx/client/events_api/message_status_event.py b/pybotx/client/events_api/message_status_event.py index 7a460abc..15104da0 100644 --- a/pybotx/client/events_api/message_status_event.py +++ b/pybotx/client/events_api/message_status_event.py @@ -42,7 +42,6 @@ class BotXAPIMessageStatusResponsePayload(VerifiedPayloadBaseModel): result: BotXAPIMessageStatusResult def to_domain(self) -> MessageStatus: - return MessageStatus( group_chat_id=self.result.group_chat_id, sent_to=self.result.sent_to, diff --git a/pybotx/client/exceptions/base.py b/pybotx/client/exceptions/base.py index 9bd8de1d..17a21c5b 100644 --- a/pybotx/client/exceptions/base.py +++ b/pybotx/client/exceptions/base.py @@ -22,7 +22,7 @@ def from_response( content = response.content message = ( - f"{method} {url}\n" # noqa: WPS221 (Strange error on CI) + f"{method} {url}\n" # (Strange error on CI) f"failed with code {status_code} and payload:\n" f"{content!r}" ) diff --git a/pybotx/client/exceptions/callbacks.py b/pybotx/client/exceptions/callbacks.py index 4f030310..888a1636 100644 --- a/pybotx/client/exceptions/callbacks.py +++ b/pybotx/client/exceptions/callbacks.py @@ -16,7 +16,7 @@ def __init__(self, callback: BotAPIMethodFailedCallback) -> None: def __reduce__(self) -> Any: # This method required to pass exception from pybotx logger to bot logger. - return type(self), (self.callback,) # pragma: no cover + return type(self), (self.callback,) class CallbackNotReceivedError(Exception): diff --git a/pybotx/client/exceptions/chats.py b/pybotx/client/exceptions/chats.py index 4134b5a0..50a2d3a1 100644 --- a/pybotx/client/exceptions/chats.py +++ b/pybotx/client/exceptions/chats.py @@ -15,3 +15,24 @@ class ChatCreationProhibitedError(BaseClientError): class ChatCreationError(BaseClientError): """Error while chat creation.""" + + +class ThreadCreationError(BaseClientError): + """Error while thread creation (invalid scheme).""" + + +class ThreadCreationProhibitedError(BaseClientError): + """ + Error while permission checks. + + 1. Bot has no permissions to create thread + 2. Threads are not allowed for that message + 3. Bot is not a chat member where message is located + 4. Message is located in personal chat + 5. Usupported event type + 6. Unsuppoerted chat type + 7. Thread is already created + 8. No access for message + 9. Message in stealth mode + 10. Message is deleted + """ diff --git a/pybotx/client/exceptions/http.py b/pybotx/client/exceptions/http.py index 0c95b614..cf09eef4 100644 --- a/pybotx/client/exceptions/http.py +++ b/pybotx/client/exceptions/http.py @@ -16,7 +16,7 @@ def __init__(self, response: httpx.Response) -> None: def __reduce__(self) -> Any: # This method required to pass exception from pybotx logger to bot logger. - return type(self), (self.response,) # pragma: no cover + return type(self), (self.response,) class InvalidBotXStatusCodeError(InvalidBotXResponseError): diff --git a/pybotx/client/files_api/download_file.py b/pybotx/client/files_api/download_file.py index 5aa73573..2f15409a 100644 --- a/pybotx/client/files_api/download_file.py +++ b/pybotx/client/files_api/download_file.py @@ -61,7 +61,7 @@ async def execute( params=payload.jsonable_dict(), ) as response: # https://github.com/nedbat/coveragepy/issues/1223 - async for chunk in response.aiter_bytes(): # pragma: no cover + async for chunk in response.aiter_bytes(): # pragma: no branch await async_buffer.write(chunk) await async_buffer.seek(0) diff --git a/pybotx/client/get_token.py b/pybotx/client/get_token.py index 1a96fda4..cf331c24 100644 --- a/pybotx/client/get_token.py +++ b/pybotx/client/get_token.py @@ -13,7 +13,7 @@ async def get_token( bot_id: UUID, httpx_client: httpx.AsyncClient, bot_accounts_storage: BotAccountsStorage, -) -> str: # noqa: DAR101, DAR201 +) -> str: """Request token for bot. Moved to separate file because used in `AuthorizedBotXMethod` and `Bot.get_token`. diff --git a/pybotx/client/smartapps_api/smartapp_manifest.py b/pybotx/client/smartapps_api/smartapp_manifest.py index aebf36ee..aef3c018 100644 --- a/pybotx/client/smartapps_api/smartapp_manifest.py +++ b/pybotx/client/smartapps_api/smartapp_manifest.py @@ -1,12 +1,11 @@ from typing import List, Literal from uuid import UUID -from pydantic import Field - from pybotx.client.authorized_botx_method import AuthorizedBotXMethod from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel from pybotx.models.enums import SmartappManifestWebLayoutChoices as WebLayoutChoices +from pydantic import Field class SmartappManifestIosParams(VerifiedPayloadBaseModel): diff --git a/pybotx/client/stickers_api/add_sticker.py b/pybotx/client/stickers_api/add_sticker.py index 3b67e4bd..76fef6a3 100644 --- a/pybotx/client/stickers_api/add_sticker.py +++ b/pybotx/client/stickers_api/add_sticker.py @@ -55,7 +55,7 @@ def to_domain(self, pack_id: UUID) -> Sticker: ) -def bad_request_error_handler(response: httpx.Response) -> NoReturn: # noqa: WPS238 +def bad_request_error_handler(response: httpx.Response) -> NoReturn: reason = response.json().get("reason") if reason == "pack_not_found": diff --git a/pybotx/client/users_api/user_from_csv.py b/pybotx/client/users_api/user_from_csv.py index ba18e976..d06f12f2 100644 --- a/pybotx/client/users_api/user_from_csv.py +++ b/pybotx/client/users_api/user_from_csv.py @@ -1,8 +1,6 @@ from typing import Optional, Union from uuid import UUID -from pydantic import Field, validator - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.enums import ( APISyncSourceTypes, @@ -11,6 +9,7 @@ convert_user_kind_to_domain, ) from pybotx.models.users import UserFromCSV +from pydantic import Field, field_validator class BotXAPIUserFromCSVResult(VerifiedPayloadBaseModel): @@ -37,7 +36,7 @@ class BotXAPIUserFromCSVResult(VerifiedPayloadBaseModel): other_ip_phone: Optional[str] = Field(alias="Other IP phone") personnel_number: Optional[str] = Field(alias="Personnel number") - @validator( + @field_validator( "email", "company", "department", @@ -53,7 +52,7 @@ class BotXAPIUserFromCSVResult(VerifiedPayloadBaseModel): "ip_phone", "other_ip_phone", "personnel_number", - pre=True, + mode="before", ) @classmethod def replace_empty_string_with_none(cls, field_value: str) -> Optional[str]: diff --git a/pybotx/client/users_api/user_from_search.py b/pybotx/client/users_api/user_from_search.py index 6c0ce435..f74f2504 100644 --- a/pybotx/client/users_api/user_from_search.py +++ b/pybotx/client/users_api/user_from_search.py @@ -2,11 +2,10 @@ from typing import List, Literal, Optional from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.enums import APIUserKinds, convert_user_kind_to_domain from pybotx.models.users import UserFromSearch +from pydantic import Field class BotXAPISearchUserResult(VerifiedPayloadBaseModel): diff --git a/pybotx/client/users_api/users_as_csv.py b/pybotx/client/users_api/users_as_csv.py index 1393a91e..3ac3fb69 100644 --- a/pybotx/client/users_api/users_as_csv.py +++ b/pybotx/client/users_api/users_as_csv.py @@ -43,5 +43,5 @@ async def execute( params=payload.jsonable_dict(), ) as response: # https://github.com/nedbat/coveragepy/issues/1223 - async for chunk in response.aiter_bytes(): # pragma: no cover + async for chunk in response.aiter_bytes(): # pragma: no branch await async_buffer.write(chunk) diff --git a/pybotx/constants.py b/pybotx/constants.py index 39d40a25..6e95c04a 100644 --- a/pybotx/constants.py +++ b/pybotx/constants.py @@ -1,7 +1,7 @@ try: from typing import Final except ImportError: - from typing_extensions import Final # type: ignore # noqa: WPS440 + from typing_extensions import Final # type: ignore CHUNK_SIZE: Final = 1024 * 1024 # 1Mb BOT_API_VERSION: Final = 4 diff --git a/pybotx/models/api_base.py b/pybotx/models/api_base.py index 983d6760..a4e0fbe9 100644 --- a/pybotx/models/api_base.py +++ b/pybotx/models/api_base.py @@ -1,10 +1,9 @@ import json from typing import Any, Dict, List, Optional, Set, Union, cast -from pydantic import BaseModel -from pydantic.json import pydantic_encoder - from pybotx.missing import Undefined +from pydantic import BaseModel, ConfigDict +from pydantic_core import to_jsonable_python def _remove_undefined( @@ -46,9 +45,9 @@ def _remove_undefined( class PayloadBaseModel(BaseModel): - def json(self) -> str: # type: ignore [override] - clean_dict = _remove_undefined(self.dict()) - return json.dumps(clean_dict, default=pydantic_encoder, ensure_ascii=False) + 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]: return cast( @@ -67,8 +66,7 @@ def __init__( _fields_set: Optional[Set[str]] = None, **kwargs: Any, ) -> None: - model = BaseModel.construct(_fields_set, **kwargs) - self.__dict__.update(model.__dict__) # noqa: WPS609 (Replace self attrs) + model = self.__class__.model_construct(_fields_set=_fields_set, **kwargs) + self.__dict__.update(model.__dict__) - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/pybotx/models/async_files.py b/pybotx/models/async_files.py index f41940e0..edb9fc5e 100644 --- a/pybotx/models/async_files.py +++ b/pybotx/models/async_files.py @@ -8,6 +8,7 @@ from pybotx.bot.contextvars import bot_id_var, bot_var, chat_id_var from pybotx.constants import CHUNK_SIZE from pybotx.models.api_base import VerifiedPayloadBaseModel +from pydantic import ConfigDict from pybotx.models.enums import ( APIAttachmentTypes, AttachmentTypes, @@ -75,8 +76,7 @@ class APIAsyncFileBase(VerifiedPayloadBaseModel): file_size: int file_hash: str - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") class ApiAsyncFileImage(APIAsyncFileBase): diff --git a/pybotx/models/attachments.py b/pybotx/models/attachments.py index 628434f2..2f7d6365 100644 --- a/pybotx/models/attachments.py +++ b/pybotx/models/attachments.py @@ -152,8 +152,8 @@ class BotAPIAttachmentVoice(VerifiedPayloadBaseModel): class BotAPIAttachmentLocationData(VerifiedPayloadBaseModel): location_name: str location_address: str - location_lat: str - location_lng: str + location_lat: Union[str, float] + location_lng: Union[str, float] class BotAPIAttachmentLocation(VerifiedPayloadBaseModel): @@ -213,7 +213,7 @@ class BotAPIAttachmentLink(VerifiedPayloadBaseModel): ] -def convert_api_attachment_to_domain( # noqa: WPS212 +def convert_api_attachment_to_domain( api_attachment: BotAPIAttachment, message_body: str, ) -> IncomingAttachment: @@ -298,8 +298,8 @@ def convert_api_attachment_to_domain( # noqa: WPS212 return Location( name=api_attachment.data.location_name, address=api_attachment.data.location_address, - latitude=api_attachment.data.location_lat, - longitude=api_attachment.data.location_lng, + latitude=str(api_attachment.data.location_lat), + longitude=str(api_attachment.data.location_lng), ) if attachment_type == AttachmentTypes.CONTACT: diff --git a/pybotx/models/base_command.py b/pybotx/models/base_command.py index 3ad82203..d710f50a 100644 --- a/pybotx/models/base_command.py +++ b/pybotx/models/base_command.py @@ -2,8 +2,6 @@ from typing import Any, Dict, Literal, Optional, Union from uuid import UUID -from pydantic import validator - from pybotx.bot.api.exceptions import ( UnknownSystemEventError, UnsupportedBotAPIVersionError, @@ -17,6 +15,7 @@ BotAPICommandTypes, BotAPISystemEventTypes, ) +from pydantic import field_validator class BotAPICommandPayload(VerifiedPayloadBaseModel): @@ -27,9 +26,9 @@ class BotAPICommandPayload(VerifiedPayloadBaseModel): class BotAPIDeviceMeta(VerifiedPayloadBaseModel): - pushes: Optional[bool] - timezone: Optional[str] - permissions: Optional[Dict[str, Any]] + pushes: Optional[bool] = None + timezone: Optional[str] = None + permissions: Optional[Dict[str, Any]] = None class BaseBotAPIContext(VerifiedPayloadBaseModel): @@ -38,12 +37,12 @@ class BaseBotAPIContext(VerifiedPayloadBaseModel): class BotAPIUserContext(BaseBotAPIContext): user_huid: UUID - user_udid: Optional[UUID] - ad_domain: Optional[str] - ad_login: Optional[str] - username: Optional[str] - is_admin: Optional[bool] - is_creator: Optional[bool] + 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 class BotAPIChatContext(BaseBotAPIContext): @@ -52,14 +51,14 @@ class BotAPIChatContext(BaseBotAPIContext): class BotAPIDeviceContext(BaseBotAPIContext): - app_version: Optional[str] - platform: Optional[BotAPIClientPlatforms] - platform_package_id: Optional[str] - device: Optional[str] - device_meta: Optional[BotAPIDeviceMeta] - device_software: Optional[str] - manufacturer: Optional[str] - locale: Optional[str] + 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 class BotAPIBaseCommand(VerifiedPayloadBaseModel): @@ -67,7 +66,7 @@ class BotAPIBaseCommand(VerifiedPayloadBaseModel): sync_id: UUID proto_version: int - @validator("proto_version", pre=True) + @field_validator("proto_version", mode="before") @classmethod def validate_proto_version(cls, version: Any) -> int: if isinstance(version, int) and version == BOT_API_VERSION: @@ -79,10 +78,10 @@ def validate_proto_version(cls, version: Any) -> int: class BotAPIBaseSystemEventPayload(VerifiedPayloadBaseModel): command_type: Literal[BotAPICommandTypes.SYSTEM] - @validator("body", pre=True, check_fields=False) + @field_validator("body", mode="before", check_fields=False) @classmethod def find_unknown_system_event(cls, body: str) -> str: - if body not in BotAPISystemEventTypes.__members__.values(): # noqa: WPS609 + if body not in BotAPISystemEventTypes.__members__.values(): raise UnknownSystemEventError(body) return body diff --git a/pybotx/models/bot_account.py b/pybotx/models/bot_account.py index 7fe3a468..c58f110a 100644 --- a/pybotx/models/bot_account.py +++ b/pybotx/models/bot_account.py @@ -1,10 +1,10 @@ from dataclasses import dataclass -from functools import cached_property + from typing import Optional from urllib.parse import urlparse from uuid import UUID -from pydantic import AnyHttpUrl, BaseModel +from pydantic import AnyHttpUrl, BaseModel, ConfigDict @dataclass @@ -18,13 +18,16 @@ class BotAccountWithSecret(BaseModel): cts_url: AnyHttpUrl secret_key: str - class Config: - allow_mutation = False - keep_untouched = (cached_property,) + model_config = ConfigDict(frozen=True) + + def __setattr__(self, name: str, value: object) -> None: + if not getattr(self.model_config, "frozen", True) and name in self.model_fields: + raise TypeError("BotAccountWithSecret is immutable") # pragma: no cover + super().__setattr__(name, value) - @cached_property + @property def host(self) -> str: - hostname = urlparse(self.cts_url).hostname + hostname = urlparse(str(self.cts_url)).hostname if hostname is None: raise ValueError("Could not parse host from cts_url.") diff --git a/pybotx/models/commands.py b/pybotx/models/commands.py index 5f4e21f3..79972a80 100644 --- a/pybotx/models/commands.py +++ b/pybotx/models/commands.py @@ -1,9 +1,18 @@ -from typing import Union +from typing import List, Union from pybotx.models.message.incoming_message import ( BotAPIIncomingMessage, IncomingMessage, ) + +__all__: List[str] = [ + "BotAPIIncomingMessage", + "IncomingMessage", + "BotAPISystemEvent", + "BotAPICommand", + "SystemEvent", + "BotCommand", +] from pybotx.models.system_events.added_to_chat import ( AddedToChatEvent, BotAPIAddedToChat, diff --git a/pybotx/models/enums.py b/pybotx/models/enums.py index 05bc35fa..7611e695 100644 --- a/pybotx/models/enums.py +++ b/pybotx/models/enums.py @@ -54,11 +54,13 @@ class ChatTypes(AutoName): PERSONAL_CHAT: Personal chat with user. GROUP_CHAT: Group chat. CHANNEL: Public channel. + THREAD: Thread in a chat. """ PERSONAL_CHAT = auto() GROUP_CHAT = auto() CHANNEL = auto() + THREAD = auto() class SyncSourceTypes(AutoName): @@ -81,7 +83,7 @@ class ConferenceLinkTypes(AutoName): IncomingSyncSourceTypes = Union[SyncSourceTypes, UNSUPPORTED] -class StrEnum(str, Enum): # noqa: WPS600 (pydantic needs this inheritance) +class StrEnum(str, Enum): # (pydantic needs this inheritance) """Enum base for API models.""" # https://github.com/pydantic/pydantic/issues/3850 @@ -92,6 +94,7 @@ class APIChatTypes(Enum): CHAT = "chat" GROUP_CHAT = "group_chat" CHANNEL = "channel" + THREAD = "thread" class BotAPICommandTypes(StrEnum): @@ -295,6 +298,7 @@ def convert_chat_type_from_domain(chat_type: ChatTypes) -> APIChatTypes: ChatTypes.PERSONAL_CHAT: APIChatTypes.CHAT, ChatTypes.GROUP_CHAT: APIChatTypes.GROUP_CHAT, ChatTypes.CHANNEL: APIChatTypes.CHANNEL, + ChatTypes.THREAD: APIChatTypes.THREAD, } converted_type = chat_types_mapping.get(chat_type) @@ -305,15 +309,15 @@ def convert_chat_type_from_domain(chat_type: ChatTypes) -> APIChatTypes: @overload -def convert_chat_type_to_domain( +def convert_chat_type_to_domain( # pragma: no cover chat_type: APIChatTypes, -) -> ChatTypes: ... # noqa: WPS428, E704 +) -> ChatTypes: ... @overload -def convert_chat_type_to_domain( +def convert_chat_type_to_domain( # pragma: no cover chat_type: str, -) -> UNSUPPORTED: ... # noqa: WPS428, E704 +) -> UNSUPPORTED: ... def convert_chat_type_to_domain( @@ -323,6 +327,7 @@ def convert_chat_type_to_domain( APIChatTypes.CHAT: ChatTypes.PERSONAL_CHAT, APIChatTypes.GROUP_CHAT: ChatTypes.GROUP_CHAT, APIChatTypes.CHANNEL: ChatTypes.CHANNEL, + APIChatTypes.THREAD: ChatTypes.THREAD, } converted_type: Optional[IncomingChatTypes] @@ -338,15 +343,15 @@ def convert_chat_type_to_domain( @overload -def convert_sync_source_type_to_domain( +def convert_sync_source_type_to_domain( # pragma: no cover sync_type: APISyncSourceTypes, -) -> SyncSourceTypes: ... # noqa: WPS428, E704 +) -> SyncSourceTypes: ... @overload -def convert_sync_source_type_to_domain( +def convert_sync_source_type_to_domain( # pragma: no cover sync_type: str, -) -> UNSUPPORTED: ... # noqa: WPS428, E704 +) -> UNSUPPORTED: ... def convert_sync_source_type_to_domain( diff --git a/pybotx/models/message/incoming_message.py b/pybotx/models/message/incoming_message.py index d11a1488..2f16604b 100644 --- a/pybotx/models/message/incoming_message.py +++ b/pybotx/models/message/incoming_message.py @@ -3,8 +3,6 @@ from typing import Any, Dict, List, Optional, Tuple, Union, cast from uuid import UUID -from pydantic import Field - from pybotx.logger import logger from pybotx.models.attachments import ( BotAPIAttachment, @@ -43,6 +41,7 @@ ) from pybotx.models.message.reply import BotAPIReply, Reply from pybotx.models.stickers import Sticker +from pydantic import Field, ValidationError, field_validator, TypeAdapter @dataclass @@ -157,7 +156,6 @@ def convert_bot_api_entity_to_domain(api_entity: BotAPIEntity) -> Entity: 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, @@ -168,7 +166,6 @@ def convert_bot_api_entity_to_domain(api_entity: BotAPIEntity) -> Entity: ) 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)) @@ -196,10 +193,32 @@ class BotAPIIncomingMessage(BotAPIBaseCommand): sender: BotAPIIncomingMessageContext = Field(..., alias="from") source_sync_id: Optional[UUID] - attachments: List[Union[BotAPIAttachment, Dict[str, Any]]] # noqa: WPS234 - entities: List[Union[BotAPIEntity, Dict[str, Any]]] # noqa: WPS234 + attachments: List[Union[BotAPIAttachment, Dict[str, Any]]] + entities: List[Union[BotAPIEntity, Dict[str, Any]]] - def to_domain(self, raw_command: Dict[str, Any]) -> IncomingMessage: # noqa: WPS231 + @staticmethod + def validate_items(value: List[Union[Dict[str, Any], Any]], info: Any) -> List[Any]: + item_model = ( + BotAPIAttachment if info.field_name == "attachments" else BotAPIEntity + ) + parsed: List[Any] = [] + for item in value: + if isinstance(item, dict): + try: + parsed.append(TypeAdapter(item_model).validate_python(item)) + except ValidationError: + parsed.append(item) + return parsed + + @field_validator("attachments", "entities", mode="before") + @classmethod + def _validate_items_field( + cls, value: List[Union[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: if self.sender.device_meta: pushes = self.sender.device_meta.pushes timezone = self.sender.device_meta.timezone @@ -255,7 +274,7 @@ def to_domain(self, raw_command: Dict[str, Any]) -> IncomingMessage: # noqa: WP self.attachments[0], self.payload.body, ) - if isinstance(attachment_domain, FileAttachmentBase): # noqa: WPS223 + if isinstance(attachment_domain, FileAttachmentBase): file = attachment_domain elif isinstance(attachment_domain, Location): location = attachment_domain @@ -278,7 +297,7 @@ def to_domain(self, raw_command: Dict[str, Any]) -> IncomingMessage: # noqa: WP entity_domain = convert_bot_api_entity_to_domain(entity) if isinstance( entity_domain, - Mention.__args__, # type: ignore [attr-defined] # noqa: WPS609 + Mention.__args__, # type: ignore [attr-defined] ): mentions.append(entity_domain) elif isinstance(entity_domain, Forward): diff --git a/pybotx/models/message/markup.py b/pybotx/models/message/markup.py index 644f5a10..a32d10ce 100644 --- a/pybotx/models/message/markup.py +++ b/pybotx/models/message/markup.py @@ -1,9 +1,12 @@ +import json from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, Iterator, List, Literal, Optional, Union +from typing import Any, Dict, Iterator, List, Literal, Optional, Union, cast from pybotx.missing import Missing, Undefined -from pybotx.models.api_base import UnverifiedPayloadBaseModel +from pybotx.models.api_base import UnverifiedPayloadBaseModel, _remove_undefined +from pydantic import RootModel +from pydantic_core import to_jsonable_python class ButtonTextAlign(Enum): @@ -68,7 +71,7 @@ def __eq__(self, other: object) -> bool: raise NotImplementedError # https://github.com/wemake-services/wemake-python-styleguide/issues/2172 - return self._buttons == other._buttons # noqa: WPS437 + return self._buttons == other._buttons def __repr__(self) -> str: buttons = [] @@ -180,8 +183,13 @@ class BotXAPIButton(UnverifiedPayloadBaseModel): opts: BotXAPIButtonOptions -class BotXAPIMarkup(UnverifiedPayloadBaseModel): - __root__: 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 api_button_from_domain(button: Button) -> BotXAPIButton: @@ -216,7 +224,7 @@ def api_button_from_domain(button: Button) -> BotXAPIButton: def api_markup_from_domain(markup: Markup) -> BotXAPIMarkup: return BotXAPIMarkup( - __root__=[ + root=[ [api_button_from_domain(button) for button in buttons] for buttons in markup - ], + ] ) diff --git a/pybotx/models/message/mentions.py b/pybotx/models/message/mentions.py index 3e965add..ec8359ce 100644 --- a/pybotx/models/message/mentions.py +++ b/pybotx/models/message/mentions.py @@ -3,8 +3,6 @@ from typing import Dict, List, Literal, Optional, Tuple, Union from uuid import UUID, uuid4 -from pydantic import Field, validator - from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel from pybotx.models.enums import ( @@ -13,6 +11,7 @@ MentionTypes, convert_mention_type_from_domain, ) +from pydantic import Field, field_validator def build_embed_mention( @@ -176,7 +175,7 @@ class BotAPIMentionData(VerifiedPayloadBaseModel): mention_id: UUID mention_data: Optional[BotAPINestedMentionData] - @validator("mention_data", pre=True) + @field_validator("mention_data", mode="before") @classmethod def validate_mention_data( cls, diff --git a/pybotx/models/status.py b/pybotx/models/status.py index 14889b47..e4e37022 100644 --- a/pybotx/models/status.py +++ b/pybotx/models/status.py @@ -2,8 +2,6 @@ from typing import Any, Dict, List, Literal, NewType, Optional, Union from uuid import UUID -from pydantic import validator - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.enums import ( APIChatTypes, @@ -11,6 +9,7 @@ convert_chat_type_to_domain, ) from pybotx.models.message.incoming_message import IncomingMessage +from pydantic import field_validator BotMenu = NewType("BotMenu", Dict[str, str]) @@ -42,12 +41,12 @@ def from_incoming_message( class BotAPIStatusRecipient(VerifiedPayloadBaseModel): bot_id: UUID user_huid: UUID - ad_login: Optional[str] - ad_domain: Optional[str] - is_admin: Optional[bool] + ad_login: Optional[str] = None + ad_domain: Optional[str] = None + is_admin: Optional[bool] = None chat_type: Union[APIChatTypes, str] - @validator("ad_login", "ad_domain", "is_admin", pre=True) + @field_validator("ad_login", "ad_domain", "is_admin", mode="before") @classmethod def replace_empty_string( cls, diff --git a/pybotx/models/stickers.py b/pybotx/models/stickers.py index bf583efb..48de8de8 100644 --- a/pybotx/models/stickers.py +++ b/pybotx/models/stickers.py @@ -29,7 +29,7 @@ async def download( ) -> None: bot = bot_var.get() - response = await bot._httpx_client.get(self.image_link) # noqa: WPS437 + response = await bot._httpx_client.get(self.image_link) response.raise_for_status() await async_buffer.write(response.content) diff --git a/pybotx/models/system_events/added_to_chat.py b/pybotx/models/system_events/added_to_chat.py index e4680ac7..770c7742 100644 --- a/pybotx/models/system_events/added_to_chat.py +++ b/pybotx/models/system_events/added_to_chat.py @@ -2,8 +2,6 @@ from typing import Any, Dict, List, Literal from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BotAPIBaseCommand, @@ -14,6 +12,7 @@ from pybotx.models.bot_account import BotAccount from pybotx.models.chats import Chat from pybotx.models.enums import BotAPISystemEventTypes, convert_chat_type_to_domain +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/chat_created.py b/pybotx/models/system_events/chat_created.py index 6761d1aa..8791b96e 100644 --- a/pybotx/models/system_events/chat_created.py +++ b/pybotx/models/system_events/chat_created.py @@ -2,8 +2,6 @@ from typing import Any, Dict, List, Literal, Optional from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BotAPIBaseCommand, @@ -21,6 +19,7 @@ convert_chat_type_to_domain, convert_user_kind_to_domain, ) +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/chat_deleted_by_user.py b/pybotx/models/system_events/chat_deleted_by_user.py index ab23ed12..8ce451ef 100644 --- a/pybotx/models/system_events/chat_deleted_by_user.py +++ b/pybotx/models/system_events/chat_deleted_by_user.py @@ -2,8 +2,6 @@ from typing import Any, Dict, Literal from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BaseBotAPIContext, @@ -13,6 +11,7 @@ ) from pybotx.models.bot_account import BotAccount from pybotx.models.enums import BotAPISystemEventTypes +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/conference_changed.py b/pybotx/models/system_events/conference_changed.py index 2421bd16..d124a662 100644 --- a/pybotx/models/system_events/conference_changed.py +++ b/pybotx/models/system_events/conference_changed.py @@ -3,8 +3,6 @@ from typing import Any, Dict, List, Literal, Optional from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BaseBotAPIContext, @@ -19,6 +17,7 @@ ConferenceLinkTypes, convert_conference_link_type_to_domain, ) +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/conference_created.py b/pybotx/models/system_events/conference_created.py index ce27a51d..56f88932 100644 --- a/pybotx/models/system_events/conference_created.py +++ b/pybotx/models/system_events/conference_created.py @@ -2,8 +2,6 @@ from typing import Any, Dict, Literal from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BaseBotAPIContext, @@ -13,6 +11,7 @@ ) from pybotx.models.bot_account import BotAccount from pybotx.models.enums import BotAPISystemEventTypes +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/conference_deleted.py b/pybotx/models/system_events/conference_deleted.py index 7fb65a2b..a2d19556 100644 --- a/pybotx/models/system_events/conference_deleted.py +++ b/pybotx/models/system_events/conference_deleted.py @@ -2,8 +2,6 @@ from typing import Any, Dict, Literal from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BaseBotAPIContext, @@ -13,6 +11,7 @@ ) from pybotx.models.bot_account import BotAccount from pybotx.models.enums import BotAPISystemEventTypes +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/cts_login.py b/pybotx/models/system_events/cts_login.py index d1ae31c6..114540d1 100644 --- a/pybotx/models/system_events/cts_login.py +++ b/pybotx/models/system_events/cts_login.py @@ -2,8 +2,6 @@ from typing import Any, Dict, Literal from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BaseBotAPIContext, @@ -13,6 +11,7 @@ ) from pybotx.models.bot_account import BotAccount from pybotx.models.enums import BotAPISystemEventTypes +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/cts_logout.py b/pybotx/models/system_events/cts_logout.py index f9f7fb3d..f136094d 100644 --- a/pybotx/models/system_events/cts_logout.py +++ b/pybotx/models/system_events/cts_logout.py @@ -2,8 +2,6 @@ from typing import Any, Dict, Literal from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BaseBotAPIContext, @@ -13,6 +11,7 @@ ) from pybotx.models.bot_account import BotAccount from pybotx.models.enums import BotAPISystemEventTypes +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/deleted_from_chat.py b/pybotx/models/system_events/deleted_from_chat.py index ad4d3357..2f754404 100644 --- a/pybotx/models/system_events/deleted_from_chat.py +++ b/pybotx/models/system_events/deleted_from_chat.py @@ -2,8 +2,6 @@ from typing import Any, Dict, List, Literal from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BotAPIBaseCommand, @@ -17,6 +15,7 @@ BotAPISystemEventTypes, convert_chat_type_to_domain, ) +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/event_edit.py b/pybotx/models/system_events/event_edit.py index 2e0bd662..fd04ba24 100644 --- a/pybotx/models/system_events/event_edit.py +++ b/pybotx/models/system_events/event_edit.py @@ -2,8 +2,6 @@ from typing import Any, Dict, List, Literal, Optional from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.attachments import ( BotAPIAttachment, @@ -23,6 +21,7 @@ Entity, convert_bot_api_entity_to_domain, ) +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/internal_bot_notification.py b/pybotx/models/system_events/internal_bot_notification.py index 1a357914..84cc1eab 100644 --- a/pybotx/models/system_events/internal_bot_notification.py +++ b/pybotx/models/system_events/internal_bot_notification.py @@ -1,8 +1,6 @@ from dataclasses import dataclass from typing import Any, Dict, Literal -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BotAPIBaseCommand, @@ -15,6 +13,7 @@ from pybotx.models.bot_sender import BotSender from pybotx.models.chats import Chat from pybotx.models.enums import BotAPISystemEventTypes, convert_chat_type_to_domain +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/left_from_chat.py b/pybotx/models/system_events/left_from_chat.py index df21da77..dd32afb4 100644 --- a/pybotx/models/system_events/left_from_chat.py +++ b/pybotx/models/system_events/left_from_chat.py @@ -2,8 +2,6 @@ from typing import Any, Dict, List, Literal from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BotAPIBaseCommand, @@ -14,6 +12,7 @@ from pybotx.models.bot_account import BotAccount from pybotx.models.chats import Chat from pybotx.models.enums import BotAPISystemEventTypes, convert_chat_type_to_domain +from pydantic import Field @dataclass diff --git a/pybotx/models/system_events/smartapp_event.py b/pybotx/models/system_events/smartapp_event.py index 1925a4e9..c7e8d5e2 100644 --- a/pybotx/models/system_events/smartapp_event.py +++ b/pybotx/models/system_events/smartapp_event.py @@ -2,8 +2,6 @@ from typing import Any, Dict, List, Literal, Optional from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.async_files import APIAsyncFile, File, convert_async_file_to_domain from pybotx.models.base_command import ( @@ -22,6 +20,7 @@ convert_client_platform_to_domain, ) from pybotx.models.message.incoming_message import UserDevice, UserSender +from pydantic import Field @dataclass @@ -39,7 +38,7 @@ class SmartAppEvent(BotCommandBase): ref: Optional[UUID] smartapp_id: UUID - data: Dict[str, Any] # noqa: WPS110 + data: Dict[str, Any] opts: Optional[Dict[str, Any]] smartapp_api_version: Optional[int] files: List[File] @@ -50,7 +49,7 @@ class SmartAppEvent(BotCommandBase): class BotAPISmartAppData(VerifiedPayloadBaseModel): ref: UUID smartapp_id: UUID - data: Dict[str, Any] # noqa: WPS110 + data: Dict[str, Any] opts: Dict[str, Any] smartapp_api_version: int diff --git a/pybotx/models/system_events/user_joined_to_chat.py b/pybotx/models/system_events/user_joined_to_chat.py index 0b36e2d8..ece7ad0b 100644 --- a/pybotx/models/system_events/user_joined_to_chat.py +++ b/pybotx/models/system_events/user_joined_to_chat.py @@ -2,8 +2,6 @@ from typing import Any, Dict, List, Literal from uuid import UUID -from pydantic import Field - from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.base_command import ( BotAPIBaseCommand, @@ -14,6 +12,7 @@ from pybotx.models.bot_account import BotAccount from pybotx.models.chats import Chat from pybotx.models.enums import BotAPISystemEventTypes, convert_chat_type_to_domain +from pydantic import Field, ConfigDict @dataclass @@ -90,5 +89,4 @@ def to_domain(self, raw_command: Dict[str, Any]) -> JoinToChatEvent: ), ) - class Config: - allow_population_by_field_name = True + model_config = ConfigDict(populate_by_name=True) diff --git a/pyproject.toml b/pyproject.toml index 6bb84255..22e01d4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [tool.poetry] name = "pybotx" -version = "0.75.1" +version = "0.76.0a1" description = "A python library for interacting with eXpress BotX API" authors = [ "Sidnev Nikolay ", "Maxim Gorbachev ", "Alexander Samoylenko ", - "Arseniy Zhiltsov " + "Arseniy Zhiltsov ", ] readme = "README.md" repository = "https://github.com/ExpressApp/pybotx" @@ -22,19 +22,14 @@ httpx = "^0.28.0" # https://github.com/encode/httpcore/pull/880 httpcore = "1.0.9" loguru = ">=0.6.0,<0.7.0" -pydantic = ">=1.6.0,<1.11.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" [tool.poetry.group.dev.dependencies] -add-trailing-comma = "3.1.0" -autoflake = "2.3.1" -black = "25.1.0" -isort = "6.0.1" mypy = "1.15.0" typing-extensions = ">=3.7.4,<5.0.0" -wemake-python-styleguide = "0.19.2" bandit = "1.8.3" # https://github.com/PyCQA/bandit/issues/837 pytest = "8.3.5" @@ -48,6 +43,7 @@ deepdiff = "^8.5.0,<=9.0.0" 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" [build-system] requires = ["poetry>=1.2.0"] @@ -56,3 +52,24 @@ build-backend = "poetry.masonry.api" [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] +addopts = [ + "--strict-markers", + "--tb=short", + "--cov=pybotx", + "--cov-report=term-missing", + "--cov-branch", + "--no-cov-on-fail", + "--cov-fail-under=100", +] +markers = [ + "wip: Work in progress", + "mock_authorization: Mock authorization", +] +filterwarnings = [ + "ignore:Pydantic serializer warnings:UserWarning", +] + +[tool.ruff] +target-version = "py39" +line-length = 88 diff --git a/scripts/docs-lint b/scripts/docs-lint index 44d43938..8636692d 100755 --- a/scripts/docs-lint +++ b/scripts/docs-lint @@ -14,9 +14,7 @@ while IFS= read -d '' -r snippet || [[ -n "${snippet}" ]]; do snippet_number=$((snippet_number+1)) done < <(grep -Pzo '(?s)(?<=```python\n).*?(?=```)' README.md); -isort --profile black --check-only "${snippets_dir_path}" -black --check --diff "${snippets_dir_path}" mypy "${snippets_dir_path}" -flake8 "${snippets_dir_path}" +ruff check "${snippets_dir_path}" --ignore F403,F405 rm -rf "${snippets_dir_path}" diff --git a/scripts/format b/scripts/format index 5889b92e..39a91901 100755 --- a/scripts/format +++ b/scripts/format @@ -2,20 +2,5 @@ set -ex -autoflake --recursive --in-place \ - --remove-all-unused-imports \ - --ignore-init-module-imports \ - pybotx tests -isort --profile black pybotx tests -black pybotx tests - -find pybotx -type f -name "*.py" | xargs add-trailing-comma --py36-plus --exit-zero-even-if-changed -find tests -type f -name "*.py" | xargs add-trailing-comma --py36-plus --exit-zero-even-if-changed - -# This `black` is needed again in order to transfer parameters/arguments to new lines -# after inserting commas. -# The first `black` won't be able to transfer parameters/arguments to new lines because -# there is no comma at the end of the line. -# Inserting commas must be after the first `black`, so that there is one new line break, -# if the line is out of max-line-length. -black pybotx tests > /dev/null +ruff format pybotx tests +ruff check --fix pybotx tests \ No newline at end of file diff --git a/scripts/lint b/scripts/lint index 02918ac9..134d3f1f 100755 --- a/scripts/lint +++ b/scripts/lint @@ -2,10 +2,7 @@ set -ex -black --check --diff pybotx tests -isort --profile black --check-only pybotx tests - mypy pybotx tests -flake8 pybotx tests +ruff check pybotx tests ./scripts/wip-marks diff --git a/setup.cfg b/setup.cfg index 0463678d..5b249bd0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,115 +31,6 @@ ignore_missing_imports = True ignore_missing_imports = True -[isort] -profile = black -multi_line_output = 3 - -[darglint] -# darglint configuration: -# https://github.com/terrencepreilly/darglint -strictness = short -docstring_style = sphinx - -[flake8] -format = wemake -show-source = true - -max-line-length = 88 -inline-quotes = double -nested_classes_whitelist = Config, Meta -allowed_domain_names = data, handler, result, content, file - -per-file-ignores = - pybotx/bot/bot.py:WPS203, - pybotx/constants.py:WPS432, - pybotx/__init__.py:WPS203,WPS410,WPS412,F401, - # https://github.com/wemake-services/wemake-python-styleguide/issues/2172 - pybotx/bot/handler_collector.py:WPS203,WPS226,WPS437, - pybotx/bot/handler.py:WPS226, - pybotx/client/notifications_api/internal_bot_notification.py:WPS202, - pybotx/client/smartapps_api/smartapp_custom_notification.py:WPS118, - # Complex model converting - pybotx/models/message/incoming_message.py:WPS232, - # WPS reacts at using `}` in f-strings - pybotx/models/message/mentions.py:WPS226, - # Protected attr usage is OK with async_files - pybotx/models/async_files.py:WPS437, - pybotx/models/api_base.py:WPS232,WPS231,WPS110,WPS440 - # This ignores make logger code cleaner - pybotx/logger.py:WPS219,WPS226 - # Allow using methods names with trailing underscore - pybotx/models/enums.py:WPS120 - - tests/*:DAR101,E501,WPS110,WPS114,WPS116,WPS118,WPS202,WPS221,WPS226,WPS237,WPS402,WPS420,WPS428,WPS430,WPS432,WPS441,WPS442,WPS520,PT011,S105,S106,WPS437,WPS609,WPS231,WPS431 - - # Import ignores for README lint - .snippets/*:F403,F405,WPS347,WPS421,S106,WPS237 - -ignore = - # This project uses google style docstring - RST, - # Upper-case constant in class - WPS115, - # Too many module members - WPS202, - # Too many arguments - WPS211, - # f-strings - WPS305, - # Class without base class - WPS306, - # Implicit string concatenation - WPS326, - # Walrus operator - WPS332, - # Explicit string concatenation - WPS336, - # Module docstring - D100, - # Class docstring - D101, - # Method docstring - D102, - # Function docstring - D103, - # Package docstring - D104, - # Magic method docstring - D105, - # Nested class docstring - D106, - # __init__ docstring - D107, - # Allow empty line after docstring - D202, - # Line break before binary operator - W503, - # Too many methods - WPS214, - # Too many imports - WPS201, - # Overused expression - WPS204, - # Too many local vars - WPS210, - # Too many await expressions - WPS217, - # Too many imported names from module - WPS235, - # Multiple conditions - WPS337, - # Nested imports (often used with ImportError) - WPS433, - # Forbidden `@staticmethod` - WPS602, - # Allow certain dunder methods - WPS603, - # Forbidden `assert` - S101, - E704, - - [tool:pytest] testpaths = tests diff --git a/tests/client/chats_api/factories.py b/tests/client/chats_api/factories.py new file mode 100644 index 00000000..df64dae6 --- /dev/null +++ b/tests/client/chats_api/factories.py @@ -0,0 +1,72 @@ +from datetime import datetime +from uuid import UUID + +from factory.base import Factory, DictFactory +from factory.declarations import LazyFunction, SubFactory + +from pybotx import ChatInfo, ChatInfoMember, ChatTypes, UserKinds + + +class ChatInfoMemberFactory(Factory[ChatInfoMember]): + class Meta: + model = ChatInfoMember + + is_admin = False + huid = LazyFunction(lambda: UUID("705df263-6bfd-536a-9d51-13524afaab5c")) # type: ignore[no-untyped-call] + kind = UserKinds.BOT + + +class ChatInfoFactory(Factory[ChatInfo]): + class Meta: + model = ChatInfo + + chat_type = ChatTypes.GROUP_CHAT + creator_id = LazyFunction(lambda: UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364")) # type: ignore[no-untyped-call] + description = "Desc" + chat_id = LazyFunction(lambda: UUID("740cf331-d833-5250-b5a5-5b5cbc697ff5")) # type: ignore[no-untyped-call] + created_at = LazyFunction( + lambda: datetime.fromisoformat("2019-08-29T11:22:48.358586Z") + ) # type: ignore[no-untyped-call] + members = LazyFunction( + lambda: [ + ChatInfoMemberFactory( # type: ignore[no-untyped-call] + is_admin=True, + huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + kind=UserKinds.RTS_USER, + ), + ChatInfoMemberFactory(), # type: ignore[no-untyped-call] + ] + ) + name = "Chat Name" + shared_history = False + + +class APIChatMemberFactory(DictFactory): + admin = False + user_huid = "705df263-6bfd-536a-9d51-13524afaab5c" + user_kind = "botx" + + +class APIPersonalChatResultFactory(DictFactory): + chat_type = "group_chat" + creator = "6fafda2c-6505-57a5-a088-25ea5d1d0364" + description = "Desc" + group_chat_id = "740cf331-d833-5250-b5a5-5b5cbc697ff5" + inserted_at = "2019-08-29T11:22:48.358586Z" + members = LazyFunction( + lambda: [ + APIChatMemberFactory( # type: ignore[no-untyped-call] + admin=True, + user_huid="6fafda2c-6505-57a5-a088-25ea5d1d0364", + user_kind="user", + ), + APIChatMemberFactory(), # type: ignore[no-untyped-call] + ] + ) + name = "Chat Name" + shared_history = False + + +class APIPersonalChatResponseFactory(DictFactory): + status = "ok" + result = SubFactory(APIPersonalChatResultFactory) # type: ignore[no-untyped-call] diff --git a/tests/client/chats_api/test_chat_info_validation.py b/tests/client/chats_api/test_chat_info_validation.py new file mode 100644 index 00000000..86d38ed3 --- /dev/null +++ b/tests/client/chats_api/test_chat_info_validation.py @@ -0,0 +1,35 @@ +from uuid import UUID + +from pybotx.client.chats_api.chat_info import ( + BotXAPIChatInfoResult, + BotXAPIChatInfoMember, +) +from pybotx.models.enums import APIUserKinds + + +class MockValidationInfo: + """Mock class for ValidationInfo protocol.""" + + def __init__(self, field_name: str) -> None: + self.field_name = field_name + + +def test_validate_members_non_dict() -> None: + # - Arrange - + # Create a non-dict member + non_dict_member = BotXAPIChatInfoMember( + admin=True, + user_huid=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + user_kind=APIUserKinds.BOTX, + ) + + # - Act - + # Call validate_members with a list containing a non-dict item + result = BotXAPIChatInfoResult.validate_members( + [non_dict_member], + MockValidationInfo(field_name="members"), + ) + + # - Assert - + # Verify that the non-dict item is included in the result + assert non_dict_member in result diff --git a/tests/client/chats_api/test_create_chat.py b/tests/client/chats_api/test_create_chat.py index 10bc5f88..4abca1d9 100644 --- a/tests/client/chats_api/test_create_chat.py +++ b/tests/client/chats_api/test_create_chat.py @@ -37,6 +37,7 @@ async def test__create_chat__bot_have_no_permissions_raised( "description": None, "chat_type": "group_chat", "members": [], + "avatar": None, }, ).mock( return_value=httpx.Response( @@ -84,6 +85,7 @@ async def test__create_chat__botx_error_raised( "description": None, "chat_type": "group_chat", "members": [], + "avatar": None, }, ).mock( return_value=httpx.Response( @@ -130,6 +132,7 @@ async def test__create_chat__maximum_filled_succeed( "chat_type": "group_chat", "members": ["2fc83441-366a-49ba-81fc-6c39f065bb58"], "shared_history": True, + "avatar": None, }, ).mock( return_value=httpx.Response( @@ -157,3 +160,107 @@ async def test__create_chat__maximum_filled_succeed( # - Assert - assert created_chat_id == UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa") assert endpoint.called + + +def test__create_chat_payload__invalid_avatar_non_data_url_error() -> None: + """Test that BotXAPICreateChatRequestPayload validator raises ValueError for non-data URL avatar.""" + from pybotx.client.chats_api.create_chat import BotXAPICreateChatRequestPayload + + # Test the validator directly since UnverifiedPayloadBaseModel bypasses validation + # The validator is a classmethod that expects (cls, value) + with pytest.raises(ValueError, match="Avatar must be a data URL \\(RFC2397\\)"): + BotXAPICreateChatRequestPayload._validate_avatar("invalid-url") + + +def test__create_chat_payload__invalid_avatar_bad_rfc2397_format_error() -> None: + """Test that BotXAPICreateChatRequestPayload validator raises ValueError for invalid RFC2397 format.""" + from pybotx.client.chats_api.create_chat import BotXAPICreateChatRequestPayload + + # Test the validator directly since UnverifiedPayloadBaseModel bypasses validation + with pytest.raises(ValueError, match="Invalid data URL format"): + BotXAPICreateChatRequestPayload._validate_avatar("data:invalid-format") + + +def test__create_chat_payload__avatar_validator_with_none() -> None: + """Test that BotXAPICreateChatRequestPayload avatar validator handles None correctly.""" + from pybotx.client.chats_api.create_chat import BotXAPICreateChatRequestPayload + + # Test the validator directly with None value + result = BotXAPICreateChatRequestPayload._validate_avatar(None) + assert result is None + + +def test__create_chat_payload__avatar_validator_with_valid_data_url() -> None: + """Test that BotXAPICreateChatRequestPayload avatar validator handles valid data URL correctly.""" + from pybotx.client.chats_api.create_chat import BotXAPICreateChatRequestPayload + + valid_avatar = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + result = BotXAPICreateChatRequestPayload._validate_avatar(valid_avatar) + assert result == valid_avatar + + +def test__create_chat_payload__convert_chat_type_validator() -> None: + """Test that BotXAPICreateChatRequestPayload converts ChatTypes to APIChatTypes.""" + from pybotx.client.chats_api.create_chat import BotXAPICreateChatRequestPayload + from pybotx.models.enums import ChatTypes, APIChatTypes + + # Test with ChatTypes enum + values = {"chat_type": ChatTypes.GROUP_CHAT} + result = BotXAPICreateChatRequestPayload._convert_chat_type(values) # type: ignore[operator] + assert result["chat_type"] == APIChatTypes.GROUP_CHAT + + # Test with non-ChatTypes 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 + + # Test with missing chat_type key + values = {"name": "test"} # type: ignore[dict-item] + result = BotXAPICreateChatRequestPayload._convert_chat_type(values) # type: ignore[operator] + 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"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - 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, + ) + + # - Assert - + assert created_chat_id == UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa") + assert endpoint.called diff --git a/tests/client/chats_api/test_create_thread.py b/tests/client/chats_api/test_create_thread.py new file mode 100644 index 00000000..bfb56fbd --- /dev/null +++ b/tests/client/chats_api/test_create_thread.py @@ -0,0 +1,261 @@ +from http import HTTPStatus +from typing import Any, Callable +from uuid import UUID + +import httpx +import pytest +from respx import Route +from respx.router import MockRouter + +from pybotx import ( + Bot, + BotAccountWithSecret, + EventNotFoundError, + HandlerCollector, + ThreadCreationError, + ThreadCreationProhibitedError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + +ENDPOINT = "api/v3/botx/chats/create_thread" + + +@pytest.fixture +def sync_id() -> str: + return "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" + + +@pytest.fixture +def create_mocked_endpoint( + 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, +) -> None: + # - Arrange - + thread_id = "2a8c0d1e-c4d1-4308-b024-6e1a9f4a4b6d" + endpoint = create_mocked_endpoint( + { + "status": "ok", + "result": {"thread_id": thread_id}, + }, + HTTPStatus.OK, + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + created_thread_id = await bot.create_thread( + bot_id=bot_id, + sync_id=UUID(sync_id), + ) + + # - Assert - + assert str(created_thread_id) == thread_id + assert endpoint.called + + +@pytest.mark.parametrize( + "return_json, response_status, expected_exc_type", + ( + ( + { + "status": "error", + "reason": "thread_creation_is_prohibited", + "errors": ["This bot is not allowed to create thread"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "threads_not_enabled", + "errors": ["Threads not enabled for this chat"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "bot_is_not_a_chat_member", + "errors": ["This bot is not a chat member"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "can_not_create_for_personal_chat", + "errors": ["This is personal chat"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "unsupported_event_type", + "errors": ["This event type is unsupported"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "unsupported_chat_type", + "errors": ["This chat type is unsupported"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "thread_already_created", + "errors": ["Thread already created"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "no_access_for_message", + "errors": ["There is no access for this message"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "event_in_stealth_mode", + "errors": ["This event is in stealth mode"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "event_already_deleted", + "errors": ["This event already deleted"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "event_not_found", + "errors": ["Event not found"], + "error_data": {"bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46"}, + }, + HTTPStatus.NOT_FOUND, + EventNotFoundError, + ), + ( + { + "status": "error", + "reason": "|specified reason|", + "errors": ["|specified errors|"], + "error_data": {}, + }, + HTTPStatus.UNPROCESSABLE_ENTITY, + ThreadCreationError, + ), + ( + { + "status": "error", + "reason": None, + "errors": [], + "error_data": {}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "errors": [], + "error_data": {}, + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ( + { + "status": "error", + "reason": "unexpected reason", + "errors": [], + }, + HTTPStatus.FORBIDDEN, + ThreadCreationProhibitedError, + ), + ), +) +async def test__create_thread__botx_error_raised( + create_mocked_endpoint: Callable[[dict[str, Any], int], Route], + sync_id: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + 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]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(expected_exc_type) as exc: + await bot.create_thread( + bot_id=bot_id, + sync_id=UUID(sync_id), + ) + + # - Assert - + assert endpoint.called + + if return_json.get("reason"): + assert return_json["reason"] in str(exc.value) diff --git a/tests/client/chats_api/test_list_chats_validation.py b/tests/client/chats_api/test_list_chats_validation.py new file mode 100644 index 00000000..b4a1935a --- /dev/null +++ b/tests/client/chats_api/test_list_chats_validation.py @@ -0,0 +1,41 @@ +from datetime import datetime +from uuid import UUID + +from pybotx.client.chats_api.list_chats import ( + BotXAPIListChatResponsePayload, + BotXAPIListChatResult, +) +from pybotx.models.enums import APIChatTypes + + +class MockValidationInfo: + """Mock class for ValidationInfo protocol.""" + + def __init__(self, field_name: str) -> None: + self.field_name = field_name + + +def test_validate_result_non_dict() -> None: + # - Arrange - + # Create a non-dict result + non_dict_result = BotXAPIListChatResult( + group_chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + chat_type=APIChatTypes.GROUP_CHAT, + name="Test Chat", + description="Test Description", + members=[UUID("154af49e-5e18-4dca-ad73-4f96b6de63fa")], + inserted_at=datetime.now(), + updated_at=datetime.now(), + shared_history=True, + ) + + # - Act - + # Call validate_result with a list containing a non-dict item + result = BotXAPIListChatResponsePayload.validate_result( + [non_dict_result], + MockValidationInfo(field_name="result"), + ) + + # - Assert - + # Verify that the non-dict item is included in the result + assert non_dict_result in result diff --git a/tests/client/chats_api/test_personal_chat.py b/tests/client/chats_api/test_personal_chat.py new file mode 100644 index 00000000..99449007 --- /dev/null +++ b/tests/client/chats_api/test_personal_chat.py @@ -0,0 +1,162 @@ +# tests/client/chats_api/test_personal_chat.py + +from datetime import datetime as dt +from http import HTTPStatus +from typing import Callable, Any +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 tests.client.chats_api.factories import ( + APIPersonalChatResponseFactory, + ChatInfoFactory, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__personal_chat__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/v1/botx/chats/personal", + headers={"Authorization": "Bearer token"}, + 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]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatNotFoundError) 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) + assert endpoint.called + + +async def test__personal_chat__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + datetime_formatter: Callable[[str], dt], + bot_account: BotAccountWithSecret, +) -> 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"}, + 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]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + chat_info = await bot.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] + + diff = DeepDiff(chat_info, expected_chat_info) + assert diff == {}, diff + + assert endpoint.called + + +async def test__personal_chat__skipped_members( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + datetime_formatter: Callable[[str], dt], + bot_account: BotAccountWithSecret, + loguru_caplog: pytest.LogCaptureFixture, +) -> None: + # - Arrange - + api_response: Any = APIPersonalChatResponseFactory() # type: ignore[no-untyped-call] + # Add an unsupported user type to the members list + api_response["result"]["members"].append( + { + "admin": "not-a-bool", + "user_huid": "5f5c9b04-f7d2-45a6-b36b-0123456789ab", + "user_kind": "unsupported_user_type", + } + ) + + endpoint = respx_mock.get( + f"https://{host}/api/v1/botx/chats/personal", + headers={"Authorization": "Bearer token"}, + 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]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + chat_info = await bot.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] + + diff = DeepDiff(chat_info, expected_chat_info) + assert diff == {}, diff + + 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 new file mode 100644 index 00000000..54254027 --- /dev/null +++ b/tests/client/chats_api/test_personal_chat_additional.py @@ -0,0 +1,99 @@ +import uuid +from datetime import datetime +from typing import Any, Dict +from uuid import UUID + + +import pybotx.client.chats_api.personal_chat as personal_chat_module +from pybotx.client.chats_api.personal_chat import ( + BotXAPIPersonalChatRequestPayload, + BotXAPIPersonalChatResult, + BotXAPIPersonalChatMember, + BotXAPIPersonalChatResponsePayload, +) +from pybotx.models.enums import APIUserKinds, APIChatTypes + + +def test_request_payload_as_query_params_returns_string_uuid() -> None: + """Проверяем, что as_query_params сериализует UUID в строку.""" + huid = uuid.uuid4() + payload = BotXAPIPersonalChatRequestPayload.from_domain(huid) + params = payload.as_query_params() + assert isinstance(params["user_huid"], str) + assert params["user_huid"] == str(huid) + + +def test_parse_members_various_types() -> None: + """Проверяем все ветки _parse_members: dict → модель, готовый экземпляр и неизвестный тип.""" + uid = uuid.uuid4() + + dict_valid: Dict[str, Any] = { + "admin": True, + "user_huid": str(uid), + "user_kind": APIUserKinds.USER.value, + } + member_instance = BotXAPIPersonalChatMember( + admin=False, user_huid=uid, user_kind=APIUserKinds.USER + ) + unknown_item: Any = 12345 + + parsed = BotXAPIPersonalChatResult._parse_members( + [dict_valid, member_instance, unknown_item] + ) + + assert len(parsed) == 2 + assert isinstance(parsed[0], BotXAPIPersonalChatMember) + assert parsed[1] is member_instance + + +def test_to_domain_handles_conversion_error(monkeypatch: Any) -> None: + """Если convert_user_kind_to_domain падает — to_domain игнорирует участника.""" + uid = UUID("00000000-0000-0000-0000-000000000001") + member = BotXAPIPersonalChatMember( + admin=True, user_huid=uid, user_kind=APIUserKinds.USER + ) + result = BotXAPIPersonalChatResult( + chat_type=APIChatTypes.CHAT, + creator=None, + description=None, + group_chat_id=uid, + inserted_at=datetime.utcnow(), + members=[member], + name="test", + shared_history=False, + ) + payload = BotXAPIPersonalChatResponsePayload(status="ok", result=result) + + def fake_convert(kind: Any) -> None: + raise RuntimeError("boom") + + monkeypatch.setattr( + personal_chat_module, + "convert_user_kind_to_domain", + fake_convert, + ) + + chat_info = payload.to_domain() + assert chat_info.members == [] + assert chat_info.chat_id == uid + + +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"} + result = BotXAPIPersonalChatResult( + chat_type=APIChatTypes.CHAT, + creator=None, + description=None, + group_chat_id=uid, + inserted_at=datetime.utcnow(), + members=[unsupported], + name="test", + shared_history=False, + ) + payload = BotXAPIPersonalChatResponsePayload(status="ok", result=result) + + chat_info = payload.to_domain() + assert chat_info.members == [] + assert chat_info.chat_id == uid diff --git a/tests/client/exceptions/test_callbacks.py b/tests/client/exceptions/test_callbacks.py new file mode 100644 index 00000000..fc59bfdd --- /dev/null +++ b/tests/client/exceptions/test_callbacks.py @@ -0,0 +1,26 @@ +import pickle +from uuid import UUID + +from pybotx.client.exceptions.callbacks import BotXMethodFailedCallbackReceivedError +from pybotx.models.method_callbacks import BotAPIMethodFailedCallback + + +def test_botx_method_failed_callback_received_error_reduce() -> None: + # - Arrange - + callback = BotAPIMethodFailedCallback( + sync_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + status="error", + reason="test_reason", + errors=["test_error"], + error_data={}, + ) + error = BotXMethodFailedCallbackReceivedError(callback) + + # - Act - + # Pickle and unpickle the error + pickled_error = pickle.dumps(error) + unpickled_error = pickle.loads(pickled_error) + + # - Assert - + # Verify that the unpickled error has the same callback + assert unpickled_error.callback == error.callback diff --git a/tests/client/exceptions/test_http.py b/tests/client/exceptions/test_http.py new file mode 100644 index 00000000..6631afe3 --- /dev/null +++ b/tests/client/exceptions/test_http.py @@ -0,0 +1,25 @@ +import pickle +import httpx + +from pybotx.client.exceptions.http import InvalidBotXResponseError + + +def test_invalid_botx_response_error_reduce() -> None: + # - Arrange - + request = httpx.Request("GET", "https://example.com") + response = httpx.Response( + status_code=400, + json={"status": "error", "reason": "test_reason", "errors": ["test_error"]}, + request=request, + ) + error = InvalidBotXResponseError(response) + + # - Act - + # Pickle and unpickle the error + pickled_error = pickle.dumps(error) + unpickled_error = pickle.loads(pickled_error) + + # - Assert - + # Verify that the unpickled error has the same response + assert unpickled_error.response.status_code == error.response.status_code + assert unpickled_error.response.json() == error.response.json() diff --git a/tests/client/notifications_api/test_markup.py b/tests/client/notifications_api/test_markup.py index eac8860c..2ecc2cec 100644 --- a/tests/client/notifications_api/test_markup.py +++ b/tests/client/notifications_api/test_markup.py @@ -18,12 +18,12 @@ from pybotx.models.message.markup import ButtonTextAlign pytestmark = [ - pytest.mark.asyncio, pytest.mark.mock_authorization, pytest.mark.usefixtures("respx_mock"), ] +@pytest.mark.asyncio async def test__markup__defaults_filled( respx_mock: MockRouter, host: str, @@ -113,6 +113,7 @@ async def test__markup__defaults_filled( assert endpoint.called +@pytest.mark.asyncio async def test__markup__correctly_built( respx_mock: MockRouter, host: str, @@ -234,6 +235,7 @@ async def test__markup__correctly_built( assert endpoint.called +@pytest.mark.asyncio async def test__markup__color_and_align( respx_mock: MockRouter, host: str, @@ -374,6 +376,7 @@ async def test__markup__color_and_align( assert endpoint.called +@pytest.mark.asyncio async def test__markup__link( respx_mock: MockRouter, host: str, diff --git a/tests/client/users_api/convert_to_datetime.py b/tests/client/users_api/convert_to_datetime.py index 788bd108..a71979c5 100644 --- a/tests/client/users_api/convert_to_datetime.py +++ b/tests/client/users_api/convert_to_datetime.py @@ -4,6 +4,6 @@ def convert_to_datetime(str_datetime: str) -> datetime: datetime_instance = datetime.strptime( str_datetime, - "%Y-%m-%dT%H:%M:%S.%fZ", # noqa: WPS325 + "%Y-%m-%dT%H:%M:%S.%fZ", ) return datetime_instance.replace(tzinfo=timezone.utc) diff --git a/tests/client/users_api/test_users_as_csv.py b/tests/client/users_api/test_users_as_csv.py index 57d776cd..200b62c0 100644 --- a/tests/client/users_api/test_users_as_csv.py +++ b/tests/client/users_api/test_users_as_csv.py @@ -45,7 +45,7 @@ async def test__users_as_csv__no_user_kind_selected_error( # - Act - with pytest.raises(NoUserKindSelectedError) as exc: async with lifespan_wrapper(built_bot) as bot: - async with bot.users_as_csv( # noqa: WPS328 + async with bot.users_as_csv( bot_id=bot_id, cts_user=False, unregistered=False, diff --git a/tests/conftest.py b/tests/conftest.py index 04def898..b2755b68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,6 @@ import jwt import pytest from aiofiles.tempfile import NamedTemporaryFile -from pydantic import BaseModel from respx.router import MockRouter from pybotx import ( @@ -27,6 +26,7 @@ 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 @pytest.fixture(autouse=True) @@ -47,7 +47,7 @@ def prepared_bot_accounts_storage( @pytest.fixture def datetime_formatter() -> Callable[[str], datetime]: - class DateTimeFormatter(BaseModel): # noqa: WPS431 + class DateTimeFormatter(BaseModel): value: datetime def factory(dt_str: str) -> datetime: @@ -149,7 +149,7 @@ def loguru_caplog( ) -> Generator[pytest.LogCaptureFixture, None, None]: # https://github.com/Delgan/loguru/issues/59 - class PropogateHandler(logging.Handler): # noqa: WPS431 + class PropogateHandler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: logging.getLogger(record.name).handle(record) diff --git a/tests/models/test_bot_account.py b/tests/models/test_bot_account.py index f9aff896..3163457c 100644 --- a/tests/models/test_bot_account.py +++ b/tests/models/test_bot_account.py @@ -1,18 +1,22 @@ from uuid import UUID import pytest +from pydantic import ConfigDict from pybotx import BotAccountWithSecret def test__bot_account__could_not_parse_host(bot_id: UUID, cts_url: str) -> None: # - Arrange - - bot_account = BotAccountWithSecret( + # Create a mutable version of BotAccountWithSecret for testing + class MutableBotAccountWithSecret(BotAccountWithSecret): + model_config = ConfigDict(frozen=False) + + bot_account = MutableBotAccountWithSecret( id=bot_id, cts_url=cts_url, secret_key="secret_key", ) - bot_account.Config.allow_mutation = True bot_account.cts_url = "cts_url" # type: ignore # - Assert - diff --git a/tests/models/test_bot_account_with_secret.py b/tests/models/test_bot_account_with_secret.py new file mode 100644 index 00000000..c7d66533 --- /dev/null +++ b/tests/models/test_bot_account_with_secret.py @@ -0,0 +1,42 @@ +import pytest +from pydantic import ValidationError +from unittest.mock import patch +from uuid import UUID + +from pybotx.models.bot_account import BotAccountWithSecret + + +def test_bot_account_with_secret_immutable() -> None: + # - Arrange - + bot_account = BotAccountWithSecret( + id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + cts_url="https://example.com", + secret_key="secret", + ) + + # - Act & Assert - + with pytest.raises(ValidationError) as exc: + bot_account.id = UUID("154af49e-5e18-4dca-ad73-4f96b6de63fa") # type: ignore[misc] + + assert "frozen_instance" in str(exc.value) + + +def test_bot_account_with_secret_not_frozen() -> None: + # - Arrange - + bot_account = BotAccountWithSecret( + id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + cts_url="https://example.com", + secret_key="secret", + ) + + # Mock the condition to force the TypeError + with patch.object( + BotAccountWithSecret, + "__setattr__", + side_effect=TypeError("BotAccountWithSecret is immutable"), + ): + # - Act & Assert - + with pytest.raises(TypeError) as exc: + bot_account.id = UUID("154af49e-5e18-4dca-ad73-4f96b6de63fa") # type: ignore[misc] + + assert "BotAccountWithSecret is immutable" in str(exc.value) diff --git a/tests/models/test_botx_api_markup.py b/tests/models/test_botx_api_markup.py new file mode 100644 index 00000000..206cd724 --- /dev/null +++ b/tests/models/test_botx_api_markup.py @@ -0,0 +1,116 @@ +import json +from typing import Dict, Any, List + + +from pybotx.models.message.markup import ( + BotXAPIMarkup, + BotXAPIButton, + BotXAPIButtonOptions, +) + + +def test_botx_api_markup_json() -> None: + # - Arrange - + markup = BotXAPIMarkup( + root=[ + [ + BotXAPIButton( + command="/test", + label="Test", + data={}, + opts=BotXAPIButtonOptions( + silent=True, + font_color=None, + background_color=None, + align="center", + h_size=None, + show_alert=None, + alert_text=None, + handler=None, + link=None, + ), + ) + ] + ] + ) + + # - Act - + json_str = markup.json() + + # - Assert - + expected_json = json.dumps( + [ + [ + { + "command": "/test", + "label": "Test", + "data": {}, + "opts": { + "silent": True, + "font_color": None, + "background_color": None, + "align": "center", + "h_size": None, + "show_alert": None, + "alert_text": None, + "handler": None, + "link": None, + }, + } + ] + ], + ensure_ascii=False, + ) + assert json_str == expected_json + + +def test_botx_api_markup_jsonable_dict() -> None: + # - Arrange - + markup = BotXAPIMarkup( + root=[ + [ + BotXAPIButton( + command="/test", + label="Test", + data={}, + opts=BotXAPIButtonOptions( + silent=True, + font_color=None, + background_color=None, + align="center", + h_size=None, + show_alert=None, + alert_text=None, + handler=None, + link=None, + ), + ) + ] + ] + ) + + # - Act - + jsonable_dict = markup.jsonable_dict() + + # - Assert - + expected_dict: List[List[Dict[str, Any]]] = [ + [ + { + "command": "/test", + "label": "Test", + "data": {}, + "opts": { + "silent": True, + "font_color": None, + "background_color": None, + "align": "center", + "h_size": None, + "show_alert": None, + "alert_text": None, + "handler": None, + "link": None, + }, + } + ] + ] + assert jsonable_dict == expected_dict diff --git a/tests/models/test_enums.py b/tests/models/test_enums.py new file mode 100644 index 00000000..d7d39fb7 --- /dev/null +++ b/tests/models/test_enums.py @@ -0,0 +1,24 @@ +import pytest +from unittest.mock import Mock + +from pybotx.models.enums import convert_chat_type_from_domain, ChatTypes, APIChatTypes + + +def test__convert_chat_type_from_domain__successful_conversion() -> None: + """Test that convert_chat_type_from_domain successfully converts ChatTypes to APIChatTypes.""" + assert convert_chat_type_from_domain(ChatTypes.PERSONAL_CHAT) == APIChatTypes.CHAT + assert ( + convert_chat_type_from_domain(ChatTypes.GROUP_CHAT) == APIChatTypes.GROUP_CHAT + ) + assert convert_chat_type_from_domain(ChatTypes.CHANNEL) == APIChatTypes.CHANNEL + assert convert_chat_type_from_domain(ChatTypes.THREAD) == APIChatTypes.THREAD + + +def test__convert_chat_type_from_domain__unsupported_chat_type_raises_error() -> None: + """Test that convert_chat_type_from_domain raises NotImplementedError for unsupported chat types.""" + # Create a mock chat type that's not in the mapping + unsupported_chat_type = Mock(spec=ChatTypes) + + # - Act & Assert - + with pytest.raises(NotImplementedError, match="Unsupported chat type"): + convert_chat_type_from_domain(unsupported_chat_type) diff --git a/tests/models/test_incoming_message_validation.py b/tests/models/test_incoming_message_validation.py new file mode 100644 index 00000000..df486b4b --- /dev/null +++ b/tests/models/test_incoming_message_validation.py @@ -0,0 +1,52 @@ +from pybotx.models.message.incoming_message import BotAPIIncomingMessage +from pybotx.models.message.mentions import BotAPIMention + + +class MockValidationInfo: + """Mock class for ValidationInfo protocol.""" + + def __init__(self, field_name: str) -> None: + self.field_name = field_name + + +def test_validate_items_dict() -> None: + # - Arrange - + # Create a dict entity that will be processed + entity_dict = { + "type": "mention", + "data": { + "mention_type": "user", + "mention_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "mention_data": None, + }, + } + + # - Act - + # Call validate_items with a list containing a dict item + result = BotAPIIncomingMessage.validate_items( + [entity_dict], + MockValidationInfo(field_name="entities"), + ) + + # - Assert - + # Verify that the dict item was processed and added to the result list + assert len(result) == 1 + assert isinstance(result[0], BotAPIMention) + + +def test_validate_items_non_dict() -> None: + # - Arrange - + # Create a non-dict entity + non_dict_entity = "not a dict" + + # - Act - + # Call validate_items with a list containing a non-dict item + # The non-dict item will not be processed and not added to the result list + result = BotAPIIncomingMessage.validate_items( + [non_dict_entity], + MockValidationInfo(field_name="entities"), + ) + + # - Assert - + # Verify that the non-dict item was not added to the result list + assert len(result) == 0 diff --git a/tests/test_async_buffer.py b/tests/test_async_buffer.py new file mode 100644 index 00000000..da2985dc --- /dev/null +++ b/tests/test_async_buffer.py @@ -0,0 +1,116 @@ +import os +import pytest +from typing import Optional + +from pybotx.async_buffer import ( + AsyncBufferReadable, + AsyncBufferWritable, + get_file_size, +) + + +class ConcreteAsyncBuffer(AsyncBufferReadable, AsyncBufferWritable): + """Concrete implementation of AsyncBufferReadable and AsyncBufferWritable for testing.""" + + def __init__(self) -> None: + self._buffer = bytearray() + self._position = 0 + + async def seek(self, cursor: int, whence: int = os.SEEK_SET) -> int: + if whence == os.SEEK_SET: + self._position = cursor + elif whence == os.SEEK_CUR: + self._position += cursor + elif whence == os.SEEK_END: + self._position = len(self._buffer) + cursor + return self._position + + async def tell(self) -> int: + return self._position + + async def write(self, content: bytes) -> int: + if self._position == len(self._buffer): + # Append to the end + self._buffer.extend(content) + else: + # Insert or overwrite + self._buffer[self._position : self._position + len(content)] = content + self._position += len(content) + return len(content) + + async def read(self, bytes_to_read: Optional[int] = None) -> bytes: + if bytes_to_read is None: + result = bytes(self._buffer[self._position :]) + self._position = len(self._buffer) + else: + result = bytes( + self._buffer[self._position : self._position + bytes_to_read] + ) + self._position += min(bytes_to_read, len(self._buffer) - self._position) + return result + + +@pytest.mark.asyncio +async def test_async_buffer_base_protocol() -> None: + """Test AsyncBufferBase protocol methods.""" + buffer = ConcreteAsyncBuffer() + + # Test seek and tell + position = await buffer.seek(10) + assert position == 10 + + position = await buffer.tell() + assert position == 10 + + +@pytest.mark.asyncio +async def test_async_buffer_writable_protocol() -> None: + """Test AsyncBufferWritable protocol methods.""" + buffer = ConcreteAsyncBuffer() + + # Test write + content = b"Hello, World!" + bytes_written = await buffer.write(content) + assert bytes_written == len(content) + + # Verify content was written + await buffer.seek(0) + read_content = await buffer.read() + assert read_content == content + + +@pytest.mark.asyncio +async def test_async_buffer_readable_protocol() -> None: + """Test AsyncBufferReadable protocol methods.""" + buffer = ConcreteAsyncBuffer() + + # Write some content + content = b"Hello, World!" + await buffer.write(content) + + # Test read + await buffer.seek(0) + read_content = await buffer.read(5) + assert read_content == b"Hello" + + # Test read with no bytes_to_read + read_content = await buffer.read() + assert read_content == b", World!" + + +@pytest.mark.asyncio +async def test_get_file_size() -> None: + """Test get_file_size function.""" + buffer = ConcreteAsyncBuffer() + + # Write some content + content = b"Hello, World!" + await buffer.write(content) + + # Test get_file_size + file_size = await get_file_size(buffer) + assert file_size == len(content) + + # Verify position is reset to 0 + position = await buffer.tell() + assert position == 0 diff --git a/tests/test_handler_collector.py b/tests/test_handler_collector.py index 727227f0..a9d1d48e 100644 --- a/tests/test_handler_collector.py +++ b/tests/test_handler_collector.py @@ -562,6 +562,6 @@ async def test__handler_collector__sync_smartapp_event__decorator__handler_alrea @collector.sync_smartapp_event async def duplicated_handle_sync_smartapp_event( *_: Any, - ) -> Any: ... # noqa: E704 + ) -> Any: ... assert str(exc.value) == "Handler for sync smartapp event already registered" diff --git a/tests/test_middlewares.py b/tests/test_middlewares.py index f573051b..4c2b42dd 100644 --- a/tests/test_middlewares.py +++ b/tests/test_middlewares.py @@ -35,7 +35,7 @@ async def middleware( bot: Bot, call_next: IncomingMessageHandlerFunc, ) -> None: - middlewares_called_order.append(number) # noqa: F824 + middlewares_called_order.append(number) await call_next(message, bot) @@ -83,7 +83,7 @@ async def middleware( bot: Bot, call_next: IncomingMessageHandlerFunc, ) -> None: - middlewares_called_order.append(number) # noqa: F824 + middlewares_called_order.append(number) await call_next(message, bot) @@ -127,7 +127,7 @@ async def middleware( bot: Bot, call_next: IncomingMessageHandlerFunc, ) -> None: - middlewares_called_order.append(number) # noqa: F824 + middlewares_called_order.append(number) await call_next(message, bot) @@ -174,7 +174,7 @@ async def middleware( bot: Bot, call_next: IncomingMessageHandlerFunc, ) -> None: - nonlocal middlewares_called_order # noqa: F824 + nonlocal middlewares_called_order middlewares_called_order.append(number) await call_next(message, bot)