diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..06a52ca --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 115 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4e2400a..579cb78 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ __pycache__/ .vscode/ .idea/ -.DS_Store \ No newline at end of file +.DS_Store +.coverage \ No newline at end of file diff --git a/Example/app/__init__.py b/Example/app/__init__.py deleted file mode 100644 index 0e9ee85..0000000 --- a/Example/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# This file is intentionally left empty. \ No newline at end of file diff --git a/Example/Dockerfile b/example/Dockerfile similarity index 100% rename from Example/Dockerfile rename to example/Dockerfile diff --git a/example/app/__init__.py b/example/app/__init__.py new file mode 100644 index 0000000..339827c --- /dev/null +++ b/example/app/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty. diff --git a/Example/app/main.py b/example/app/main.py similarity index 85% rename from Example/app/main.py rename to example/app/main.py index 98126a7..ba72bb6 100644 --- a/Example/app/main.py +++ b/example/app/main.py @@ -4,4 +4,4 @@ # # Define a route for the root URL ("/") # @app.get("/") # def hello_world(): -# return {"message": "Hello, World! This is a Python app running in Docker with Uvicorn."} \ No newline at end of file +# return {"message": "Hello, World! This is a Python app running in Docker with Uvicorn."} diff --git a/makefile b/makefile index 486756e..62e0885 100644 --- a/makefile +++ b/makefile @@ -4,6 +4,6 @@ format: lint: poetry run flake8 . test: - poetry run python -m pytest test/ --verbose + poetry run python -m pytest tests/ -v --cov=app diff --git a/poetry.lock b/poetry.lock index 0c28042..e5edb44 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,16 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. - -[[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"] -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"}, -] +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "anyio" @@ -42,7 +30,6 @@ description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" groups = ["main"] -groups = ["main"] 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"}, @@ -84,16 +71,129 @@ jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] [[package]] -name = "click" -version = "8.1.8" -description = "Composable command line interface toolkit" +name = "certifi" +version = "2025.6.15" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, + {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, + {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, + {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, +] + +[[package]] +name = "click" +version = "8.2.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, ] [package.dependencies] @@ -105,14 +205,95 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" -groups = ["main"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "coverage" +version = "7.9.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca"}, + {file = "coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509"}, + {file = "coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b"}, + {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3"}, + {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3"}, + {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5"}, + {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187"}, + {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce"}, + {file = "coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70"}, + {file = "coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe"}, + {file = "coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582"}, + {file = "coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86"}, + {file = "coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed"}, + {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d"}, + {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338"}, + {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875"}, + {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250"}, + {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c"}, + {file = "coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32"}, + {file = "coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125"}, + {file = "coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e"}, + {file = "coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626"}, + {file = "coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb"}, + {file = "coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300"}, + {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8"}, + {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5"}, + {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd"}, + {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898"}, + {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d"}, + {file = "coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74"}, + {file = "coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e"}, + {file = "coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342"}, + {file = "coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631"}, + {file = "coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f"}, + {file = "coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd"}, + {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86"}, + {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43"}, + {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1"}, + {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751"}, + {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67"}, + {file = "coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643"}, + {file = "coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a"}, + {file = "coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d"}, + {file = "coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0"}, + {file = "coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d"}, + {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f"}, + {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029"}, + {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece"}, + {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683"}, + {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f"}, + {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10"}, + {file = "coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363"}, + {file = "coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7"}, + {file = "coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c"}, + {file = "coverage-7.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f424507f57878e424d9a95dc4ead3fbdd72fd201e404e861e465f28ea469951"}, + {file = "coverage-7.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:535fde4001b2783ac80865d90e7cc7798b6b126f4cd8a8c54acfe76804e54e58"}, + {file = "coverage-7.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02532fd3290bb8fa6bec876520842428e2a6ed6c27014eca81b031c2d30e3f71"}, + {file = "coverage-7.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56f5eb308b17bca3bbff810f55ee26d51926d9f89ba92707ee41d3c061257e55"}, + {file = "coverage-7.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfa447506c1a52271f1b0de3f42ea0fa14676052549095e378d5bff1c505ff7b"}, + {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9ca8e220006966b4a7b68e8984a6aee645a0384b0769e829ba60281fe61ec4f7"}, + {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49f1d0788ba5b7ba65933f3a18864117c6506619f5ca80326b478f72acf3f385"}, + {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68cd53aec6f45b8e4724c0950ce86eacb775c6be01ce6e3669fe4f3a21e768ed"}, + {file = "coverage-7.9.1-cp39-cp39-win32.whl", hash = "sha256:95335095b6c7b1cc14c3f3f17d5452ce677e8490d101698562b2ffcacc304c8d"}, + {file = "coverage-7.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:e1b5191d1648acc439b24721caab2fd0c86679d8549ed2c84d5a7ec1bedcc244"}, + {file = "coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514"}, + {file = "coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c"}, + {file = "coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "exceptiongroup" @@ -120,7 +301,7 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, @@ -156,21 +337,79 @@ standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "htt [[package]] name = "flake8" -version = "7.1.2" +version = "7.2.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false -python-versions = ">=3.8.1" -groups = ["main"] +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a"}, - {file = "flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd"}, + {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.12.0,<2.13.0" -pyflakes = ">=3.2.0,<3.3.0" +pycodestyle = ">=2.13.0,<2.14.0" +pyflakes = ">=3.3.0,<3.4.0" + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "idna" @@ -193,7 +432,7 @@ version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -206,7 +445,6 @@ description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.9.0" groups = ["main"] -groups = ["main"] files = [ {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, @@ -223,7 +461,6 @@ description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" groups = ["main"] -groups = ["main"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -231,28 +468,26 @@ files = [ [[package]] name = "mypy-extensions" -version = "1.0.0" +version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false -python-versions = ">=3.5" -groups = ["main"] +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] [[package]] name = "packaging" -version = "24.2" +version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main"] -groups = ["main"] +groups = ["main", "dev"] files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] [[package]] @@ -262,7 +497,6 @@ description = "Utility library for gitignore style pattern matching of file path optional = false python-versions = ">=3.8" groups = ["main"] -groups = ["main"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -270,37 +504,20 @@ files = [ [[package]] name = "platformdirs" -version = "4.3.6" +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.8" -groups = ["main"] -groups = ["main"] -files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, ] [package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] +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" @@ -308,7 +525,7 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -320,186 +537,133 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pycodestyle" -version = "2.12.1" +version = "2.13.0" description = "Python style guide checker" optional = false -python-versions = ">=3.8" -groups = ["main"] +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, - {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, + {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 = "2.11.5" -description = "Data validation using Python type hints" +version = "1.10.22" +description = "Data validation and settings management using python type hints" optional = false -python-versions = ">=3.9" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7"}, - {file = "pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a"}, + {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"}, ] [package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.33.2" -typing-extensions = ">=4.12.2" -typing-inspection = ">=0.4.0" +typing-extensions = ">=4.2.0" [package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] [[package]] -name = "pydantic-core" -version = "2.33.2" -description = "Core functionality for Pydantic validation and serialization" +name = "pyflakes" +version = "3.3.2" +description = "passive checker of Python programs" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, + {file = "pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a"}, + {file = "pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"}, ] -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - [[package]] -name = "pyflakes" -version = "3.2.0" -description = "passive checker of Python programs" +name = "pygments" +version = "2.19.1" +description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["main"] -groups = ["main"] +groups = ["main", "dev"] files = [ - {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, - {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, ] +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pytest" -version = "8.3.5" +version = "8.4.0" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.8" -groups = ["main"] +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, - {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, + {file = "pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e"}, + {file = "pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" pluggy = ">=1.5,<2" +pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" @@ -520,6 +684,48 @@ pytest = ">=8.2,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "pytest-cov" +version = "6.2.1" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, + {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=6.2.5" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "requests" +version = "2.32.4" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + [[package]] name = "sniffio" version = "1.3.1" @@ -556,10 +762,7 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.10\"" -groups = ["main"] -markers = "python_version == \"3.10\"" +groups = ["main", "dev"] files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -594,36 +797,40 @@ files = [ {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +markers = {main = "python_version == \"3.10\"", dev = "python_full_version <= \"3.11.0a6\""} [[package]] name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.14.0" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false -python-versions = ">=3.8" -groups = ["main"] +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, + {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, + {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, ] +markers = {dev = "python_version == \"3.10\""} [[package]] -name = "typing-inspection" -version = "0.4.1" -description = "Runtime typing introspection tools" +name = "urllib3" +version = "2.4.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, + {file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"}, + {file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"}, ] -[package.dependencies] -typing-extensions = ">=4.12.0" +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.1" -lock-version = "2.1" python-versions = "^3.10" -content-hash = "357e38772eabc56599609c62571ae9101ca7ed84f9c8a26ba5e4de279fe5bcec" +content-hash = "dbd221e0ba7cf913f0f3c585f3d96deefbe53b6902263fc2000066af71cdf715" diff --git a/pyproject.toml b/pyproject.toml index d1ac148..374398d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,9 @@ [tool.poetry] name = "durable-execution-engine-sdk" version = "0.1.0" -packages = [{include = "src"}] +description = "" +authors = [] +packages = [{ include = "app", from = "src" }] [tool.poetry.dependencies] python = "^3.10" @@ -11,8 +13,16 @@ flake8 = "^7.1.2" pytest = "^8.3.5" pytest-asyncio = "^0.25.3" fastapi = "^0.115.12" -pydantic = "^2.11.5" +pydantic = "<2.0.0" +requests = "^2.32.4" +httpx = "^0.28.1" + +[tool.poetry.group.dev.dependencies] +pytest-cov = "^6.2.1" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 79 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..370ff6f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,24 @@ +[pytest] +# src directory to Python path so tests can import from app +pythonpath = src + +# Where to look for test files +testpaths = tests + +# Pattern for test files to discover +python_files = test_*.py + +# Pattern for test classes +python_classes = Test + +# Pattern for test functions +python_functions = test_* + +asyncio_mode = auto + +# Command line options to always include +addopts = -ra -q --cov=app + +# Environment variables for tests +env = + DURABLE_ENGINE_BASE_URL=http://test-engine:8000 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e014ea3..0000000 --- a/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -black==25.1.0 ; python_version >= "3.10" and python_version < "4.0" -click==8.1.8 ; python_version >= "3.10" and python_version < "4.0" -colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows" -flake8==7.1.2 ; python_version >= "3.10" and python_version < "4.0" -isort==6.0.1 ; python_version >= "3.10" and python_version < "4.0" -mccabe==0.7.0 ; python_version >= "3.10" and python_version < "4.0" -mypy-extensions==1.0.0 ; python_version >= "3.10" and python_version < "4.0" -packaging==24.2 ; python_version >= "3.10" and python_version < "4.0" -pathspec==0.12.1 ; python_version >= "3.10" and python_version < "4.0" -platformdirs==4.3.6 ; python_version >= "3.10" and python_version < "4.0" -pycodestyle==2.12.1 ; python_version >= "3.10" and python_version < "4.0" -pyflakes==3.2.0 ; python_version >= "3.10" and python_version < "4.0" -tomli==2.2.1 ; python_version >= "3.10" and python_version < "3.11" -typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "3.11" -pytest -pytest-asyncio diff --git a/src/app/__init__.py b/src/app/__init__.py index c3135ca..ce23bf1 100644 --- a/src/app/__init__.py +++ b/src/app/__init__.py @@ -1 +1,23 @@ -__all__ = ["workflow_context", "service", "app"] \ No newline at end of file +from .app import DurableApp +from .service import Service +from .workflow_context import WorkflowContext +from .types import ( + EndureException, + ErrorResponse, + Response, + Log, + LogStatus, + RetryMechanism, +) + +__all__ = [ + "DurableApp", + "Service", + "WorkflowContext", + "EndureException", + "ErrorResponse", + "Response", + "Log", + "LogStatus", + "RetryMechanism", +] diff --git a/src/app/_internal/__init__.py b/src/app/_internal/__init__.py new file mode 100644 index 0000000..0dffed3 --- /dev/null +++ b/src/app/_internal/__init__.py @@ -0,0 +1,15 @@ +""" +Internal implementation details. Do not import directly. +This module is for internal use by app.py, service.py, and workflow_context.py only. +""" +from .internal_client import InternalEndureClient +from .service_registry import ServiceRegistry +from .utils import validate_retention_period +from .workflow import Workflow + +__all__ = [ + "InternalEndureClient", + "ServiceRegistry", + "validate_retention_period", + "Workflow", +] diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py new file mode 100644 index 0000000..fa01686 --- /dev/null +++ b/src/app/_internal/internal_client.py @@ -0,0 +1,104 @@ +import os +import logging +import requests +from ..types import Log, Response + + +class InternalEndureClient: + + _base_url = os.getenv("DURABLE_ENGINE_BASE_URL") + + @classmethod + def send_log(self, execution_id: str, log: Log, action_name: str): + """ + Sends a log message to the Durable Execution Engine. + + Args: + execution_id (str): The ID of the execution context. + log (Log): The log message object to send. + action_name (str): The name of the action. + + Returns: + dict: A dictionary containing the response from the Durable Execution Engine. + + Raises: + ValueError: If DURABLE_ENGINE_BASE_URL is not set or if required parameters are missing. + requests.exceptions.HTTPError: If the request fails. + """ # noqa: E501 + try: + if not self._base_url: + raise ValueError( + "DURABLE_ENGINE_BASE_URL is not set in environment variables." + ) + + if not log or not action_name: + raise ValueError("log and action_name must be provided.") + + url = ( + f"{self._base_url}/executions/{execution_id}/log/{action_name}" + ) + headers = {"Content-Type": "application/json"} + payload = log.to_dict() + response = requests.patch(url, headers=headers, json=payload) + response.raise_for_status() + try: + response_payload = response.json() + except ValueError: + response_payload = {} + response = Response( + status_code=response.status_code, + payload=response_payload, + ) + except requests.exceptions.HTTPError as e: + try: + error_payload = e.response.json() + except Exception: + error_payload = {} + response = Response( + status_code=e.response.status_code, + payload=error_payload, + ) + except requests.exceptions.RequestException as e: + logging.error( + "Engine is unreachable. Aborting retries: {}".format(e) + ) + raise e + return response.to_dict() + + @classmethod + def mark_execution_as_running(self, execution_id: str): + """ + Marks an execution as running in the Durable Execution Engine. + + Args: + execution_id (str): The ID of the execution context. + + Returns: + dict: A dictionary containing the response from the Durable Execution Engine. + + Raises: + ValueError: If DURABLE_ENGINE_BASE_URL is not set or if execution_id is missing. + requests.exceptions.HTTPError: If the request fails. + """ + try: + if not self._base_url: + raise ValueError( + "DURABLE_ENGINE_BASE_URL is not set in environment variables." + ) + url = f"{self._base_url}/executions/{execution_id}/started" + headers = {"Content-Type": "application/json"} + response = requests.patch(url, headers=headers) + response.raise_for_status() + response = Response( + status_code=response.status_code, + ) + except requests.exceptions.HTTPError as e: + response = Response( + status_code=e.response.status_code, + ) + except requests.exceptions.RequestException as e: + logging.error( + "Engine is unreachable. Aborting retries: {}".format(e) + ) + raise e + return response.to_dict() diff --git a/src/app/_internal/service_registry.py b/src/app/_internal/service_registry.py new file mode 100644 index 0000000..cc1e669 --- /dev/null +++ b/src/app/_internal/service_registry.py @@ -0,0 +1,126 @@ +from typing import Dict, List + +from fastapi import APIRouter + +from .workflow import Workflow + + +class ServiceRegistry: + """ + Singleton class for managing durable workflow services and their API routes in FastAPI. + Each service can contain multiple workflows, and each workflow gets its own API endpoint + for execution. + + Attributes: + _instance (ServiceRegistry): The singleton instance of the registry. + _services (Dict[str, List[Workflow]]): Mapping of service names to lists of registered workflows. + _router (APIRouter): FastAPI router containing dynamically registered workflow endpoints. + + Methods: + __new__(cls): Creates or returns the singleton instance. + register_workflow(service_name: str, workflow: Workflow): Registers a workflow under a service. + register_workflow_in_router(service_name: str, workflow: Workflow): Creates an API endpoint for the workflow. + get_services() -> Dict[str, List[Workflow]]: Returns a copy of registered services and workflows. + get_router() -> APIRouter: Returns the router with all workflow endpoints. + clear(): Resets the registry to its initial state. + """ # noqa: E501 + + _instance = None + _services: Dict[str, List[Workflow]] + _router: APIRouter + + def __new__(cls): + """ + Implements the singleton pattern, ensuring only one instance of ServiceRegistry exists. + Creates and initializes the instance if it doesn't exist, otherwise returns the existing instance. + + Returns: + ServiceRegistry: The singleton instance of the registry. + """ + if cls._instance is None: + cls._instance = super(ServiceRegistry, cls).__new__(cls) + cls._instance._services = {} + cls._instance._router = APIRouter() + return cls._instance + + def register_workflow(self, service_name: str, workflow: Workflow): + """ + Registers a workflow under the specified service name. If the service doesn't exist, + it will be created. Prevents duplicate workflow names within the same service. + + Args: + service_name (str): Name of the service to register the workflow under. + workflow (Workflow): The workflow instance to register. + + Raises: + ValueError: If service_name is empty or not a string, + if workflow is None or not a Workflow instance, + or if a workflow with the same name already exists in the service. + """ + if not service_name or not isinstance(service_name, str): + raise ValueError("Service name must be a non-empty string") + if not workflow or not isinstance(workflow, Workflow): + raise ValueError("Workflow must be a valid Workflow instance") + + if service_name not in self._services: + self._services[service_name] = [] + + # checks for duplicate workflow names within the service + if any(w.name == workflow.name for w in self._services[service_name]): + raise ValueError( + f"Workflow with name '{workflow.name}' already exists in service '{service_name}'" + ) + + self._services[service_name].append(workflow) + + def register_workflow_in_router( + self, service_name: str, workflow: Workflow + ): + """ + Creates an API endpoint for the workflow under the given service name. + The endpoint will be available at /execute/{service_name}/{workflow.name} + and will accept POST requests. + + Args: + service_name (str): The service name to use in the endpoint path. + workflow (Workflow): The workflow whose handler will be registered. + """ + self._router.add_api_route( + f"/execute/{service_name}/{workflow.name}", + workflow.get_handler_route(), + methods=["POST"], + ) + + def get_services(self) -> Dict[str, List[Workflow]]: + """ + Returns a shallow copy of the services dictionary to prevent direct modification + of the internal state. + + Returns: + Dict[str, List[Workflow]]: A copy of the mapping between service names and their workflows. + """ + return self._services.copy() + + def get_router(self) -> APIRouter: + """ + Returns the FastAPI router containing all registered workflow endpoints. + + Returns: + APIRouter: The router with all workflow endpoints. + """ + return self._router + + def clear(self): + """ + Resets the registry to its initial state by: + - Clearing all registered services + - Creating a new empty router + - Resetting the singleton instance + - Creating a new instance + + This is primarily useful for testing purposes. + """ + self._services.clear() + self._router = APIRouter() + self.__class__._instance = None + self.__class__() diff --git a/src/app/_internal/utils.py b/src/app/_internal/utils.py new file mode 100644 index 0000000..54c77fc --- /dev/null +++ b/src/app/_internal/utils.py @@ -0,0 +1,16 @@ +def validate_retention_period(retention: int) -> None: + """ + Validate that the retention period is a non-negative integer. + + Args: + retention (int): The retention period in days. + + Raises: + ValueError: If the retention period is not a non-negative integer. + """ + if not isinstance(retention, int): + raise ValueError("Retention period must be an integer.") + if retention < 0: + raise ValueError("Retention period must be a non-negative integer.") + if retention > 30: + raise ValueError("Retention period cannot exceed 30 days.") diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py new file mode 100644 index 0000000..d7ac68d --- /dev/null +++ b/src/app/_internal/workflow.py @@ -0,0 +1,250 @@ +import asyncio +import requests +from typing import Any, Callable, Union, get_type_hints, get_origin, get_args + +from fastapi import Request, status, HTTPException, types +from pydantic import ValidationError + +from app.workflow_context import WorkflowContext + +from .internal_client import InternalEndureClient +from ..types import EndureException + + +class Workflow: + """ + Represents a workflow function that can be executed through a FastAPI endpoint. + + A Workflow encapsulates a Python function and manages its execution through the durable + execution engine. It extracts detailed type information from the function signature, + including support for complex types like Unions, generics, and user-defined classes, + and provides a FastAPI-compatible handler route with comprehensive error handling. + + Attributes: + func (Callable): The workflow function to be executed. + name (str): The name of the workflow (derived from function name). + retention_period (int, optional): Number of days to retain workflow execution history. + input (Any): Structured description of the input type (derived from type hints). + output (Any): Structured description of the return type (derived from type hints). + + Example: + @workflow + def process_data(ctx: WorkflowContext, input: dict[str, int]) -> list[str]: + # This will be wrapped in a Workflow instance with: + # - name: "process_data" + # - input: "dict[str, int]" + # - output: "list[str]" + """ # noqa: E501 + + def __init__(self, func: Callable, retention_period: int = None): + """ + Initialize a new Workflow instance. + + Args: + func (Callable): The workflow function to wrap. Must have exactly two parameters: + - ctx: WorkflowContext - The workflow execution context + - input: Any - The input parameter with optional type annotation + The function can be either synchronous or asynchronous. + retention_period (int, optional): Number of days to retain workflow execution + history and state. Default is None. + + Note: + The function's type hints are used to generate input/output type descriptions, + falling back to Any if no type hints are provided. + """ # noqa: E501 + self.func = func + self.name = func.__name__ + self.retention_period = retention_period + self.input, self.output = self._get_io(func) + + def _get_type_description(self, typ): + """ + Recursively analyze a type annotation and convert it to a structured description. + Handles complex Python type hints including: + - Basic types (int, str, etc.) + - Unions (Union[A, B] or A | B) + - Optional types (Optional[T] or T | None) + - Generic containers (list[T], dict[K, V]) + - User-defined classes (converted to field dictionaries) + + Args: + typ: The type to analyze (can be a type hint, class, or Any) + + Returns: + Union[str, dict]: A string for simple types or a dict for complex types, + representing the structure of the type. + + Example: + >>> _get_type_description(dict[str, list[int]]) + "dict[str, list[int]]" + >>> _get_type_description(Optional[MyClass]) + "MyClass | None" + """ + if typ is Any: + return "Any" + + if typ is type(None): + return "None" + + origin = get_origin(typ) + args = get_args(typ) + + # Union and | (UnionType in Python 3.10+) + if origin in (Union, types.UnionType): + type_names = [self._get_type_description(arg) for arg in args] + return " | ".join( + sorted(type_names, key=lambda x: (x == "None", x)) + ) + + # Case: User-defined class + if hasattr(typ, "__annotations__") and not origin: + fields = getattr(typ, "__annotations__", {}) + return { + name: self._get_type_description(t) + for name, t in fields.items() + } + + # Case: Generic container like list[Class], dict[str, Class], etc. + if origin: + origin_name = ( + origin.__name__ if hasattr(origin, "__name__") else str(origin) + ) + + # Special case: dict[str, SomeClass] + if origin is dict and len(args) == 2: + key_type = self._get_type_description(args[0]) + value_type = self._get_type_description(args[1]) + return f"{origin_name}[{key_type}, {value_type}]" + + # Case: list[SomeClass] or other single-arg generics + elif len(args) == 1: + inner_type = self._get_type_description(args[0]) + return f"{origin_name}[{inner_type}]" + + # Fallback for multi-arg generics like tuple[int, str] + else: + inner_types = [self._get_type_description(arg) for arg in args] + return f"{origin_name}[{', '.join(inner_types)}]" + + # Case: Primitive or normal class + if isinstance(typ, type): + return typ.__name__ + + # Fallback: stringify (removes "typing." prefix) + return str(typ).replace("typing.", "") + + def _get_io(self, func): + """ + Extracts and analyze input and output type information from the function's type hints. + + Args: + func (Callable): The workflow function to analyze. + + Returns: + tuple: A tuple containing (input_type, output_type), where each is either: + - A string representing a simple type (e.g., "int", "str") + - A string representing a complex type (e.g., "list[int]", "dict[str, MyClass]") + - A dict representing the structure of a user-defined class + If type hints aren't provided, "Any" is used as a fallback. + + Note: + Uses _get_type_description to convert raw type hints into structured descriptions. + """ # noqa: E501 + hints = get_type_hints(func) + input_type = hints.get("input", Any) + output_type = hints.get("return", Any) + + return self._get_type_description( + input_type + ), self._get_type_description(output_type) + + def get_handler_route(self): + """ + Generate a FastAPI-compatible route handler for the workflow function. + + Creates an async handler that processes incoming requests, sets up the workflow context, + marks the execution as running, executes the workflow function, and returns the result. + + Returns: + Callable: An async function that can be registered as a FastAPI route handler. + + Request Format: + Expects a JSON object with: + - execution_id (str): Unique identifier for the workflow execution + - input (Any): Input data matching the workflow's input type + + Response Format: + Returns a JSON object with: + - output (Any): The workflow function's return value + + Error Handling: + - HTTP 400: Invalid JSON, missing required fields + - HTTP 422: Input validation errors + - HTTP 500: Internal server errors + - Preserves status codes from EndureException and HTTPException + All errors return JSON with 'error' and optional 'details' fields. + + Notes: + - Supports both synchronous and asynchronous workflow functions + - Automatically marks execution as running via InternalEndureClient + - Converts HTTPException to EndureException for consistent error format + """ # noqa: E501 + + async def handler(request: Request): + try: + body = await request.json() + if not isinstance(body, dict): + raise EndureException( + status_code=status.HTTP_400_BAD_REQUEST, + output={"error": "Request body must be a JSON object"}, + ) + if "execution_id" not in body or "input" not in body: + raise EndureException( + status_code=status.HTTP_400_BAD_REQUEST, + output={ + "error": "Request must include 'execution_id' and 'input' fields" + }, + ) + ctx = WorkflowContext(execution_id=body["execution_id"]) + InternalEndureClient.mark_execution_as_running( + body["execution_id"] + ) + output = self.func(ctx, body["input"]) + if asyncio.iscoroutine(output): + output = await output + return {"output": output} + except ValueError as ve: + if isinstance(ve, ValidationError): + raise EndureException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + output={ + "error": "Validation error", + "details": str(ve), + }, + ) + raise EndureException( + status_code=status.HTTP_400_BAD_REQUEST, + output={"error": "Value error", "details": str(ve)}, + ) + except HTTPException as he: + raise EndureException( + status_code=he.status_code, output={"error": he.detail} + ) + except EndureException as ee: + raise ee + # in case Engine retruned 400/500 from MarkExecutionAsRunning or Send_Log + except requests.exceptions.RequestException as re: + raise EndureException( + status_code=re.status_code, + output={"error": re.detail}, + ) + except Exception as e: + raise EndureException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + output={ + "error": "Internal server error", + "details": str(e), + }, + ) + + return handler diff --git a/src/app/app.py b/src/app/app.py index f25e34f..f7cc349 100644 --- a/src/app/app.py +++ b/src/app/app.py @@ -1,42 +1,85 @@ +from dataclasses import asdict + from fastapi import FastAPI, Request -from src.app.service_registry import ServiceRegistry from fastapi.responses import JSONResponse -from .types import EndureException, ErrorResponse -from dataclasses import asdict + +from app._internal import ( + ServiceRegistry, +) +from app.types import EndureException, ErrorResponse + + class DurableApp: """ - DurableApp is a wrapper class for a FastAPI application that integrates a service discovery endpoint. + A wrapper for FastAPI applications that integrates durable workflow execution capabilities. + + This class provides: + 1. Service discovery via the "/discover" endpoint + 2. Automatic workflow route registration + 3. Centralized error handling for EndureException + Args: app (FastAPI): The FastAPI application instance to be wrapped. + Attributes: app (FastAPI): The FastAPI application instance. - Methods: - _discover(): - Handles GET requests to the "/discover" endpoint. - Returns a dictionary containing all registered services and their workflows. - Each service includes its name and a list of workflows, where each workflow contains: - - name: The workflow's name. - - input: The expected input schema or parameters for the workflow. - - output: The output schema or result type of the workflow. - - idem_retention: The retention policy for idempotency. - Usage: - Instantiate DurableApp with a FastAPI app to automatically register the "/discover" endpoint for service discovery and add the registered services to the FastAPI router. - """ + serviceRegistry (ServiceRegistry): Registry managing workflow services and routes. + + Features: + - Service Discovery: The "/discover" endpoint returns metadata about all registered + services and their workflows, including input/output schemas and retention policies. + - Error Handling: Converts EndureException to consistent JSON responses. + - Route Management: Automatically registers workflow routes from ServiceRegistry. + + Example Response from /discover: + { + "services": [ + { + "name": "data_service", + "workflows": [ + { + "name": "process_data", + "input": "dict[str, int]", + "output": "list[str]", + "idem_retention": 7 + } + ] + } + ] + } + """ # noqa: E501 + def __init__(self, app): + """ + Initialize the DurableApp wrapper. + + Args: + app (FastAPI): The FastAPI application to wrap. + + This method: + 1. Stores the FastAPI app instance + 2. Creates a ServiceRegistry instance + 3. Registers the /discover endpoint + 4. Includes all workflow routes in the app + 5. Sets up EndureException handling + """ self.app: FastAPI = app - serviceRegistry = ServiceRegistry.get_instance() - serviceRegistry.get_router().add_api_route( + self.serviceRegistry = ServiceRegistry() + self.serviceRegistry.get_router().add_api_route( "/discover", self._discover, methods=["GET"], ) - self.app.include_router(serviceRegistry.get_router()) - self.app.add_exception_handler( - EndureException, - self.raise_exception - ) - + self.app.include_router(self.serviceRegistry.get_router()) + self.app.add_exception_handler(EndureException, self.raise_exception) + def _discover(self): + """ + Handle GET requests to the "/discover" endpoint. + + Returns: + dict: A dictionary containing all registered services and their workflows. + """ services = self.serviceRegistry.get_services() return { "services": [ @@ -47,7 +90,7 @@ def _discover(self): "name": workflow.name, "input": workflow.input, "output": workflow.output, - "idem_retention": workflow.retention, + "idem_retention": workflow.retention_period, } for workflow in workflows ], @@ -55,10 +98,38 @@ def _discover(self): for service_name, workflows in services.items() ] } - - async def raise_exception(request: Request, exc: EndureException): + + async def raise_exception( + self, request: Request, exc: EndureException, _=None + ): + """ + FastAPI exception handler for EndureException. + + This handler converts EndureException instances into consistent JSON responses + using the ErrorResponse model. + + Args: + request (Request): The FastAPI request object (required by FastAPI). + exc (EndureException): The exception to handle. Contains: + - status_code: HTTP status code to return + - output: Error details to include in response + _ (Any, optional): Unused parameter that may be provided by FastAPI. + + Returns: + JSONResponse: An error response with: + - status_code: From the exception + - content: Dict from ErrorResponse including the exception's output + + Example: + For an EndureException(status_code=400, output={"error": "Invalid input"}), + returns a 400 response with body: + { + "output": { + "error": "Invalid input" + } + } + """ return JSONResponse( status_code=exc.status_code, - content=asdict(ErrorResponse(output=exc.output)) + content=asdict(ErrorResponse(output=exc.output)), ) - \ No newline at end of file diff --git a/src/app/internal_client.py b/src/app/internal_client.py deleted file mode 100644 index 9935015..0000000 --- a/src/app/internal_client.py +++ /dev/null @@ -1,65 +0,0 @@ -from dataclasses import asdict -import os -from types import Log, Response -import requests - -class InternalEndureClient: - - def __init__(self): - self._base_url = os.getenv("DURABLE_ENGINE_BASE_URL") - - @classmethod - def send_log(self, execution_id: str, log: Log, action_name: str): - """ - Sends a log message to the Durable Execution Engine. - - Args: - execution_id (str): The ID of the execution context. - log (dict): The log message to send. - action_name (str): The name of the action. - """ - if not self._base_url: - raise ValueError("DURABLE_ENGINE_BASE_URL is not set in environment variables.") - - if not execution_id or not log or not action_name: - raise ValueError("execution_id, log, and action_name must be provided.") - - url = f"{self._base_url}/executions/execution/{execution_id}/log/{action_name}" - headers = { - "Content-Type": "application/json" - } - payload = asdict(log) - response = requests.patch(url, headers=headers, json=payload) - response.raise_for_status() - response = Response(status=response.status_code, payload=response.json()) - return response.to_dict() - - @classmethod - def mark_execution_as_running(self, execution_id: str): - """ - Marks an execution as running. - - Args: - execution_id (str): The ID of the execution context. - """ - if not self._base_url: - raise ValueError("DURABLE_ENGINE_BASE_URL is not set in environment variables.") - - if not execution_id: - raise ValueError("execution_id must be provided.") - - url = f"{self._base_url}/executions/{execution_id}/started" - headers = { - "Content-Type": "application/json" - } - response = requests.patch(url, headers=headers) - response.raise_for_status() - response = Response(status=response.status_code, payload=response.json()) - return response.to_dict() - - - - - - - diff --git a/src/app/service.py b/src/app/service.py index 4b92601..a008d98 100644 --- a/src/app/service.py +++ b/src/app/service.py @@ -1,64 +1,136 @@ -from src.app.workflow_context import WorkflowContext -from .service_registry import ServiceRegistry -from .workflow import Workflow -from utils import validate_retention_period , validate_input +from app._internal import ( + ServiceRegistry, + Workflow, + validate_retention_period, +) +from app.workflow_context import WorkflowContext + + +class Service: + """ + A service container for registering and managing durable workflows. + + The Service class acts as a namespace for grouping related workflows and provides + a decorator interface for registering workflow functions. It integrates with the + ServiceRegistry to manage workflow registration and route creation. + + Attributes: + name (str): The unique name of the service, used in API endpoint paths. + registry (ServiceRegistry): The singleton registry instance managing all services. + + Example: + ```python + from app import Service + from app.workflow_context import WorkflowContext + + # Create a service named "order_processing" + service = Service("order_processing") + + # Register a workflow in this service + @service.workflow(retention=30) + def process_order(input: dict, ctx: WorkflowContext): + # workflow implementation + pass + + # The workflow is now available at: + # POST /execute/order_processing/process_order + ``` + """ -class Service : def __init__(self, name: str): + """ + Initialize a new service with the given name. + + Args: + name (str): The unique name for this service. This name will be used: + - In the API endpoint paths (/execute/{name}/{workflow}) + - In the service discovery endpoint response + - For grouping related workflows + + Note: + All services share the same ServiceRegistry instance, ensuring + consistent workflow registration across the application. + """ self.name = name - - def workflow(self,**config): + self.registry = ServiceRegistry() + + def workflow(self, **config): """ Decorator that registers a function as a workflow in the service registry. - - This decorator validates the workflow function signature, creates a Workflow - instance, and registers it with the ServiceRegistry for execution through - the API router. - + + This decorator: + 1. Validates the workflow configuration (e.g., retention period) + 2. Validates the function signature requirements + 3. Creates a Workflow instance to wrap the function + 4. Registers the workflow with the ServiceRegistry + 5. Creates an API endpoint for the workflow + Args: **config: Configuration options for the workflow. - - retention (int, optional): Number of days to retain workflow - execution history and state. Must be a positive integer. - Default: 7 days. - + - retention (int, optional): Number of days to retain workflow + execution history and state. Must be a non-negative integer. + Default: 7 days. + Returns: - callable: The original function (unmodified), which can now be executed - as a registered workflow. - + callable: The original function (unmodified). The function can still + be called directly, but is now also available via HTTP endpoint. + Raises: - ValueError: If the retention period is invalid (not a positive integer). - ValueError: If the workflow function doesn't have exactly two parameters - named 'input' and 'ctx'. - ValueError: If the 'ctx' parameter isn't annotated with WorkflowContext type. - - Requirements: - - Workflow function must have exactly two parameters: 'input' and 'ctx' - - The 'ctx' parameter must be type-annotated as WorkflowContext - + ValueError: If: + - retention period is not a non-negative integer + - function doesn't have exactly two parameters named 'input' and 'ctx' + - 'ctx' parameter isn't annotated with WorkflowContext type + + Function Requirements: + The decorated function must: + 1. Have exactly two parameters: + - input: Any type (with optional type annotation) + - ctx: WorkflowContext (must include type annotation) + 2. Return a value (any type, with optional type annotation) + 3. Can be sync or async + + API Endpoint: + The workflow will be available at: + POST /execute/{service_name}/{workflow_name} + + With request body: + { + "execution_id": str, + "input": Any # Must match the function's input type + } + Example: - from my_app import Service - from src.app.workflow_context import WorkflowContext - - service = Service("my_service") - + ```python + service = Service("data_processing") + @service.workflow(retention=30) - def process_order(input: dict, ctx: WorkflowContext): - Notes: - Once registered, the workflow can be invoked via the API endpoint: - POST /execute/{service_name}/{workflow_name} - """ + def process_data(input: dict[str, int], ctx: WorkflowContext) -> list[str]: + results = [] + for key, value in input.items(): + # Process data... + results.append(f"{key}: {value}") + return results + ``` + """ # noqa: E501 + def decorator(func): - retention_period = config.get('retention', 7) + retention_period = config.get("retention", 7) validate_retention_period(retention_period) - input_keys = func.__code__.co_varnames[:func.__code__.co_argcount] - if ('input' and 'ctx' not in input_keys) or len(input_keys) != 2: - raise ValueError("The workflow function must have an 'input' and 'ctx' argument.") - if not isinstance(func.__annotations__.get('ctx'), WorkflowContext): - raise ValueError("The 'ctx' argument must be of type WorkflowContext.") + input_keys = func.__code__.co_varnames[: func.__code__.co_argcount] + if ( + not ("input" in input_keys and "ctx" in input_keys) + or len(input_keys) != 2 + ): + raise ValueError( + "The workflow function must have an 'input' and 'ctx' argument." + ) + if func.__annotations__.get("ctx") != WorkflowContext: + raise ValueError( + "The 'ctx' argument must be of type WorkflowContext." + ) workflow = Workflow(func, retention_period) - registry = ServiceRegistry.get_instance() - registry.register_workflow(self.name, workflow) - registry.register_workflow_in_router(self.name, workflow) + self.registry.register_workflow(self.name, workflow) + self.registry.register_workflow_in_router(self.name, workflow) return func + return decorator - \ No newline at end of file diff --git a/src/app/service_registry.py b/src/app/service_registry.py deleted file mode 100644 index 5171e70..0000000 --- a/src/app/service_registry.py +++ /dev/null @@ -1,52 +0,0 @@ -from typing import Dict, List -from fastapi import APIRouter -from .workflow import Workflow - -class ServiceRegistry: - """ - Singleton class for managing workflow services and their API routes. - Attributes: - _instance (ServiceRegistry): The singleton instance of the registry. - _services (Dict[str, List[Workflow]]): Mapping of service names to lists of registered workflows. - _router (APIRouter): FastAPI router for dynamically registered workflow routes. - Methods: - __new__(cls): - Ensures only one instance of ServiceRegistry exists (singleton pattern). - register_workflow(service_name: str, workflow: Workflow): - Registers a workflow under the specified service name. - register_workflow_in_router(service_name: str, workflow: Workflow): - Adds an API route for the workflow under the given service name to the router. - get_services() -> Dict[str, List[Workflow]]: - Returns the dictionary of registered services and their workflows. - get_router() -> APIRouter: - Returns the FastAPI router containing all registered workflow routes. - """ - _instance = None - _services: Dict[str, List[Workflow]] - _router: APIRouter - - def __new__(cls): - if cls._instance is None: - cls._instance = super(ServiceRegistry, cls).__new__(cls) - cls._instance._services = {} - cls._instance._router = APIRouter() - return cls._instance - - def register_workflow(self, service_name: str, workflow: Workflow): - if service_name not in self._services: - self._services[service_name] = [] - self._services[service_name].append(workflow) - - def register_workflow_in_router(self, service_name: str, workflow: Workflow): - self._router.add_api_route( - f"execute/{service_name}/{workflow.name}", - workflow.get_handler_route(), - methods=["POST"], - ) - - def get_services(self) -> Dict[str, List[Workflow]]: - return self._services - - def get_router(self) -> APIRouter: - return self._router - diff --git a/src/app/types.py b/src/app/types.py index b47585f..f901a12 100644 --- a/src/app/types.py +++ b/src/app/types.py @@ -1,9 +1,7 @@ - from dataclasses import dataclass, field from datetime import datetime -from typing import Optional from enum import Enum - +from typing import Optional class LogStatus(Enum): @@ -11,37 +9,59 @@ class LogStatus(Enum): COMPLETED = "completed" FAILED = "failed" + class RetryMechanism(Enum): EXPONENTIAL = "exponential" - LINEAR = "linear" - CONSTANT = "constant" + LINEAR = "linear" + CONSTANT = "constant" + + +def log_to_dict(log: "Log") -> dict: + """Convert a Log instance to a dictionary with proper enum handling""" + return { + "status": log.status.value if log.status else None, + "input": log.input, + "output": log.output, + "max_retries": log.max_retries, + "retry_mechanism": ( + log.retry_mechanism.value if log.retry_mechanism else None + ), + "timestamp": log.timestamp.isoformat() if log.timestamp else None, + } + @dataclass -class Log(): +class Log: status: LogStatus - input:Optional[dict] = None + input: Optional[dict] = None output: Optional[dict] = None max_retries: Optional[int] = None - retry_method: Optional[RetryMechanism] = None + retry_mechanism: Optional[RetryMechanism] = None timestamp: datetime = field(default_factory=datetime.now) - + + def to_dict(self): + """Convert Log to a dictionary for JSON serialization""" + return log_to_dict(self) + + @dataclass -class Response(): - status: int +class Response: + status_code: int payload: Optional[dict] = None def to_dict(self): return { - "status": self.status, - "payload": self.payload if self.payload is not None else {} + "status_code": self.status_code, + "payload": (self.payload if self.payload is not None else {}), } + class EndureException(Exception): - def __init__(self,status_code: int , output:any): + def __init__(self, status_code: int, output: any): self.output = output self.status_code = status_code -@dataclass -class ErrorResponse(): - output: any +@dataclass +class ErrorResponse: + output: any diff --git a/src/app/utils.py b/src/app/utils.py deleted file mode 100644 index ec4a5c1..0000000 --- a/src/app/utils.py +++ /dev/null @@ -1,19 +0,0 @@ - - -def validate_retention_period(retention_period: str): - """ - Validate the retention period format. - - Args: - retention_period (str): The retention period string to validate. - - Returns: - None: If the retention period is valid. - """ - if retention_period < 0: - raise ValueError("Retention must be a non-negative integer.") - if retention_period > 30: - raise ValueError("Retention must be less than or equal to 30.") - if not isinstance(retention_period, int): - raise TypeError("Retention must be an integer.") - diff --git a/src/app/workflow.py b/src/app/workflow.py deleted file mode 100644 index de6b41a..0000000 --- a/src/app/workflow.py +++ /dev/null @@ -1,99 +0,0 @@ -from typing import Callable, get_type_hints, Any -from fastapi import Request, HTTPException, status -from pydantic.typing import create_model -import asyncio -from pydantic import ValidationError, create_model -from .workflow_context import WorkflowContext -from .internal_client import InternalEndureClient -from .types import EndureException -class Workflow: - """ - Represents a workflow function that can be executed through a FastAPI endpoint. - - A Workflow encapsulates a function and manages its execution through the durable - execution engine. It extracts type information from the function signature - and provides a FastAPI-compatible handler route. - - Attributes: - func (Callable): The workflow function to be executed. - name (str): The name of the workflow (derived from function name). - retention_period (int, optional): Number of days to retain workflow execution history. - input (Any): The input type of the workflow function (from type hints). - output (Any): The return type of the workflow function (from type hints). - """ - def __init__(self, func: Callable, retention_period: int = None): - """ - Initialize a new Workflow instance. - - Args: - func (Callable): The workflow function to wrap. Must have parameters - 'input' and 'ctx' where 'ctx' is a WorkflowContext. - retention_period (int, optional): Number of days to retain workflow execution - history and state. Default is None. - """ - self.func = func - self.name = func.__name__ - self.retention_period = retention_period - self.input, self.output = self._get_io(func) - - def _get_io(self, func): - """ - Extract input and output type information from the function's type hints. - - Args: - func (Callable): The workflow function to analyze. - - Returns: - tuple: A tuple containing (input_type, output_type). If type hints - aren't provided, Any is used as a fallback. - """ - hints = get_type_hints(func) - return hints.get('input', Any) , hints.get('return', Any) - - def get_handler_route(self): - """ - Generate a FastAPI-compatible route handler for the workflow function. - - Creates a dynamic Pydantic model for request validation and an async handler - that processes incoming requests, sets up the workflow context, executes - the workflow function, and returns the result. - - Returns: - Callable: An async function that can be registered as a FastAPI route handler. - - Raises: - HTTPException: With status code 400 for validation errors or 500 for - other exceptions during handler creation or execution. - - Notes: - - The handler expects a JSON request with 'execution_id' and 'input' fields. - - Both synchronous and asynchronous workflow functions are supported. - """ - FullRequest = create_model( - f"{self.name}Request", - execution_id=(str, ...), - input=(self.input, ...) - ) - async def handler(request: Request): - try: - body = await request.json() - full = FullRequest(**body) - ctx = WorkflowContext(execution_id=full.execution_id) - InternalEndureClient.mark_execution_as_running(self.execution_id) - result = self.func(ctx, full.input) - if asyncio.iscoroutine(result): - result = await result - return {"output": result} - except ValidationError as ve: - print(f"Validation error: {ve}") - raise EndureException( - status_code=status.HTTP_400_BAD_REQUEST, - output={"error": "Validation error", "details": ve.errors()} - ) - except Exception as e: - print(f"Error in workflow handler: {e}") - raise EndureException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - output={"error": "Internal server error", "details": str(e)} - ) - return handler \ No newline at end of file diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 0e535fc..98ca83b 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -1,105 +1,215 @@ -from fastapi import HTTPException -from src.app.internal_client import InternalEndureClient -from src.app.types import Log, LogStatus, RetryMechanism -from fastapi import status -from datetime import datetime import time +import logging +import asyncio +from fastapi import status +import requests +from app._internal.internal_client import ( + InternalEndureClient, +) +from app.types import ( + Log, + LogStatus, + RetryMechanism, + EndureException, +) +from pydantic import ValidationError + + class WorkflowContext: """ Provides context for workflow execution and durable action management. - + This class serves as the bridge between workflow functions and the durable execution - engine. It provides mechanisms for executing actions with durability guarantees, - including automatic retry logic and execution state tracking. - + engine, providing exactly-once execution semantics for workflow actions. It manages: + - Action execution state tracking + - Automatic retries with configurable mechanisms + - Idempotency through result caching + - Communication with the durable execution engine + Attributes: - execution_id (str): The unique identifier for the workflow execution. + execution_id (str): The unique identifier for the workflow execution, + used to correlate all actions within a workflow. + + Example: + ```python + @service.workflow() + def process_order(input: dict, ctx: WorkflowContext): + # Execute an action with retry capability + result = ctx.execute_action( + action=process_payment, + input_data={"amount": 100}, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL_BACKOFF + ) + return result + ``` """ - def __init__(self,execution_id: str): + + def __init__(self, execution_id: str): """ Initialize a new workflow context. - + Args: execution_id (str): The unique identifier for this workflow execution. - Used for tracking and correlating actions. + This ID is used to: + - Track action execution states + - Correlate logs in the durable engine + - Enable idempotent execution + - Manage retries across process restarts """ self.execution_id = execution_id - - def execute_action(self, action: callable, input_data , max_retries: int , retry_mechanism: RetryMechanism) -> any: + + async def execute_action( + self, + action: callable, + input_data, + max_retries: int, + retry_mechanism: RetryMechanism, + ) -> any: """ - Execute an action with durability guarantees. - - This method provides durability by tracking action execution state in the - durable execution engine. It handles automatic retries based on the configured - retry mechanism and ensures exactly-once execution semantics. - - Execution Flow: - 1. Logs the start of the action execution to the engine - 2. Executes the action with the provided input - 3. Logs success/failure of the action - 4. Handles retries according to the retry mechanism if failures occur - 5. Returns the action result or cached result from previous execution - + Execute an action with durability guarantees and automatic retry capabilities. + + This method ensures exactly-once execution semantics by: + 1. Logging action state to the durable engine + 2. Handling idempotency checks + 3. Managing retries with configurable backoff + 4. Preserving execution results + + Execution States: + - STARTED: Initial action execution attempt + - COMPLETED: Successful execution + - FAILED: Failed attempt, may trigger retry + + Retry Behavior: + - Retries are managed by the durable engine + - Sleep duration between retries is determined by retry_mechanism + - Retries continue until success or max_retries is reached + Args: - action (callable): The function to execute. - input_data: The input data to pass to the action function. - max_retries (int): Maximum number of retry attempts for the action. - retry_mechanism (RetryMechanism): Strategy to use for retrying failed actions. - Controls backoff timing and behavior. - + action (callable): The function to execute. Must accept input_data as its only parameter. + input_data: The input to pass to the action function. Will be preserved for retries. + max_retries (int): Maximum number of retry attempts after initial failure. + retry_mechanism (RetryMechanism): Strategy for timing retries: + - LINEAR_BACKOFF + - EXPONENTIAL_BACKOFF + etc. + Returns: - any: The result of the action execution, or the cached result if the - action was already executed successfully. - + any: Either: + - The result of a successful action execution + - The cached result if action was previously completed + - Empty dict if no result available but marked complete + Raises: - RuntimeError: If the action execution fails and cannot be retried, - or if there are issues with the execution engine. - - Notes: - - The method communicates with the durable execution engine to ensure - the action is executed exactly once, even across process restarts. - - If the action was already successfully executed (idempotency), the - cached result is returned without re-executing the action. - - For failed actions, retry timing is controlled by the execution engine - based on the specified retry mechanism. - """ - try: - log = Log(status=LogStatus.STARTED, input=input_data, retry_mechanism=retry_mechanism, max_retries=max_retries) - engine_response = InternalEndureClient.send_log(self.execution_id , log , action.__name__) - if not engine_response: - raise RuntimeError("Failed to mark execution as running.") - status_code = engine_response.status_code - match status_code: - case status.HTTP_201_CREATED | status.HTTP_200_OK: - result = action(input_data) - log = Log(status=LogStatus.COMPLETED, output=result) - InternalEndureClient.send_log(self.execution_id, log, action.__name__) - return result - case status.HTTP_208_ALREADY_REPORTED: - output = engine_response.payload.get("output") - return output if output else {} - - except HTTPException as e: - raise RuntimeError(f"Action execution failed: {str(e)} , status code: {e.status_code}") - - except Exception as e: - log = Log(status=LogStatus.FAILED, output={"error": str(e)}) - engine_response = InternalEndureClient.send_log(self.execution_id, log, action.__name__) - status_code = engine_response.status_code - # Retry logic based on the retry mechanism - while status_code == status.HTTP_200_OK: - try: - retry_at_unix = engine_response.payload.get("retry_at") - if retry_at_unix: - sleep_seconds = retry_at_unix - time.time() - if sleep_seconds > 0: - time.sleep(sleep_seconds) - result = action(input_data) - log = Log(status=LogStatus.COMPLETED, output=result) - InternalEndureClient.send_log(self.execution_id, log, action.__name__) - return result - except Exception as e: - log = Log(status=LogStatus.FAILED, output={"error": str(e)}) - engine_response = InternalEndureClient.send_log(self.execution_id, log, action.__name__) - status_code = engine_response.status_code + RuntimeError: If: + - Engine communication fails + - Action fails and max retries are exhausted + - retry_at time is missing from engine response + - Any unhandled exception during execution + + Communication with Engine: + - Uses InternalEndureClient.send_log for state updates + - Recognizes response codes: + - 201/200: Continue execution + - 208: Return cached result + - Other: Error condition + Example: + ```python + def process_payment(input_data: dict) -> dict: + # Process payment logic + return {"status": "success"} + + # In a workflow function: + result = ctx.execute_action( + action=process_payment, + input_data={"amount": 100}, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL_BACKOFF + ) + ``` + """ + log = Log( + status=LogStatus.STARTED, + input=input_data, + retry_mechanism=retry_mechanism, + max_retries=max_retries, + ) + engine_response = InternalEndureClient.send_log( + self.execution_id, log, action.__name__ + ) + if not engine_response: + raise ValueError( + "Base URL is not set in environment variables or missing required parameters (log or action_name)." + ) + status_code = engine_response["status_code"] + match status_code: + case status.HTTP_201_CREATED | status.HTTP_200_OK: + attempt = 0 + while attempt <= max_retries: + try: + try: + if asyncio.iscoroutinefunction(action): + result = await action(input_data) + else: + result = action(input_data) + except (ValueError, ValidationError) as e: + InternalEndureClient.send_log( + self.execution_id, + Log( + status=LogStatus.FAILED, + output={"error": str(e)}, + ), + action.__name__, + ) + logging.info( + f"WORKFLOW DEBUG: About to raise exception of type {type(e)}: {e}" + ) + raise + log = Log( + status=LogStatus.COMPLETED, + output=result, + ) + InternalEndureClient.send_log( + self.execution_id, + log, + action.__name__, + ) + return result + except ( + ValueError, + ValidationError, + requests.exceptions.RequestException, + ) as e: + logging.debug( + f"DEBUG: Caught exception of type {type(e)}: {e}" + ) + raise + except Exception as e: + if attempt == max_retries: + raise EndureException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + output={ + "error": str( + "Action failed after reaching max retries" + ) + }, + ) + log = Log( + status=LogStatus.FAILED, + output={"error": str(e)}, + ) + engine_response = InternalEndureClient.send_log( + self.execution_id, log, action.__name__ + ) + attempt += 1 + retry_at_unix = engine_response.get("payload", {}).get( + "retry_at" + ) + if retry_at_unix: + sleep_seconds = retry_at_unix - time.time() + if sleep_seconds > 0: + time.sleep(sleep_seconds) + case status.HTTP_208_ALREADY_REPORTED: + output = engine_response.get("payload", {}).get("output") + return output if output else {} diff --git a/test/test_initial.py b/test/test_initial.py deleted file mode 100644 index 603860f..0000000 --- a/test/test_initial.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_dummy(): - assert True \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c7200e6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,105 @@ +import os +import pytest +from unittest.mock import AsyncMock, Mock, patch +from app import ( + DurableApp, + Service, + WorkflowContext, + Log, + LogStatus, + RetryMechanism, + Response, +) +from app._internal import InternalEndureClient, ServiceRegistry + + +def setup_module(module): + """Setup module-level test environment""" + os.environ["DURABLE_ENGINE_BASE_URL"] = "http://test-engine:8000" + InternalEndureClient._base_url = "http://test-engine:8000" + + +@pytest.fixture(autouse=True) +def cleanup_test_env(): + """Setup and cleanup test environment for each test""" + os.environ["DURABLE_ENGINE_BASE_URL"] = "http://test-engine:8000" + InternalEndureClient._base_url = "http://test-engine:8000" + + yield + + if "DURABLE_ENGINE_BASE_URL" in os.environ: + del os.environ["DURABLE_ENGINE_BASE_URL"] + InternalEndureClient._base_url = None + + +# This fixture provides a fresh App instance for each test that needs it +@pytest.fixture +def app(): + app_instance = DurableApp() + yield app_instance + + +@pytest.fixture +def service(): + service_instance = Service() + yield service_instance + + +@pytest.fixture +def workflow_context(): + context = WorkflowContext("test-execution-id") + yield context + + +@pytest.fixture +def mock_internal_client(): + """Mock both requests and InternalEndureClient to prevent real HTTP calls""" + http_response = Mock() + http_response.status_code = 201 + http_response.json.return_value = {} + + # Blocking actual HTTP requests by mocking requests.patch + with patch("requests.patch", return_value=http_response): + with patch( + "app._internal.workflow.InternalEndureClient" + ) as MockClient: + MockClient.send_log = Mock() + MockClient.send_log.side_effect = [ + Response(status_code=201, payload={}).to_dict(), + Response(status_code=200, payload={}).to_dict(), + ] + yield MockClient + + +@pytest.fixture +async def mock_request(): + mock = AsyncMock() + mock.json = AsyncMock() + return mock + + +@pytest.fixture +def sample_action(): + def action(input_data): + return {"result": input_data} + + return action + + +@pytest.fixture +def sample_log(): + return Log( + status=LogStatus.STARTED, + input={"test": "data"}, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + + +@pytest.fixture(autouse=True) +def clear_registry(): + """Clear the registry before each test""" + registry = ServiceRegistry() + registry.clear() + yield + registry.clear() diff --git a/tests/internal/__init__.py b/tests/internal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/internal/test_internal_client.py b/tests/internal/test_internal_client.py new file mode 100644 index 0000000..b7aa0de --- /dev/null +++ b/tests/internal/test_internal_client.py @@ -0,0 +1,142 @@ +import os +import pytest +from unittest.mock import patch +from fastapi import status + + +from app._internal.internal_client import InternalEndureClient +from app.types import Log, LogStatus, Response + + +class TestInternalClient: + + @pytest.fixture + def mock_response(self): + return Response( + status_code=status.HTTP_201_CREATED, + payload={"message": "Log sent successfully"}, + ) + + def test_send_log_success(self, sample_log, mock_response): + """Test successful log sending with proper response handling""" + with patch("requests.patch") as mock_patch: + mock_patch.return_value.status_code = status.HTTP_201_CREATED + mock_patch.return_value.json.return_value = mock_response.payload + + result = InternalEndureClient.send_log( + execution_id="test-execution-id", + log=sample_log, + action_name="test_action", + ) + + mock_patch.assert_called_once() + call_args = mock_patch.call_args + assert ( + call_args[0][0] + == f"{InternalEndureClient._base_url}/executions/test-execution-id/log/test_action" + ) + assert call_args[1]["headers"] == { + "Content-Type": "application/json" + } + assert call_args[1]["json"] == sample_log.to_dict() + + assert result["status_code"] == status.HTTP_201_CREATED + assert result["payload"] == mock_response.payload + + def test_send_log_missing_env_var(self): + """Test error handling when DURABLE_ENGINE_BASE_URL is not set""" + # temporarily remove the environment variable for this test + original_url = os.environ.pop("DURABLE_ENGINE_BASE_URL", None) + original_base_url = InternalEndureClient._base_url + InternalEndureClient._base_url = None + + try: + with pytest.raises(ValueError) as exc_info: + InternalEndureClient.send_log( + execution_id="test-execution-id", + log=Log(status=LogStatus.STARTED), + action_name="test_action", + ) + assert "DURABLE_ENGINE_BASE_URL is not set" in str(exc_info.value) + finally: + # restoring the environment variable and base_url + if original_url: + os.environ["DURABLE_ENGINE_BASE_URL"] = original_url + InternalEndureClient._base_url = original_base_url + + def test_send_log_invalid_inputs(self): + """Test error handling for invalid input parameters""" + # none log + with pytest.raises(ValueError) as exc_info: + InternalEndureClient.send_log( + execution_id="test-execution-id", + log=None, + action_name="test_action", + ) + assert "log and action_name must be provided" in str(exc_info.value) + + # empty action_name + with pytest.raises(ValueError) as exc_info: + InternalEndureClient.send_log( + execution_id="test-execution-id", + log=Log(status=LogStatus.STARTED), + action_name="", + ) + assert "log and action_name must be provided" in str(exc_info.value) + + def test_send_log_http_error(self, sample_log): + """Test handling of HTTP errors from the engine""" + with patch("requests.patch") as mock_patch: + mock_patch.side_effect = Exception("HTTP Error") + + with pytest.raises(Exception) as exc_info: + InternalEndureClient.send_log( + execution_id="test-execution-id", + log=sample_log, + action_name="test_action", + ) + assert "HTTP Error" in str(exc_info.value) + + def test_mark_execution_as_running_success(self): + """Test successful execution marking""" + with patch("requests.patch") as mock_patch: + mock_patch.return_value.status_code = status.HTTP_200_OK + mock_patch.return_value.json.return_value = {} + + result = InternalEndureClient.mark_execution_as_running( + "test-execution-id" + ) + + mock_patch.assert_called_once() + call_args = mock_patch.call_args + assert ( + call_args[0][0] + == f"{InternalEndureClient._base_url}/executions/test-execution-id/started" + ) + assert call_args[1]["headers"] == { + "Content-Type": "application/json" + } + + assert result["status_code"] == status.HTTP_200_OK + assert result["payload"] == {} + + def test_send_log_empty_body(self, sample_log): + """Test handling of 200 OK with empty body (non-JSON).""" + + class MockResponse: + status_code = 200 + + def raise_for_status(self): + pass + + def json(self): + raise ValueError("No JSON") + + with patch("requests.patch", return_value=MockResponse()): + result = InternalEndureClient.send_log( + execution_id="test-execution-id", + log=sample_log, + action_name="test_action", + ) + assert result["status_code"] == 200 + assert result["payload"] == {} diff --git a/tests/internal/test_service_registry.py b/tests/internal/test_service_registry.py new file mode 100644 index 0000000..2ae179b --- /dev/null +++ b/tests/internal/test_service_registry.py @@ -0,0 +1,107 @@ +import pytest +from app._internal.workflow import Workflow, WorkflowContext +from app._internal.service_registry import ServiceRegistry +from typing import Dict +from fastapi import APIRouter + + +class TestServiceRegistry: + @pytest.fixture(autouse=True) + def setup_method(self): + self.registry = ServiceRegistry() + yield + self.registry.clear() + + def test_register_workflow(self): + registry = ServiceRegistry() + service_name = "test_service" + + def mock_workflow(ctx: WorkflowContext, input: Dict) -> Dict: + return {"result": "success"} + + workflow = Workflow(mock_workflow) + + registry.register_workflow(service_name, workflow) + + services = registry.get_services() + assert service_name in services + assert len(services[service_name]) == 1 + assert services[service_name][0].name == mock_workflow.__name__ + + def test_register_workflow_in_router(self): + + registry = ServiceRegistry() + service_name = "test_service" + + def mock_workflow(ctx: WorkflowContext, input: Dict) -> Dict: + return {"result": "success"} + + workflow = Workflow(mock_workflow) + + registry.register_workflow_in_router(service_name, workflow) + + router = registry.get_router() + routes = router.routes + assert len(routes) == 1 + assert routes[0].path == f"/execute/{service_name}/{workflow.name}" + assert "POST" in routes[0].methods + + def test_get_services(self): + + registry = ServiceRegistry() + service_name1 = "service1" + service_name2 = "service2" + + def workflow1(ctx: WorkflowContext, input: Dict) -> Dict: + return {"result": "success1"} + + def workflow2(ctx: WorkflowContext, input: Dict) -> Dict: + return {"result": "success2"} + + w1 = Workflow(workflow1) + w2 = Workflow(workflow2) + + registry.register_workflow(service_name1, w1) + registry.register_workflow(service_name2, w2) + + services = registry.get_services() + assert len(services) == 2 + assert service_name1 in services + assert service_name2 in services + assert services[service_name1][0].name == workflow1.__name__ + assert services[service_name2][0].name == workflow2.__name__ + + def test_get_router(self): + + registry = ServiceRegistry() + router = registry.get_router() + + assert isinstance(router, APIRouter) + assert router == registry._router + + def test_register_invalid_service_name(self): + def workflow(ctx: WorkflowContext, input: Dict) -> Dict: + return {} + + w = Workflow(workflow) + with pytest.raises( + ValueError, match="Service name must be a non-empty string" + ): + self.registry.register_workflow("", w) + with pytest.raises(ValueError): + self.registry.register_workflow(None, w) + + def test_register_duplicate_workflow(self): + service_name = "test_service" + + def workflow(ctx: WorkflowContext, input: Dict) -> Dict: + return {} + + w1 = Workflow(workflow) + w2 = Workflow(workflow) + + self.registry.register_workflow(service_name, w1) + with pytest.raises( + ValueError, match="Workflow with name .* already exists" + ): + self.registry.register_workflow(service_name, w2) diff --git a/tests/internal/test_workflow.py b/tests/internal/test_workflow.py new file mode 100644 index 0000000..74f3c5e --- /dev/null +++ b/tests/internal/test_workflow.py @@ -0,0 +1,346 @@ +import pytest +from unittest.mock import AsyncMock, patch +from typing import Any +from app._internal.workflow import Workflow +from app.types import EndureException +from app.workflow_context import WorkflowContext +from starlette.responses import Response +from fastapi import HTTPException +from pydantic import BaseModel + + +class InputModel: + name: str + age: int + tags: list[str] + + +class OutputModel: + success: bool + data: dict[str, Any] + timestamps: list[int] + + def __init__(self, success=True, data=None, timestamps=None): + self.success = success + self.data = data or {} + self.timestamps = timestamps or [] + + +class TestWorkflow: + + @pytest.fixture + def mock_request(self): + return AsyncMock() + + @staticmethod + def sync_workflow(ctx: WorkflowContext, input: dict) -> str: + return f"Hello, {input['name']}!" + + @staticmethod + def typed_workflow(ctx: WorkflowContext, input: dict) -> dict: + return {"message": input["name"]} + + @staticmethod + async def async_workflow(ctx: WorkflowContext, input: int) -> int: + return input * 2 + + @staticmethod + def list_workflow( + ctx: WorkflowContext, input: list[str] + ) -> tuple[int, str]: + return (1, "test") + + @staticmethod + def complex_workflow( + ctx: WorkflowContext, input: dict[str, list[int]] + ) -> dict[str, Any]: + return {"result": [1, 2, 3]} + + @staticmethod + def class_workflow(ctx: WorkflowContext, input: InputModel) -> OutputModel: + return OutputModel() + + @staticmethod + def nested_class_workflow( + ctx: WorkflowContext, input: InputModel + ) -> dict[str, OutputModel]: + return {"result": OutputModel()} + + class DefaultValueModel: + name: str = "default_name" + count: int = 0 + items: list[str] = [] + options: dict[str, bool] | None = None + + @staticmethod + def default_value_workflow( + ctx: WorkflowContext, input: "TestWorkflow.DefaultValueModel" + ) -> str: + return f"Processed {input.name}" + + def test_workflow_initialization(self): + workflow = Workflow(self.sync_workflow) + assert workflow.name == "sync_workflow" + assert workflow.func == self.sync_workflow + assert workflow.retention_period is None + + # Testing with retention period + workflow_with_retention = Workflow( + self.sync_workflow, retention_period=7 + ) + assert workflow_with_retention.retention_period == 7 + + def test_get_io_types(self): + # Test 1: Basic types (dict) + workflow = Workflow(self.typed_workflow) + assert workflow.input == "dict" + assert workflow.output == "dict" + + # Test 2: Untyped workflow + def untyped_workflow(ctx, input): + return input + + workflow_untyped = Workflow(untyped_workflow) + assert workflow_untyped.input == "Any" + assert workflow_untyped.output == "Any" + + # Test 3: Simple type (int) + workflow_async = Workflow(self.async_workflow) + assert workflow_async.input == "int" + assert workflow_async.output == "int" + + # Test 4: Generic types (list and tuple) + workflow_list = Workflow(self.list_workflow) + assert workflow_list.input == "list[str]" + assert workflow_list.output == "tuple[int, str]" + + # Test 5: Nested generic types + workflow_complex = Workflow(self.complex_workflow) + assert workflow_complex.input == "dict[str, list[int]]" + assert workflow_complex.output == "dict[str, Any]" + + # Test 6: Optional types + def optional_workflow( + ctx: WorkflowContext, input: str | None + ) -> list[int] | None: + return [1, 2, 3] if input else None + + workflow_optional = Workflow(optional_workflow) + assert workflow_optional.input == "str | None" + assert workflow_optional.output == "list[int] | None" + + def test_get_io_types_with_classes(self): + # Test 7: Class types + workflow = Workflow(self.class_workflow) + assert workflow.input == { + "name": "str", + "age": "int", + "tags": "list[str]", + } + assert workflow.output == { + "success": "bool", + "data": "dict[str, Any]", + "timestamps": "list[int]", + } + + # Test 8: Nested class types + workflow_nested = Workflow(self.nested_class_workflow) + assert workflow_nested.input == { + "name": "str", + "age": "int", + "tags": "list[str]", + } + assert ( + workflow_nested.output + == "dict[str, {'success': 'bool', 'data': 'dict[str, Any]', 'timestamps': 'list[int]'}]" + ) + + def test_get_io_types_with_defaults(self): + # Test 9: Default values in class + workflow = Workflow(self.default_value_workflow) + assert workflow.input == { + "name": "str", + "count": "int", + "items": "list[str]", + "options": "dict[str, bool] | None", + } + assert workflow.output == "str" + + ctx = WorkflowContext(execution_id="test-id") + result = self.default_value_workflow(ctx, self.DefaultValueModel()) + assert result == "Processed default_name" + + @pytest.mark.asyncio + async def test_handler_route_successful_execution( + self, mock_request, mock_internal_client + ): + with patch( + "app._internal.workflow.InternalEndureClient.mark_execution_as_running" + ) as mock_mark_running: + workflow = Workflow(self.sync_workflow) + handler = workflow.get_handler_route() + + execution_id = "test-execution-id" + input_data = {"name": "Farah", "age": 30} + mock_request.json.return_value = { + "execution_id": execution_id, + "input": input_data, + } + + result = await handler(mock_request) + + assert result == {"output": "Hello, Farah!"} + mock_mark_running.assert_called_once_with(execution_id) + + @pytest.mark.asyncio + async def test_handler_route_async_workflow( + self, mock_request, mock_internal_client + ): + with patch( + "app._internal.workflow.InternalEndureClient.mark_execution_as_running" + ) as mock_mark_running: + mock_mark_running.return_value = Response(status_code=200) + workflow = Workflow(self.async_workflow) + handler = workflow.get_handler_route() + + mock_request.json.return_value = { + "execution_id": "test-execution-id", + "input": 5, + } + + result = await handler(mock_request) + + assert result == {"output": 10} + mock_mark_running.assert_called_once_with("test-execution-id") + + @pytest.mark.asyncio + async def test_handler_route_execution_error(self, mock_request): + def failing_workflow(ctx: WorkflowContext, input: Any): + raise ValueError("Workflow execution failed") + + workflow = Workflow(failing_workflow) + handler = workflow.get_handler_route() + + mock_request.json.return_value = { + "execution_id": "test-execution-id", + "input": "test-input", + } + + with patch( + "app._internal.workflow.InternalEndureClient.mark_execution_as_running" + ) as mock_mark_running: + mock_mark_running.return_value = None + with pytest.raises(EndureException) as exc_info: + await handler(mock_request) + + assert exc_info.value.status_code == 400 + assert exc_info.value.output["error"] == "Value error" + assert ( + "Workflow execution failed" in exc_info.value.output["details"] + ) + + @pytest.mark.asyncio + async def test_missing_required_fields(self, mock_request): + """Test handling of requests missing required fields.""" + workflow = Workflow(self.sync_workflow) + handler = workflow.get_handler_route() + + mock_request.json.return_value = {} + + with pytest.raises(EndureException) as exc_info: + await handler(mock_request) + + assert exc_info.value.status_code == 400 + assert ( + exc_info.value.output["error"] + == "Request must include 'execution_id' and 'input' fields" + ) + + @pytest.mark.asyncio + async def test_invalid_input_type(self, mock_request): + """Test handling of invalid input type for workflow.""" + workflow = Workflow(self.sync_workflow) + handler = workflow.get_handler_route() + + with patch( + "app._internal.workflow.InternalEndureClient.mark_execution_as_running" + ) as mock_mark_running: + mock_mark_running.return_value = None + mock_request.json.return_value = { + "execution_id": "test-id", + "input": 123, # Invalid input type for sync_workflow (expects dict) + } + + with pytest.raises(EndureException) as exc_info: + await handler(mock_request) + + assert exc_info.value.status_code == 500 + assert "'int' object is not subscriptable" == str( + exc_info.value.output["details"] + ) + + @pytest.mark.asyncio + async def test_malformed_json(self, mock_request): + """Test handling of malformed JSON in request.""" + workflow = Workflow(self.sync_workflow) + handler = workflow.get_handler_route() + mock_request.json.side_effect = ValueError("Invalid JSON format") + with pytest.raises(EndureException) as exc_info: + await handler(mock_request) + assert exc_info.value.status_code == 400 + assert exc_info.value.output["error"] == "Value error" + + @pytest.mark.asyncio + async def test_workflow_http_exception(self, mock_request): + """Test handling of HTTPException raised from within workflow.""" + + async def failing_workflow(ctx: WorkflowContext, input: dict) -> str: + raise HTTPException(status_code=403, detail="Custom error message") + + workflow = Workflow(failing_workflow) + handler = workflow.get_handler_route() + + with patch( + "app._internal.workflow.InternalEndureClient.mark_execution_as_running" + ) as mock_mark_running: + mock_mark_running.return_value = None + mock_request.json.return_value = { + "execution_id": "test-id", + "input": {}, + } + + with pytest.raises(EndureException) as exc_info: + await handler(mock_request) + + assert exc_info.value.status_code == 403 + assert exc_info.value.output["error"] == "Custom error message" + + @pytest.mark.asyncio + async def test_workflow_validation_exception(self, mock_request): + """Test handling of validation exceptions raised from within workflow.""" + + class TestModel(BaseModel): + required_field: str + + async def failing_workflow(ctx: WorkflowContext, input: dict) -> str: + # This will raise a ValidationError because required_field is missing + TestModel(**input) + return "should not reach here" + + workflow = Workflow(failing_workflow) + handler = workflow.get_handler_route() + + with patch( + "app._internal.workflow.InternalEndureClient.mark_execution_as_running" + ) as mock_mark_running: + mock_mark_running.return_value = None + mock_request.json.return_value = { + "execution_id": "test-id", + "input": {}, + } + + with pytest.raises(EndureException) as exc_info: + await handler(mock_request) + + assert exc_info.value.status_code == 422 + assert "validation error" in exc_info.value.output["error"].lower() diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..6ea29d0 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,139 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from unittest.mock import patch + +from app import DurableApp, Service, WorkflowContext, EndureException +from app._internal import ServiceRegistry + + +class TestApp: + @pytest.fixture(autouse=True) + def setup(self): + ServiceRegistry().clear() + self.app = FastAPI() + self.client = TestClient(self.app) + + self.mark_running_patcher = patch( + "app._internal.internal_client.InternalEndureClient.mark_execution_as_running" + ) + self.send_log_patcher = patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) + + self.mock_mark_running = self.mark_running_patcher.start() + self.mock_send_log = self.send_log_patcher.start() + self.mock_mark_running.return_value = {"status_code": 200} + self.mock_send_log.return_value = {"status_code": 200} + + yield + + self.mark_running_patcher.stop() + self.send_log_patcher.stop() + self.app = None + self.durable_app = None + self.client = None + + def test_discover_endpoint_returns_correct_format(self): + + test_service = Service("test_service") + + @test_service.workflow(retention=7) + def test_workflow(input: dict, ctx: WorkflowContext): + return {"result": "test"} + + self.durable_app = DurableApp(self.app) + + response = self.client.get("/discover") + assert response.status_code == 200 + + data = response.json() + assert "services" in data + assert len(data["services"]) == 1 + + service = data["services"][0] + assert service["name"] == "test_service" + assert len(service["workflows"]) == 1 + + workflow = service["workflows"][0] + assert workflow["name"] == "test_workflow" + assert workflow["input"] == "dict" + assert workflow["output"] == "Any" + assert workflow["idem_retention"] == 7 + + def test_router_registration(self): + + test_service = Service("test_service") + + @test_service.workflow(retention=7) + def test_workflow(input: dict, ctx: WorkflowContext): + return {"result": "test"} + + # creating DurableApp instance after registering workflows + self.durable_app = DurableApp(self.app) + + response = self.client.post( + "/execute/test_service/test_workflow", + json={"execution_id": "test-123", "input": {"test": "data"}}, + ) + assert response.status_code == 200 + assert response.json() == {"output": {"result": "test"}} + + def test_exception_handling(self): + test_service = Service("test_service") + + @test_service.workflow(retention=7) + def failing_workflow(input: dict, ctx: WorkflowContext): + raise EndureException( + status_code=400, + output={"error": "Test error", "details": "Test details"}, + ) + + self.durable_app = DurableApp(self.app) + + response = self.client.post( + "/execute/test_service/failing_workflow", + json={"execution_id": "test-123", "input": {"test": "data"}}, + ) + assert response.status_code == 400 + assert response.json() == { + "output": {"error": "Test error", "details": "Test details"} + } + + def test_multiple_services_and_workflows(self): + + service1 = Service("service1") + service2 = Service("service2") + + @service1.workflow(retention=7) + def workflow1(input: dict, ctx: WorkflowContext): + return {"result": "workflow1"} + + @service1.workflow(retention=14) + def workflow2(input: dict, ctx: WorkflowContext): + return {"result": "workflow2"} + + @service2.workflow(retention=30) + def workflow3(input: dict, ctx: WorkflowContext): + return {"result": "workflow3"} + + self.durable_app = DurableApp(self.app) + + response = self.client.get("/discover") + assert response.status_code == 200 + + data = response.json() + assert len(data["services"]) == 2 + + service1_data = next( + s for s in data["services"] if s["name"] == "service1" + ) + assert len(service1_data["workflows"]) == 2 + workflow_names = {w["name"] for w in service1_data["workflows"]} + assert workflow_names == {"workflow1", "workflow2"} + + service2_data = next( + s for s in data["services"] if s["name"] == "service2" + ) + assert len(service2_data["workflows"]) == 1 + assert service2_data["workflows"][0]["name"] == "workflow3" diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..2bc5faf --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,168 @@ +import pytest +from app import WorkflowContext, Service +from app._internal import ServiceRegistry, Workflow + + +class TestService: + @pytest.fixture + def service(self): + """Create a test service instance""" + return Service("test_service") + + @pytest.fixture + def valid_workflow(self): + """Create a valid workflow function""" + + def valid_workflow(input: dict, ctx: WorkflowContext): + return {"result": input} + + return valid_workflow + + @pytest.fixture + def invalid_workflow_missing_ctx(self): + """Create an invalid workflow function missing ctx parameter""" + + def invalid_workflow_1(input: dict): + return {"result": input} + + return invalid_workflow_1 + + @pytest.fixture + def invalid_workflow_wrong_ctx_type(self): + """Create an invalid workflow function with wrong ctx type""" + + def invalid_workflow_2(input: any, ctx: dict): + return {"result": input} + + return invalid_workflow_2 + + def test_workflow_decorator_valid_signature(self, service, valid_workflow): + """Test that a workflow with valid signature is properly registered""" + + service.workflow(retention=30)(valid_workflow) + + registry = ServiceRegistry() + services = registry.get_services() + + assert service.name in services + workflow_instances = services[service.name] + assert len(workflow_instances) == 1 + workflow_instance = workflow_instances[0] + assert isinstance(workflow_instance, Workflow) + assert workflow_instance.name == valid_workflow.__name__ + assert workflow_instance.retention_period == 30 + assert workflow_instance.func == valid_workflow + + def test_workflow_decorator_default_retention( + self, service, valid_workflow + ): + """Test that default retention period is set when not specified""" + + service.workflow()(valid_workflow) + + registry = ServiceRegistry() + services = registry.get_services() + + workflow_instances = services[service.name] + assert len(workflow_instances) == 1 + workflow_instance = workflow_instances[0] + assert workflow_instance.retention_period == 7 + + def test_workflow_decorator_invalid_retention( + self, service, valid_workflow + ): + """Test that invalid retention period raises ValueError""" + with pytest.raises(ValueError) as exc_info: + service.workflow(retention=-1)(valid_workflow) + assert "Retention period must be a non-negative integer" in str( + exc_info.value + ) + + def test_workflow_decorator_invalid_signature_missing_ctx( + self, service, invalid_workflow_missing_ctx + ): + """Test that workflow without ctx parameter raises ValueError""" + with pytest.raises(ValueError) as exc_info: + service.workflow()(invalid_workflow_missing_ctx) + assert ( + "The workflow function must have an 'input' and 'ctx' argument" + in str(exc_info.value) + ) + + def test_workflow_decorator_invalid_signature_wrong_ctx_type( + self, service, invalid_workflow_wrong_ctx_type + ): + """Test that workflow with wrong ctx type raises ValueError""" + with pytest.raises(ValueError) as exc_info: + service.workflow()(invalid_workflow_wrong_ctx_type) + assert "The 'ctx' argument must be of type WorkflowContext" in str( + exc_info.value + ) + + def test_workflow_decorator_multiple_workflows(self, service): + """Test registering multiple workflows for the same service""" + + def workflow1(input: dict, ctx: WorkflowContext): + return {"result": input} + + workflow1.__name__ = "test_workflow_1" + + def workflow2(input: dict, ctx: WorkflowContext): + return {"result": input} + + workflow2.__name__ = "test_workflow_2" + + service.workflow(retention=0)(workflow1) + service.workflow(retention=20)(workflow2) + + registry = ServiceRegistry() + services = registry.get_services() + + assert service.name in services + workflow_instances = services[service.name] + assert len(workflow_instances) == 2 + + workflow_info = [ + (w.name, w.retention_period) for w in workflow_instances + ] + assert (workflow1.__name__, 0) in workflow_info + assert (workflow2.__name__, 20) in workflow_info + + def test_workflow_decorator_preserves_function( + self, service, valid_workflow + ): + """Test that the decorator preserves the original function""" + decorated_workflow = service.workflow()(valid_workflow) + + assert decorated_workflow == valid_workflow + + result = decorated_workflow( + {"test": "data"}, WorkflowContext("test-execution-id") + ) + assert result == {"result": {"test": "data"}} + + def test_workflow_decorator_invalid_signature_missing_input(self, service): + """Test that workflow without 'input' parameter raises ValueError""" + + def wf_missing_input(foo: dict, ctx: WorkflowContext): + return {"result": foo} + + with pytest.raises(ValueError) as exc_info: + service.workflow()(wf_missing_input) + assert ( + "The workflow function must have an 'input' and 'ctx' argument" + in str(exc_info.value) + ) + + def test_workflow_decorator_invalid_signature_too_many_args(self, service): + """Test that workflow with too many arguments raises ValueError""" + + def wf_too_many_args(input: dict, ctx: WorkflowContext, extra: int): + return {"result": input} + + with pytest.raises(ValueError) as exc_info: + service.workflow()(wf_too_many_args) + assert ( + "The workflow function must have an 'input' and 'ctx' argument" + in str(exc_info.value) + ) diff --git a/tests/test_workflow_context.py b/tests/test_workflow_context.py new file mode 100644 index 0000000..7e3bf16 --- /dev/null +++ b/tests/test_workflow_context.py @@ -0,0 +1,371 @@ +from app.types import LogStatus, RetryMechanism, Response +import pytest +from unittest.mock import patch +from fastapi import status, HTTPException +import time +from pydantic import ValidationError, BaseModel +import requests +import asyncio + + +@pytest.mark.asyncio +async def test_successful_action_execution(workflow_context, sample_action): + """Test successful execution of an action with proper logging""" + input_data = {"input": "data"} + retry_mechanism = RetryMechanism.EXPONENTIAL + max_retries = 3 + + mock_started_response = Response(status_code=201, payload={}) + mock_completed_response = Response(status_code=200, payload={}) + + with patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) as mock_send_log: + mock_send_log.side_effect = [ + mock_started_response.to_dict(), + mock_completed_response.to_dict(), + ] + + await workflow_context.execute_action( + action=sample_action, + input_data=input_data, + max_retries=max_retries, + retry_mechanism=retry_mechanism, + ) + + assert mock_send_log.call_count == 2 + + # Verifying the STARTED log + started_log_call = mock_send_log.call_args_list[0] + assert started_log_call[0][0] == "test-execution-id" + assert started_log_call[0][1].status == LogStatus.STARTED + assert started_log_call[0][1].input == input_data + assert started_log_call[0][1].retry_mechanism == retry_mechanism + assert started_log_call[0][1].max_retries == max_retries + assert started_log_call[0][2] == sample_action.__name__ + + # Verifying the COMPLETED log + completed_log_call = mock_send_log.call_args_list[1] + assert completed_log_call[0][0] == "test-execution-id" + assert completed_log_call[0][1].status == LogStatus.COMPLETED + assert completed_log_call[0][1].output == {"result": input_data} + assert completed_log_call[0][2] == sample_action.__name__ + + +@pytest.mark.asyncio +async def test_already_executed_action(workflow_context, sample_action): + """Test handling of already executed actions""" + input_data = {"input": "data"} + idempotent_result = {"output": "result"} + + mock_response = Response( + status_code=status.HTTP_208_ALREADY_REPORTED, payload=idempotent_result + ) + with patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) as mock_send_log: + mock_send_log.return_value = mock_response.to_dict() + result = await workflow_context.execute_action( + action=sample_action, + input_data=input_data, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + assert result == idempotent_result["output"] + assert mock_send_log.call_count == 1 + + +@pytest.mark.asyncio +async def test_action_with_retry_success(workflow_context): + """Test action that fails with a generic Exception + (not ValueError/ValidationError) and succeeds after retry.""" + input_data = {"input": "data"} + action_result = {"result": "processed data"} + attempt_count = 0 + + class CustomException(Exception): + pass + + def failing_action(input_data): + nonlocal attempt_count + attempt_count += 1 + if attempt_count == 1: + raise CustomException("First attempt fails") + return action_result + + retry_time = time.time() + mock_responses = [ + Response(status_code=status.HTTP_201_CREATED, payload={}), + Response( + status_code=status.HTTP_200_OK, payload={"retry_at": retry_time} + ), + Response(status_code=status.HTTP_200_OK, payload={}), + ] + with patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) as mock_send_log: + mock_send_log.side_effect = [r.to_dict() for r in mock_responses] + result = await workflow_context.execute_action( + action=failing_action, + input_data=input_data, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + assert result == action_result + assert attempt_count == 2 + assert mock_send_log.call_count == 3 + + +@pytest.mark.asyncio +async def test_action_with_http_exception(workflow_context, sample_action): + """Test that HTTPException from the engine is re-raised immediately (not retried).""" + with patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) as mock_send_log: + mock_send_log.side_effect = HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Bad request" + ) + with pytest.raises(HTTPException) as exc_info: + await workflow_context.execute_action( + action=sample_action, + input_data={"test": "data"}, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert exc_info.value.detail == "Bad request" + + +@pytest.mark.asyncio +async def test_action_exhausts_retries(workflow_context): + """Test that a generic Exception (not ValueError/ValidationError) after all retries raises EndureException.""" + + class CustomException(Exception): + pass + + def failing_action(input_data): + raise CustomException("Always fails") + + retry_time = time.time() + mock_responses = [ + Response(status_code=status.HTTP_201_CREATED, payload={}), + Response( + status_code=status.HTTP_200_OK, payload={"retry_at": retry_time} + ), + Response( + status_code=status.HTTP_200_OK, payload={"retry_at": retry_time} + ), + Response(status_code=status.HTTP_400_BAD_REQUEST, payload={}), + ] + with patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) as mock_send_log: + mock_send_log.side_effect = [r.to_dict() for r in mock_responses] + with pytest.raises(Exception) as exc_info: + await workflow_context.execute_action( + action=failing_action, + input_data={"test": "data"}, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + assert ( + exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + ) + assert ( + exc_info.value.output["error"] + == "Action failed after reaching max retries" + ) + + +@pytest.mark.asyncio +async def test_retry_respects_timing(workflow_context): + """Test that retry mechanism respects the timing specified by the engine.""" + input_data = {"test": "data"} + future_retry_time = time.time() + 5 + + class CustomException(Exception): + pass + + def failing_action(input_data): + raise CustomException("Action fails") + + mock_responses = [ + Response(status_code=status.HTTP_201_CREATED, payload={}), + Response( + status_code=status.HTTP_200_OK, + payload={"retry_at": future_retry_time}, + ), + Response( + status_code=status.HTTP_200_OK, + payload={"retry_at": future_retry_time}, + ), + Response( + status_code=status.HTTP_200_OK, + payload={"retry_at": future_retry_time}, + ), + Response(status_code=status.HTTP_400_BAD_REQUEST, payload={}), + ] + with patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) as mock_send_log: + mock_send_log.side_effect = [r.to_dict() for r in mock_responses] + with patch("time.sleep") as mock_sleep: + try: + await workflow_context.execute_action( + action=failing_action, + input_data=input_data, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + except Exception: + pass + assert mock_sleep.call_count == 3 + sleep_duration = mock_sleep.call_args[0][0] + assert sleep_duration > 0 and sleep_duration <= 5 + assert mock_send_log.call_count == 4 + + +@pytest.mark.asyncio +async def test_action_with_value_error(workflow_context): + """Test that ValueError from the action is re-raised immediately (not retried) and logs FAILED.""" + + def action_raises_value_error(input_data): + raise ValueError("Immediate failure") + + mock_responses = [ + Response(status_code=status.HTTP_201_CREATED, payload={}), + Response(status_code=status.HTTP_200_OK, payload={}), + ] + with patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) as mock_send_log: + mock_send_log.side_effect = [r.to_dict() for r in mock_responses] + with pytest.raises(ValueError): + await workflow_context.execute_action( + action=action_raises_value_error, + input_data={}, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + assert mock_send_log.call_count == 2 + call_args_list = mock_send_log.call_args_list + assert call_args_list[0][0][1].status == LogStatus.STARTED + assert call_args_list[1][0][1].status == LogStatus.FAILED + assert "Immediate failure" in call_args_list[1][0][1].output["error"] + + +@pytest.mark.asyncio +async def test_action_with_validation_error(workflow_context): + """Test that ValidationError from the action is re-raised immediately (not retried) and logs FAILED.""" + + class DummyModel(BaseModel): + x: int + + def action_raises_validation_error(input_data): + raise ValidationError([], model=DummyModel) + + mock_started_response = Response( + status_code=status.HTTP_201_CREATED, payload={} + ) + mock_failed_response = Response(status_code=status.HTTP_200_OK, payload={}) + with patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) as mock_send_log: + mock_send_log.side_effect = [ + mock_started_response.to_dict(), + mock_failed_response.to_dict(), + ] + with pytest.raises(ValidationError): + await workflow_context.execute_action( + action=action_raises_validation_error, + input_data={}, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + assert mock_send_log.call_count == 2 + started_log = mock_send_log.call_args_list[0][0][1] + failed_log = mock_send_log.call_args_list[1][0][1] + assert hasattr(started_log, "status") + assert hasattr(failed_log, "status") + assert started_log.status == LogStatus.STARTED + assert failed_log.status == LogStatus.FAILED + assert "validation error" in failed_log.output["error"].lower() + + +@pytest.mark.asyncio +async def test_action_with_requests_exception(workflow_context): + """Test that requests.exceptions.RequestException + is re-raised immediately (not retried) and only logs STARTED.""" + + def action_raises_requests_exception(input_data): + raise requests.exceptions.RequestException("Request failed") + + mock_response = Response(status_code=status.HTTP_201_CREATED, payload={}) + with patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) as mock_send_log: + mock_send_log.return_value = mock_response.to_dict() + with pytest.raises(requests.exceptions.RequestException): + await workflow_context.execute_action( + action=action_raises_requests_exception, + input_data={}, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + assert mock_send_log.call_count == 1 + call_args_list = mock_send_log.call_args_list + assert call_args_list[0][0][1].status == LogStatus.STARTED + + +@pytest.mark.asyncio +async def test_value_error_in_first_send_log(workflow_context): + """Test that ValueError in the first send_log is raised and not logged as FAILED.""" + + def dummy_action(input_data): + return "should not be called" + + with patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) as mock_send_log: + mock_send_log.side_effect = ValueError("First log error") + with pytest.raises(ValueError) as exc_info: + await workflow_context.execute_action( + action=dummy_action, + input_data={}, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + assert "First log error" in str(exc_info.value) + assert mock_send_log.call_count == 1 + + +@pytest.mark.asyncio +async def test_execute_action_with_async_action(workflow_context): + called = False + + async def async_action(input_data): + nonlocal called + called = True + await asyncio.sleep(0.01) + return {"result": input_data} + + mock_started_response = Response(status_code=201, payload={}) + mock_completed_response = Response(status_code=200, payload={}) + + with patch( + "app._internal.internal_client.InternalEndureClient.send_log" + ) as mock_send_log: + mock_send_log.side_effect = [ + mock_started_response.to_dict(), + mock_completed_response.to_dict(), + ] + + result = await workflow_context.execute_action( + action=async_action, + input_data={"foo": "bar"}, + max_retries=1, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + + assert called is True + assert result == {"result": {"foo": "bar"}}