From 8e2e8dbe2db28d65c4529598ff3dbc21cb6f90c9 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Sat, 14 Jun 2025 16:30:57 +0300 Subject: [PATCH 01/33] adjust project structure --- poetry.lock | 140 ++++---------------- pytest.ini | 18 +++ src/app/__init__.py | 6 +- src/app/_internal/__init__.py | 44 ++++++ src/app/{ => _internal}/internal_client.py | 0 src/app/{ => _internal}/service_registry.py | 0 src/app/{ => _internal}/types.py | 0 src/app/{ => _internal}/utils.py | 0 src/app/{ => _internal}/workflow.py | 0 src/app/app.py | 3 +- src/app/service.py | 4 +- src/app/workflow_context.py | 3 +- tests/__init__.py | 0 tests/conftest.py | 19 +++ tests/internal/__init__.py | 0 tests/test_app.py | 0 tests/test_service.py | 0 tests/test_workflow_context.py | 0 18 files changed, 117 insertions(+), 120 deletions(-) create mode 100644 pytest.ini create mode 100644 src/app/_internal/__init__.py rename src/app/{ => _internal}/internal_client.py (100%) rename src/app/{ => _internal}/service_registry.py (100%) rename src/app/{ => _internal}/types.py (100%) rename src/app/{ => _internal}/utils.py (100%) rename src/app/{ => _internal}/workflow.py (100%) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/internal/__init__.py create mode 100644 tests/test_app.py create mode 100644 tests/test_service.py create mode 100644 tests/test_workflow_context.py diff --git a/poetry.lock b/poetry.lock index 0c28042..e3848c4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,39 +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"}, -] - -[[package]] -name = "anyio" -version = "4.9.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} - -[package.extras] -doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] -trio = ["trio (>=0.26.1)"] +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "black" @@ -133,27 +98,6 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] -[[package]] -name = "fastapi" -version = "0.115.12" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d"}, - {file = "fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681"}, -] - -[package.dependencies] -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.40.0,<0.47.0" -typing-extensions = ">=4.8.0" - -[package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] - [[package]] name = "flake8" version = "7.1.2" @@ -172,21 +116,6 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.12.0,<2.13.0" pyflakes = ">=3.2.0,<3.3.0" -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - [[package]] name = "iniconfig" version = "2.1.0" @@ -478,28 +407,44 @@ files = [ {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, ] +[[package]] +name = "pygments" +version = "2.19.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {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" +python-versions = ">=3.9" groups = ["main"] 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,36 +465,6 @@ pytest = ">=8.2,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "starlette" -version = "0.46.2" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, - {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, -] - -[package.dependencies] -anyio = ">=3.6.2,<5" - -[package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] - [[package]] name = "tomli" version = "2.2.1" @@ -602,6 +517,7 @@ description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" groups = ["main"] +markers = "python_version == \"3.10\"" 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"}, @@ -626,4 +542,4 @@ typing-extensions = ">=4.12.0" lock-version = "2.1" lock-version = "2.1" python-versions = "^3.10" -content-hash = "357e38772eabc56599609c62571ae9101ca7ed84f9c8a26ba5e4de279fe5bcec" +content-hash = "6ca9b0380f82637367dc582967b02f828ec57fac01db53db5a44f5eed0c192b4" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b297415 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,18 @@ +[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_* + +# Command line options to always include +addopts = -ra -q --cov=app \ No newline at end of file diff --git a/src/app/__init__.py b/src/app/__init__.py index c3135ca..2455409 100644 --- a/src/app/__init__.py +++ b/src/app/__init__.py @@ -1 +1,5 @@ -__all__ = ["workflow_context", "service", "app"] \ No newline at end of file +from .app import App +from .service import Service +from .workflow_context import WorkflowContext + +__all__ = ["App", "Service", "WorkflowContext"] \ No newline at end of file diff --git a/src/app/_internal/__init__.py b/src/app/_internal/__init__.py new file mode 100644 index 0000000..01567d7 --- /dev/null +++ b/src/app/_internal/__init__.py @@ -0,0 +1,44 @@ +""" +Internal implementation details. Do not import directly. +This module is for internal use by app.py, service.py, and workflow_context.py only. +""" +import inspect +import sys + +def _check_caller(): + frame = inspect.currentframe().f_back.f_back + caller_module = frame.f_globals['__name__'] + allowed_modules = {'app.app', 'app.service', 'app.workflow_context'} + if caller_module not in allowed_modules: + raise ImportError( + f"The '_internal' module cannot be imported from '{caller_module}'. " + "It is only for use by app.py, service.py, and workflow_context.py" + ) + +_check_caller() + +from .internal_client import InternalEndureClient +from .service_registry import ServiceRegistry +from .types import ( + LogStatus, + RetryMechanism, + Log, + Response, + EndureException, + ErrorResponse +) +from .utils import validate_retention_period +from .workflow import Workflow + +__all__ = [ + "InternalEndureClient", + "ServiceRegistry", + "LogStatus", + "RetryMechanism", + "Log", + "Response", + "EndureException", + "ErrorResponse", + "validate_retention_period", + "Workflow" +] diff --git a/src/app/internal_client.py b/src/app/_internal/internal_client.py similarity index 100% rename from src/app/internal_client.py rename to src/app/_internal/internal_client.py diff --git a/src/app/service_registry.py b/src/app/_internal/service_registry.py similarity index 100% rename from src/app/service_registry.py rename to src/app/_internal/service_registry.py diff --git a/src/app/types.py b/src/app/_internal/types.py similarity index 100% rename from src/app/types.py rename to src/app/_internal/types.py diff --git a/src/app/utils.py b/src/app/_internal/utils.py similarity index 100% rename from src/app/utils.py rename to src/app/_internal/utils.py diff --git a/src/app/workflow.py b/src/app/_internal/workflow.py similarity index 100% rename from src/app/workflow.py rename to src/app/_internal/workflow.py diff --git a/src/app/app.py b/src/app/app.py index f25e34f..6859b42 100644 --- a/src/app/app.py +++ b/src/app/app.py @@ -1,7 +1,6 @@ from fastapi import FastAPI, Request -from src.app.service_registry import ServiceRegistry from fastapi.responses import JSONResponse -from .types import EndureException, ErrorResponse +from ._internal.types import EndureException, ErrorResponse, ServiceRegistry from dataclasses import asdict class DurableApp: """ diff --git a/src/app/service.py b/src/app/service.py index 4b92601..025cc71 100644 --- a/src/app/service.py +++ b/src/app/service.py @@ -1,7 +1,5 @@ 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 ._internal import ServiceRegistry, Workflow , validate_retention_period class Service : def __init__(self, name: str): diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 0e535fc..7ae0d64 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -1,6 +1,5 @@ from fastapi import HTTPException -from src.app.internal_client import InternalEndureClient -from src.app.types import Log, LogStatus, RetryMechanism +from ._internal import InternalEndureClient , Log, LogStatus, RetryMechanism from fastapi import status from datetime import datetime import time 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..6c376fc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +import pytest +from app import App, Service, WorkflowContext + +# This fixture provides a fresh App instance for each test that needs it +@pytest.fixture +def app(): + app_instance = App() + yield app_instance + + +@pytest.fixture +def service(): + service_instance = Service() + yield service_instance + +@pytest.fixture +def workflow_context(): + context = WorkflowContext() + yield context \ No newline at end of file diff --git a/tests/internal/__init__.py b/tests/internal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_workflow_context.py b/tests/test_workflow_context.py new file mode 100644 index 0000000..e69de29 From fa3a316ac608dfce379b451bceb20637171c6e2f Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Sun, 15 Jun 2025 14:10:18 +0300 Subject: [PATCH 02/33] fix all modules imports issues --- .gitignore | 3 +- makefile | 2 +- poetry.lock | 654 ++++++++++++++++++--------- pyproject.toml | 6 +- src/app/__init__.py | 8 +- src/app/_internal/__init__.py | 44 +- src/app/_internal/internal_client.py | 2 +- src/app/_internal/types.py | 3 - src/app/_internal/workflow.py | 5 +- src/app/app.py | 2 +- src/app/service.py | 7 +- src/app/workflow_context.py | 4 +- test/test_initial.py | 2 - tests/conftest.py | 7 +- 14 files changed, 495 insertions(+), 254 deletions(-) delete mode 100644 test/test_initial.py 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/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 e3848c4..9340e1e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,28 @@ # This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +[[package]] +name = "anyio" +version = "4.9.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, + {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] +trio = ["trio (>=0.26.1)"] + [[package]] name = "black" version = "25.1.0" @@ -7,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"}, @@ -49,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] @@ -70,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" @@ -85,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"}, @@ -98,23 +314,58 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "fastapi" +version = "0.115.12" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d"}, + {file = "fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.47.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + [[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 = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "iniconfig" @@ -122,7 +373,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"}, @@ -135,7 +386,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"}, @@ -152,7 +402,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"}, @@ -160,28 +409,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]] @@ -191,7 +438,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"}, @@ -199,37 +445,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" @@ -237,7 +466,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"}, @@ -249,162 +478,93 @@ 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\""] - -[[package]] -name = "pydantic-core" -version = "2.33.2" -description = "Core functionality for Pydantic validation and serialization" -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"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] [[package]] name = "pyflakes" -version = "3.2.0" +version = "3.3.2" description = "passive checker of Python programs" optional = false -python-versions = ">=3.8" -groups = ["main"] +python-versions = ">=3.9" groups = ["main"] 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 = "pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a"}, + {file = "pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"}, ] [[package]] @@ -413,7 +573,7 @@ version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, @@ -428,7 +588,7 @@ version = "8.4.0" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e"}, {file = "pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6"}, @@ -465,16 +625,85 @@ 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" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "starlette" +version = "0.46.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, + {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + [[package]] name = "tomli" 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"}, @@ -509,37 +738,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"] -markers = "python_version == \"3.10\"" +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 = "6ca9b0380f82637367dc582967b02f828ec57fac01db53db5a44f5eed0c192b4" +content-hash = "3539d7f89bdaead2510eed57532d474fc46400b791383270c7bed1f7431b4c4c" diff --git a/pyproject.toml b/pyproject.toml index d1ac148..a1044c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,11 @@ 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" + +[tool.poetry.group.dev.dependencies] +pytest-cov = "^6.2.1" [build-system] requires = ["poetry-core"] diff --git a/src/app/__init__.py b/src/app/__init__.py index 2455409..ced7bbc 100644 --- a/src/app/__init__.py +++ b/src/app/__init__.py @@ -1,5 +1,9 @@ -from .app import App +from .app import DurableApp from .service import Service from .workflow_context import WorkflowContext -__all__ = ["App", "Service", "WorkflowContext"] \ No newline at end of file +__all__ = [ + "DurableApp", + "Service", + "WorkflowContext" +] \ No newline at end of file diff --git a/src/app/_internal/__init__.py b/src/app/_internal/__init__.py index 01567d7..8aeab1e 100644 --- a/src/app/_internal/__init__.py +++ b/src/app/_internal/__init__.py @@ -4,11 +4,32 @@ """ import inspect import sys +import os +from .internal_client import InternalEndureClient +from .service_registry import ServiceRegistry +from .utils import validate_retention_period +from .workflow import Workflow +from .types import ( + EndureException, + ErrorResponse +) + +def _is_testing(): + # Skipping during pytest or unittest runs + return ( + 'PYTEST_CURRENT_TEST' in os.environ or + any('pytest' in arg or 'unittest' in arg for arg in sys.argv) + ) def _check_caller(): + if _is_testing(): + return + frame = inspect.currentframe().f_back.f_back - caller_module = frame.f_globals['__name__'] + caller_module = frame.f_globals.get('__name__', '') + allowed_modules = {'app.app', 'app.service', 'app.workflow_context'} + if caller_module not in allowed_modules: raise ImportError( f"The '_internal' module cannot be imported from '{caller_module}'. " @@ -17,28 +38,11 @@ def _check_caller(): _check_caller() -from .internal_client import InternalEndureClient -from .service_registry import ServiceRegistry -from .types import ( - LogStatus, - RetryMechanism, - Log, - Response, - EndureException, - ErrorResponse -) -from .utils import validate_retention_period -from .workflow import Workflow - __all__ = [ + 'EndureException', + 'ErrorResponse', "InternalEndureClient", "ServiceRegistry", - "LogStatus", - "RetryMechanism", - "Log", - "Response", - "EndureException", - "ErrorResponse", "validate_retention_period", "Workflow" ] diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index 9935015..2ced0a6 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -1,6 +1,6 @@ from dataclasses import asdict import os -from types import Log, Response +from .types import Log, Response import requests class InternalEndureClient: diff --git a/src/app/_internal/types.py b/src/app/_internal/types.py index b47585f..2a687d2 100644 --- a/src/app/_internal/types.py +++ b/src/app/_internal/types.py @@ -1,11 +1,8 @@ - from dataclasses import dataclass, field from datetime import datetime from typing import Optional from enum import Enum - - class LogStatus(Enum): STARTED = "started" COMPLETED = "completed" diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index de6b41a..0b75d2b 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -1,9 +1,8 @@ from typing import Callable, get_type_hints, Any -from fastapi import Request, HTTPException, status -from pydantic.typing import create_model +from fastapi import Request, status import asyncio from pydantic import ValidationError, create_model -from .workflow_context import WorkflowContext +from app.workflow_context import WorkflowContext from .internal_client import InternalEndureClient from .types import EndureException class Workflow: diff --git a/src/app/app.py b/src/app/app.py index 6859b42..7457217 100644 --- a/src/app/app.py +++ b/src/app/app.py @@ -1,6 +1,6 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse -from ._internal.types import EndureException, ErrorResponse, ServiceRegistry +from app._internal import EndureException, ErrorResponse, ServiceRegistry from dataclasses import asdict class DurableApp: """ diff --git a/src/app/service.py b/src/app/service.py index 025cc71..7728d8f 100644 --- a/src/app/service.py +++ b/src/app/service.py @@ -1,5 +1,5 @@ -from src.app.workflow_context import WorkflowContext -from ._internal import ServiceRegistry, Workflow , validate_retention_period +from app.workflow_context import WorkflowContext +from app._internal import ServiceRegistry, Workflow , validate_retention_period class Service : def __init__(self, name: str): @@ -35,7 +35,7 @@ def workflow(self,**config): Example: from my_app import Service - from src.app.workflow_context import WorkflowContext + from app.workflow_context import WorkflowContext service = Service("my_service") @@ -59,4 +59,3 @@ def decorator(func): registry.register_workflow_in_router(self.name, workflow) return func return decorator - \ No newline at end of file diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 7ae0d64..73852f8 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -1,7 +1,7 @@ from fastapi import HTTPException -from ._internal import InternalEndureClient , Log, LogStatus, RetryMechanism +from app._internal.types import Log, LogStatus, RetryMechanism +from app._internal.internal_client import InternalEndureClient from fastapi import status -from datetime import datetime import time class WorkflowContext: """ 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/conftest.py b/tests/conftest.py index 6c376fc..c6a6ef1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,13 @@ +import sys +import os import pytest -from app import App, Service, WorkflowContext +# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from app import DurableApp, Service, WorkflowContext # This fixture provides a fresh App instance for each test that needs it @pytest.fixture def app(): - app_instance = App() + app_instance = DurableApp() yield app_instance From 87f82996a0e86f7dde5d1966bfdc32ba4adc301f Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Sun, 15 Jun 2025 14:46:34 +0300 Subject: [PATCH 03/33] fix linting problems --- .flake8 | 2 + Example/app/main.py | 2 +- pyproject.toml | 7 +- requirements.txt | 16 ---- src/app/__init__.py | 6 +- src/app/_internal/__init__.py | 31 ++++--- src/app/_internal/internal_client.py | 62 +++++++------- src/app/_internal/service_registry.py | 13 ++- src/app/_internal/types.py | 29 ++++--- src/app/_internal/utils.py | 3 - src/app/_internal/workflow.py | 58 ++++++++----- src/app/app.py | 27 +++--- src/app/service.py | 62 ++++++++------ src/app/workflow_context.py | 118 ++++++++++++++++++-------- tests/conftest.py | 9 +- 15 files changed, 266 insertions(+), 179 deletions(-) create mode 100644 .flake8 delete mode 100644 requirements.txt diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..355d1bc --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 89 \ No newline at end of file diff --git a/Example/app/main.py b/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/pyproject.toml b/pyproject.toml index a1044c0..0b1167a 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" @@ -20,3 +22,6 @@ pytest-cov = "^6.2.1" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 79 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 ced7bbc..d18b8c7 100644 --- a/src/app/__init__.py +++ b/src/app/__init__.py @@ -2,8 +2,4 @@ from .service import Service from .workflow_context import WorkflowContext -__all__ = [ - "DurableApp", - "Service", - "WorkflowContext" -] \ No newline at end of file +__all__ = ["DurableApp", "Service", "WorkflowContext"] diff --git a/src/app/_internal/__init__.py b/src/app/_internal/__init__.py index 8aeab1e..0bf2179 100644 --- a/src/app/_internal/__init__.py +++ b/src/app/_internal/__init__.py @@ -2,33 +2,37 @@ Internal implementation details. Do not import directly. This module is for internal use by app.py, service.py, and workflow_context.py only. """ + import inspect -import sys import os +import sys + from .internal_client import InternalEndureClient from .service_registry import ServiceRegistry +from .types import EndureException, ErrorResponse from .utils import validate_retention_period from .workflow import Workflow -from .types import ( - EndureException, - ErrorResponse -) + def _is_testing(): # Skipping during pytest or unittest runs - return ( - 'PYTEST_CURRENT_TEST' in os.environ or - any('pytest' in arg or 'unittest' in arg for arg in sys.argv) + return "PYTEST_CURRENT_TEST" in os.environ or any( + "pytest" in arg or "unittest" in arg for arg in sys.argv ) + def _check_caller(): if _is_testing(): return frame = inspect.currentframe().f_back.f_back - caller_module = frame.f_globals.get('__name__', '') + caller_module = frame.f_globals.get("__name__", "") - allowed_modules = {'app.app', 'app.service', 'app.workflow_context'} + allowed_modules = { + "app.app", + "app.service", + "app.workflow_context", + } if caller_module not in allowed_modules: raise ImportError( @@ -36,13 +40,14 @@ def _check_caller(): "It is only for use by app.py, service.py, and workflow_context.py" ) + _check_caller() __all__ = [ - 'EndureException', - 'ErrorResponse', + "EndureException", + "ErrorResponse", "InternalEndureClient", "ServiceRegistry", "validate_retention_period", - "Workflow" + "Workflow", ] diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index 2ced0a6..d8300bd 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -1,7 +1,10 @@ -from dataclasses import asdict import os +from dataclasses import asdict + +import requests + from .types import Log, Response -import requests + class InternalEndureClient: @@ -12,54 +15,55 @@ def __init__(self): 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. - """ + """ # noqa: E501 if not self._base_url: - raise ValueError("DURABLE_ENGINE_BASE_URL is not set in environment variables.") - + 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" - } + 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()) + 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.") - + 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" - } + headers = {"Content-Type": "application/json"} response = requests.patch(url, headers=headers) response.raise_for_status() - response = Response(status=response.status_code, payload=response.json()) + response = Response( + status=response.status_code, + payload=response.json(), + ) return response.to_dict() - - - - - - - diff --git a/src/app/_internal/service_registry.py b/src/app/_internal/service_registry.py index 5171e70..7f81179 100644 --- a/src/app/_internal/service_registry.py +++ b/src/app/_internal/service_registry.py @@ -1,7 +1,10 @@ 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. @@ -20,7 +23,8 @@ class ServiceRegistry: Returns the dictionary of registered services and their workflows. get_router() -> APIRouter: Returns the FastAPI router containing all registered workflow routes. - """ + """ # noqa: E501 + _instance = None _services: Dict[str, List[Workflow]] _router: APIRouter @@ -37,16 +41,17 @@ def register_workflow(self, service_name: str, workflow: Workflow): self._services[service_name] = [] self._services[service_name].append(workflow) - def register_workflow_in_router(self, service_name: str, workflow: 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/_internal/types.py b/src/app/_internal/types.py index 2a687d2..33953d6 100644 --- a/src/app/_internal/types.py +++ b/src/app/_internal/types.py @@ -1,44 +1,49 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import Optional from enum import Enum +from typing import Optional + class LogStatus(Enum): STARTED = "started" COMPLETED = "completed" FAILED = "failed" + class RetryMechanism(Enum): EXPONENTIAL = "exponential" - LINEAR = "linear" - CONSTANT = "constant" + LINEAR = "linear" + CONSTANT = "constant" + @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 timestamp: datetime = field(default_factory=datetime.now) - + + @dataclass -class Response(): +class Response: status: int payload: Optional[dict] = None def to_dict(self): return { "status": self.status, - "payload": self.payload if self.payload is not None else {} + "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/_internal/utils.py b/src/app/_internal/utils.py index ec4a5c1..6879b62 100644 --- a/src/app/_internal/utils.py +++ b/src/app/_internal/utils.py @@ -1,5 +1,3 @@ - - def validate_retention_period(retention_period: str): """ Validate the retention period format. @@ -16,4 +14,3 @@ def validate_retention_period(retention_period: str): 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/_internal/workflow.py b/src/app/_internal/workflow.py index 0b75d2b..e880dc1 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -1,35 +1,41 @@ -from typing import Callable, get_type_hints, Any -from fastapi import Request, status import asyncio +from typing import Any, Callable, get_type_hints + +from fastapi import Request, status from pydantic import ValidationError, create_model + 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 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). - """ + """ # 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 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. - """ + """ # noqa: E501 self.func = func self.name = func.__name__ self.retention_period = retention_period @@ -38,47 +44,50 @@ def __init__(self, func: Callable, retention_period: int = None): 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. - """ + """ # noqa: E501 hints = get_type_hints(func) - return hints.get('input', Any) , hints.get('return', Any) + 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. - """ + """ # noqa: E501 FullRequest = create_model( f"{self.name}Request", execution_id=(str, ...), - input=(self.input, ...) + 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) + InternalEndureClient.mark_execution_as_running( + self.execution_id + ) result = self.func(ctx, full.input) if asyncio.iscoroutine(result): result = await result @@ -87,12 +96,19 @@ async def handler(request: Request): print(f"Validation error: {ve}") raise EndureException( status_code=status.HTTP_400_BAD_REQUEST, - output={"error": "Validation error", "details": ve.errors()} + 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)} + output={ + "error": "Internal server error", + "details": str(e), + }, ) - return handler \ No newline at end of file + + return handler diff --git a/src/app/app.py b/src/app/app.py index 7457217..aab36df 100644 --- a/src/app/app.py +++ b/src/app/app.py @@ -1,7 +1,15 @@ +from dataclasses import asdict + from fastapi import FastAPI, Request from fastapi.responses import JSONResponse -from app._internal import EndureException, ErrorResponse, ServiceRegistry -from dataclasses import asdict + +from app._internal import ( + EndureException, + ErrorResponse, + ServiceRegistry, +) + + class DurableApp: """ DurableApp is a wrapper class for a FastAPI application that integrates a service discovery endpoint. @@ -20,7 +28,8 @@ class DurableApp: - 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. - """ + """ # noqa: E501 + def __init__(self, app): self.app: FastAPI = app serviceRegistry = ServiceRegistry.get_instance() @@ -30,11 +39,8 @@ def __init__(self, app): methods=["GET"], ) self.app.include_router(serviceRegistry.get_router()) - self.app.add_exception_handler( - EndureException, - self.raise_exception - ) - + self.app.add_exception_handler(EndureException, self.raise_exception) + def _discover(self): services = self.serviceRegistry.get_services() return { @@ -54,10 +60,9 @@ def _discover(self): for service_name, workflows in services.items() ] } - + async def raise_exception(request: Request, exc: EndureException): 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/service.py b/src/app/service.py index 7728d8f..e41433e 100644 --- a/src/app/service.py +++ b/src/app/service.py @@ -1,61 +1,75 @@ +from app._internal import ( + ServiceRegistry, + Workflow, + validate_retention_period, +) from app.workflow_context import WorkflowContext -from app._internal import ServiceRegistry, Workflow , validate_retention_period -class Service : + +class Service: def __init__(self, name: str): self.name = name - - def workflow(self,**config): + + 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 + + This decorator validates the workflow function signature, creates a Workflow + instance, and registers it with the ServiceRegistry for execution through the API router. - + 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. + - retention (int, optional): Number of days to retain workflow + execution history and state. Must be a positive integer. Default: 7 days. - + Returns: - callable: The original function (unmodified), which can now be executed + callable: The original function (unmodified), which can now be executed as a registered workflow. - + 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 - + Example: from my_app import Service from app.workflow_context import WorkflowContext - + service = Service("my_service") - + @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} - """ + """ # 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 ("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." + ) workflow = Workflow(func, retention_period) registry = ServiceRegistry.get_instance() registry.register_workflow(self.name, workflow) registry.register_workflow_in_router(self.name, workflow) return func + return decorator diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 73852f8..44ad7cb 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -1,59 +1,75 @@ -from fastapi import HTTPException -from app._internal.types import Log, LogStatus, RetryMechanism -from app._internal.internal_client import InternalEndureClient -from fastapi import status import time + +from fastapi import HTTPException, status + +from app._internal.internal_client import ( + InternalEndureClient, +) +from app._internal.types import ( + Log, + LogStatus, + RetryMechanism, +) + + 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. - + Attributes: execution_id (str): The unique identifier for the workflow execution. """ - 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. - """ + """ # noqa: E501 self.execution_id = execution_id - - def execute_action(self, action: callable, input_data , max_retries: int , retry_mechanism: RetryMechanism) -> any: + + 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 - + 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. - + Returns: any: The result of the action execution, or the cached result if the action was already executed successfully. - + 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. @@ -61,29 +77,50 @@ def execute_action(self, action: callable, input_data , max_retries: int , retry 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. - """ + """ # noqa: E501 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__) + 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.") + 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 + 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}") - + 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__) + 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: @@ -94,11 +131,24 @@ def execute_action(self, action: callable, input_data , max_retries: int , retry 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__) + 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__) + 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 - diff --git a/tests/conftest.py b/tests/conftest.py index c6a6ef1..f211553 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,22 +1,21 @@ -import sys -import os import pytest -# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) from app import DurableApp, Service, WorkflowContext + # 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() - yield context \ No newline at end of file + yield context From 14eb4de1232a148952cfa3e0ce5f55345fd53fbd Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Sun, 15 Jun 2025 14:48:52 +0300 Subject: [PATCH 04/33] git commit -m "Rename Example folder to example" --- Example/app/__init__.py | 1 - {Example => example}/Dockerfile | 0 example/app/__init__.py | 1 + {Example => example}/app/main.py | 0 4 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 Example/app/__init__.py rename {Example => example}/Dockerfile (100%) create mode 100644 example/app/__init__.py rename {Example => example}/app/main.py (100%) 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 100% rename from Example/app/main.py rename to example/app/main.py From c592a2ecdfce575a917d7baf16cea4c3e1477339 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Sun, 15 Jun 2025 15:54:59 +0300 Subject: [PATCH 05/33] add test_service_registry.py --- src/app/_internal/service_registry.py | 2 +- tests/internal/test_service_registry.py | 82 +++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/internal/test_service_registry.py diff --git a/src/app/_internal/service_registry.py b/src/app/_internal/service_registry.py index 7f81179..2aff13e 100644 --- a/src/app/_internal/service_registry.py +++ b/src/app/_internal/service_registry.py @@ -45,7 +45,7 @@ def register_workflow_in_router( self, service_name: str, workflow: Workflow ): self._router.add_api_route( - f"execute/{service_name}/{workflow.name}", + f"/execute/{service_name}/{workflow.name}", workflow.get_handler_route(), methods=["POST"], ) diff --git a/tests/internal/test_service_registry.py b/tests/internal/test_service_registry.py new file mode 100644 index 0000000..c8b55e8 --- /dev/null +++ b/tests/internal/test_service_registry.py @@ -0,0 +1,82 @@ +import pytest +from fastapi import APIRouter +from typing import Dict, List +from app._internal import ServiceRegistry +from app._internal import Workflow +from app import WorkflowContext + + +class TestServiceRegistry: + @pytest.fixture(autouse=True) + def setup_method(self): + self.registry = ServiceRegistry() + yield + self.registry._services.clear() + self.registry._router = APIRouter() + + 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 From 6cbd78bfb12dacb04515703bbb5e866803301b4f Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Sun, 15 Jun 2025 16:24:45 +0300 Subject: [PATCH 06/33] finalize test_service_registry --- src/app/_internal/service_registry.py | 18 +++++++++++- tests/internal/test_service_registry.py | 37 +++++++++++++++++++++---- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/app/_internal/service_registry.py b/src/app/_internal/service_registry.py index 2aff13e..4981d16 100644 --- a/src/app/_internal/service_registry.py +++ b/src/app/_internal/service_registry.py @@ -37,8 +37,18 @@ def __new__(cls): return cls._instance def register_workflow(self, service_name: str, workflow: Workflow): + 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( @@ -51,7 +61,13 @@ def register_workflow_in_router( ) def get_services(self) -> Dict[str, List[Workflow]]: - return self._services + return self._services.copy() def get_router(self) -> APIRouter: return self._router + + def clear(self): + """Clear all registered services and routes""" + self._services.clear() + self._router = APIRouter() + diff --git a/tests/internal/test_service_registry.py b/tests/internal/test_service_registry.py index c8b55e8..839ee68 100644 --- a/tests/internal/test_service_registry.py +++ b/tests/internal/test_service_registry.py @@ -1,9 +1,8 @@ import pytest -from fastapi import APIRouter +from app._internal.workflow import Workflow, WorkflowContext +from app._internal.service_registry import ServiceRegistry from typing import Dict, List -from app._internal import ServiceRegistry -from app._internal import Workflow -from app import WorkflowContext +from fastapi import APIRouter class TestServiceRegistry: @@ -11,8 +10,7 @@ class TestServiceRegistry: def setup_method(self): self.registry = ServiceRegistry() yield - self.registry._services.clear() - self.registry._router = APIRouter() + self.registry.clear() def test_register_workflow(self): registry = ServiceRegistry() @@ -80,3 +78,30 @@ def test_get_router(self): 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) From aaa2bbfe7f05309b51c70851536b96eac24afe45 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Sun, 15 Jun 2025 19:25:21 +0300 Subject: [PATCH 07/33] add test_worklow.py --- pytest.ini | 2 + src/app/_internal/internal_client.py | 4 +- src/app/_internal/service_registry.py | 13 +-- src/app/_internal/workflow.py | 2 +- tests/conftest.py | 14 ++++ tests/internal/test_workflow.py | 113 ++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 tests/internal/test_workflow.py diff --git a/pytest.ini b/pytest.ini index b297415..63112c3 100644 --- a/pytest.ini +++ b/pytest.ini @@ -14,5 +14,7 @@ python_classes = Test # Pattern for test functions python_functions = test_* +asyncio_mode = auto + # Command line options to always include addopts = -ra -q --cov=app \ No newline at end of file diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index d8300bd..c3b5be3 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -8,8 +8,7 @@ class InternalEndureClient: - def __init__(self): - self._base_url = os.getenv("DURABLE_ENGINE_BASE_URL") + _base_url = os.getenv("DURABLE_ENGINE_BASE_URL") @classmethod def send_log(self, execution_id: str, log: Log, action_name: str): @@ -64,6 +63,5 @@ def mark_execution_as_running(self, execution_id: str): response.raise_for_status() response = Response( status=response.status_code, - payload=response.json(), ) return response.to_dict() diff --git a/src/app/_internal/service_registry.py b/src/app/_internal/service_registry.py index 4981d16..9effcae 100644 --- a/src/app/_internal/service_registry.py +++ b/src/app/_internal/service_registry.py @@ -41,14 +41,16 @@ def register_workflow(self, service_name: str, workflow: Workflow): 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}'") - + 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( @@ -65,9 +67,8 @@ def get_services(self) -> Dict[str, List[Workflow]]: def get_router(self) -> APIRouter: return self._router - + def clear(self): """Clear all registered services and routes""" self._services.clear() self._router = APIRouter() - diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index e880dc1..80c768f 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -86,7 +86,7 @@ async def handler(request: Request): full = FullRequest(**body) ctx = WorkflowContext(execution_id=full.execution_id) InternalEndureClient.mark_execution_as_running( - self.execution_id + full.execution_id ) result = self.func(ctx, full.input) if asyncio.iscoroutine(result): diff --git a/tests/conftest.py b/tests/conftest.py index f211553..fde75d0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,8 @@ +from unittest.mock import AsyncMock, patch import pytest from app import DurableApp, Service, WorkflowContext +from fastapi import Request +from pydantic import ValidationError # This fixture provides a fresh App instance for each test that needs it @@ -19,3 +22,14 @@ def service(): def workflow_context(): context = WorkflowContext() yield context + +@pytest.fixture +def mock_internal_client(): + with patch('app._internal.workflow.InternalEndureClient') as mock: + yield mock + +@pytest.fixture +async def mock_request(): + mock = AsyncMock() + mock.json = AsyncMock() + return mock diff --git a/tests/internal/test_workflow.py b/tests/internal/test_workflow.py new file mode 100644 index 0000000..f38c6dd --- /dev/null +++ b/tests/internal/test_workflow.py @@ -0,0 +1,113 @@ +import pytest +from typing import Any +from app._internal.workflow import Workflow +from app._internal.types import EndureException +from app.workflow_context import WorkflowContext +from starlette.responses import Response + +class TestWorkflow: + @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 + + 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): + # testing with typed workflow + workflow = Workflow(self.typed_workflow) + assert workflow.input == dict + assert workflow.output == dict + + # testing with untyped workflow + def untyped_workflow(ctx, input): + return input + + workflow_untyped = Workflow(untyped_workflow) + assert workflow_untyped.input == Any + assert workflow_untyped.output == Any + + @pytest.mark.asyncio + async def test_handler_route_successful_execution(self, mock_request, mock_internal_client): + 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 + } + + # executing the handler + result = await handler(mock_request) + + assert result == {"output": "Hello, Farah!"} + mock_internal_client.mark_execution_as_running.assert_called_once_with(execution_id) + + @pytest.mark.asyncio + async def test_handler_route_async_workflow(self, mock_request, mock_internal_client): + mock_internal_client.mark_execution_as_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} + + @pytest.mark.asyncio + async def test_handler_route_validation_error(self, mock_request): + workflow = Workflow(self.typed_workflow) + handler = workflow.get_handler_route() + + # setup request data with invalid input type + mock_request.json.return_value = { + "execution_id": "test-execution-id", + "input": "invalid-input" # this should be a dict + } + + # executing handler and expect validation error + with pytest.raises(EndureException) as exc_info: + await handler(mock_request) + + assert exc_info.value.status_code == 400 + assert "Validation error" in str(exc_info.value.output["error"]) + + @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 pytest.raises(EndureException) as exc_info: + await handler(mock_request) + + assert exc_info.value.status_code == 500 + assert "Internal server error" == str(exc_info.value.output["error"]) \ No newline at end of file From 7954004034add9b3a55ce96592d469ef48776c28 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Sun, 15 Jun 2025 19:29:31 +0300 Subject: [PATCH 08/33] trigger linter --- .flake8 | 2 +- tests/conftest.py | 6 ++-- tests/internal/test_service_registry.py | 2 +- tests/internal/test_workflow.py | 45 +++++++++++++++---------- 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/.flake8 b/.flake8 index 355d1bc..06a52ca 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,2 @@ [flake8] -max-line-length = 89 \ No newline at end of file +max-line-length = 115 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index fde75d0..0d53db3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,6 @@ from unittest.mock import AsyncMock, patch import pytest from app import DurableApp, Service, WorkflowContext -from fastapi import Request -from pydantic import ValidationError # This fixture provides a fresh App instance for each test that needs it @@ -23,11 +21,13 @@ def workflow_context(): context = WorkflowContext() yield context + @pytest.fixture def mock_internal_client(): - with patch('app._internal.workflow.InternalEndureClient') as mock: + with patch("app._internal.workflow.InternalEndureClient") as mock: yield mock + @pytest.fixture async def mock_request(): mock = AsyncMock() diff --git a/tests/internal/test_service_registry.py b/tests/internal/test_service_registry.py index 839ee68..2ae179b 100644 --- a/tests/internal/test_service_registry.py +++ b/tests/internal/test_service_registry.py @@ -1,7 +1,7 @@ import pytest from app._internal.workflow import Workflow, WorkflowContext from app._internal.service_registry import ServiceRegistry -from typing import Dict, List +from typing import Dict from fastapi import APIRouter diff --git a/tests/internal/test_workflow.py b/tests/internal/test_workflow.py index f38c6dd..1d69a81 100644 --- a/tests/internal/test_workflow.py +++ b/tests/internal/test_workflow.py @@ -5,6 +5,7 @@ from app.workflow_context import WorkflowContext from starlette.responses import Response + class TestWorkflow: @staticmethod def sync_workflow(ctx: WorkflowContext, input: dict) -> str: @@ -25,7 +26,9 @@ def test_workflow_initialization(self): assert workflow.retention_period is None # testing with retention period - workflow_with_retention = Workflow(self.sync_workflow, retention_period=7) + workflow_with_retention = Workflow( + self.sync_workflow, retention_period=7 + ) assert workflow_with_retention.retention_period == 7 def test_get_io_types(self): @@ -37,38 +40,46 @@ def test_get_io_types(self): # testing with untyped workflow def untyped_workflow(ctx, input): return input - + workflow_untyped = Workflow(untyped_workflow) assert workflow_untyped.input == Any assert workflow_untyped.output == Any @pytest.mark.asyncio - async def test_handler_route_successful_execution(self, mock_request, mock_internal_client): + async def test_handler_route_successful_execution( + self, mock_request, mock_internal_client + ): 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 + "input": input_data, } # executing the handler result = await handler(mock_request) assert result == {"output": "Hello, Farah!"} - mock_internal_client.mark_execution_as_running.assert_called_once_with(execution_id) + mock_internal_client.mark_execution_as_running.assert_called_once_with( + execution_id + ) @pytest.mark.asyncio - async def test_handler_route_async_workflow(self, mock_request, mock_internal_client): - mock_internal_client.mark_execution_as_running.return_value = Response(status_code=200) + async def test_handler_route_async_workflow( + self, mock_request, mock_internal_client + ): + mock_internal_client.mark_execution_as_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 + "input": 5, } result = await handler(mock_request) @@ -79,17 +90,17 @@ async def test_handler_route_async_workflow(self, mock_request, mock_internal_cl async def test_handler_route_validation_error(self, mock_request): workflow = Workflow(self.typed_workflow) handler = workflow.get_handler_route() - + # setup request data with invalid input type mock_request.json.return_value = { "execution_id": "test-execution-id", - "input": "invalid-input" # this should be a dict + "input": "invalid-input", # this should be a dict } # executing handler and expect validation error with pytest.raises(EndureException) as exc_info: await handler(mock_request) - + assert exc_info.value.status_code == 400 assert "Validation error" in str(exc_info.value.output["error"]) @@ -100,14 +111,14 @@ def failing_workflow(ctx: WorkflowContext, input: Any): workflow = Workflow(failing_workflow) handler = workflow.get_handler_route() - + mock_request.json.return_value = { "execution_id": "test-execution-id", - "input": "test-input" + "input": "test-input", } with pytest.raises(EndureException) as exc_info: await handler(mock_request) - + assert exc_info.value.status_code == 500 - assert "Internal server error" == str(exc_info.value.output["error"]) \ No newline at end of file + assert "Internal server error" == str(exc_info.value.output["error"]) From 88801d9a0d48e8857177f2f6c88e2a669552b1b7 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Mon, 16 Jun 2025 01:47:29 +0300 Subject: [PATCH 09/33] test wokflow_context.py --- pytest.ini | 6 +- src/app/_internal/internal_client.py | 9 +- src/app/_internal/types.py | 24 ++- src/app/workflow_context.py | 27 ++-- tests/conftest.py | 44 +++++- tests/internal/test_workflow.py | 60 ++++---- tests/test_workflow_context.py | 209 +++++++++++++++++++++++++++ 7 files changed, 328 insertions(+), 51 deletions(-) diff --git a/pytest.ini b/pytest.ini index 63112c3..370ff6f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -17,4 +17,8 @@ python_functions = test_* asyncio_mode = auto # Command line options to always include -addopts = -ra -q --cov=app \ No newline at end of file +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/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index c3b5be3..51b0a34 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -1,8 +1,5 @@ import os -from dataclasses import asdict - import requests - from .types import Log, Response @@ -32,11 +29,11 @@ def send_log(self, execution_id: str, log: Log, action_name: str): url = f"{self._base_url}/executions/execution/{execution_id}/log/{action_name}" headers = {"Content-Type": "application/json"} - payload = asdict(log) + payload = log.to_dict() response = requests.patch(url, headers=headers, json=payload) response.raise_for_status() response = Response( - status=response.status_code, + status_code=response.status_code, payload=response.json(), ) return response.to_dict() @@ -62,6 +59,6 @@ def mark_execution_as_running(self, execution_id: str): response = requests.patch(url, headers=headers) response.raise_for_status() response = Response( - status=response.status_code, + status_code=response.status_code, ) return response.to_dict() diff --git a/src/app/_internal/types.py b/src/app/_internal/types.py index 33953d6..f901a12 100644 --- a/src/app/_internal/types.py +++ b/src/app/_internal/types.py @@ -16,24 +16,42 @@ class RetryMechanism(Enum): 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: status: LogStatus 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 + status_code: int payload: Optional[dict] = None def to_dict(self): return { - "status": self.status, + "status_code": self.status_code, "payload": (self.payload if self.payload is not None else {}), } diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 44ad7cb..cb66fde 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -90,7 +90,7 @@ def execute_action( ) if not engine_response: raise RuntimeError("Failed to mark execution as running.") - status_code = engine_response.status_code + status_code = engine_response["status_code"] match status_code: case status.HTTP_201_CREATED | status.HTTP_200_OK: result = action(input_data) @@ -105,7 +105,7 @@ def execute_action( ) return result case status.HTTP_208_ALREADY_REPORTED: - output = engine_response.payload.get("output") + output = engine_response.get("payload", {}).get("output") return output if output else {} except HTTPException as e: @@ -121,15 +121,20 @@ def execute_action( engine_response = InternalEndureClient.send_log( self.execution_id, log, action.__name__ ) - status_code = engine_response.status_code + 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) + retry_at_unix = engine_response.get("payload", {}).get( + "retry_at" + ) + if not retry_at_unix: + raise RuntimeError( + "Missing retry_at in response payload" + ) + sleep_seconds = retry_at_unix - time.time() + if sleep_seconds > 0: + time.sleep(sleep_seconds) result = action(input_data) log = Log( status=LogStatus.COMPLETED, @@ -151,4 +156,8 @@ def execute_action( log, action.__name__, ) - status_code = engine_response.status_code + status_code = engine_response["status_code"] + if status_code != status.HTTP_200_OK: + raise RuntimeError( + f"Action execution failed: {str(e)}" + ) diff --git a/tests/conftest.py b/tests/conftest.py index 0d53db3..062be28 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,20 @@ -from unittest.mock import AsyncMock, patch +# Set environment variables before any imports +import os + +os.environ["DURABLE_ENGINE_BASE_URL"] = "http://test-engine:8000" + import pytest +from unittest.mock import AsyncMock, Mock, patch from app import DurableApp, Service, WorkflowContext +from app._internal.types import Response + + +@pytest.fixture(autouse=True) +def cleanup_test_env(): + """Clean up test environment variables after each test""" + yield + if "DURABLE_ENGINE_BASE_URL" in os.environ: + del os.environ["DURABLE_ENGINE_BASE_URL"] # This fixture provides a fresh App instance for each test that needs it @@ -18,14 +32,28 @@ def service(): @pytest.fixture def workflow_context(): - context = WorkflowContext() + context = WorkflowContext("test-execution-id") yield context @pytest.fixture def mock_internal_client(): - with patch("app._internal.workflow.InternalEndureClient") as mock: - yield mock + """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 @@ -33,3 +61,11 @@ 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 diff --git a/tests/internal/test_workflow.py b/tests/internal/test_workflow.py index 1d69a81..3ee1ba7 100644 --- a/tests/internal/test_workflow.py +++ b/tests/internal/test_workflow.py @@ -1,4 +1,5 @@ import pytest +from unittest.mock import patch from typing import Any from app._internal.workflow import Workflow from app._internal.types import EndureException @@ -49,42 +50,45 @@ def untyped_workflow(ctx, input): async def test_handler_route_successful_execution( self, mock_request, mock_internal_client ): - workflow = Workflow(self.sync_workflow) - handler = workflow.get_handler_route() + 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, - } + execution_id = "test-execution-id" + input_data = {"name": "Farah", "age": 30} + mock_request.json.return_value = { + "execution_id": execution_id, + "input": input_data, + } - # executing the handler - result = await handler(mock_request) + # executing the handler + result = await handler(mock_request) - assert result == {"output": "Hello, Farah!"} - mock_internal_client.mark_execution_as_running.assert_called_once_with( - execution_id - ) + 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 ): - mock_internal_client.mark_execution_as_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} + 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_validation_error(self, mock_request): diff --git a/tests/test_workflow_context.py b/tests/test_workflow_context.py index e69de29..d730ef1 100644 --- a/tests/test_workflow_context.py +++ b/tests/test_workflow_context.py @@ -0,0 +1,209 @@ +from app._internal.types import LogStatus, RetryMechanism, Response +import pytest +from unittest.mock import patch +from fastapi import status, HTTPException +import time + + +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(), + ] + + 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__ + + +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 = 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 + + +def test_action_with_retry_success(workflow_context): + """Test action that fails initially but succeeds after retry""" + input_data = {"input": "data"} + action_result = {"result": "processed data"} + attempt_count = 0 + + def failing_action(input_data): + nonlocal attempt_count + attempt_count += 1 + if attempt_count == 1: + raise ValueError("First attempt fails") + return action_result + + retry_time = time.time() + mock_responses = [ + Response( + status_code=status.HTTP_201_CREATED, payload={} + ), # Initial STARTED log + Response( + status_code=status.HTTP_200_OK, payload={"retry_at": retry_time} + ), # First failure log + Response(status_code=status.HTTP_200_OK, payload={}), # Success log + ] + 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 = 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 + failure_log_call = mock_send_log.call_args_list[1] + assert failure_log_call[0][1].status == LogStatus.FAILED + assert "First attempt fails" in failure_log_call[0][1].output["error"] + success_log_call = mock_send_log.call_args_list[2] + assert success_log_call[0][1].status == LogStatus.COMPLETED + assert success_log_call[0][1].output == action_result + + +def test_action_with_http_exception(workflow_context, sample_action): + """Test handling of HTTPException from the engine""" + 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(RuntimeError) as exc_info: + workflow_context.execute_action( + action=sample_action, + input_data={"test": "data"}, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + assert "Action execution failed" in str(exc_info.value) + assert "400" in str(exc_info.value) + + +def test_action_exhausts_retries(workflow_context): + """Test action that fails and exhausts all retries""" + + def failing_action(input_data): + raise ValueError("Action always fails") + + retry_time = time.time() + mock_responses = [ + Response( + status_code=status.HTTP_201_CREATED, payload={} + ), # Initial STARTED log + Response( + status_code=status.HTTP_200_OK, payload={"retry_at": retry_time} + ), # First failure + Response( + status_code=status.HTTP_200_OK, payload={"retry_at": retry_time} + ), # Second failure + Response( + status_code=status.HTTP_400_BAD_REQUEST, payload={} + ), # Final failure - no more retries + ] + 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(RuntimeError) as exc_info: + workflow_context.execute_action( + action=failing_action, + input_data={"test": "data"}, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + assert "Action execution failed" in str(exc_info.value) + assert mock_send_log.call_count == 4 + + +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 + + def failing_action(input_data): + raise ValueError("Action fails") + + mock_responses = [ + Response( + status_code=status.HTTP_201_CREATED, payload={} + ), # Initial STARTED log + Response( + status_code=status.HTTP_200_OK, + payload={"retry_at": future_retry_time}, + ), # Failure with future retry time + Response( + status_code=status.HTTP_400_BAD_REQUEST, payload={} + ), # Final failure + ] + 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: + workflow_context.execute_action( + action=failing_action, + input_data=input_data, + max_retries=3, + retry_mechanism=RetryMechanism.EXPONENTIAL, + ) + except RuntimeError: + pass + mock_sleep.assert_called_once() + sleep_duration = mock_sleep.call_args[0][0] + assert sleep_duration > 0 and sleep_duration <= 5 + assert mock_send_log.call_count == 3 From 0d0e7f6031815f2067b316f8c6f316b72fead0ef Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Mon, 16 Jun 2025 01:51:01 +0300 Subject: [PATCH 10/33] linting fix --- tests/conftest.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 062be28..5887caa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,15 @@ -# Set environment variables before any imports import os - -os.environ["DURABLE_ENGINE_BASE_URL"] = "http://test-engine:8000" - import pytest from unittest.mock import AsyncMock, Mock, patch from app import DurableApp, Service, WorkflowContext from app._internal.types import Response +def pytest_configure(config): + """Configure test environment before any tests run""" + os.environ["DURABLE_ENGINE_BASE_URL"] = "http://engine:8000" + + @pytest.fixture(autouse=True) def cleanup_test_env(): """Clean up test environment variables after each test""" From f19c2abb8ebe1a0cbfeba111c04a2362d5efecb7 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Mon, 16 Jun 2025 12:35:03 +0300 Subject: [PATCH 11/33] test internal_client.py --- tests/conftest.py | 29 +++++- tests/internal/test_internal_client.py | 135 +++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 tests/internal/test_internal_client.py diff --git a/tests/conftest.py b/tests/conftest.py index 5887caa..d69330c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,20 +2,33 @@ import pytest from unittest.mock import AsyncMock, Mock, patch from app import DurableApp, Service, WorkflowContext -from app._internal.types import Response +from app._internal.types import Response, Log, LogStatus, RetryMechanism +from app._internal.internal_client import InternalEndureClient def pytest_configure(config): """Configure test environment before any tests run""" - os.environ["DURABLE_ENGINE_BASE_URL"] = "http://engine:8000" + os.environ["DURABLE_ENGINE_BASE_URL"] = "http://test-engine:8000" + InternalEndureClient._base_url = "http://test-engine:8000" + + +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(): - """Clean up test environment variables after each test""" + """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 @@ -70,3 +83,13 @@ 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, + ) diff --git a/tests/internal/test_internal_client.py b/tests/internal/test_internal_client.py new file mode 100644 index 0000000..9e01805 --- /dev/null +++ b/tests/internal/test_internal_client.py @@ -0,0 +1,135 @@ +import os +import pytest +from unittest.mock import patch +from fastapi import status + +from app._internal.internal_client import InternalEndureClient +from app._internal.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/execution/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""" + # empty execution_id + with pytest.raises(ValueError) as exc_info: + InternalEndureClient.send_log( + execution_id="", + log=Log(status=LogStatus.STARTED), + action_name="test_action", + ) + assert "execution_id, log, and action_name must be provided" in str( + exc_info.value + ) + + # none log + with pytest.raises(ValueError) as exc_info: + InternalEndureClient.send_log( + execution_id="test-execution-id", + log=None, + action_name="test_action", + ) + assert "execution_id, 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 "execution_id, 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"] == {} From 2e0529706ec91bd9886501efbcd796c158d533f9 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Mon, 16 Jun 2025 15:31:28 +0300 Subject: [PATCH 12/33] test service.py --- src/app/_internal/__init__.py | 13 ++- src/app/_internal/utils.py | 22 +++--- src/app/service.py | 12 ++- tests/conftest.py | 25 ++++-- tests/test_service.py | 145 ++++++++++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 27 deletions(-) diff --git a/src/app/_internal/__init__.py b/src/app/_internal/__init__.py index 0bf2179..7554d4e 100644 --- a/src/app/_internal/__init__.py +++ b/src/app/_internal/__init__.py @@ -9,7 +9,14 @@ from .internal_client import InternalEndureClient from .service_registry import ServiceRegistry -from .types import EndureException, ErrorResponse +from .types import ( + EndureException, + ErrorResponse, + Response, + Log, + LogStatus, + RetryMechanism, +) from .utils import validate_retention_period from .workflow import Workflow @@ -48,6 +55,10 @@ def _check_caller(): "ErrorResponse", "InternalEndureClient", "ServiceRegistry", + "Log", + "LogStatus", + "RetryMechanism", "validate_retention_period", + "Response", "Workflow", ] diff --git a/src/app/_internal/utils.py b/src/app/_internal/utils.py index 6879b62..54c77fc 100644 --- a/src/app/_internal/utils.py +++ b/src/app/_internal/utils.py @@ -1,16 +1,16 @@ -def validate_retention_period(retention_period: str): +def validate_retention_period(retention: int) -> None: """ - Validate the retention period format. + Validate that the retention period is a non-negative integer. Args: - retention_period (str): The retention period string to validate. + retention (int): The retention period in days. - Returns: - None: If the retention period is valid. + Raises: + ValueError: If the retention period is not a non-negative integer. """ - 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.") + 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/service.py b/src/app/service.py index e41433e..69833bb 100644 --- a/src/app/service.py +++ b/src/app/service.py @@ -21,7 +21,7 @@ def workflow(self, **config): 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. + execution history and state. Must be a non-negative integer. Default: 7 days. Returns: @@ -29,7 +29,7 @@ def workflow(self, **config): as a registered workflow. Raises: - ValueError: If the retention period is invalid (not a positive integer). + ValueError: If the retention period is invalid (not a non-negative 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. @@ -41,6 +41,7 @@ def workflow(self, **config): Example: from my_app import Service from app.workflow_context import WorkflowContext + from typing import Dict, Any service = Service("my_service") @@ -59,15 +60,12 @@ def decorator(func): raise ValueError( "The workflow function must have an 'input' and 'ctx' argument." ) - if not isinstance( - func.__annotations__.get("ctx"), - WorkflowContext, - ): + 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 = ServiceRegistry() registry.register_workflow(self.name, workflow) registry.register_workflow_in_router(self.name, workflow) return func diff --git a/tests/conftest.py b/tests/conftest.py index d69330c..af5d79c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,14 +2,14 @@ import pytest from unittest.mock import AsyncMock, Mock, patch from app import DurableApp, Service, WorkflowContext -from app._internal.types import Response, Log, LogStatus, RetryMechanism -from app._internal.internal_client import InternalEndureClient - - -def pytest_configure(config): - """Configure test environment before any tests run""" - os.environ["DURABLE_ENGINE_BASE_URL"] = "http://test-engine:8000" - InternalEndureClient._base_url = "http://test-engine:8000" +from app._internal import ( + Log, + LogStatus, + RetryMechanism, + InternalEndureClient, + ServiceRegistry, + Response, +) def setup_module(module): @@ -93,3 +93,12 @@ def sample_log(): 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/test_service.py b/tests/test_service.py index e69de29..5b99c58 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -0,0 +1,145 @@ +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) + + # Get the registry instance + registry = ServiceRegistry() + services = registry.get_services() + + # Verify both workflows are registered + assert service.name in services + workflow_instances = services[service.name] + assert len(workflow_instances) == 2 + + # Get workflow names and retention periods + 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"}} From cd1509649fb3e9c046b9053bc9111758130b7827 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Mon, 16 Jun 2025 21:45:38 +0300 Subject: [PATCH 13/33] add and test the input and output extraction from a workflow fn --- poetry.lock | 61 +++++++++++++- pyproject.toml | 1 + src/app/_internal/workflow.py | 85 +++++++++++++++----- tests/internal/test_workflow.py | 137 +++++++++++++++++++++++++------- 4 files changed, 235 insertions(+), 49 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9340e1e..e5edb44 100644 --- a/poetry.lock +++ b/poetry.lock @@ -352,6 +352,65 @@ mccabe = ">=0.7.0,<0.8.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" version = "3.10" @@ -774,4 +833,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "3539d7f89bdaead2510eed57532d474fc46400b791383270c7bed1f7431b4c4c" +content-hash = "dbd221e0ba7cf913f0f3c585f3d96deefbe53b6902263fc2000066af71cdf715" diff --git a/pyproject.toml b/pyproject.toml index 0b1167a..374398d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ pytest-asyncio = "^0.25.3" fastapi = "^0.115.12" pydantic = "<2.0.0" requests = "^2.32.4" +httpx = "^0.28.1" [tool.poetry.group.dev.dependencies] pytest-cov = "^6.2.1" diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index 80c768f..a84fc5f 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -1,7 +1,7 @@ import asyncio -from typing import Any, Callable, get_type_hints +from typing import Any, Callable, Union, get_type_hints , get_origin, get_args -from fastapi import Request, status +from fastapi import Request, status, HTTPException, types from pydantic import ValidationError, create_model from app.workflow_context import WorkflowContext @@ -41,6 +41,57 @@ def __init__(self, func: Callable, retention_period: int = None): self.retention_period = retention_period self.input, self.output = self._get_io(func) + def _get_type_description(self, typ): + if typ is Any: + return "Any" + # Normalize NoneType to "None" + if typ is type(None): + return "None" + + origin = get_origin(typ) + args = get_args(typ) + + # Handle 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): """ Extract input and output type information from the function's type hints. @@ -53,8 +104,12 @@ def _get_io(self, func): aren't provided, Any is used as a fallback. """ # noqa: E501 hints = get_type_hints(func) - return hints.get("input", Any), hints.get("return", Any) + 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. @@ -74,35 +129,27 @@ def get_handler_route(self): - The handler expects a JSON request with 'execution_id' and 'input' fields. - Both synchronous and asynchronous workflow functions are supported. """ # noqa: E501 - 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) + ctx = WorkflowContext(execution_id=body['execution_id']) InternalEndureClient.mark_execution_as_running( - full.execution_id + body['execution_id'] ) - result = self.func(ctx, full.input) + result = self.func(ctx, body['input']) if asyncio.iscoroutine(result): result = await result return {"output": result} - except ValidationError as ve: - print(f"Validation error: {ve}") + except HTTPException as he: raise EndureException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=he.status_code, output={ - "error": "Validation error", - "details": ve.errors(), + "error": he.detail, + "details": he.errors(), }, ) except Exception as e: - print(f"Error in workflow handler: {e}") raise EndureException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, output={ diff --git a/tests/internal/test_workflow.py b/tests/internal/test_workflow.py index 3ee1ba7..2932545 100644 --- a/tests/internal/test_workflow.py +++ b/tests/internal/test_workflow.py @@ -6,7 +6,19 @@ from app.workflow_context import WorkflowContext from starlette.responses import Response - +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: @staticmethod def sync_workflow(ctx: WorkflowContext, input: dict) -> str: @@ -20,31 +32,117 @@ def typed_workflow(ctx: WorkflowContext, input: dict) -> dict: 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 + # 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): - # testing with typed workflow + # Test 1: Basic types (dict) workflow = Workflow(self.typed_workflow) - assert workflow.input == dict - assert workflow.output == dict + assert workflow.input == "dict" + assert workflow.output == "dict" - # testing with untyped workflow + # 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 + 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( @@ -62,8 +160,7 @@ async def test_handler_route_successful_execution( "execution_id": execution_id, "input": input_data, } - - # executing the handler + result = await handler(mock_request) assert result == {"output": "Hello, Farah!"} @@ -90,24 +187,6 @@ async def test_handler_route_async_workflow( assert result == {"output": 10} mock_mark_running.assert_called_once_with("test-execution-id") - @pytest.mark.asyncio - async def test_handler_route_validation_error(self, mock_request): - workflow = Workflow(self.typed_workflow) - handler = workflow.get_handler_route() - - # setup request data with invalid input type - mock_request.json.return_value = { - "execution_id": "test-execution-id", - "input": "invalid-input", # this should be a dict - } - - # executing handler and expect validation error - with pytest.raises(EndureException) as exc_info: - await handler(mock_request) - - assert exc_info.value.status_code == 400 - assert "Validation error" in str(exc_info.value.output["error"]) - @pytest.mark.asyncio async def test_handler_route_execution_error(self, mock_request): def failing_workflow(ctx: WorkflowContext, input: Any): From 7fb29ad92183e045df688ef1a0f6eec784ea8ab2 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Mon, 16 Jun 2025 23:29:50 +0300 Subject: [PATCH 14/33] add exceptions test cases for workflow class --- src/app/_internal/service_registry.py | 1 + src/app/_internal/workflow.py | 58 ++++++++----- tests/internal/test_workflow.py | 120 +++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 23 deletions(-) diff --git a/src/app/_internal/service_registry.py b/src/app/_internal/service_registry.py index 9effcae..23d1f40 100644 --- a/src/app/_internal/service_registry.py +++ b/src/app/_internal/service_registry.py @@ -72,3 +72,4 @@ def clear(self): """Clear all registered services and routes""" self._services.clear() self._router = APIRouter() + self.__class__._instance = None diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index a84fc5f..442780c 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -1,4 +1,5 @@ import asyncio +import json from typing import Any, Callable, Union, get_type_hints , get_origin, get_args from fastapi import Request, status, HTTPException, types @@ -109,7 +110,6 @@ def _get_io(self, func): 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. @@ -122,40 +122,58 @@ def get_handler_route(self): 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. + EndureException: With appropriate status code and error details. + 400 for validation/request errors, 500 for internal errors. Notes: - The handler expects a JSON request with 'execution_id' and 'input' fields. - Both synchronous and asynchronous workflow functions are supported. """ # noqa: E501 - + async def handler(request: Request): try: body = await request.json() - ctx = WorkflowContext(execution_id=body['execution_id']) - InternalEndureClient.mark_execution_as_running( - body['execution_id'] + except (json.JSONDecodeError, ValueError): + raise EndureException( + status_code=status.HTTP_400_BAD_REQUEST, + output={"error": "Invalid JSON format"} + ) + + 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"} ) - result = self.func(ctx, body['input']) - if asyncio.iscoroutine(result): - result = await result - return {"output": result} + + ctx = WorkflowContext(execution_id=body['execution_id']) + InternalEndureClient.mark_execution_as_running(body['execution_id']) + + try: + output = self.func(ctx, body['input']) + if asyncio.iscoroutine(output): + output = await output + return {"output": output} except HTTPException as he: raise EndureException( status_code=he.status_code, - output={ - "error": he.detail, - "details": he.errors(), - }, + output={"error": he.detail} + ) + except ValidationError as ve: + raise EndureException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + output={"error": "Validation error", "details": str(ve)} ) except Exception as e: raise EndureException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - output={ - "error": "Internal server error", - "details": str(e), - }, + output={"error": "Internal server error", "details": str(e)} ) - + return handler + diff --git a/tests/internal/test_workflow.py b/tests/internal/test_workflow.py index 2932545..f79772b 100644 --- a/tests/internal/test_workflow.py +++ b/tests/internal/test_workflow.py @@ -1,10 +1,12 @@ import pytest -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from typing import Any from app._internal.workflow import Workflow from app._internal.types import EndureException from app.workflow_context import WorkflowContext from starlette.responses import Response +from fastapi import HTTPException +from pydantic import ValidationError, BaseModel class InputModel: name: str @@ -19,7 +21,18 @@ def __init__(self, success=True, data=None, timestamps=None): self.success = success self.data = data or {} self.timestamps = timestamps or [] + class TestWorkflow: + @pytest.fixture(autouse=True) + def setup(self): + # Clear any state before each test + yield + # Cleanup after each test + + @pytest.fixture + def mock_request(self): + return AsyncMock() + @staticmethod def sync_workflow(ctx: WorkflowContext, input: dict) -> str: return f"Hello, {input['name']}!" @@ -200,8 +213,109 @@ def failing_workflow(ctx: WorkflowContext, input: Any): "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 == 500 + assert exc_info.value.output["error"] == "Internal server error" + + @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 == 500 - assert "Internal server error" == str(exc_info.value.output["error"]) + assert exc_info.value.status_code == 400 + assert exc_info.value.output["error"] == "Invalid JSON format" + + @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() From 7286683f39680581985abef16098d4c98d6d0da4 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Tue, 17 Jun 2025 00:42:10 +0300 Subject: [PATCH 15/33] add test_app.py --- src/app/__init__.py | 10 +- src/app/_internal/__init__.py | 14 --- src/app/_internal/internal_client.py | 2 +- src/app/_internal/service_registry.py | 1 + src/app/_internal/workflow.py | 5 +- src/app/app.py | 24 +++-- src/app/service.py | 6 +- src/app/{_internal => }/types.py | 0 src/app/workflow_context.py | 2 +- tests/conftest.py | 8 +- tests/internal/test_internal_client.py | 2 +- tests/internal/test_workflow.py | 2 +- tests/test_app.py | 140 +++++++++++++++++++++++++ tests/test_workflow_context.py | 2 +- 14 files changed, 180 insertions(+), 38 deletions(-) rename src/app/{_internal => }/types.py (100%) diff --git a/src/app/__init__.py b/src/app/__init__.py index d18b8c7..0aa4851 100644 --- a/src/app/__init__.py +++ b/src/app/__init__.py @@ -1,5 +1,13 @@ 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"] +__all__ = ["DurableApp", "Service", "WorkflowContext", "EndureException", "ErrorResponse", "Response", "Log", "LogStatus", "RetryMechanism"] diff --git a/src/app/_internal/__init__.py b/src/app/_internal/__init__.py index 7554d4e..a118b3d 100644 --- a/src/app/_internal/__init__.py +++ b/src/app/_internal/__init__.py @@ -9,14 +9,6 @@ from .internal_client import InternalEndureClient from .service_registry import ServiceRegistry -from .types import ( - EndureException, - ErrorResponse, - Response, - Log, - LogStatus, - RetryMechanism, -) from .utils import validate_retention_period from .workflow import Workflow @@ -51,14 +43,8 @@ def _check_caller(): _check_caller() __all__ = [ - "EndureException", - "ErrorResponse", "InternalEndureClient", "ServiceRegistry", - "Log", - "LogStatus", - "RetryMechanism", "validate_retention_period", - "Response", "Workflow", ] diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index 51b0a34..34cc2d3 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -1,6 +1,6 @@ import os import requests -from .types import Log, Response +from ..types import Log, Response class InternalEndureClient: diff --git a/src/app/_internal/service_registry.py b/src/app/_internal/service_registry.py index 23d1f40..139fa1c 100644 --- a/src/app/_internal/service_registry.py +++ b/src/app/_internal/service_registry.py @@ -73,3 +73,4 @@ def clear(self): self._services.clear() self._router = APIRouter() self.__class__._instance = None + self.__class__() diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index 442780c..626a25b 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -8,7 +8,7 @@ from app.workflow_context import WorkflowContext from .internal_client import InternalEndureClient -from .types import EndureException +from ..types import EndureException class Workflow: @@ -159,6 +159,9 @@ async def handler(request: Request): if asyncio.iscoroutine(output): output = await output return {"output": output} + except EndureException: + # re-raising EndureException to preserve its status code + raise except HTTPException as he: raise EndureException( status_code=he.status_code, diff --git a/src/app/app.py b/src/app/app.py index aab36df..c5d4508 100644 --- a/src/app/app.py +++ b/src/app/app.py @@ -4,11 +4,9 @@ from fastapi.responses import JSONResponse from app._internal import ( - EndureException, - ErrorResponse, ServiceRegistry, ) - +from app.types import EndureException, ErrorResponse class DurableApp: """ @@ -32,13 +30,14 @@ class DurableApp: def __init__(self, app): 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.include_router(self.serviceRegistry.get_router()) self.app.add_exception_handler(EndureException, self.raise_exception) def _discover(self): @@ -52,7 +51,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 ], @@ -61,7 +60,16 @@ def _discover(self): ] } - async def raise_exception(request: Request, exc: EndureException): + async def raise_exception(self, request: Request, exc: EndureException, _=None): + """ + Exception handler for EndureException. + Args: + request: The FastAPI request object + exc: The EndureException that was raised + _: An optional unused parameter that may be provided by FastAPI's exception handler + Returns: + JSONResponse: A response with the exception's status code and output + """ return JSONResponse( status_code=exc.status_code, content=asdict(ErrorResponse(output=exc.output)), diff --git a/src/app/service.py b/src/app/service.py index 69833bb..b0dbae7 100644 --- a/src/app/service.py +++ b/src/app/service.py @@ -9,6 +9,7 @@ class Service: def __init__(self, name: str): self.name = name + self.registry = ServiceRegistry() def workflow(self, **config): """ @@ -65,9 +66,8 @@ def decorator(func): "The 'ctx' argument must be of type WorkflowContext." ) workflow = Workflow(func, retention_period) - registry = ServiceRegistry() - 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 diff --git a/src/app/_internal/types.py b/src/app/types.py similarity index 100% rename from src/app/_internal/types.py rename to src/app/types.py diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index cb66fde..c13c6bc 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -5,7 +5,7 @@ from app._internal.internal_client import ( InternalEndureClient, ) -from app._internal.types import ( +from app.types import ( Log, LogStatus, RetryMechanism, diff --git a/tests/conftest.py b/tests/conftest.py index af5d79c..0a39d8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,10 @@ import os import pytest from unittest.mock import AsyncMock, Mock, patch -from app import DurableApp, Service, WorkflowContext +from app import DurableApp, Service, WorkflowContext , Log, LogStatus, RetryMechanism, Response from app._internal import ( - Log, - LogStatus, - RetryMechanism, InternalEndureClient, - ServiceRegistry, - Response, + ServiceRegistry ) diff --git a/tests/internal/test_internal_client.py b/tests/internal/test_internal_client.py index 9e01805..6548970 100644 --- a/tests/internal/test_internal_client.py +++ b/tests/internal/test_internal_client.py @@ -4,7 +4,7 @@ from fastapi import status from app._internal.internal_client import InternalEndureClient -from app._internal.types import Log, LogStatus, Response +from app.types import Log, LogStatus, Response class TestInternalClient: diff --git a/tests/internal/test_workflow.py b/tests/internal/test_workflow.py index f79772b..e285d02 100644 --- a/tests/internal/test_workflow.py +++ b/tests/internal/test_workflow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch from typing import Any from app._internal.workflow import Workflow -from app._internal.types import EndureException +from app.types import EndureException from app.workflow_context import WorkflowContext from starlette.responses import Response from fastapi import HTTPException diff --git a/tests/test_app.py b/tests/test_app.py index e69de29..45279bc 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -0,0 +1,140 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from unittest.mock import patch, MagicMock + +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_send_log.return_value = {"status_code": 200} + + yield + + self.mark_running_patcher.stop() + self.send_log_patcher.stop() + ServiceRegistry().clear() + 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" + \ No newline at end of file diff --git a/tests/test_workflow_context.py b/tests/test_workflow_context.py index d730ef1..04d08a1 100644 --- a/tests/test_workflow_context.py +++ b/tests/test_workflow_context.py @@ -1,4 +1,4 @@ -from app._internal.types import LogStatus, RetryMechanism, Response +from app.types import LogStatus, RetryMechanism, Response import pytest from unittest.mock import patch from fastapi import status, HTTPException From 18526a32c76a168e000f6320ad715744e481a735 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Tue, 17 Jun 2025 00:42:33 +0300 Subject: [PATCH 16/33] update test_app.py --- tests/test_app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_app.py b/tests/test_app.py index 45279bc..0d80d5f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -19,6 +19,7 @@ def setup(self): 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 From d84685c2ce3c5a1613ed2314350c33edad4b70ab Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Tue, 17 Jun 2025 00:43:55 +0300 Subject: [PATCH 17/33] linting triggered --- src/app/__init__.py | 12 +++- src/app/_internal/workflow.py | 58 ++++++++++-------- src/app/app.py | 6 +- src/app/service.py | 2 +- tests/conftest.py | 13 ++-- tests/internal/test_workflow.py | 104 ++++++++++++++++++++------------ tests/test_app.py | 49 ++++++++------- 7 files changed, 150 insertions(+), 94 deletions(-) diff --git a/src/app/__init__.py b/src/app/__init__.py index 0aa4851..ce23bf1 100644 --- a/src/app/__init__.py +++ b/src/app/__init__.py @@ -10,4 +10,14 @@ RetryMechanism, ) -__all__ = ["DurableApp", "Service", "WorkflowContext", "EndureException", "ErrorResponse", "Response", "Log", "LogStatus", "RetryMechanism"] +__all__ = [ + "DurableApp", + "Service", + "WorkflowContext", + "EndureException", + "ErrorResponse", + "Response", + "Log", + "LogStatus", + "RetryMechanism", +] diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index 626a25b..0547090 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -1,9 +1,9 @@ import asyncio import json -from typing import Any, Callable, Union, get_type_hints , get_origin, get_args +from typing import Any, Callable, Union, get_type_hints, get_origin, get_args from fastapi import Request, status, HTTPException, types -from pydantic import ValidationError, create_model +from pydantic import ValidationError from app.workflow_context import WorkflowContext @@ -55,11 +55,13 @@ def _get_type_description(self, typ): # Handle 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))) - + 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__', {}) + if hasattr(typ, "__annotations__") and not origin: + fields = getattr(typ, "__annotations__", {}) return { name: self._get_type_description(t) for name, t in fields.items() @@ -67,7 +69,9 @@ def _get_type_description(self, typ): # Case: Generic container like list[Class], dict[str, Class], etc. if origin: - origin_name = origin.__name__ if hasattr(origin, '__name__') else str(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: @@ -92,7 +96,6 @@ def _get_type_description(self, typ): # Fallback: stringify (removes "typing." prefix) return str(typ).replace("typing.", "") - def _get_io(self, func): """ Extract input and output type information from the function's type hints. @@ -108,8 +111,10 @@ def _get_io(self, 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) - + 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. @@ -129,33 +134,37 @@ def get_handler_route(self): - The handler expects a JSON request with 'execution_id' and 'input' fields. - Both synchronous and asynchronous workflow functions are supported. """ # noqa: E501 - + async def handler(request: Request): try: body = await request.json() except (json.JSONDecodeError, ValueError): raise EndureException( status_code=status.HTTP_400_BAD_REQUEST, - output={"error": "Invalid JSON format"} + output={"error": "Invalid JSON format"}, ) if not isinstance(body, dict): raise EndureException( status_code=status.HTTP_400_BAD_REQUEST, - output={"error": "Request body must be a JSON object"} + output={"error": "Request body must be a JSON object"}, ) - if 'execution_id' not in body or 'input' not in body: + 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"} + 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']) + ctx = WorkflowContext(execution_id=body["execution_id"]) + InternalEndureClient.mark_execution_as_running( + body["execution_id"] + ) try: - output = self.func(ctx, body['input']) + output = self.func(ctx, body["input"]) if asyncio.iscoroutine(output): output = await output return {"output": output} @@ -164,19 +173,20 @@ async def handler(request: Request): raise except HTTPException as he: raise EndureException( - status_code=he.status_code, - output={"error": he.detail} + status_code=he.status_code, output={"error": he.detail} ) except ValidationError as ve: raise EndureException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - output={"error": "Validation error", "details": str(ve)} + output={"error": "Validation error", "details": str(ve)}, ) except Exception as e: raise EndureException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - output={"error": "Internal server error", "details": str(e)} + output={ + "error": "Internal server error", + "details": str(e), + }, ) - + return handler - diff --git a/src/app/app.py b/src/app/app.py index c5d4508..9fa1249 100644 --- a/src/app/app.py +++ b/src/app/app.py @@ -8,6 +8,7 @@ ) from app.types import EndureException, ErrorResponse + class DurableApp: """ DurableApp is a wrapper class for a FastAPI application that integrates a service discovery endpoint. @@ -33,7 +34,6 @@ def __init__(self, app): self.serviceRegistry = ServiceRegistry() self.serviceRegistry.get_router().add_api_route( "/discover", - self._discover, methods=["GET"], ) @@ -60,7 +60,9 @@ def _discover(self): ] } - async def raise_exception(self, request: Request, exc: EndureException, _=None): + async def raise_exception( + self, request: Request, exc: EndureException, _=None + ): """ Exception handler for EndureException. Args: diff --git a/src/app/service.py b/src/app/service.py index b0dbae7..952740b 100644 --- a/src/app/service.py +++ b/src/app/service.py @@ -9,7 +9,7 @@ class Service: def __init__(self, name: str): self.name = name - self.registry = ServiceRegistry() + self.registry = ServiceRegistry() def workflow(self, **config): """ diff --git a/tests/conftest.py b/tests/conftest.py index 0a39d8d..c7200e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,16 @@ 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 +from app import ( + DurableApp, + Service, + WorkflowContext, + Log, + LogStatus, + RetryMechanism, + Response, ) +from app._internal import InternalEndureClient, ServiceRegistry def setup_module(module): diff --git a/tests/internal/test_workflow.py b/tests/internal/test_workflow.py index e285d02..0b88e0c 100644 --- a/tests/internal/test_workflow.py +++ b/tests/internal/test_workflow.py @@ -6,22 +6,26 @@ from app.workflow_context import WorkflowContext from starlette.responses import Response from fastapi import HTTPException -from pydantic import ValidationError, BaseModel +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(autouse=True) def setup(self): @@ -46,11 +50,15 @@ async def async_workflow(ctx: WorkflowContext, input: int) -> int: return input * 2 @staticmethod - def list_workflow(ctx: WorkflowContext, input: list[str]) -> tuple[int, str]: + 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]: + def complex_workflow( + ctx: WorkflowContext, input: dict[str, list[int]] + ) -> dict[str, Any]: return {"result": [1, 2, 3]} @staticmethod @@ -58,7 +66,9 @@ def class_workflow(ctx: WorkflowContext, input: InputModel) -> OutputModel: return OutputModel() @staticmethod - def nested_class_workflow(ctx: WorkflowContext, input: InputModel) -> dict[str, OutputModel]: + def nested_class_workflow( + ctx: WorkflowContext, input: InputModel + ) -> dict[str, OutputModel]: return {"result": OutputModel()} class DefaultValueModel: @@ -68,7 +78,9 @@ class DefaultValueModel: options: dict[str, bool] | None = None @staticmethod - def default_value_workflow(ctx: WorkflowContext, input: 'TestWorkflow.DefaultValueModel') -> str: + def default_value_workflow( + ctx: WorkflowContext, input: "TestWorkflow.DefaultValueModel" + ) -> str: return f"Processed {input.name}" def test_workflow_initialization(self): @@ -92,6 +104,7 @@ def test_get_io_types(self): # 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" @@ -112,8 +125,11 @@ def untyped_workflow(ctx, input): assert workflow_complex.output == "dict[str, Any]" # Test 6: Optional types - def optional_workflow(ctx: WorkflowContext, input: str | None) -> list[int] | None: + 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" @@ -122,37 +138,39 @@ 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]' + "name": "str", + "age": "int", + "tags": "list[str]", } assert workflow.output == { - 'success': 'bool', - 'data': 'dict[str, Any]', - 'timestamps': 'list[int]' + "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]' + "name": "str", + "age": "int", + "tags": "list[str]", } - assert workflow_nested.output == "dict[str, {'success': 'bool', 'data': 'dict[str, Any]', 'timestamps': 'list[int]'}]" + 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' + "name": "str", + "count": "int", + "items": "list[str]", + "options": "dict[str, bool] | None", } - assert workflow.output == 'str' + assert workflow.output == "str" - ctx = WorkflowContext(execution_id="test-id") result = self.default_value_workflow(ctx, self.DefaultValueModel()) assert result == "Processed default_name" @@ -173,7 +191,7 @@ async def test_handler_route_successful_execution( "execution_id": execution_id, "input": input_data, } - + result = await handler(mock_request) assert result == {"output": "Hello, Farah!"} @@ -213,7 +231,9 @@ def failing_workflow(ctx: WorkflowContext, input: Any): "input": "test-input", } - with patch('app._internal.workflow.InternalEndureClient.mark_execution_as_running') as mock_mark_running: + 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) @@ -233,7 +253,10 @@ async def test_missing_required_fields(self, mock_request): 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" + 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): @@ -241,18 +264,22 @@ async def test_invalid_input_type(self, mock_request): workflow = Workflow(self.sync_workflow) handler = workflow.get_handler_route() - with patch('app._internal.workflow.InternalEndureClient.mark_execution_as_running') as mock_mark_running: + 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) + "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"]) + assert "'int' object is not subscriptable" == str( + exc_info.value.output["details"] + ) @pytest.mark.asyncio async def test_malformed_json(self, mock_request): @@ -271,20 +298,20 @@ async def test_malformed_json(self, mock_request): @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" - ) + 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: + + 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": {} + "input": {}, } with pytest.raises(EndureException) as exc_info: @@ -296,6 +323,7 @@ async def failing_workflow(ctx: WorkflowContext, input: dict) -> str: @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 @@ -307,11 +335,13 @@ async def failing_workflow(ctx: WorkflowContext, input: dict) -> str: workflow = Workflow(failing_workflow) handler = workflow.get_handler_route() - with patch('app._internal.workflow.InternalEndureClient.mark_execution_as_running') as mock_mark_running: + 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": {} + "input": {}, } with pytest.raises(EndureException) as exc_info: diff --git a/tests/test_app.py b/tests/test_app.py index 0d80d5f..1d792c3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,9 +1,9 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from unittest.mock import patch, MagicMock +from unittest.mock import patch -from app import DurableApp, Service, WorkflowContext , EndureException +from app import DurableApp, Service, WorkflowContext, EndureException from app._internal import ServiceRegistry @@ -13,17 +13,21 @@ 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.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() ServiceRegistry().clear() @@ -32,7 +36,7 @@ def setup(self): self.client = None def test_discover_endpoint_returns_correct_format(self): - + test_service = Service("test_service") @test_service.workflow(retention=7) @@ -40,11 +44,10 @@ 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 @@ -55,12 +58,12 @@ def test_workflow(input: dict, ctx: WorkflowContext): workflow = service["workflows"][0] assert workflow["name"] == "test_workflow" - assert workflow["input"] == 'dict' - assert workflow["output"] == 'Any' + 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) @@ -70,7 +73,6 @@ def test_workflow(input: dict, ctx: WorkflowContext): # 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"}}, @@ -88,10 +90,8 @@ def failing_workflow(input: dict, ctx: WorkflowContext): 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"}}, @@ -102,7 +102,7 @@ def failing_workflow(input: dict, ctx: WorkflowContext): } def test_multiple_services_and_workflows(self): - + service1 = Service("service1") service2 = Service("service2") @@ -118,24 +118,23 @@ def workflow2(input: dict, ctx: WorkflowContext): 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") + 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") + 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" - \ No newline at end of file From 8904057ef864f479bcef8e2adb1b2cda310a7af8 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Tue, 17 Jun 2025 12:20:58 +0300 Subject: [PATCH 18/33] adjust functions descriptions --- src/app/_internal/internal_client.py | 18 +++- src/app/_internal/service_registry.py | 76 ++++++++++++++--- src/app/_internal/workflow.py | 95 ++++++++++++++++----- src/app/app.py | 91 ++++++++++++++++---- src/app/service.py | 106 ++++++++++++++++++------ src/app/workflow_context.py | 114 ++++++++++++++++++-------- tests/internal/test_workflow.py | 5 -- tests/test_app.py | 1 - tests/test_service.py | 3 - 9 files changed, 392 insertions(+), 117 deletions(-) diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index 34cc2d3..fb5de11 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -14,8 +14,15 @@ def send_log(self, execution_id: str, log: Log, action_name: str): Args: execution_id (str): The ID of the execution context. - log (dict): The log message to send. + 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 if not self._base_url: raise ValueError( @@ -41,10 +48,17 @@ def send_log(self, execution_id: str, log: Log, action_name: str): @classmethod def mark_execution_as_running(self, execution_id: str): """ - Marks an execution as running. + 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. """ if not self._base_url: raise ValueError( diff --git a/src/app/_internal/service_registry.py b/src/app/_internal/service_registry.py index 139fa1c..cc1e669 100644 --- a/src/app/_internal/service_registry.py +++ b/src/app/_internal/service_registry.py @@ -7,22 +7,22 @@ class ServiceRegistry: """ - Singleton class for managing workflow services and their API routes. + 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 for dynamically registered workflow routes. + _router (APIRouter): FastAPI router containing dynamically registered workflow endpoints. + 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. + __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 @@ -30,6 +30,13 @@ class ServiceRegistry: _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 = {} @@ -37,6 +44,19 @@ def __new__(cls): 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): @@ -56,6 +76,15 @@ def register_workflow(self, service_name: str, workflow: 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(), @@ -63,13 +92,34 @@ def register_workflow_in_router( ) 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): - """Clear all registered services and routes""" + """ + 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 diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index 0547090..190252a 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -15,16 +15,25 @@ 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. + 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): The input type of the workflow function (from type hints). - output (Any): The return type of the workflow function (from type hints). + 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): @@ -32,10 +41,16 @@ 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. + 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__ @@ -43,16 +58,38 @@ def __init__(self, func: Callable, retention_period: int = None): 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" - # Normalize NoneType to "None" + if typ is type(None): return "None" origin = get_origin(typ) args = get_args(typ) - # Handle Union and | (UnionType in Python 3.10+) + # 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( @@ -98,14 +135,20 @@ def _get_type_description(self, typ): def _get_io(self, func): """ - Extract input and output type information from the function's type hints. + 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). If type hints - aren't provided, Any is used as a fallback. + 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) @@ -119,20 +162,32 @@ 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. + 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. - Raises: - EndureException: With appropriate status code and error details. - 400 for validation/request errors, 500 for internal errors. + 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: - - The handler expects a JSON request with 'execution_id' and 'input' fields. - - Both synchronous and asynchronous workflow functions are supported. + - 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): diff --git a/src/app/app.py b/src/app/app.py index 9fa1249..f7cc349 100644 --- a/src/app/app.py +++ b/src/app/app.py @@ -11,25 +11,58 @@ 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 self.serviceRegistry = ServiceRegistry() self.serviceRegistry.get_router().add_api_route( @@ -41,6 +74,12 @@ def __init__(self, app): 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": [ @@ -64,13 +103,31 @@ async def raise_exception( self, request: Request, exc: EndureException, _=None ): """ - Exception handler for EndureException. + FastAPI exception handler for EndureException. + + This handler converts EndureException instances into consistent JSON responses + using the ErrorResponse model. + Args: - request: The FastAPI request object - exc: The EndureException that was raised - _: An optional unused parameter that may be provided by FastAPI's exception handler + 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: A response with the exception's status code and output + 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, diff --git a/src/app/service.py b/src/app/service.py index 952740b..68bdd8d 100644 --- a/src/app/service.py +++ b/src/app/service.py @@ -7,7 +7,50 @@ 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 + ``` + """ + 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 self.registry = ServiceRegistry() @@ -15,42 +58,59 @@ 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 non-negative integer. - Default: 7 days. + 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 non-negative 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. + 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 - Requirements: - - Workflow function must have exactly two parameters: 'input' and 'ctx' - - The 'ctx' parameter must be type-annotated as WorkflowContext + 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 - Example: - from my_app import Service - from app.workflow_context import WorkflowContext - from typing import Dict, Any + 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 + } - service = Service("my_service") + Example: + ```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): diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index c13c6bc..476a410 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -17,11 +17,29 @@ 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): @@ -30,8 +48,12 @@ def __init__(self, execution_id: str): Args: execution_id (str): The unique identifier for this workflow execution. - Used for tracking and correlating actions. - """ # noqa: E501 + 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( @@ -42,42 +64,68 @@ def execute_action( retry_mechanism: RetryMechanism, ) -> any: """ - Execute an action with durability guarantees. + 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 - 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 States: + - STARTED: Initial action execution attempt + - COMPLETED: Successful execution + - FAILED: Failed attempt, may trigger retry - 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 + 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. - """ # noqa: E501 + 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 + ) + ``` + """ try: log = Log( status=LogStatus.STARTED, diff --git a/tests/internal/test_workflow.py b/tests/internal/test_workflow.py index 0b88e0c..0d9935b 100644 --- a/tests/internal/test_workflow.py +++ b/tests/internal/test_workflow.py @@ -27,11 +27,6 @@ def __init__(self, success=True, data=None, timestamps=None): class TestWorkflow: - @pytest.fixture(autouse=True) - def setup(self): - # Clear any state before each test - yield - # Cleanup after each test @pytest.fixture def mock_request(self): diff --git a/tests/test_app.py b/tests/test_app.py index 1d792c3..6ea29d0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -30,7 +30,6 @@ def setup(self): self.mark_running_patcher.stop() self.send_log_patcher.stop() - ServiceRegistry().clear() self.app = None self.durable_app = None self.client = None diff --git a/tests/test_service.py b/tests/test_service.py index 5b99c58..2cba7fa 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -115,16 +115,13 @@ def workflow2(input: dict, ctx: WorkflowContext): service.workflow(retention=0)(workflow1) service.workflow(retention=20)(workflow2) - # Get the registry instance registry = ServiceRegistry() services = registry.get_services() - # Verify both workflows are registered assert service.name in services workflow_instances = services[service.name] assert len(workflow_instances) == 2 - # Get workflow names and retention periods workflow_info = [ (w.name, w.retention_period) for w in workflow_instances ] From 6cca019207ba4a2391bfd003f51b07b509501391 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Wed, 18 Jun 2025 16:32:04 +0300 Subject: [PATCH 19/33] revert patch changes --- src/app/workflow_context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 476a410..6b4caf5 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -208,4 +208,6 @@ def process_payment(input_data: dict) -> dict: if status_code != status.HTTP_200_OK: raise RuntimeError( f"Action execution failed: {str(e)}" - ) + ) + + From 6cc2a6514e8106ef031c53b7d8150b9689dca2d4 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Wed, 18 Jun 2025 16:32:15 +0300 Subject: [PATCH 20/33] Revert "revert patch changes" This reverts commit 6cca019207ba4a2391bfd003f51b07b509501391. --- src/app/workflow_context.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 6b4caf5..476a410 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -208,6 +208,4 @@ def process_payment(input_data: dict) -> dict: if status_code != status.HTTP_200_OK: raise RuntimeError( f"Action execution failed: {str(e)}" - ) - - + ) From dc507bc8fb839dc651c8e69ae39c5d574784552d Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Wed, 18 Jun 2025 23:04:27 +0300 Subject: [PATCH 21/33] adjust workflow_context and internal_client to properly handle exceptions --- src/app/_internal/internal_client.py | 67 +++++++++++++++++----------- src/app/workflow_context.py | 31 +++++++------ 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index fb5de11..7d5dad1 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -1,6 +1,7 @@ import os import requests from ..types import Log, Response +from fastapi import status class InternalEndureClient: @@ -29,20 +30,28 @@ def send_log(self, execution_id: str, log: Log, action_name: str): "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." - ) + if not log or not action_name: + raise ValueError( + "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 = log.to_dict() - response = requests.patch(url, headers=headers, json=payload) - response.raise_for_status() - response = Response( - status_code=response.status_code, - payload=response.json(), - ) + 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() + response = Response( + status_code=response.status_code, + payload=response.json(), + ) + except requests.exceptions.HTTPError as e: + response = Response( + status_code=e.response.status_code, + payload=e.response.json(), + ) + except (requests.exceptions.RequestException) as e: + print("Engine is unreachable. Aborting retries: {}".format(e)) + return return response.to_dict() @classmethod @@ -60,19 +69,23 @@ def mark_execution_as_running(self, execution_id: str): ValueError: If DURABLE_ENGINE_BASE_URL is not set or if execution_id is missing. requests.exceptions.HTTPError: If the request fails. """ - if not self._base_url: - raise ValueError( - "DURABLE_ENGINE_BASE_URL is not set in environment variables." + 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, ) - - 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_code=response.status_code, - ) + except requests.exceptions.HTTPError as e: + response = Response( + status_code=e.response.status_code, + ) + except (requests.exceptions.RequestException) as e: + print("Engine is unreachable. Aborting retries: {}".format(e)) + return return response.to_dict() diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 476a410..549d0c5 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -1,6 +1,7 @@ import time -from fastapi import HTTPException, status +from fastapi import HTTPException, status , ValidationException +from fastapi.exceptions import RequestValidationError from app._internal.internal_client import ( InternalEndureClient, @@ -9,6 +10,7 @@ Log, LogStatus, RetryMechanism, + EndureException, ) @@ -137,7 +139,9 @@ def process_payment(input_data: dict) -> dict: self.execution_id, log, action.__name__ ) if not engine_response: - raise RuntimeError("Failed to mark execution as running.") + 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: @@ -155,18 +159,15 @@ def process_payment(input_data: dict) -> dict: case status.HTTP_208_ALREADY_REPORTED: output = engine_response.get("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 ValueError as e: + print(f"ValueError: {e}") + raise e except Exception as e: log = Log( status=LogStatus.FAILED, output={"error": str(e)}, ) - engine_response = InternalEndureClient.send_log( + InternalEndureClient.send_log( self.execution_id, log, action.__name__ ) status_code = engine_response["status_code"] @@ -177,8 +178,9 @@ def process_payment(input_data: dict) -> dict: "retry_at" ) if not retry_at_unix: - raise RuntimeError( - "Missing retry_at in response payload" + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Missing retry_at in response payload" ) sleep_seconds = retry_at_unix - time.time() if sleep_seconds > 0: @@ -199,13 +201,14 @@ def process_payment(input_data: dict) -> dict: status=LogStatus.FAILED, output={"error": str(e)}, ) - engine_response = InternalEndureClient.send_log( + InternalEndureClient.send_log( self.execution_id, log, action.__name__, ) status_code = engine_response["status_code"] if status_code != status.HTTP_200_OK: - raise RuntimeError( - f"Action execution failed: {str(e)}" + raise EndureException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + output={"error": str("Action failed after reaching max retries")}, ) From da6df6f1e7fd59a306366cf5b003931eaac5e65e Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Wed, 18 Jun 2025 23:05:20 +0300 Subject: [PATCH 22/33] adjust workflow_context and internal_client to be in a try except block --- src/app/_internal/internal_client.py | 9 +++--- src/app/_internal/workflow.py | 42 ++++++++++++++-------------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index 7d5dad1..dd786ca 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -25,10 +25,11 @@ def send_log(self, execution_id: str, log: Log, action_name: str): ValueError: If DURABLE_ENGINE_BASE_URL is not set or if required parameters are missing. requests.exceptions.HTTPError: If the request fails. """ # noqa: E501 - if not self._base_url: - raise ValueError( - "DURABLE_ENGINE_BASE_URL is not set in environment variables." - ) + 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( diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index 190252a..33efbb4 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -193,32 +193,32 @@ def get_handler_route(self): 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"] + ) except (json.JSONDecodeError, ValueError): raise EndureException( status_code=status.HTTP_400_BAD_REQUEST, output={"error": "Invalid JSON format"}, ) - - 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"] - ) - - try: + except ValueError as e: + raise e + except Exception as e: output = self.func(ctx, body["input"]) if asyncio.iscoroutine(output): output = await output From e1236552c0f8f21fe6596dcf93e28192377c2080 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Wed, 18 Jun 2025 23:30:06 +0300 Subject: [PATCH 23/33] re-raising exceptions for users using WorkflowContext --- src/app/_internal/internal_client.py | 10 ++++++---- src/app/_internal/workflow.py | 21 ++++++++------------- src/app/workflow_context.py | 6 +++--- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index dd786ca..8cb4cbd 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -27,11 +27,13 @@ def send_log(self, execution_id: str, log: Log, action_name: str): """ # noqa: E501 try: if not self._base_url: + print("DURABLE_ENGINE_BASE_URL is not set in environment variables.") raise ValueError( "DURABLE_ENGINE_BASE_URL is not set in environment variables." ) if not log or not action_name: + print("log and action_name must be provided.") raise ValueError( "log and action_name must be provided." ) @@ -50,9 +52,9 @@ def send_log(self, execution_id: str, log: Log, action_name: str): status_code=e.response.status_code, payload=e.response.json(), ) - except (requests.exceptions.RequestException) as e: + except requests.exceptions.RequestException as e: print("Engine is unreachable. Aborting retries: {}".format(e)) - return + raise e return response.to_dict() @classmethod @@ -86,7 +88,7 @@ def mark_execution_as_running(self, execution_id: str): response = Response( status_code=e.response.status_code, ) - except (requests.exceptions.RequestException) as e: + except requests.exceptions.RequestException as e: print("Engine is unreachable. Aborting retries: {}".format(e)) - return + raise e return response.to_dict() diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index 33efbb4..b768dd9 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -198,7 +198,6 @@ async def handler(request: Request): 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, @@ -206,26 +205,22 @@ async def handler(request: Request): "error": "Request must include 'execution_id' and 'input' fields" }, ) - ctx = WorkflowContext(execution_id=body["execution_id"]) InternalEndureClient.mark_execution_as_running( body["execution_id"] ) - except (json.JSONDecodeError, ValueError): - raise EndureException( - status_code=status.HTTP_400_BAD_REQUEST, - output={"error": "Invalid JSON format"}, - ) - except ValueError as e: - raise e - except Exception as e: output = self.func(ctx, body["input"]) if asyncio.iscoroutine(output): output = await output return {"output": output} - except EndureException: - # re-raising EndureException to preserve its status code - raise + except ValueError as ve: + raise EndureException( + status_code=status.HTTP_400_BAD_REQUEST, + output={"error": "Value error", "details": str(ve)}, + ) + # except EndureException: + # # re-raising EndureException to preserve its status code + # raise except HTTPException as he: raise EndureException( status_code=he.status_code, output={"error": he.detail} diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 549d0c5..695b858 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -1,8 +1,7 @@ import time from fastapi import HTTPException, status , ValidationException -from fastapi.exceptions import RequestValidationError - +import requests from app._internal.internal_client import ( InternalEndureClient, ) @@ -160,7 +159,8 @@ def process_payment(input_data: dict) -> dict: output = engine_response.get("payload", {}).get("output") return output if output else {} except ValueError as e: - print(f"ValueError: {e}") + raise e + except requests.exceptions.RequestException as e: raise e except Exception as e: log = Log( From 1c55be84f42f4a23e1a10328e872dc8c1a6d6c83 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Wed, 18 Jun 2025 23:42:02 +0300 Subject: [PATCH 24/33] adjust internal_client.py test cases --- src/app/_internal/internal_client.py | 1 - src/app/_internal/workflow.py | 3 --- src/app/workflow_context.py | 2 +- tests/internal/test_internal_client.py | 17 +++-------------- 4 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index 8cb4cbd..36705fb 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -1,7 +1,6 @@ import os import requests from ..types import Log, Response -from fastapi import status class InternalEndureClient: diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index b768dd9..bde37b2 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -218,9 +218,6 @@ async def handler(request: Request): status_code=status.HTTP_400_BAD_REQUEST, output={"error": "Value error", "details": str(ve)}, ) - # except EndureException: - # # re-raising EndureException to preserve its status code - # raise except HTTPException as he: raise EndureException( status_code=he.status_code, output={"error": he.detail} diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 695b858..ca1682d 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -1,6 +1,6 @@ import time -from fastapi import HTTPException, status , ValidationException +from fastapi import HTTPException, status import requests from app._internal.internal_client import ( InternalEndureClient, diff --git a/tests/internal/test_internal_client.py b/tests/internal/test_internal_client.py index 6548970..65878e7 100644 --- a/tests/internal/test_internal_client.py +++ b/tests/internal/test_internal_client.py @@ -32,7 +32,7 @@ def test_send_log_success(self, sample_log, mock_response): call_args = mock_patch.call_args assert ( call_args[0][0] - == f"{InternalEndureClient._base_url}/executions/execution/test-execution-id/log/test_action" + == f"{InternalEndureClient._base_url}/executions/test-execution-id/log/test_action" ) assert call_args[1]["headers"] == { "Content-Type": "application/json" @@ -65,17 +65,6 @@ def test_send_log_missing_env_var(self): def test_send_log_invalid_inputs(self): """Test error handling for invalid input parameters""" - # empty execution_id - with pytest.raises(ValueError) as exc_info: - InternalEndureClient.send_log( - execution_id="", - log=Log(status=LogStatus.STARTED), - action_name="test_action", - ) - assert "execution_id, log, and action_name must be provided" in str( - exc_info.value - ) - # none log with pytest.raises(ValueError) as exc_info: InternalEndureClient.send_log( @@ -83,7 +72,7 @@ def test_send_log_invalid_inputs(self): log=None, action_name="test_action", ) - assert "execution_id, log, and action_name must be provided" in str( + assert "log and action_name must be provided" in str( exc_info.value ) @@ -94,7 +83,7 @@ def test_send_log_invalid_inputs(self): log=Log(status=LogStatus.STARTED), action_name="", ) - assert "execution_id, log, and action_name must be provided" in str( + assert "log and action_name must be provided" in str( exc_info.value ) From 3fc52b9b2721678e985b396e689908b89d0704d3 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Thu, 19 Jun 2025 13:30:25 +0300 Subject: [PATCH 25/33] adjust workflow test cases --- src/app/_internal/workflow.py | 14 ++++++++------ tests/internal/test_workflow.py | 10 ++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index bde37b2..e02bed6 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -214,19 +214,21 @@ async def handler(request: Request): 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 ValidationError as ve: - raise EndureException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - output={"error": "Validation error", "details": str(ve)}, - ) + except EndureException as ee: + raise ee except Exception as e: raise EndureException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/tests/internal/test_workflow.py b/tests/internal/test_workflow.py index 0d9935b..56c220b 100644 --- a/tests/internal/test_workflow.py +++ b/tests/internal/test_workflow.py @@ -233,8 +233,9 @@ def failing_workflow(ctx: WorkflowContext, input: Any): with pytest.raises(EndureException) as exc_info: await handler(mock_request) - assert exc_info.value.status_code == 500 - assert exc_info.value.output["error"] == "Internal server error" + 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): @@ -281,14 +282,11 @@ 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"] == "Invalid JSON format" + assert exc_info.value.output["error"] == "Value error" @pytest.mark.asyncio async def test_workflow_http_exception(self, mock_request): From f472bab4396b3670f5f8d2730d9cf312a1fbb29f Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Thu, 19 Jun 2025 17:06:37 +0300 Subject: [PATCH 26/33] refactor WorkflowContext class and update test cases --- src/app/_internal/workflow.py | 8 +- src/app/workflow_context.py | 147 ++++++++++++-------------- tests/test_workflow_context.py | 187 +++++++++++++++++++++++---------- 3 files changed, 201 insertions(+), 141 deletions(-) diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index e02bed6..d2861fb 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -1,5 +1,5 @@ import asyncio -import json +import requests from typing import Any, Callable, Union, get_type_hints, get_origin, get_args from fastapi import Request, status, HTTPException, types @@ -229,6 +229,12 @@ async def handler(request: Request): ) 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, diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index ca1682d..2891dad 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -11,6 +11,7 @@ RetryMechanism, EndureException, ) +from pydantic import ValidationError class WorkflowContext: @@ -127,88 +128,70 @@ def process_payment(input_data: dict) -> dict: ) ``` """ - try: - log = Log( - status=LogStatus.STARTED, - input=input_data, - retry_mechanism=retry_mechanism, - max_retries=max_retries, + 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)." ) - 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: - 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.get("payload", {}).get("output") - return output if output else {} - except ValueError as e: - raise e - except requests.exceptions.RequestException as e: - raise e - except Exception as e: - log = Log( - status=LogStatus.FAILED, - output={"error": str(e)}, - ) - 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.get("payload", {}).get( - "retry_at" - ) - if not retry_at_unix: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Missing retry_at in response payload" + 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: + 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__, + ) + print(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: + print(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)}, ) - 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)}, - ) - InternalEndureClient.send_log( - self.execution_id, - log, - action.__name__, - ) - status_code = engine_response["status_code"] - if status_code != status.HTTP_200_OK: - raise EndureException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - output={"error": str("Action failed after reaching max retries")}, + 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/tests/test_workflow_context.py b/tests/test_workflow_context.py index 04d08a1..eb5ed04 100644 --- a/tests/test_workflow_context.py +++ b/tests/test_workflow_context.py @@ -3,6 +3,8 @@ from unittest.mock import patch from fastapi import status, HTTPException import time +from pydantic import ValidationError, BaseModel +import requests def test_successful_action_execution(workflow_context, sample_action): @@ -71,27 +73,23 @@ def test_already_executed_action(workflow_context, sample_action): def test_action_with_retry_success(workflow_context): - """Test action that fails initially but succeeds after retry""" + """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 ValueError("First attempt fails") + raise CustomException("First attempt fails") return action_result - retry_time = time.time() mock_responses = [ - Response( - status_code=status.HTTP_201_CREATED, payload={} - ), # Initial STARTED log - Response( - status_code=status.HTTP_200_OK, payload={"retry_at": retry_time} - ), # First failure log - Response(status_code=status.HTTP_200_OK, payload={}), # Success log + 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" @@ -106,88 +104,68 @@ def failing_action(input_data): assert result == action_result assert attempt_count == 2 assert mock_send_log.call_count == 3 - failure_log_call = mock_send_log.call_args_list[1] - assert failure_log_call[0][1].status == LogStatus.FAILED - assert "First attempt fails" in failure_log_call[0][1].output["error"] - success_log_call = mock_send_log.call_args_list[2] - assert success_log_call[0][1].status == LogStatus.COMPLETED - assert success_log_call[0][1].output == action_result def test_action_with_http_exception(workflow_context, sample_action): - """Test handling of HTTPException from the engine""" + """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(RuntimeError) as exc_info: + with pytest.raises(HTTPException) as exc_info: workflow_context.execute_action( action=sample_action, input_data={"test": "data"}, max_retries=3, retry_mechanism=RetryMechanism.EXPONENTIAL, ) - assert "Action execution failed" in str(exc_info.value) - assert "400" in str(exc_info.value) + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert exc_info.value.detail == "Bad request" def test_action_exhausts_retries(workflow_context): - """Test action that fails and exhausts all retries""" - + """Test that a generic Exception (not ValueError/ValidationError) after all retries raises EndureException.""" + class CustomException(Exception): + pass def failing_action(input_data): - raise ValueError("Action always fails") - + raise CustomException("Always fails") retry_time = time.time() mock_responses = [ - Response( - status_code=status.HTTP_201_CREATED, payload={} - ), # Initial STARTED log - Response( - status_code=status.HTTP_200_OK, payload={"retry_at": retry_time} - ), # First failure - Response( - status_code=status.HTTP_200_OK, payload={"retry_at": retry_time} - ), # Second failure - Response( - status_code=status.HTTP_400_BAD_REQUEST, payload={} - ), # Final failure - no more retries + 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(RuntimeError) as exc_info: + with pytest.raises(Exception) as exc_info: workflow_context.execute_action( action=failing_action, input_data={"test": "data"}, max_retries=3, retry_mechanism=RetryMechanism.EXPONENTIAL, ) - assert "Action execution failed" in str(exc_info.value) - assert mock_send_log.call_count == 4 - + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert exc_info.value.output["error"] == "Action failed after reaching max retries" def test_retry_respects_timing(workflow_context): - """Test that retry mechanism respects the timing specified by the engine""" + """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 ValueError("Action fails") - + raise CustomException("Action fails") mock_responses = [ - Response( - status_code=status.HTTP_201_CREATED, payload={} - ), # Initial STARTED log - Response( - status_code=status.HTTP_200_OK, - payload={"retry_at": future_retry_time}, - ), # Failure with future retry time - Response( - status_code=status.HTTP_400_BAD_REQUEST, payload={} - ), # Final failure + 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" @@ -201,9 +179,102 @@ def failing_action(input_data): max_retries=3, retry_mechanism=RetryMechanism.EXPONENTIAL, ) - except RuntimeError: + except Exception: pass - mock_sleep.assert_called_once() + 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 == 3 + assert mock_send_log.call_count == 4 + + +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) as exc_info: + 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"] + + +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): + 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() + + +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): + 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 + + +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: + 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 + + From e91541cd690f85444e4ecef18481f14458783959 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Thu, 19 Jun 2025 18:04:52 +0300 Subject: [PATCH 27/33] update test cases for internal_client.py --- src/app/_internal/internal_client.py | 24 +++++-- src/app/_internal/workflow.py | 7 +- src/app/workflow_context.py | 26 ++++++-- tests/internal/test_internal_client.py | 30 +++++++-- tests/internal/test_workflow.py | 4 +- tests/test_workflow_context.py | 91 ++++++++++++++++++++------ 6 files changed, 140 insertions(+), 42 deletions(-) diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index 36705fb..10cd3cb 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -26,30 +26,40 @@ def send_log(self, execution_id: str, log: Log, action_name: str): """ # noqa: E501 try: if not self._base_url: - print("DURABLE_ENGINE_BASE_URL is not set in environment variables.") + print( + "DURABLE_ENGINE_BASE_URL is not set in environment variables." + ) raise ValueError( "DURABLE_ENGINE_BASE_URL is not set in environment variables." ) if not log or not action_name: print("log and action_name must be provided.") - raise ValueError( - "log and action_name must be provided." - ) + raise ValueError("log and action_name must be provided.") - url = f"{self._base_url}/executions/{execution_id}/log/{action_name}" + 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.json(), + 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=e.response.json(), + payload=error_payload, ) except requests.exceptions.RequestException as e: print("Engine is unreachable. Aborting retries: {}".format(e)) diff --git a/src/app/_internal/workflow.py b/src/app/_internal/workflow.py index d2861fb..d7ac68d 100644 --- a/src/app/_internal/workflow.py +++ b/src/app/_internal/workflow.py @@ -217,7 +217,10 @@ async def handler(request: Request): if isinstance(ve, ValidationError): raise EndureException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - output={"error": "Validation error", "details": str(ve)}, + output={ + "error": "Validation error", + "details": str(ve), + }, ) raise EndureException( status_code=status.HTTP_400_BAD_REQUEST, @@ -234,7 +237,7 @@ async def handler(request: Request): 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, diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 2891dad..6df4d7b 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -1,6 +1,6 @@ import time -from fastapi import HTTPException, status +from fastapi import status import requests from app._internal.internal_client import ( InternalEndureClient, @@ -158,7 +158,9 @@ def process_payment(input_data: dict) -> dict: ), action.__name__, ) - print(f"WORKFLOW DEBUG: About to raise exception of type {type(e)}: {e}") + print( + f"WORKFLOW DEBUG: About to raise exception of type {type(e)}: {e}" + ) raise log = Log( status=LogStatus.COMPLETED, @@ -170,14 +172,24 @@ def process_payment(input_data: dict) -> dict: action.__name__, ) return result - except (ValueError, ValidationError, requests.exceptions.RequestException) as e: - print(f"DEBUG: Caught exception of type {type(e)}: {e}") + except ( + ValueError, + ValidationError, + requests.exceptions.RequestException, + ) as e: + print( + 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")}, + output={ + "error": str( + "Action failed after reaching max retries" + ) + }, ) log = Log( status=LogStatus.FAILED, @@ -187,7 +199,9 @@ def process_payment(input_data: dict) -> dict: self.execution_id, log, action.__name__ ) attempt += 1 - retry_at_unix = engine_response.get("payload", {}).get("retry_at") + 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: diff --git a/tests/internal/test_internal_client.py b/tests/internal/test_internal_client.py index 65878e7..b7aa0de 100644 --- a/tests/internal/test_internal_client.py +++ b/tests/internal/test_internal_client.py @@ -3,6 +3,7 @@ from unittest.mock import patch from fastapi import status + from app._internal.internal_client import InternalEndureClient from app.types import Log, LogStatus, Response @@ -72,9 +73,7 @@ def test_send_log_invalid_inputs(self): log=None, action_name="test_action", ) - assert "log and action_name must be provided" in str( - exc_info.value - ) + assert "log and action_name must be provided" in str(exc_info.value) # empty action_name with pytest.raises(ValueError) as exc_info: @@ -83,9 +82,7 @@ def test_send_log_invalid_inputs(self): log=Log(status=LogStatus.STARTED), action_name="", ) - assert "log and action_name must be provided" in str( - exc_info.value - ) + 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""" @@ -122,3 +119,24 @@ def test_mark_execution_as_running_success(self): 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_workflow.py b/tests/internal/test_workflow.py index 56c220b..74f3c5e 100644 --- a/tests/internal/test_workflow.py +++ b/tests/internal/test_workflow.py @@ -235,7 +235,9 @@ def failing_workflow(ctx: WorkflowContext, input: Any): 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"] + assert ( + "Workflow execution failed" in exc_info.value.output["details"] + ) @pytest.mark.asyncio async def test_missing_required_fields(self, mock_request): diff --git a/tests/test_workflow_context.py b/tests/test_workflow_context.py index eb5ed04..02c8e82 100644 --- a/tests/test_workflow_context.py +++ b/tests/test_workflow_context.py @@ -73,22 +73,28 @@ def test_already_executed_action(workflow_context, sample_action): def test_action_with_retry_success(workflow_context): - """Test action that fails with a generic Exception (not ValueError/ValidationError) and succeeds after retry.""" + """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={"retry_at": retry_time} + ), Response(status_code=status.HTTP_200_OK, payload={}), ] with patch( @@ -127,15 +133,22 @@ def test_action_with_http_exception(workflow_context, sample_action): 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_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( @@ -149,22 +162,40 @@ def failing_action(input_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" + assert ( + exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + ) + assert ( + exc_info.value.output["error"] + == "Action failed after reaching max retries" + ) + 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_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( @@ -189,15 +220,19 @@ def failing_action(input_data): 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={}) + Response(status_code=status.HTTP_200_OK, payload={}), ] - with patch("app._internal.internal_client.InternalEndureClient.send_log") as mock_send_log: + 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) as exc_info: + with pytest.raises(ValueError): workflow_context.execute_action( action=action_raises_value_error, input_data={}, @@ -213,16 +248,25 @@ def action_raises_value_error(input_data): 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_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 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): workflow_context.execute_action( @@ -243,11 +287,16 @@ def action_raises_validation_error(input_data): def test_action_with_requests_exception(workflow_context): - """Test that requests.exceptions.RequestException is re-raised immediately (not retried) and only logs STARTED.""" + """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: + 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): workflow_context.execute_action( @@ -263,9 +312,13 @@ def action_raises_requests_exception(input_data): 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: + + 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: workflow_context.execute_action( @@ -276,5 +329,3 @@ def dummy_action(input_data): ) assert "First log error" in str(exc_info.value) assert mock_send_log.call_count == 1 - - From 6fd4f3964cbbabb5f4ff1a8c87e9a9db7e84dba7 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Thu, 19 Jun 2025 23:23:29 +0300 Subject: [PATCH 28/33] adjust input/ctx condition --- src/app/_internal/__init__.py | 35 ----------------------------------- src/app/service.py | 5 ++++- 2 files changed, 4 insertions(+), 36 deletions(-) diff --git a/src/app/_internal/__init__.py b/src/app/_internal/__init__.py index a118b3d..0dffed3 100644 --- a/src/app/_internal/__init__.py +++ b/src/app/_internal/__init__.py @@ -2,46 +2,11 @@ Internal implementation details. Do not import directly. This module is for internal use by app.py, service.py, and workflow_context.py only. """ - -import inspect -import os -import sys - from .internal_client import InternalEndureClient from .service_registry import ServiceRegistry from .utils import validate_retention_period from .workflow import Workflow - -def _is_testing(): - # Skipping during pytest or unittest runs - return "PYTEST_CURRENT_TEST" in os.environ or any( - "pytest" in arg or "unittest" in arg for arg in sys.argv - ) - - -def _check_caller(): - if _is_testing(): - return - - frame = inspect.currentframe().f_back.f_back - caller_module = frame.f_globals.get("__name__", "") - - allowed_modules = { - "app.app", - "app.service", - "app.workflow_context", - } - - if caller_module not in allowed_modules: - raise ImportError( - f"The '_internal' module cannot be imported from '{caller_module}'. " - "It is only for use by app.py, service.py, and workflow_context.py" - ) - - -_check_caller() - __all__ = [ "InternalEndureClient", "ServiceRegistry", diff --git a/src/app/service.py b/src/app/service.py index 68bdd8d..a008d98 100644 --- a/src/app/service.py +++ b/src/app/service.py @@ -117,7 +117,10 @@ def decorator(func): 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: + 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." ) From 8819528d82f8da38529ec3fa31088a825d72d5dd Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Thu, 19 Jun 2025 23:43:43 +0300 Subject: [PATCH 29/33] adjust workflow_context to support async action execution --- src/app/workflow_context.py | 9 +++-- tests/test_workflow_context.py | 66 ++++++++++++++++------------------ 2 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index 6df4d7b..bc1a70f 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -1,5 +1,5 @@ import time - +import asyncio from fastapi import status import requests from app._internal.internal_client import ( @@ -58,7 +58,7 @@ def __init__(self, execution_id: str): """ self.execution_id = execution_id - def execute_action( + async def execute_action( self, action: callable, input_data, @@ -148,7 +148,10 @@ def process_payment(input_data: dict) -> dict: while attempt <= max_retries: try: try: - result = action(input_data) + 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, diff --git a/tests/test_workflow_context.py b/tests/test_workflow_context.py index 02c8e82..c6d22d5 100644 --- a/tests/test_workflow_context.py +++ b/tests/test_workflow_context.py @@ -7,7 +7,8 @@ import requests -def test_successful_action_execution(workflow_context, sample_action): +@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 @@ -24,7 +25,7 @@ def test_successful_action_execution(workflow_context, sample_action): mock_completed_response.to_dict(), ] - workflow_context.execute_action( + await workflow_context.execute_action( action=sample_action, input_data=input_data, max_retries=max_retries, @@ -50,7 +51,8 @@ def test_successful_action_execution(workflow_context, sample_action): assert completed_log_call[0][2] == sample_action.__name__ -def test_already_executed_action(workflow_context, sample_action): +@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"} @@ -62,7 +64,7 @@ def test_already_executed_action(workflow_context, sample_action): "app._internal.internal_client.InternalEndureClient.send_log" ) as mock_send_log: mock_send_log.return_value = mock_response.to_dict() - result = workflow_context.execute_action( + result = await workflow_context.execute_action( action=sample_action, input_data=input_data, max_retries=3, @@ -72,7 +74,8 @@ def test_already_executed_action(workflow_context, sample_action): assert mock_send_log.call_count == 1 -def test_action_with_retry_success(workflow_context): +@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"} @@ -101,7 +104,7 @@ def failing_action(input_data): "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 = workflow_context.execute_action( + result = await workflow_context.execute_action( action=failing_action, input_data=input_data, max_retries=3, @@ -112,7 +115,8 @@ def failing_action(input_data): assert mock_send_log.call_count == 3 -def test_action_with_http_exception(workflow_context, sample_action): +@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" @@ -121,7 +125,7 @@ def test_action_with_http_exception(workflow_context, sample_action): status_code=status.HTTP_400_BAD_REQUEST, detail="Bad request" ) with pytest.raises(HTTPException) as exc_info: - workflow_context.execute_action( + await workflow_context.execute_action( action=sample_action, input_data={"test": "data"}, max_retries=3, @@ -131,7 +135,8 @@ def test_action_with_http_exception(workflow_context, sample_action): assert exc_info.value.detail == "Bad request" -def test_action_exhausts_retries(workflow_context): +@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): @@ -156,7 +161,7 @@ def failing_action(input_data): ) as mock_send_log: mock_send_log.side_effect = [r.to_dict() for r in mock_responses] with pytest.raises(Exception) as exc_info: - workflow_context.execute_action( + await workflow_context.execute_action( action=failing_action, input_data={"test": "data"}, max_retries=3, @@ -171,7 +176,8 @@ def failing_action(input_data): ) -def test_retry_respects_timing(workflow_context): +@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 @@ -204,7 +210,7 @@ def failing_action(input_data): mock_send_log.side_effect = [r.to_dict() for r in mock_responses] with patch("time.sleep") as mock_sleep: try: - workflow_context.execute_action( + await workflow_context.execute_action( action=failing_action, input_data=input_data, max_retries=3, @@ -218,12 +224,11 @@ def failing_action(input_data): assert mock_send_log.call_count == 4 -def test_action_with_value_error(workflow_context): +@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={}), @@ -233,7 +238,7 @@ def action_raises_value_error(input_data): ) as mock_send_log: mock_send_log.side_effect = [r.to_dict() for r in mock_responses] with pytest.raises(ValueError): - workflow_context.execute_action( + await workflow_context.execute_action( action=action_raises_value_error, input_data={}, max_retries=3, @@ -246,20 +251,17 @@ def action_raises_value_error(input_data): assert "Immediate failure" in call_args_list[1][0][1].output["error"] -def test_action_with_validation_error(workflow_context): +@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: @@ -267,15 +269,13 @@ def action_raises_validation_error(input_data): mock_started_response.to_dict(), mock_failed_response.to_dict(), ] - with pytest.raises(ValidationError): - workflow_context.execute_action( + 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] @@ -285,21 +285,18 @@ def action_raises_validation_error(input_data): assert failed_log.status == LogStatus.FAILED assert "validation error" in failed_log.output["error"].lower() - -def test_action_with_requests_exception(workflow_context): - """Test that requests.exceptions.RequestException - is re-raised immediately (not retried) and only logs STARTED.""" - +@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): - workflow_context.execute_action( + await workflow_context.execute_action( action=action_raises_requests_exception, input_data={}, max_retries=3, @@ -310,18 +307,17 @@ def action_raises_requests_exception(input_data): assert call_args_list[0][0][1].status == LogStatus.STARTED -def test_value_error_in_first_send_log(workflow_context): +@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: - workflow_context.execute_action( + await workflow_context.execute_action( action=dummy_action, input_data={}, max_retries=3, From 5be366615970de1c7b0db1a8f68ec0d622654a7c Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Thu, 19 Jun 2025 23:45:27 +0300 Subject: [PATCH 30/33] add test case --- tests/test_workflow_context.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_workflow_context.py b/tests/test_workflow_context.py index c6d22d5..e889605 100644 --- a/tests/test_workflow_context.py +++ b/tests/test_workflow_context.py @@ -5,6 +5,7 @@ import time from pydantic import ValidationError, BaseModel import requests +import asyncio @pytest.mark.asyncio @@ -325,3 +326,35 @@ def dummy_action(input_data): ) 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"}} From 7617eddd7c81f0e83e910c6b8fedf26284641c21 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Thu, 19 Jun 2025 23:48:41 +0300 Subject: [PATCH 31/33] add test cases for input/ctx validation --- tests/test_service.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_service.py b/tests/test_service.py index 2cba7fa..2703749 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -140,3 +140,23 @@ def test_workflow_decorator_preserves_function( {"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) + ) \ No newline at end of file From b310b754781ffbd358c313af56363c8104b10a6f Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Thu, 19 Jun 2025 23:51:09 +0300 Subject: [PATCH 32/33] trigger linter --- tests/test_service.py | 12 +++++++++--- tests/test_workflow_context.py | 13 ++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/test_service.py b/tests/test_service.py index 2703749..2bc5faf 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -143,20 +143,26 @@ def test_workflow_decorator_preserves_function( 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) + "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) - ) \ No newline at end of file + "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 index e889605..7e3bf16 100644 --- a/tests/test_workflow_context.py +++ b/tests/test_workflow_context.py @@ -228,8 +228,10 @@ def failing_action(input_data): @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={}), @@ -255,10 +257,13 @@ def action_raises_value_error(input_data): @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={} ) @@ -286,11 +291,15 @@ def action_raises_validation_error(input_data): 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.""" + """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" @@ -311,8 +320,10 @@ def action_raises_requests_exception(input_data): @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: From 80900c3e942022bdec3c8b40716e89bbf6adecf9 Mon Sep 17 00:00:00 2001 From: Farah Tharwat Date: Fri, 20 Jun 2025 00:06:58 +0300 Subject: [PATCH 33/33] add logging instead of prints --- src/app/_internal/internal_client.py | 13 +++++++------ src/app/workflow_context.py | 5 +++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/app/_internal/internal_client.py b/src/app/_internal/internal_client.py index 10cd3cb..fa01686 100644 --- a/src/app/_internal/internal_client.py +++ b/src/app/_internal/internal_client.py @@ -1,4 +1,5 @@ import os +import logging import requests from ..types import Log, Response @@ -26,15 +27,11 @@ def send_log(self, execution_id: str, log: Log, action_name: str): """ # noqa: E501 try: if not self._base_url: - print( - "DURABLE_ENGINE_BASE_URL is not set in environment variables." - ) raise ValueError( "DURABLE_ENGINE_BASE_URL is not set in environment variables." ) if not log or not action_name: - print("log and action_name must be provided.") raise ValueError("log and action_name must be provided.") url = ( @@ -62,7 +59,9 @@ def send_log(self, execution_id: str, log: Log, action_name: str): payload=error_payload, ) except requests.exceptions.RequestException as e: - print("Engine is unreachable. Aborting retries: {}".format(e)) + logging.error( + "Engine is unreachable. Aborting retries: {}".format(e) + ) raise e return response.to_dict() @@ -98,6 +97,8 @@ def mark_execution_as_running(self, execution_id: str): status_code=e.response.status_code, ) except requests.exceptions.RequestException as e: - print("Engine is unreachable. Aborting retries: {}".format(e)) + logging.error( + "Engine is unreachable. Aborting retries: {}".format(e) + ) raise e return response.to_dict() diff --git a/src/app/workflow_context.py b/src/app/workflow_context.py index bc1a70f..98ca83b 100644 --- a/src/app/workflow_context.py +++ b/src/app/workflow_context.py @@ -1,4 +1,5 @@ import time +import logging import asyncio from fastapi import status import requests @@ -161,7 +162,7 @@ def process_payment(input_data: dict) -> dict: ), action.__name__, ) - print( + logging.info( f"WORKFLOW DEBUG: About to raise exception of type {type(e)}: {e}" ) raise @@ -180,7 +181,7 @@ def process_payment(input_data: dict) -> dict: ValidationError, requests.exceptions.RequestException, ) as e: - print( + logging.debug( f"DEBUG: Caught exception of type {type(e)}: {e}" ) raise