diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 146aa7f..e22b73e 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -22,7 +22,7 @@ jobs: cache-dependency-path: poetry.lock - name: Install python deps - run: poetry install --with dev --all-extras + run: poetry install --with dev - name: Check python package typing run: poetry run poe type diff --git a/README.md b/README.md index ba43330..66d9c16 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,20 @@ qualibrate-runner start In this case, ensure that the correct address and port are specified in the configuration file. For more details, refer to the [configuration documentation](https://qua-platform.github.io/qualibrate/configuration/). +## Testing + +To run the test suite: + +```bash +poetry run pytest +``` + +For verbose output: + +```bash +poetry run pytest -v +``` + ## License QUAlibrate-Runner is licensed under the BSD-3 license. See the [LICENSE](https://github.com/qua-platform/qualibrate-runner/blob/main/LICENSE) file for more details. diff --git a/poetry.lock b/poetry.lock index e37b5fe..69d60bd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6,7 +6,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] 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"}, @@ -18,7 +18,7 @@ version = "4.11.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, @@ -54,7 +54,7 @@ version = "1.2.0" description = "Backport of CPython tarfile module" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] markers = "python_version < \"3.12\"" files = [ {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, @@ -71,7 +71,7 @@ version = "2.0.0b7" description = "A better Protobuf / gRPC generator & library" optional = false python-versions = "<4.0,>=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "betterproto-2.0.0b7-py3-none-any.whl", hash = "sha256:401ab8055e2f814e77b9c88a74d0e1ae3d1e8a969cced6aeb1b59f71ad63fbd2"}, {file = "betterproto-2.0.0b7.tar.gz", hash = "sha256:1b1458ca5278d519bcd62556a4c236f998a91d503f0f71c67b0b954747052af2"}, @@ -140,7 +140,7 @@ version = "1.3.0" description = "A simple, correct Python build frontend" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4"}, {file = "build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397"}, @@ -163,7 +163,7 @@ version = "0.14.3" description = "httplib2 caching for requests" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "cachecontrol-0.14.3-py3-none-any.whl", hash = "sha256:b35e44a3113f17d2a31c1e6b27b9de6d4405f84ae51baa8c1d3cc5b633010cae"}, {file = "cachecontrol-0.14.3.tar.gz", hash = "sha256:73e7efec4b06b20d9267b441c1f733664f989fb8688391b670ca812d70795d11"}, @@ -185,7 +185,7 @@ version = "2025.8.3" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, @@ -197,8 +197,7 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "(sys_platform == \"linux\" or sys_platform == \"darwin\") and (platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\")" +groups = ["main", "dev", "test"] files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -285,6 +284,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] +markers = {main = "(platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and (sys_platform == \"linux\" or sys_platform == \"darwin\")", dev = "(sys_platform == \"linux\" or sys_platform == \"darwin\") and (platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\")", test = "(platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and (sys_platform == \"linux\" or sys_platform == \"darwin\")"} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -295,7 +295,7 @@ version = "3.4.3" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, @@ -384,7 +384,7 @@ version = "2.1.0" description = "Cleo allows you to create beautiful and testable command-line interfaces." optional = false python-versions = ">=3.7,<4.0" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "cleo-2.1.0-py3-none-any.whl", hash = "sha256:4a31bd4dd45695a64ee3c4758f583f134267c2bc518d8ae9a29cf237d009b07e"}, {file = "cleo-2.1.0.tar.gz", hash = "sha256:0b2c880b5d13660a7ea651001fb4acb527696c01f15c9ee650f377aa543fd523"}, @@ -400,7 +400,7 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -415,12 +415,12 @@ 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", "dev"] -markers = "os_name == \"nt\" or platform_system == \"Windows\"" +groups = ["main", "dev", "test"] 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 os_name == \"nt\"", dev = "os_name == \"nt\" or platform_system == \"Windows\"", test = "platform_system == \"Windows\" or os_name == \"nt\" or sys_platform == \"win32\""} [[package]] name = "contourpy" @@ -428,7 +428,7 @@ version = "1.3.2" description = "Python library for calculating contours of 2D quadrilateral grids" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934"}, {file = "contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989"}, @@ -505,7 +505,7 @@ version = "0.4.1" description = "Manage Python errors with ease" optional = false python-versions = ">=3.7,<4.0" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5"}, {file = "crashtest-0.4.1.tar.gz", hash = "sha256:80d7b1f316ebfbd429f648076d6275c877ba30ba48979de4191714a75266f0ce"}, @@ -517,7 +517,7 @@ version = "46.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] markers = "sys_platform == \"linux\"" files = [ {file = "cryptography-46.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475"}, @@ -596,7 +596,7 @@ version = "0.12.1" description = "Composable style cycles" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, @@ -612,7 +612,7 @@ version = "2.43.0" description = "Collection of all Datadog Public endpoints" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "datadog_api_client-2.43.0-py3-none-any.whl", hash = "sha256:1d038b545263ad0b665f46d9d36dc0f0ed491770d9dcd68380be4685a9ae6cc3"}, {file = "datadog_api_client-2.43.0.tar.gz", hash = "sha256:f3aec5db9bcceb39f6315e505eea908da7133a19f8475ef996ef8be8e3c84855"}, @@ -668,7 +668,7 @@ version = "4.48.2" description = "Dependency injection framework for Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "dependency_injector-4.48.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:54d0178be10f17b768afb5c0ed1c5c565abaa2d097b2bc5a529a31c580613df2"}, {file = "dependency_injector-4.48.2-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12a15979fd534b728b3061c8aa52fd55adb77574758817daae9df8a1c2eb830b"}, @@ -718,7 +718,7 @@ version = "0.4.0" description = "Distribution utilities" optional = false python-versions = "*" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, @@ -730,7 +730,7 @@ version = "0.22.8" description = "Python Git Library" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "dulwich-0.22.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546176d18b8cc0a492b0f23f07411e38686024cffa7e9d097ae20512a2e57127"}, {file = "dulwich-0.22.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d2434dd72b2ae09b653c9cfe6764a03c25cfbd99fbbb7c426f0478f6fb1100f"}, @@ -797,7 +797,7 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, @@ -838,7 +838,7 @@ version = "2.21.2" description = "Fastest Python implementation of JSON schema" optional = false python-versions = "*" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463"}, {file = "fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de"}, @@ -853,7 +853,7 @@ version = "3.19.1" description = "A platform independent file lock." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, @@ -865,7 +865,7 @@ version = "0.6.3" description = "A utility to find python versions on your system" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "findpython-0.6.3-py3-none-any.whl", hash = "sha256:a85bb589b559cdf1b87227cc233736eb7cad894b9e68021ee498850611939ebc"}, {file = "findpython-0.6.3.tar.gz", hash = "sha256:5863ea55556d8aadc693481a14ac4f3624952719efc1c5591abb0b4a9e965c94"}, @@ -880,7 +880,7 @@ version = "4.60.1" description = "Tools to manipulate font files" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28"}, {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15"}, @@ -955,6 +955,21 @@ type1 = ["xattr ; sys_platform == \"darwin\""] unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""] woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] +[[package]] +name = "freezegun" +version = "1.5.5" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"}, + {file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "genson" version = "1.3.0" @@ -973,7 +988,7 @@ version = "1.75.1" description = "HTTP/2-based RPC framework" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "grpcio-1.75.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:1712b5890b22547dd29f3215c5788d8fc759ce6dd0b85a6ba6e2731f2d04c088"}, {file = "grpcio-1.75.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8d04e101bba4b55cea9954e4aa71c24153ba6182481b487ff376da28d4ba46cf"}, @@ -1050,7 +1065,7 @@ version = "0.4.5" description = "Pure-Python gRPC implementation for asyncio" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "grpclib-0.4.5.tar.gz", hash = "sha256:bf83ed55aca59497e168761d9555056efc54a8f865316c3b39becd007e9f9a73"}, ] @@ -1068,7 +1083,7 @@ 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", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -1080,7 +1095,7 @@ version = "4.3.0" description = "Pure-Python HTTP/2 protocol implementation" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"}, {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"}, @@ -1096,7 +1111,7 @@ version = "4.1.0" description = "Pure-Python HPACK header encoding" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, @@ -1108,7 +1123,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -1130,7 +1145,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1156,7 +1171,7 @@ version = "6.1.0" description = "Pure-Python HTTP/2 framing" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, @@ -1168,7 +1183,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1183,7 +1198,7 @@ version = "8.7.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] markers = "python_version < \"3.12\"" files = [ {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, @@ -1226,13 +1241,25 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["pygments", "pytest (>=6,!=8.1.*)"] type = ["pytest-mypy"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["test"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "installer" version = "0.7.0" description = "A library for installing Python wheels." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "installer-0.7.0-py3-none-any.whl", hash = "sha256:05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53"}, {file = "installer-0.7.0.tar.gz", hash = "sha256:a26d3e3116289bb08216e0d0f7d925fcef0b0194eedfa0c944bcaaa106c4b631"}, @@ -1260,7 +1287,7 @@ version = "3.4.0" description = "Utility functions for Python class constructs" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, @@ -1279,7 +1306,7 @@ version = "6.0.1" description = "Useful decorators and context managers" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4"}, {file = "jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3"}, @@ -1298,7 +1325,7 @@ version = "4.3.0" description = "Functools like those found in stdlib" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8"}, {file = "jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294"}, @@ -1321,7 +1348,7 @@ version = "0.9.0" description = "Low-level, pure Python DBus protocol wrapper." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] markers = "sys_platform == \"linux\"" files = [ {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, @@ -1356,7 +1383,7 @@ version = "1.5.2" description = "Lightweight pipelining with Python functions" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241"}, {file = "joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55"}, @@ -1368,7 +1395,7 @@ version = "1.33" description = "Apply JSON-Patches (RFC 6902)" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"}, {file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"}, @@ -1383,7 +1410,7 @@ version = "3.0.0" description = "Identify specific nodes in a JSON document (RFC 6901)" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, @@ -1395,7 +1422,7 @@ version = "25.6.0" description = "Store and access your passwords safely." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd"}, {file = "keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66"}, @@ -1425,7 +1452,7 @@ version = "1.4.9" description = "A fast implementation of the Cassowary constraint solver" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b"}, {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f"}, @@ -1635,7 +1662,7 @@ version = "3.23.3" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "marshmallow-3.23.3-py3-none-any.whl", hash = "sha256:20c0f8c613f68bcb45b2a0d3282e2f172575560170bf220d67aafb42717910e4"}, {file = "marshmallow-3.23.3.tar.gz", hash = "sha256:d586c8685ebdb80bf754e1f96e3f305aaf30951f1fc69175b977453633467e76"}, @@ -1655,7 +1682,7 @@ version = "5.11" description = "An unofficial extension to Marshmallow to allow for polymorphic fields" optional = false python-versions = ">=3.5" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "marshmallow-polyfield-5.11.tar.gz", hash = "sha256:8075a9cc490da4af58b902b4a40a99882dd031adb7aaa96abd147a4fcd53415f"}, ] @@ -1669,7 +1696,7 @@ version = "3.10.6" description = "Python plotting package" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "matplotlib-3.10.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bc7316c306d97463a9866b89d5cc217824e799fa0de346c8f68f4f3d27c8693d"}, {file = "matplotlib-3.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d00932b0d160ef03f59f9c0e16d1e3ac89646f7785165ce6ad40c842db16cc2e"}, @@ -1748,7 +1775,7 @@ version = "10.8.0" description = "More routines for operating on iterables, beyond itertools" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"}, {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, @@ -1760,7 +1787,7 @@ version = "1.1.1" description = "MessagePack serializer" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed"}, {file = "msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8"}, @@ -1829,7 +1856,7 @@ version = "6.6.4" description = "multidict implementation" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f"}, {file = "multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb"}, @@ -2045,7 +2072,7 @@ version = "1.26.4" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, @@ -2091,12 +2118,112 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] +[[package]] +name = "pandas" +version = "2.3.3" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c"}, + {file = "pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4"}, + {file = "pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151"}, + {file = "pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084"}, + {file = "pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493"}, + {file = "pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3"}, + {file = "pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9"}, + {file = "pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa"}, + {file = "pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + [[package]] name = "pastel" version = "0.2.1" @@ -2127,7 +2254,7 @@ version = "2025.9.18" description = "Installer for Python Build Standalone" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "pbs_installer-2025.9.18-py3-none-any.whl", hash = "sha256:8ef55d7675698747505c237015d14c81759bd66a0d4c8b20cec9a2dc96e8434c"}, {file = "pbs_installer-2025.9.18.tar.gz", hash = "sha256:c0a51a7c1e015723bd8396f02e15b5876e439f74b0f45bbac436b189f903219f"}, @@ -2148,7 +2275,7 @@ version = "11.3.0" description = "Python Imaging Library (Fork)" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, @@ -2273,7 +2400,7 @@ version = "1.12.1.2" description = "Query metadata from sdists / bdists / installed packages." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343"}, {file = "pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b"}, @@ -2288,7 +2415,7 @@ version = "4.4.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, @@ -2305,7 +2432,7 @@ version = "5.24.1" description = "An open-source, interactive data visualization library for Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "plotly-5.24.1-py3-none-any.whl", hash = "sha256:f67073a1e637eb0dc3e46324d9d51e2fe76e9727c892dde64ddf1e1b51f29089"}, {file = "plotly-5.24.1.tar.gz", hash = "sha256:dbc8ac8339d248a4bcc36e08a5659bacfe1b079390b8953533f4eb22169b4bae"}, @@ -2315,6 +2442,22 @@ files = [ packaging = "*" tenacity = ">=6.2.0" +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "poethepoet" version = "0.37.0" @@ -2341,7 +2484,7 @@ version = "2.1.3" description = "Python dependency management and packaging made easy." optional = false python-versions = "<4.0,>=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "poetry-2.1.3-py3-none-any.whl", hash = "sha256:7054d3f97ccce7f31961ead16250407c4577bfe57e2037a190ae2913fc40a20c"}, {file = "poetry-2.1.3.tar.gz", hash = "sha256:f2c9bd6790b19475976d88ea4553bcc3533c0dc73f740edc4fffe9e2add50594"}, @@ -2377,7 +2520,7 @@ version = "2.1.3" description = "Poetry PEP 517 Build Backend" optional = false python-versions = "<4.0,>=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "poetry_core-2.1.3-py3-none-any.whl", hash = "sha256:2c704f05016698a54ca1d327f46ce2426d72eaca6ff614132c8477c292266771"}, {file = "poetry_core-2.1.3.tar.gz", hash = "sha256:0522a015477ed622c89aad56a477a57813cace0c8e7ff2a2906b7ef4a2e296a4"}, @@ -2389,7 +2532,7 @@ version = "6.32.1" description = "" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "protobuf-6.32.1-cp310-abi3-win32.whl", hash = "sha256:a8a32a84bc9f2aad712041b8b366190f71dde248926da517bde9e832e4412085"}, {file = "protobuf-6.32.1-cp310-abi3-win_amd64.whl", hash = "sha256:b00a7d8c25fa471f16bc8153d0e53d6c9e827f0953f3c09aaa4331c718cae5e1"}, @@ -2408,12 +2551,12 @@ version = "2.23" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "(sys_platform == \"linux\" or sys_platform == \"darwin\") and (platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and implementation_name != \"PyPy\"" +groups = ["main", "dev", "test"] files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] +markers = {main = "(platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and implementation_name != \"PyPy\" and (sys_platform == \"linux\" or sys_platform == \"darwin\")", dev = "(sys_platform == \"linux\" or sys_platform == \"darwin\") and (platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and implementation_name != \"PyPy\"", test = "(platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and implementation_name != \"PyPy\" and (sys_platform == \"linux\" or sys_platform == \"darwin\")"} [[package]] name = "pydantic" @@ -2421,7 +2564,7 @@ version = "2.11.9" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"}, {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"}, @@ -2443,7 +2586,7 @@ version = "2.33.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] 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"}, @@ -2555,7 +2698,7 @@ version = "2.11.0" description = "Settings management using Pydantic" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, @@ -2573,13 +2716,28 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] toml = ["tomli (>=2.0.1)"] yaml = ["pyyaml (>=6.0.1)"] +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyparsing" version = "3.2.5" description = "pyparsing - Classes and methods to define and execute parsing grammars" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e"}, {file = "pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6"}, @@ -2594,19 +2752,61 @@ version = "1.2.0" description = "Wrappers to call pyproject.toml-based build backend hooks." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, ] +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +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", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2621,7 +2821,7 @@ version = "1.1.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, @@ -2660,13 +2860,25 @@ files = [ [package.extras] dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] +[[package]] +name = "pytz" +version = "2025.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["test"] +files = [ + {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, + {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" description = "A (partial) reimplementation of pywin32 using ctypes/cffi" optional = false python-versions = ">=3.6" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] markers = "sys_platform == \"win32\"" files = [ {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, @@ -2762,7 +2974,7 @@ version = "2.1.6" description = "SDK to control an Octave with QUA" optional = false python-versions = "<3.13,>=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "qm_octave-2.1.6-py3-none-any.whl", hash = "sha256:2e3df8085330ecf0eb1cc3dd661ebec7b3505959d0fabb3a2dc6175ec7c7cba3"}, {file = "qm_octave-2.1.6.tar.gz", hash = "sha256:8a16e6a8c76b060bc2856020a3f54f2e6d936ffc24e2b7963376ce990a8110be"}, @@ -2780,7 +2992,7 @@ version = "1.2.3" description = "QUA language SDK to control a Quantum Computer" optional = false python-versions = "<3.13,>=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "qm_qua-1.2.3-py3-none-any.whl", hash = "sha256:d719aa0f6ca8dc02e3f9941103942d2f260390ba1527857e08e164cd5e1e1df1"}, {file = "qm_qua-1.2.3.tar.gz", hash = "sha256:529cbb443c04f895cd9b2b663ac4617cb221dbdd490220d1de955a2a9bf1c9c5"}, @@ -2812,7 +3024,7 @@ version = "0.20.0" description = "The qualang_tools package includes various tools related to QUA programs in Python" optional = false python-versions = "<3.13,>=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "qualang_tools-0.20.0-py3-none-any.whl", hash = "sha256:0dc71d2c00eaa98da86b70e082cf0c59d9d61b1494d74bc5aee6b07b8f1bff9c"}, {file = "qualang_tools-0.20.0.tar.gz", hash = "sha256:23de4e0dc81387e45803afdf58c51f1a9c6f8b6ea650becda7d3b31904b0d342"}, @@ -2838,7 +3050,7 @@ version = "0.1.8" description = "Configuration for Qualibrate" optional = false python-versions = "<4.0,>=3.10" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "qualibrate_config-0.1.8-py3-none-any.whl", hash = "sha256:58969295ada9253540e18208387acad9097a4883051dd8ed72887e89b4f6d37f"}, {file = "qualibrate_config-0.1.8.tar.gz", hash = "sha256:7deccb0d160a0f1863ca43c87c5712d7a7e40a4c89fa03a8ed06d2ff552a1176"}, @@ -2880,13 +3092,59 @@ url = "https://github.com/qua-platform/qualibrate-core.git" reference = "HEAD" resolved_reference = "e02b120503bdeccf0579ef406ece2c2cc48f36d0" +[[package]] +name = "quam" +version = "0.4.2" +description = "Quantum Abstract Machine (QUAM) facilitates development of abstraction layers in experiments." +optional = false +python-versions = "<3.13,>=3.9" +groups = ["main", "test"] +files = [ + {file = "quam-0.4.2-py3-none-any.whl", hash = "sha256:4651c97f3a0f601f02e7d4d980973bc9eeab47af8954a1a776f4ba6a6ca601f8"}, + {file = "quam-0.4.2.tar.gz", hash = "sha256:0804833adc1813b41d78b14183ddec5135bc97d013fce2744041712e69e668a9"}, +] + +[package.dependencies] +numpy = ">=1.21.2" +qm-qua = ">=1.2.2" +qualang-tools = ">=0.15.0" +qualibrate-config = ">=0.1.6" +typeguard = ">=4.1.0" + +[package.extras] +build = ["build (>=1.2.1)", "setuptools (>=71)", "setuptools-scm (>=8.1.0)"] +dev = ["black (>=23.7.0)", "flake8 (>=5.0.1)", "ipykernel (>=6.24.0)", "pyproject-flake8 (>=5.0.0)", "pytest (>=7.1.2)", "pytest-cov (>=4.1.0)", "pytest-mock (>=3.6.1)"] +docs = ["mkdocs-gen-files", "mkdocs-jupyter", "mkdocstrings[python] (>=0.18)"] + +[[package]] +name = "quam-builder" +version = "0.2.1" +description = "A Python tool designed to programmatically construct QUAM (Quantum Abstract Machine) configurations for the Quantum Orchestration Platform (QOP)." +optional = false +python-versions = ">=3.9,<3.13" +groups = ["test"] +files = [] +develop = false + +[package.dependencies] +qm-qua = ">=1.2.2" +qualang-tools = ">=0.19.0" +quam = ">=0.4.0" +xarray = ">=2024.7.0" + +[package.source] +type = "git" +url = "https://github.com/qua-platform/quam-builder.git" +reference = "HEAD" +resolved_reference = "e3683ff182bda5c5f339af088993b907355c2466" + [[package]] name = "rapidfuzz" version = "3.14.1" description = "rapid fuzzy string matching" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "rapidfuzz-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:489440e4b5eea0d150a31076eb183bed0ec84f934df206c72ae4fc3424501758"}, {file = "rapidfuzz-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eff22cc938c3f74d194df03790a6c3325d213b28cf65cdefd6fdeae759b745d5"}, @@ -2991,7 +3249,7 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -3013,7 +3271,7 @@ version = "1.0.0" description = "A utility belt for advanced users of python-requests" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, @@ -3057,7 +3315,7 @@ version = "1.7.2" description = "A set of python modules for machine learning and data mining" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f"}, {file = "scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c"}, @@ -3113,7 +3371,7 @@ version = "1.15.3" description = "Fundamental algorithms for scientific computing in Python" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c"}, {file = "scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253"}, @@ -3177,7 +3435,7 @@ version = "3.4.0" description = "Python bindings to FreeDesktop.org Secret Service API" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] markers = "sys_platform == \"linux\"" files = [ {file = "secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e"}, @@ -3194,7 +3452,7 @@ version = "1.5.4" description = "Tool to Detect Surrounding Shell" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, @@ -3206,7 +3464,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -3218,7 +3476,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -3249,7 +3507,7 @@ version = "9.1.2" description = "Retry code until it succeeds" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, @@ -3265,7 +3523,7 @@ version = "3.6.0" description = "threadpoolctl" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb"}, {file = "threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e"}, @@ -3277,7 +3535,7 @@ version = "4.8.2" description = "TinyDB is a tiny, document oriented database optimized for your happiness :)" optional = false python-versions = "<4.0,>=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "tinydb-4.8.2-py3-none-any.whl", hash = "sha256:f97030ee5cbc91eeadd1d7af07ab0e48ceb04aa63d4a983adbaca4cba16e86c3"}, {file = "tinydb-4.8.2.tar.gz", hash = "sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d"}, @@ -3289,8 +3547,7 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version <= \"3.11\"" +groups = ["main", "dev", "test"] 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"}, @@ -3325,6 +3582,7 @@ 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.12\"", dev = "python_version < \"3.12\"", test = "python_version == \"3.10\""} [[package]] name = "tomli-w" @@ -3332,7 +3590,7 @@ version = "1.2.0" description = "A lil' TOML writer" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"}, {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, @@ -3344,7 +3602,7 @@ version = "0.13.3" description = "Style preserving TOML library" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, @@ -3356,7 +3614,7 @@ version = "2025.9.11.17" description = "Canonical source for classifiers on PyPI (pypi.org)." optional = false python-versions = "*" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "trove_classifiers-2025.9.11.17-py3-none-any.whl", hash = "sha256:5d392f2d244deb1866556457d6f3516792124a23d1c3a463a2e8668a5d1c15dd"}, {file = "trove_classifiers-2025.9.11.17.tar.gz", hash = "sha256:931ca9841a5e9c9408bc2ae67b50d28acf85bef56219b56860876dd1f2d024dd"}, @@ -3368,7 +3626,7 @@ version = "4.4.4" description = "Run-time type checker for Python" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e"}, {file = "typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74"}, @@ -3398,7 +3656,7 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -3410,7 +3668,7 @@ version = "0.4.1" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] 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"}, @@ -3419,13 +3677,25 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2025.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["test"] +files = [ + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, +] + [[package]] name = "urllib3" version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, @@ -3463,7 +3733,7 @@ version = "20.34.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026"}, {file = "virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a"}, @@ -3558,13 +3828,39 @@ files = [ {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, ] +[[package]] +name = "xarray" +version = "2024.11.0" +description = "N-D labeled arrays and datasets in Python" +optional = false +python-versions = ">=3.10" +groups = ["test"] +files = [ + {file = "xarray-2024.11.0-py3-none-any.whl", hash = "sha256:6ee94f63ddcbdd0cf3909d1177f78cdac756640279c0e32ae36819a89cdaba37"}, + {file = "xarray-2024.11.0.tar.gz", hash = "sha256:1ccace44573ddb862e210ad3ec204210654d2c750bec11bbe7d842dfc298591f"}, +] + +[package.dependencies] +numpy = ">=1.24" +packaging = ">=23.2" +pandas = ">=2.1" + +[package.extras] +accel = ["bottleneck", "flox", "numba (>=0.54)", "numbagg", "opt_einsum", "scipy"] +complete = ["xarray[accel,etc,io,parallel,viz]"] +dev = ["hypothesis", "jinja2", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-env", "pytest-timeout", "pytest-xdist", "ruff", "sphinx", "sphinx_autosummary_accessors", "xarray[complete]"] +etc = ["sparse"] +io = ["cftime", "fsspec", "h5netcdf", "netCDF4", "pooch", "pydap ; python_version < \"3.10\"", "scipy", "zarr"] +parallel = ["dask[complete]"] +viz = ["cartopy", "matplotlib", "nc-time-axis", "seaborn"] + [[package]] name = "xattr" version = "1.2.0" description = "Python wrapper for extended filesystem attributes" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] markers = "sys_platform == \"darwin\"" files = [ {file = "xattr-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3df4d8d91e2996c3c72a390ec82e8544acdcb6c7df67b954f1736ff37ea4293e"}, @@ -3651,7 +3947,7 @@ version = "3.23.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] markers = "python_version < \"3.12\"" files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, @@ -3672,7 +3968,7 @@ version = "0.25.0" description = "Zstandard bindings for Python" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd"}, {file = "zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7"}, @@ -3781,4 +4077,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.10,<3.13" -content-hash = "44963590f85aa49537e1cb5e580f69ecaa3a61ebfcc0aa415e1a9df00652774f" +content-hash = "adbe3e17c43b3b31c0a98121cccef55fafef3e3044b7ee16e4e8dc53677151e0" diff --git a/pyproject.toml b/pyproject.toml index b535131..87f9010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ jsonpatch = "^1.33" qualibrate-core = "^0.6.0,<0.7.0" qualibrate-config = "^0.1.8,<0.2.0" websockets = "^15.0.1" +quam = "^0.4.2" [tool.poetry.group.dev.dependencies] mypy = "^1.18.2" @@ -24,6 +25,13 @@ ruff = "^0.13.1" poethepoet = "^0.37.0" qualibrate-core = {git = "https://github.com/qua-platform/qualibrate-core.git"} +[tool.poetry.group.test.dependencies] +pytest = "^8.4.2" +pytest-mock = "^3.15.1" +freezegun = "^1.2.0" +xarray = "^2024.1.0" +quam-builder = {git = "https://github.com/qua-platform/quam-builder.git"} + #[tool.poetry.group.qm-dev.dependencies] #qualibrate-config = {path = "../qualibrate-config", develop = true} #qualibrate-core = {path = "../qualibrate-core", develop = true} @@ -38,7 +46,7 @@ warn_return_any = true plugins = ['pydantic.mypy'] [[tool.mypy.overrides]] -module = ["jsonpatch", "jsonpointer"] +module = ["jsonpatch", "jsonpointer", "qualang_tools.*"] ignore_missing_imports = true [tool.ruff] @@ -70,6 +78,12 @@ check.sequence = ["lint", "format", "type"] fix.sequence = ["lint-fix", "format-fix", "type"] fix.ignore_fail = "return_non_zero" +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/qualibrate_runner/api/routes/others.py b/qualibrate_runner/api/routes/others.py index b1aa7de..2d875e7 100644 --- a/qualibrate_runner/api/routes/others.py +++ b/qualibrate_runner/api/routes/others.py @@ -98,13 +98,13 @@ def state_updated( or state.last_run.status != RunStatusEnum.FINISHED ): raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Node not executed or finished unsuccessful.", ) state_updates = state.last_run.state_updates if key not in state_updates: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Unknown state update key.", ) state_updates[key].updated = True diff --git a/qualibrate_runner/api/routes/submit.py b/qualibrate_runner/api/routes/submit.py index 0e8e7f0..da2ba5e 100644 --- a/qualibrate_runner/api/routes/submit.py +++ b/qualibrate_runner/api/routes/submit.py @@ -86,7 +86,7 @@ def submit_node_run( ) -> str: if state.is_running: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Already running", ) validate_input_parameters( @@ -107,7 +107,7 @@ def submit_workflow_run( ) -> str: if state.is_running: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Already running", ) validate_input_parameters(graph.full_parameters_class, input_parameters) diff --git a/qualibrate_runner/config/models.py b/qualibrate_runner/config/models.py index 661e134..9fbfa09 100644 --- a/qualibrate_runner/config/models.py +++ b/qualibrate_runner/config/models.py @@ -1,11 +1,45 @@ +""" +Global state tracking for the qualibrate runner. + +This module defines the State model, which tracks the currently executing +calibration job and its status. The State is used by: +- The web UI to display real-time execution status +- The API to report job progress +- Monitoring systems to track calibration workflows + +The State is a singleton-like object (one per runner instance) that is +continuously updated as jobs execute. +""" + from pydantic import BaseModel, ConfigDict from qualibrate_runner.core.models.enums import RunStatusEnum from qualibrate_runner.core.models.last_run import LastRun from qualibrate_runner.core.types import QGraphType, QNodeType +__all__ = ["State", "RunStatusEnum"] + class State(BaseModel): + """ + Tracks the current and last executed calibration job. + + This model serves as the single source of truth for the runner's execution + state. It is continuously updated by run_job.run_node() and + run_job.run_workflow() as jobs execute. + + The State is designed to be serializable (via Pydantic) so it can be + exposed through APIs for monitoring and UI purposes. + + Attributes: + last_run: Complete information about the most recent execution, + including status, timing, results, and errors. None if no job + has been executed yet. + run_item: Reference to the actual QualibrationNode or Graph being + executed. This allows access to the live object during execution. + None when no job is running. + """ + model_config = ConfigDict(arbitrary_types_allowed=True) last_run: LastRun | None = None @@ -19,6 +53,17 @@ def is_running(self) -> bool: ) def clear(self) -> None: + """ + Clear the state, removing all execution history. + + This resets both last_run and run_item to None, effectively clearing + all execution state. Used to start fresh or clean up after errors. + + Raises: + RuntimeError: If called while a job is currently running (status + is RUNNING). You must wait for the job to complete or error + before clearing state. + """ if ( self.last_run is not None and self.last_run.status == RunStatusEnum.RUNNING diff --git a/qualibrate_runner/core/models/common.py b/qualibrate_runner/core/models/common.py index c4c339e..1948863 100644 --- a/qualibrate_runner/core/models/common.py +++ b/qualibrate_runner/core/models/common.py @@ -1,3 +1,13 @@ +""" +Common models for execution tracking. + +This module defines shared models used across the qualibrate runner: +- RunError: Captures exception information when executions fail +- StateUpdate: Records changes to quantum machine state (QuAM) + +These models are designed to be serializable for API responses and storage. +""" + from typing import Any from pydantic import BaseModel, Field @@ -6,14 +16,51 @@ class RunError(BaseModel): - """Model representing an error encountered during execution.""" + """ + Error information for failed executions. + + When a node or workflow execution fails, this model captures + error information for debugging and display purposes. + + The information is captured from Python exceptions in run_job.py and + stored in LastRun.error. + + Attributes: + error_class: The exception class name (e.g., "ValueError", "KeyError") + message: The exception's string representation (str(exception)) + traceback: Full Python traceback as a list of formatted strings, + allowing reconstruction of the error context + """ error_class: str = Field(..., description="The class of the error.") message: str = Field(..., description="The error message.") traceback: list[str] = Field(..., description="The traceback of the error.") + details_headline: str | None = Field( + None, description="The headline of the details." + ) + details: str | None = Field(None, description="The details of the error.") class StateUpdate(BaseModel): + """ + Record of a single change to quantum machine state (QuAM). + + During calibration execution, nodes may update the quantum machine + state with: + - Audit trails (what changed and when) + - Rollback capability (knowing old values) + - Display in UI (showing calibration effects) + + Each StateUpdate represents one attribute change on one quantum element. + + Attributes: + key: The QuAM path to the element being updated (e.g., "qubit_0") + attr: The specific attribute being changed (e.g., "resonance_frequency") + old: The value before the update + new: The value after the update + updated: Whether the update was successfully applied (typically True) + """ + key: str attr: str | int old: Any diff --git a/qualibrate_runner/core/models/last_run.py b/qualibrate_runner/core/models/last_run.py index e850ad0..b1c46e1 100644 --- a/qualibrate_runner/core/models/last_run.py +++ b/qualibrate_runner/core/models/last_run.py @@ -1,3 +1,17 @@ +""" +Execution tracking model for calibration jobs. + +This module defines the LastRun model, which captures information about +a single execution of a node or workflow. It includes: +- Execution status and timing +- Input parameters and results +- Error information if the execution failed +- State updates (changes to QuAM quantum machine state) + +The LastRun is updated by run_job.run_node() and run_job.run_workflow() +throughout the execution lifecycle (RUNNING -> FINISHED/ERROR). +""" + from collections.abc import Mapping from datetime import datetime from typing import Annotated, Any @@ -13,8 +27,18 @@ class LastRun(BaseModel): - """Model representing the last executed run.""" + """ + Execution record for a calibration node or workflow. + + This model captures info about a single execution, from start to + completion (or error). It's designed to be updated twice: + 1. Initially: when execution starts (RUNNING status, start time) + 2. Finally: when execution completes (FINISHED/ERROR status, results/error) + + The model supports serialization to JSON for API responses and storage. + """ + # Execution status can be RUNNING, FINISHED, or ERROR status: Annotated[ RunStatusEnum, Field( @@ -70,7 +94,7 @@ class LastRun(BaseModel): ] = None @computed_field(description="Duration of the run in seconds.") - def run_duration(self) -> float: + def run_duration(self) -> float: # Unit: seconds duration = ( self.completed_at - self.started_at if self.completed_at is not None diff --git a/qualibrate_runner/core/run_job.py b/qualibrate_runner/core/run_job.py index 7d956eb..495fcf0 100644 --- a/qualibrate_runner/core/run_job.py +++ b/qualibrate_runner/core/run_job.py @@ -1,3 +1,19 @@ +""" +Job execution orchestration for QualibrationNodes and workflows. + +This module provides the top-level execution layer for running calibration +nodes and workflows (graphs). It handles: +- Parameter validation +- State tracking throughout execution +- Error capture and reporting +- Timestamp management (start/completion times) +- Integration with the active QualibrationLibrary + +The execution follows a try-except-finally pattern to ensure state is always +updated, even when errors occur. This state is used by the web UI and other +monitoring systems to track calibration progress. +""" + import traceback from collections.abc import Mapping from datetime import datetime @@ -20,15 +36,48 @@ def validate_input_parameters( parameters_class: type[BaseModel], passed_parameters: Mapping[str, Any], ) -> BaseModel: + """ + Validate input parameters against a Pydantic model schema. + + This function ensures that parameters passed to nodes or workflows conform + to their expected schema. If validation fails, it raises an HTTP exception + suitable for API responses. + + Args: + parameters_class: The Pydantic model class defining the expected + parameter schema + passed_parameters: Dictionary of parameter names and values to validate + + Returns: + A validated Pydantic model instance with the parameters + + Raises: + HTTPException: With status 422 (Unprocessable Entity) if validation + fails, containing detailed error information from Pydantic + """ try: return parameters_class.model_validate(passed_parameters) except ValidationError as ex: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=ex.errors() + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=ex.errors(), ) from ex def get_active_library_or_error() -> QLibraryType: + """ + Retrieve the currently active QualibrationLibrary. + + This is a thin wrapper around QualibrationLibrary.get_active_library that + explicitly requests an existing library (create=False). It will raise an + error if no active library exists. + + Returns: + The active QualibrationLibrary instance + + Raises: + Exception: If no active library exists (raised by get_active_library) + """ return QualibrationLibrary.get_active_library(create=False) @@ -37,33 +86,90 @@ def run_node( passed_input_parameters: Mapping[str, Any], state: State, ) -> None: + """ + Execute a single QualibrationNode with comprehensive state tracking. + + This function orchestrates the execution of a calibration node, managing + the complete lifecycle: + 1. Initialize state with RUNNING status + 2. Execute the node in interactive mode + 3. Capture any errors with full traceback + 4. Update state with final results (success or error) + + The state is ALWAYS updated in the finally block, ensuring monitoring + systems have accurate information even when exceptions occur. + + Args: + node: The QualibrationNode instance to execute + passed_input_parameters: Runtime parameters to pass to the node's + run method + state: Global state object that tracks the current run status and + results for monitoring/UI purposes + + Raises: + Exception: Any exception raised by node.run() is re-raised after + being captured in state.last_run.error + + Side Effects: + - Updates state.run_item to reference the executing node + - Updates state.last_run with execution status, timing, and results + - Calls node.run() which may execute quantum programs and update + quantum machine state + """ + # Set the currently executing item for monitoring state.run_item = node run_status = RunStatusEnum.RUNNING + + # Initialize the run tracking with start time and RUNNING status state.last_run = LastRun( name=node.name, status=RunStatusEnum.RUNNING, - idx=-1, + idx=-1, # Snapshot index, updated after successful run passed_parameters=passed_input_parameters, started_at=datetime.now().astimezone(), runnable_type=RunnableType.NODE, ) + + # Track snapshot index and error state idx = -1 run_error = None + try: + # Execute the node in interactive mode with provided parameters + # interactive=True enables that the user approves changes to + # variables in the frontend after execution node.run(interactive=True, **passed_input_parameters) except Exception as ex: + # Capture error details for state tracking run_status = RunStatusEnum.ERROR + run_summary = getattr(node, "run_summary", None) + run_summary_error = ( + getattr(run_summary, "error", None) if run_summary else None + ) # Very safe way to access error run_error = RunError( error_class=ex.__class__.__name__, message=str(ex), + details_headline=getattr( + run_summary_error, "details_headline", None + ) + if run_summary_error + else None, + details=getattr(run_summary_error, "details", None) + if run_summary_error + else None, traceback=traceback.format_tb(ex.__traceback__), ) + # Re-raise to allow caller to handle the error raise else: + # If successful, capture the snapshot index for data storage + # The snapshot_idx links results to stored data files _idx = node.snapshot_idx if hasattr(node, "snapshot_idx") else -1 idx = _idx if _idx is not None else -1 run_status = RunStatusEnum.FINISHED finally: + # ALWAYS update state with final results, even if an error occurred + # This ensures the UI and monitoring systems have current information state.last_run = LastRun( name=state.last_run.name, status=run_status, @@ -74,7 +180,7 @@ def run_node( passed_parameters=passed_input_parameters, started_at=state.last_run.started_at, completed_at=datetime.now().astimezone(), - state_updates=node.state_updates, + state_updates=node.state_updates, # QuAM state changes error=run_error, ) @@ -84,41 +190,102 @@ def run_workflow( passed_input_parameters: Mapping[str, Any], state: State, ) -> None: + """ + Execute a calibration workflow (DAG of QualibrationNodes). + + This function orchestrates the execution of a workflow (also called a + graph), which is a directed acyclic graph (DAG) of interconnected + QualibrationNodes. The workflow execution follows these steps: + 1. Initialize state with RUNNING status + 2. Get a fresh copy of the workflow from the active library + 3. Validate and structure the input parameters + 4. Execute the workflow's DAG, running nodes in dependency order + 5. Capture any errors with full traceback + 6. Update state with final results + + The workflow is retrieved fresh from the library to ensure a clean instance + for each run, avoiding state pollution between runs. + + Args: + workflow: The workflow (graph) instance to execute - note that a fresh + copy is retrieved from the library, so this is mainly used for + the name + passed_input_parameters: Runtime parameters containing both workflow- + level parameters and node-specific parameters. These are validated + against workflow.full_parameters_class which includes both + 'parameters' (workflow-level) and 'nodes' (per-node overrides) + state: Global state object that tracks the current run status and + results for monitoring/UI purposes + + Raises: + Exception: Any exception raised during workflow execution is re-raised + after being captured in state.last_run.error + + Side Effects: + - Updates state.run_item to reference the executing workflow + - Updates state.last_run with execution status, timing, and results + - Executes all nodes in the workflow DAG in dependency order + - May execute quantum programs and update quantum machine state + """ run_status = RunStatusEnum.RUNNING + + # Initialize the run tracking with start time and RUNNING status state.last_run = LastRun( name=workflow.name, status=run_status, - idx=-1, + idx=-1, # Snapshot index, updated after successful run started_at=datetime.now().astimezone(), runnable_type=RunnableType.GRAPH, passed_parameters=passed_input_parameters, ) + + # Track snapshot index and error state idx = -1 run_error = None + try: + # Get the active library to retrieve a fresh workflow copy library = get_active_library_or_error() - workflow = library.graphs[workflow.name] # copied graph instance + + # Get a fresh copy of the workflow from the library + # This ensures each run starts with a clean state + workflow = library.graphs[workflow.name] + + # Set the currently executing item for monitoring state.run_item = workflow + + # Validate and structure input parameters + # full_parameters_class expects: {parameters: {...}, nodes: {...}} + # where 'parameters' are workflow-level and 'nodes' are per-node input_parameters = workflow.full_parameters_class( **passed_input_parameters ) + + # Execute the workflow DAG + # Nodes are executed in dependency order determined by the DAG workflow.run( nodes=input_parameters.nodes.model_dump(), **input_parameters.parameters.model_dump(), ) except Exception as ex: + # Capture error details for state tracking run_status = RunStatusEnum.ERROR run_error = RunError( error_class=ex.__class__.__name__, message=str(ex), traceback=traceback.format_tb(ex.__traceback__), ) + # Re-raise to allow caller to handle the error raise else: + # If successful, capture the snapshot index for data storage + # The snapshot_idx links results to stored data files idx = workflow.snapshot_idx if hasattr(workflow, "snapshot_idx") else -1 idx = idx if idx is not None else -1 run_status = RunStatusEnum.FINISHED finally: + # ALWAYS update state with final results, even if an error occurred + # This ensures the UI and monitoring systems have current information state.last_run = LastRun( name=state.last_run.name, status=run_status, 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..06e51aa --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,248 @@ +""" +Shared fixtures for qualibrate-runner tests. + +This module provides common fixtures used across unit and integration tests, +including mock objects, sample data, and test utilities. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, cast +from unittest.mock import Mock, create_autospec + +import pytest +from qualibrate import QualibrationGraph, QualibrationLibrary, QualibrationNode + +from qualibrate_runner.config.models import State +from qualibrate_runner.core.models.common import RunError, StateUpdate +from qualibrate_runner.core.models.enums import RunnableType, RunStatusEnum +from qualibrate_runner.core.models.last_run import LastRun + + +@pytest.fixture +def aware_datetime() -> datetime: + """Provide a timezone-aware datetime for testing.""" + return datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) + + +@pytest.fixture +def later_datetime() -> datetime: + """Provide a later timezone-aware datetime for testing durations.""" + return datetime(2024, 1, 15, 10, 30, 5, 500000, tzinfo=timezone.utc) + + +@pytest.fixture +def sample_traceback() -> list[str]: + """Provide a sample traceback as list of strings.""" + return [ + ' File "/path/to/node.py", line 42, in run\n' + " result = self.process_data()\n", + ' File "/path/to/node.py", line 55, in process_data\n' + ' raise ValueError("Invalid data")\n', + ] + + +@pytest.fixture +def sample_run_error(sample_traceback: list[str]) -> RunError: + """Provide a sample RunError instance.""" + return RunError( + error_class="ValueError", + message="Invalid data", + traceback=sample_traceback, + ) + + +@pytest.fixture +def sample_state_update() -> StateUpdate: + """Provide a sample StateUpdate instance.""" + return StateUpdate( + key="qubit_0", + attr="resonance_frequency", + old=5.0e9, + new=5.1e9, + updated=True, + ) + + +@pytest.fixture +def sample_last_run_running(aware_datetime: datetime) -> LastRun: + """Provide a LastRun instance with RUNNING status.""" + return LastRun( + status=RunStatusEnum.RUNNING, + started_at=aware_datetime, + name="test_node", + idx=-1, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + + +@pytest.fixture +def sample_last_run_finished( + aware_datetime: datetime, later_datetime: datetime +) -> LastRun: + """Provide a LastRun instance with FINISHED status.""" + return LastRun( + status=RunStatusEnum.FINISHED, + started_at=aware_datetime, + completed_at=later_datetime, + name="test_node", + idx=42, + runnable_type=RunnableType.NODE, + passed_parameters={"amplitude": 0.5}, + run_result=None, # Must be None or a real RunSummary for validation + ) + + +@pytest.fixture +def sample_last_run_error( + aware_datetime: datetime, + later_datetime: datetime, + sample_run_error: RunError, +) -> LastRun: + """Provide a LastRun instance with ERROR status.""" + return LastRun( + status=RunStatusEnum.ERROR, + started_at=aware_datetime, + completed_at=later_datetime, + name="test_node", + idx=-1, + runnable_type=RunnableType.NODE, + passed_parameters={}, + error=sample_run_error, + ) + + +@pytest.fixture +def fresh_state() -> State: + """Provide a fresh State instance with no execution history.""" + return State() + + +@pytest.fixture(scope="function") +def mock_node() -> Mock: + """Provide a mock QualibrationNode with enforced interface. + + Uses create_autospec to ensure the mock conforms to QualibrationNode's + actual interface, catching typos and signature mismatches early. + + Scope is explicitly set to "function" to ensure each test gets a fresh + mock instance with clean state (empty state_updates dict, no call history). + This prevents test pollution from mutable state. + + Note: This cannot be used directly with State() due to Pydantic + validation. Use state_with_node fixture instead. + """ + node = cast(Mock, create_autospec(QualibrationNode, instance=True)) + + node.name = "test_node" + node.snapshot_idx = 42 + node.run_summary = None + node.state_updates = {} + + return node + + +@pytest.fixture(scope="function") +def mock_workflow() -> Mock: + """Provide a mock QualibrationGraph with enforced interface. + + Uses create_autospec to ensure the mock conforms to QualibrationGraph's + actual interface, catching typos and signature mismatches early. + + Scope is explicitly set to "function" to ensure each test gets a fresh + mock instance with clean call history. This prevents test pollution from + accumulated mock state. + + Note: This cannot be used directly with State() due to Pydantic + validation. Use state_with_workflow fixture instead. + """ + workflow = cast(Mock, create_autospec(QualibrationGraph, instance=True)) + + workflow.name = "test_workflow" + workflow.snapshot_idx = 100 + workflow.run_summary = None + + return workflow + + +@pytest.fixture +def state_with_node(mock_node: Mock) -> State: + """Provide a State with a mock node (bypassing validation).""" + return State.model_construct(run_item=mock_node) + + +@pytest.fixture +def state_with_workflow(mock_workflow: Mock) -> State: + """Provide a State with a mock workflow (bypassing validation).""" + return State.model_construct(run_item=mock_workflow) + + +# Run job fixtures + + +@pytest.fixture +def mock_library(mock_workflow: Mock) -> Mock: + """Provide a mock QualibrationLibrary.""" + library = Mock() + library.graphs = {"test_workflow": mock_workflow} + return library + + +@pytest.fixture +def sample_parameters_class() -> type[Any]: + """Provide a sample Pydantic model for parameter validation.""" + from pydantic import BaseModel, Field + + class TestParameters(BaseModel): + amplitude: float = Field(ge=0.0, le=1.0) + frequency: float = Field(gt=0.0) + num_averages: int = Field(ge=1, default=100) + + return TestParameters + + +@pytest.fixture +def sample_workflow_parameters_class() -> type[Any]: + """Provide a sample workflow parameters class with nodes and parameters.""" + from pydantic import BaseModel, Field + + class NodeParams(BaseModel): + node1: dict[str, Any] = Field(default_factory=dict) + node2: dict[str, Any] = Field(default_factory=dict) + + class WorkflowParams(BaseModel): + frequency: float = Field(gt=0.0) + amplitude: float = Field(ge=0.0, le=1.0, default=0.5) + + class FullParams(BaseModel): + nodes: NodeParams = Field(default_factory=NodeParams) + parameters: WorkflowParams + + return FullParams + + +@pytest.fixture(scope="function") +def test_library() -> QualibrationLibrary[Any, Any]: + """ + Provide a QualibrationLibrary instance loaded with test nodes. + + This fixture creates a library pointing to tests/fixtures/test_nodes, + which properly sets filepath and other metadata on the test nodes + during the scan process. + + Scope is explicitly set to "function" to ensure each test gets fresh + node instances, maintaining test independence. + """ + # Get path to test_nodes directory + test_nodes_path = Path(__file__).parent / "fixtures" / "test_nodes" + + # Create library with set_active=False to avoid interfering with + # other tests + library: QualibrationLibrary[Any, Any] = QualibrationLibrary( + library_folder=test_nodes_path, set_active=False + ) + + return library diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/test_helpers.py b/tests/fixtures/test_helpers.py new file mode 100644 index 0000000..d5620ed --- /dev/null +++ b/tests/fixtures/test_helpers.py @@ -0,0 +1,63 @@ +""" +Test helpers for qualibrate-runner tests. + +Minimal implementations extracted from qualibration-libs to avoid dependency. +""" + +import time +from collections.abc import Iterator, Sequence +from typing import Any + + +def format_available_items( + items: Sequence[Any] | dict[Any, Any], + max_items: int = 10, + item_type: str = "keys", +) -> str: + """Format a list of available items for error messages.""" + item_keys = list(items.keys()) if isinstance(items, dict) else list(items) + if item_keys: + items_str = "'" + "', '".join( + str(item) for item in item_keys[:max_items] + ) + items_str += "', ..." if len(item_keys) > max_items else "'." + else: + items_str = "None." + + return f"Available {item_type}: {items_str}" + + +class XarrayDataFetcher: + """ + Minimal XarrayDataFetcher for testing error handling. + + Simplified version that provides just enough functionality to test + deep error handling when KeyError is raised from __getitem__. + """ + + def __init__( + self, + job: Any, + axes: dict[str, Any] | None = None, + ): + """Initialize with job and axes.""" + self.job = job + self.axes = axes + self.t_start: float | None = None + self.data: dict[str, Any] = {"dataset": None} + + def __getitem__(self, key: str) -> Any: + """Get item from data dict, raising KeyError with helpful message.""" + try: + return self.data[key] + except KeyError as e: + keys_list = format_available_items(self.data, item_type="keys") + raise KeyError( + f"Data key '{key}' not found in XarrayDataFetcher. {keys_list}" + ) from e + + def __iter__(self) -> Iterator[Any]: + """Make iterable - yields once for testing.""" + if self.t_start is None: + self.t_start = time.time() + yield self.data["dataset"] diff --git a/tests/fixtures/test_nodes/__init__.py b/tests/fixtures/test_nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/test_nodes/node_with_actions.py b/tests/fixtures/test_nodes/node_with_actions.py new file mode 100644 index 0000000..3540675 --- /dev/null +++ b/tests/fixtures/test_nodes/node_with_actions.py @@ -0,0 +1,164 @@ +""" +Test node with multiple actions demonstrating action chaining and skipping. + +This node tests: +- Multiple actions that build on each other +- Action skipping via skip_actions parameter +- Namespace accumulation across actions +- Conditional action execution via skip_if +""" + +from typing import Any +from unittest.mock import Mock + +import numpy as np +import xarray as xr +from pydantic import Field +from qualang_tools.results import progress_counter +from qualang_tools.units import unit +from qualibrate import NodeParameters, QualibrationNode + +from tests.fixtures.test_helpers import XarrayDataFetcher + + +class Parameters(NodeParameters): + """Parameters for action test node.""" + + amplitude: float = Field(default=0.5, ge=0.0, le=1.0) + frequency: float = Field(default=5.0e9, gt=0.0) + num_points: int = Field(default=10, ge=1) + num_shots: int = Field(default=1000, ge=1) + frequency_span_in_mhz: float = Field(default=100, gt=0.0) + frequency_step_in_mhz: float = Field(default=0.25, gt=0.0) + update_state: bool = Field( + default=True, + description="Whether to run the state update action", + ) + trigger_error: bool = Field( + default=False, + description="Whether to run the action that raises an error", + ) + error_message: str = Field( + default="Test error from action", + description="Error message to use", + ) + trigger_deep_error: bool = Field( + default=False, + description=( + "Whether to run the action that raises an error inside " + "XarrayDataFetcher" + ), + ) + + +# Create the node +node: QualibrationNode[Parameters, Any] = QualibrationNode( + name="node_with_actions", + parameters=Parameters(), +) + + +@node.run_action +def prepare_data(node: QualibrationNode[Parameters, Any]) -> dict[str, Any]: + """Generate measurement data based on parameters.""" + data = [ + node.parameters.amplitude * i for i in range(node.parameters.num_points) + ] + return { + "data": data, + "data_length": len(data), + "amplitude": node.parameters.amplitude, + } + + +@node.run_action +def process_data(node: QualibrationNode[Parameters, Any]) -> dict[str, Any]: + """Process the prepared data.""" + # Access data from previous action via namespace + raw_data = node.namespace["data"] + processed = [x * 2 for x in raw_data] + mean_value = sum(raw_data) * 2 / len(raw_data) + + return { + "processed_data": processed, + "mean": mean_value, + "max": max(processed), + "min": min(processed), + } + + +@node.run_action(skip_if=not node.parameters.trigger_deep_error) # type: ignore[misc] +def execute_qua_program( + node: QualibrationNode[Parameters, Any], +) -> dict[str, Any]: + mock_job = Mock() + mock_job.result_handles.keys.return_value = ["I"] + + # Code supposed to display the progress bar (won't work) + u = unit(coerce_to_integer=True) + span = node.parameters.frequency_span_in_mhz * u.MHz + step = node.parameters.frequency_step_in_mhz * u.MHz + dfs = np.arange(-span // 2, +span // 2, step) + sweep_axes = { + "qubit": xr.DataArray(["q1", "q2"]), + "detuning": xr.DataArray( + dfs, attrs={"long_name": "readout frequency", "units": "Hz"} + ), + } + + data_fetcher = XarrayDataFetcher(mock_job, sweep_axes) + for _dataset in data_fetcher: + progress_counter( + data_fetcher[ + "nonexistent_key" + ], # This will raise KeyError from inside XarrayDataFetcher + node.parameters.num_shots, + start_time=data_fetcher.t_start, + ) + node.log(mock_job.execution_report()) + + return {"ds_raw": _dataset} + + +@node.run_action(skip_if=not node.parameters.update_state) # type: ignore[misc] +def update_machine_state( + node: QualibrationNode[Parameters, Any], +) -> dict[str, Any]: + """Update quantum machine state (if enabled).""" + # This action only runs if update_state is True + state_updated = False + + if node.machine: + # Would update machine parameters here in real scenario + # For testing, just track that we would update + state_updated = True + + return { + "state_updated": state_updated, + "frequency_used": node.parameters.frequency, + } + + +@node.run_action(skip_if=not node.parameters.trigger_error) # type: ignore[misc] +def process_data_with_error( + node: QualibrationNode[Parameters, Any], +) -> dict[str, Any]: + """Process data - this action raises an error when executed.""" + # This action always fails when it runs + raise ValueError(node.parameters.error_message) + + +@node.run_action +def finalize_results(node: QualibrationNode[Parameters, Any]) -> dict[str, Any]: + """Collect all results into final summary.""" + # Gather all data from namespace + summary = { + "total_actions_run": 4, # This action is always last + "data_points": node.namespace.get("data_length", 0), + "mean_value": node.namespace.get("mean", 0), + } + + return {"summary": summary} + + +node.results = node.namespace diff --git a/tests/fixtures/test_nodes/node_with_subroutine.py b/tests/fixtures/test_nodes/node_with_subroutine.py new file mode 100644 index 0000000..73b5c62 --- /dev/null +++ b/tests/fixtures/test_nodes/node_with_subroutine.py @@ -0,0 +1,94 @@ +""" +Test node with action that calls a subroutine. + +This node tests error handling when the error occurs in a subroutine +that is called from an action, ensuring the full call chain is captured. +""" + +from typing import Any + +from pydantic import Field +from qualibrate import NodeParameters, QualibrationNode + + +class Parameters(NodeParameters): + """Parameters for subroutine test.""" + + amplitude: float = Field(default=0.5, ge=0.0, le=1.0) + num_points: int = Field(default=10, ge=1) + trigger_subroutine_error: bool = Field( + default=False, + description="Whether to trigger error in subroutine", + ) + error_message: str = Field( + default="Error from subroutine", + description="Error message to raise", + ) + + +# Create the node +node: QualibrationNode[Parameters, Any] = QualibrationNode( + name="node_with_subroutine", + parameters=Parameters(), +) + + +def helper_function(amplitude: float, num_points: int) -> list[float]: + """Helper function that processes data. + + This subroutine is called by the action. If parameters trigger an error, + it will raise from within this function. + """ + # Do some processing + data = [amplitude * i for i in range(num_points)] + + # This line will raise if we access out of bounds + # We use this to trigger an error in the subroutine + _ = data[num_points] # IndexError: list index out of range + + return data + + +def another_helper(value: float, message: str) -> float: + """Another helper that can raise a ValueError. + + This demonstrates a deeper call stack within the node file. + """ + if value < 0: + raise ValueError(message) + return value * 2 + + +@node.run_action +def prepare_data(node: QualibrationNode[Parameters, Any]) -> dict[str, Any]: + """Generate measurement data based on parameters.""" + # Simple data generation without errors + data = [ + node.parameters.amplitude * i for i in range(node.parameters.num_points) + ] + return {"data": data, "data_length": len(data)} + + +@node.run_action(skip_if=not node.parameters.trigger_subroutine_error) # type: ignore[misc] +def process_with_subroutine( + node: QualibrationNode[Parameters, Any], +) -> dict[str, Any]: + """Process data using a helper function. + + This action calls helper_function, which will raise an IndexError + when trigger_subroutine_error is True. + """ + # Call the helper function - error will occur inside it + result = helper_function( + node.parameters.amplitude, node.parameters.num_points + ) + return {"processed": result} + + +@node.run_action +def finalize(node: QualibrationNode[Parameters, Any]) -> dict[str, str]: + """Finalize results.""" + return {"status": "completed"} + + +node.results = node.namespace diff --git a/tests/fixtures/test_nodes/test_node_can_raise_in_body.py b/tests/fixtures/test_nodes/test_node_can_raise_in_body.py new file mode 100644 index 0000000..9081b4b --- /dev/null +++ b/tests/fixtures/test_nodes/test_node_can_raise_in_body.py @@ -0,0 +1,62 @@ +""" +Simple test node without any actions. + +This node provides basic functionality for testing node execution without +the complexity of the action system. + +It can raise an exception in the main body, and can test error handling in +the orchestration layer when the error occurs in the node's main body, +before any actions are executed. +""" + +from pydantic import Field +from qualibrate import NodeParameters, QualibrationNode + + +class Parameters(NodeParameters): + """Parameters for simple test""" + + amplitude: float = Field(default=0.5, ge=0.0, le=1.0) + frequency: float = Field(default=5.0e9, gt=0.0) + num_points: int = Field(default=10, ge=1) + + """Parameters for error test""" + should_fail: bool = Field( + default=False, + description="Whether the node should raise an error in body", + ) + error_message: str = Field( + default="Test error from node body", + description="Error message to use", + ) + error_type: str = Field( + default="ValueError", + description="Type of error to raise (ValueError or RuntimeError)", + ) + + +# Create the node +node: QualibrationNode[Parameters, Parameters] = QualibrationNode( # type: ignore[type-var] + name="node_can_raise_in_body", + parameters=Parameters(), +) + +# Raise error in main body if configured +if node.parameters.should_fail: + error_msg = node.parameters.error_message + if node.parameters.error_type == "RuntimeError": + raise RuntimeError(error_msg) + else: + raise ValueError(error_msg) + +# This code only runs if we didn't fail above +data = [ + node.parameters.amplitude * i for i in range(node.parameters.num_points) +] +node.results = { + "data": data, + "length": len(data), + "amplitude": node.parameters.amplitude, + "frequency": node.parameters.frequency, + "error_raised": False, +} diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_run_job_integration.py b/tests/integration/test_run_job_integration.py new file mode 100644 index 0000000..2948b7e --- /dev/null +++ b/tests/integration/test_run_job_integration.py @@ -0,0 +1,541 @@ +""" +Integration tests for run_job.py using real node definition files. + +These tests verify the full execution chain: +run_job → node.run() → ActionManager → Action + +They use real parametric node files from tests/fixtures/test_nodes/ +to ensure the complete system works end-to-end in non-interactive mode. + +Test nodes are loaded through QualibrationLibrary to ensure proper +initialization including filepath setting. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from qualibrate import QualibrationLibrary + +from qualibrate_runner.config.models import RunStatusEnum, State +from qualibrate_runner.core.run_job import run_node + + +class TestSimpleNodeExecution: + """Integration tests for simple nodes without action system.""" + + def test_simple_node_success( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test successful execution of simple node.""" + node = test_library.nodes["node_can_raise_in_body"] + + # Execute through run_node (full orchestration) + run_node(node, {}, fresh_state) + + # Verify state tracking + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.FINISHED + assert fresh_state.last_run.completed_at is not None + assert fresh_state.last_run.error is None + + # Verify node results + assert node.results is not None + assert "data" in node.results + assert node.results["error_raised"] is False + + def test_simple_node_with_different_parameters( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test simple node with runtime parameter override.""" + node = test_library.nodes["node_can_raise_in_body"] + + # Execute with custom parameters + params = {"amplitude": 0.8, "num_points": 5} + run_node(node, params, fresh_state) + + # Verify execution succeeded + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.FINISHED + assert fresh_state.last_run.passed_parameters == params + + def test_simple_node_error_in_body( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test error capture when node body raises exception.""" + node = test_library.nodes["node_can_raise_in_body"] + + # Execute with should_fail=True + params = { + "should_fail": True, + "error_message": "Test error from body", + "error_type": "ValueError", + } + + with pytest.raises(ValueError, match="Test error from body"): + run_node(node, params, fresh_state) + + # Verify error was captured in state + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.error is not None + assert "ValueError" in fresh_state.last_run.error.error_class + assert "Test error from body" in fresh_state.last_run.error.message + + +class TestNodeWithActionsExecution: + """Integration tests for nodes with action system.""" + + def test_all_actions_execute( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test full execution with all default actions.""" + node = test_library.nodes["node_with_actions"] + + # Execute with defaults + run_node(node, {}, fresh_state) + + # Verify state tracking + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.FINISHED + assert fresh_state.last_run.error is None + + # Verify all default actions executed + assert "data" in node.namespace + assert "processed_data" in node.namespace + assert "state_updated" in node.namespace # update_state defaults True + assert "summary" in node.namespace + + def test_conditional_action_skip( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test conditional action skipping via parameters.""" + node = test_library.nodes["node_with_actions"] + + # Execute with update_state=False to skip that action + params = {"update_state": False} + run_node(node, params, fresh_state) + + # Verify execution succeeded + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.FINISHED + + # Verify normal actions ran + assert "data" in node.namespace + assert "processed_data" in node.namespace + + # Verify conditional action was skipped + assert "state_updated" not in node.namespace + + def test_action_raises_error( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test error capture when action raises exception.""" + node = test_library.nodes["node_with_actions"] + + # Execute with trigger_error=True + params = { + "trigger_error": True, + "error_message": "Test error from action", + } + + with pytest.raises(ValueError, match="Test error from action"): + run_node(node, params, fresh_state) + + # Verify error was captured + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.error is not None + assert "ValueError" in fresh_state.last_run.error.error_class + + def test_error_from_external_library( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test error propagation from external library code.""" + node = test_library.nodes["node_with_actions"] + + # Execute with trigger_deep_error=True + params = {"trigger_deep_error": True} + + # This should raise an error from inside XarrayDataFetcher + with pytest.raises(KeyError): + run_node(node, params, fresh_state) + + # Verify error was captured + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.error is not None + + +class TestNamespaceAccumulation: + """Integration tests for namespace data flow across actions.""" + + def test_namespace_accumulates_across_actions( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test that namespace accumulates data from all actions.""" + node = test_library.nodes["node_with_actions"] + + run_node(node, {}, fresh_state) + + # Verify data from each action is in namespace + # From prepare_data + assert "data" in node.namespace + assert "data_length" in node.namespace + + # From process_data + assert "processed_data" in node.namespace + assert "mean" in node.namespace + + # From finalize_results + assert "summary" in node.namespace + + def test_later_actions_use_earlier_data( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test that actions can access data from previous actions.""" + node = test_library.nodes["node_with_actions"] + + run_node(node, {}, fresh_state) + + # process_data uses data from prepare_data + raw_data = node.namespace["data"] + processed_data = node.namespace["processed_data"] + + # Verify processing is correct (doubles each value) + assert len(processed_data) == len(raw_data) + for i in range(len(raw_data)): + assert processed_data[i] == raw_data[i] * 2 + + +class TestStateTracking: + """Integration tests for state lifecycle management.""" + + def test_state_lifecycle_success( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test state transitions during successful execution.""" + node = test_library.nodes["node_with_actions"] + + # Initial state + assert fresh_state.last_run is None + + # Execute + run_node(node, {}, fresh_state) + + # Final state + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.FINISHED + assert fresh_state.last_run.started_at is not None + assert fresh_state.last_run.completed_at is not None + assert ( + fresh_state.last_run.completed_at >= fresh_state.last_run.started_at + ) + + def test_state_lifecycle_with_error( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test state updates when execution fails.""" + node = test_library.nodes["node_with_actions"] + + # Execute with error + params = {"trigger_error": True} + + with pytest.raises(ValueError): + run_node(node, params, fresh_state) + + # State should be updated even on error + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.error is not None + assert fresh_state.last_run.completed_at is not None + + def test_passed_parameters_captured( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test that passed parameters are captured in state.""" + node = test_library.nodes["node_with_actions"] + + params = { + "amplitude": 0.7, + "num_points": 8, + "update_state": False, + } + + run_node(node, params, fresh_state) + + # Verify parameters were captured + assert fresh_state.last_run is not None + assert fresh_state.last_run.passed_parameters == params + + +class TestParametricBehavior: + """Integration tests for parametric node behavior.""" + + def test_namespace_persists_across_runs( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test that namespace accumulates across multiple runs""" + node = test_library.nodes["node_with_actions"] + + # First execution: normal (with update_state=True by default) + run_node(node, {}, fresh_state) + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.FINISHED + assert "state_updated" in node.namespace + first_run_keys = set(node.namespace.keys()) + + # Second execution: namespace should accumulate, not reset + run_node(node, {"update_state": False}, fresh_state) + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.FINISHED + # Verify namespace accumulated - all keys from first run are still + # present (none have disappeared) + assert first_run_keys.issubset(set(node.namespace.keys())) + # "state_updated" still present from first run (correct behavior) + assert "state_updated" in node.namespace + + def test_error_execution_preserves_namespace( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test that namespace is preserved even when execution fails.""" + node = test_library.nodes["node_with_actions"] + + # First execution: normal + run_node(node, {}, fresh_state) + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.FINISHED + successful_run_keys = set(node.namespace.keys()) + + # Second execution: trigger error + with pytest.raises(ValueError): + run_node(node, {"trigger_error": True}, fresh_state) + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR # type: ignore[comparison-overlap] + + # Namespace from successful run should still be present + assert successful_run_keys.issubset(set(node.namespace.keys())) + + +class TestErrorMessaging: + """Integration tests for improved error messaging""" + + def test_error_details_for_action_failure( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test error details when error occurs in action.""" + node = test_library.nodes["node_with_actions"] + + # Trigger error from inside XarrayDataFetcher + params = {"trigger_deep_error": True} + + with pytest.raises(KeyError): + run_node(node, params, fresh_state) + + # Verify error was captured + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.error is not None + error = fresh_state.last_run.error + + # Test CURRENT state + assert error.details_headline is not None + assert error.details is not None + assert len(error.traceback) > 0 + assert "execute_qua_program" in error.details_headline + + assert "execute_qua_program" in error.details + assert "Completed actions" in error.details + assert "prepare_data" in error.details + assert "process_data" in error.details + + # 4. Details should contain simplified traceback + assert "Traceback:" in error.details + # Should NOT contain framework files in simplified version + assert "action_manager.py" not in error.details + assert "action.py" not in error.details + + # 5. Details should contain source snippet + assert "Source Code:" in error.details + + # 6. Full traceback should be preserved + assert len(error.traceback) > 5 # Should have many frames + # Full traceback SHOULD contain framework files + traceback_str = "".join(error.traceback) + assert ( + "action_manager.py" in traceback_str or "action.py" in traceback_str + ) + + def test_error_details_for_body_failure( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test error details when error occurs in node body (not in action).""" + node = test_library.nodes["node_can_raise_in_body"] + + # Trigger error in node body + params = { + "should_fail": True, + "error_message": "Test error from body", + "error_type": "ValueError", + } + + with pytest.raises(ValueError, match="Test error from body"): + run_node(node, params, fresh_state) + + # Verify error was captured + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.error is not None + error = fresh_state.last_run.error + + # 1. Headline should indicate body error (no action) + assert error.details_headline is not None + assert ( + "body" in error.details_headline + or "initialization" in error.details_headline + ) + + # 2. Details should NOT contain action execution summary + # (since no actions were used) + assert error.details is not None + assert ( + "Completed actions" not in error.details + or "prepare_data" not in error.details + ) + + # 3. Details should contain source snippet from node file + assert "Source Code:" in error.details + + # 4. Details should contain simplified traceback + assert "Traceback:" in error.details + # Body errors should not have action framework in simplified version + assert "action_manager.py" not in error.details + + def test_error_details_with_action_history( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test error details include completed and skipped actions.""" + node = test_library.nodes["node_with_actions"] + + # Trigger error with some actions completed and some skipped + params = { + "update_state": False, # This action will be skipped + "trigger_error": True, # This will cause the error + } + + with pytest.raises(ValueError): + run_node(node, params, fresh_state) + + # Verify error was captured + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.error is not None + error = fresh_state.last_run.error + + # 1. Should show completed actions before the error + assert error.details is not None + assert "Completed actions" in error.details + assert "prepare_data" in error.details + assert "process_data" in error.details + + # 2. Should show skipped actions + assert "Skipped actions" in error.details + assert "update_machine_state" in error.details + + # 3. Should show which action failed (simple format) + assert "process_data_with_error" in error.details + + def test_error_details_for_action_with_subroutine( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test error in subroutine called by action shows full call chain.""" + node = test_library.nodes["node_with_subroutine"] + + # Trigger error in helper_function (subroutine) + params = {"trigger_subroutine_error": True} + + with pytest.raises(IndexError): + run_node(node, params, fresh_state) + + # Verify error was captured + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.error is not None + error = fresh_state.last_run.error + + # 1. Headline should mention the action (not the subroutine) + assert error.details_headline is not None + assert "process_with_subroutine" in error.details_headline + + # 2. Simplified traceback should show BOTH the action AND the + # subroutine + assert error.details is not None + assert "Traceback:" in error.details + # Should include the action function + assert "process_with_subroutine" in error.details + # Should include the subroutine + assert "helper_function" in error.details + + # 3. Source snippet should show the subroutine (where error + # occurred) + assert "Source Code:" in error.details + # The error occurs at: result = data[num_points] + # So the snippet should show this line + + # 4. Simplified traceback should NOT include framework code + assert "action_manager.py" not in error.details + + # 5. Full traceback should be preserved with all frames + assert len(error.traceback) > 5 + + def test_error_headline_for_action( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test headline format for action errors.""" + node = test_library.nodes["node_with_actions"] + + params = { + "trigger_error": True, + "error_message": "Custom error message for testing", + } + + with pytest.raises(ValueError): + run_node(node, params, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.error is not None + error = fresh_state.last_run.error + assert error.details_headline is not None + headline = error.details_headline + assert "process_data_with_error" in headline + + # Should indicate it's in an action + assert "action" in headline + + def test_error_headline_for_body( + self, test_library: QualibrationLibrary[Any, Any], fresh_state: State + ) -> None: + """Test headline format for body/initialization errors.""" + node = test_library.nodes["node_can_raise_in_body"] + + params = { + "should_fail": True, + "error_message": "Body error message", + "error_type": "RuntimeError", + } + + with pytest.raises(RuntimeError): + run_node(node, params, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.error is not None + error = fresh_state.last_run.error + assert error.details_headline is not None + headline = error.details_headline + + # Should indicate location (body or initialization) + assert "body" in headline or "initialization" in headline + + # Should NOT mention an action name (since it's not in an action) + assert "action" not in headline or "initialization" in headline diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_config_state.py b/tests/unit/test_config_state.py new file mode 100644 index 0000000..dc34c0d --- /dev/null +++ b/tests/unit/test_config_state.py @@ -0,0 +1,337 @@ +""" +Tests for the State model. + +State tracks the current and last executed calibration job, serving as +the single source of truth for the runner's execution state. +""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import Mock + +import pytest + +from qualibrate_runner.config.models import State +from qualibrate_runner.core.models.common import RunError +from qualibrate_runner.core.models.enums import RunnableType, RunStatusEnum +from qualibrate_runner.core.models.last_run import LastRun + + +class TestStateCreation: + """Tests for creating State instances.""" + + def test_create_fresh_state(self) -> None: + """Test creating a fresh State with no execution history.""" + state = State() + + assert state.last_run is None + assert state.run_item is None + + def test_create_with_last_run( + self, sample_last_run_running: LastRun + ) -> None: + """Test creating State with a LastRun.""" + state = State(last_run=sample_last_run_running) + assert state.last_run is not None + assert state.last_run.status == RunStatusEnum.RUNNING + assert state.is_running is True + + +class TestIsRunningProperty: + """Tests for the is_running property.""" + + def test_not_running_when_finished( + self, sample_last_run_finished: LastRun + ) -> None: + """Test is_running is False when status is FINISHED.""" + state = State(last_run=sample_last_run_finished) + assert state.is_running is False + + def test_not_running_when_error( + self, sample_last_run_error: LastRun + ) -> None: + """Test is_running is False when status is ERROR.""" + state = State(last_run=sample_last_run_error) + assert state.is_running is False + + def test_is_running_changes_with_status( + self, aware_datetime: datetime, mock_node: Mock + ) -> None: + """Test is_running property changes as status changes.""" + state = State() + + # Initially not running + assert state.is_running is False + + # Start execution + state.last_run = LastRun( + status=RunStatusEnum.RUNNING, + started_at=aware_datetime, + name="test_node", + idx=-1, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + state.run_item = mock_node + + assert state.is_running is True + + # Complete execution + state.last_run = LastRun( + status=RunStatusEnum.FINISHED, + started_at=aware_datetime, + completed_at=aware_datetime, + name="test_node", + idx=42, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + + assert state.is_running is False + + +class TestClearMethod: + """Tests for the clear() method.""" + + def test_clear_finished_state( + self, sample_last_run_finished: LastRun, mock_node: Mock + ) -> None: + """Test clearing state after finished execution.""" + state = State.model_construct( + last_run=sample_last_run_finished, + run_item=mock_node, + passed_parameters={}, + ) + + # Should succeed (not running) + state.clear() + + assert state.last_run is None + assert state.run_item is None + + def test_clear_error_state( + self, sample_last_run_error: LastRun, mock_node: Mock + ) -> None: + """Test clearing state after error.""" + state = State.model_construct( + last_run=sample_last_run_error, + run_item=mock_node, + passed_parameters={}, + ) + + # Should succeed (not running) + state.clear() + + assert state.last_run is None + assert state.run_item is None + + def test_clear_while_running_raises_error( + self, sample_last_run_running: LastRun, mock_node: Mock + ) -> None: + """Test that clearing while running raises RuntimeError.""" + state = State.model_construct( + last_run=sample_last_run_running, + run_item=mock_node, + passed_parameters={}, + ) + + # Should raise RuntimeError + with pytest.raises( + RuntimeError, match="Can't clear while item is running" + ): + state.clear() + + # State should be unchanged + assert state.last_run is not None + assert state.run_item is not None + + +class TestStateLifecycle: + """Tests for the typical State lifecycle during execution.""" + + def test_full_execution_lifecycle_success( + self, aware_datetime: datetime, mock_node: Mock + ) -> None: + """Test state through full successful execution lifecycle.""" + state = State() + + # 1. Fresh state + assert state.last_run is None + assert state.run_item is None + assert state.is_running is False + + # 2. Start execution + state.last_run = LastRun( + status=RunStatusEnum.RUNNING, + started_at=aware_datetime, + name="test_node", + idx=-1, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + state.run_item = mock_node + + assert state.is_running is True + + # 3. Complete execution + state.last_run = LastRun( + status=RunStatusEnum.FINISHED, + started_at=aware_datetime, + completed_at=aware_datetime, + name="test_node", + idx=42, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + + assert state.is_running is False + assert state.last_run.status == RunStatusEnum.FINISHED + + # 4. Clear state + state.clear() + + assert state.last_run is None + assert state.run_item is None + + def test_full_execution_lifecycle_error( + self, + aware_datetime: datetime, + mock_node: Mock, + sample_run_error: RunError, + ) -> None: + """Test state through full error execution lifecycle.""" + state = State() + + # 1. Start execution + state.last_run = LastRun( + status=RunStatusEnum.RUNNING, + started_at=aware_datetime, + name="test_node", + idx=-1, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + state.run_item = mock_node + + assert state.is_running is True + + # 2. Error occurs + state.last_run = LastRun( + status=RunStatusEnum.ERROR, + started_at=aware_datetime, + completed_at=aware_datetime, + name="test_node", + idx=-1, + runnable_type=RunnableType.NODE, + error=sample_run_error, + passed_parameters={}, + ) + + assert state.is_running is False + assert state.last_run.status == RunStatusEnum.ERROR + + # 3. Clear state after error + state.clear() + + assert state.last_run is None + assert state.run_item is None + + def test_multiple_sequential_executions( + self, aware_datetime: datetime, mock_node: Mock + ) -> None: + """Test state through multiple sequential executions.""" + state = State() + + # First execution + state.last_run = LastRun( + status=RunStatusEnum.RUNNING, + started_at=aware_datetime, + name="node_1", + idx=-1, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + state.run_item = mock_node + assert state.is_running is True + + state.last_run = LastRun( + status=RunStatusEnum.FINISHED, + started_at=aware_datetime, + completed_at=aware_datetime, + name="node_1", + idx=1, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + assert state.is_running is False + + # Second execution (without clearing) + state.last_run = LastRun( + status=RunStatusEnum.RUNNING, + started_at=aware_datetime, + name="node_2", + idx=-1, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + assert state.is_running is True + assert state.last_run.name == "node_2" + + +class TestStateArbitraryTypes: + """Tests for arbitrary type support (run_item).""" + + def test_store_node_object(self, state_with_node: State) -> None: + """Test storing a QualibrationNode object in run_item.""" + assert state_with_node.run_item is not None + assert state_with_node.run_item.name == "test_node" + assert state_with_node.run_item.snapshot_idx == 42 # type: ignore[union-attr] + + def test_store_workflow_object(self, state_with_workflow: State) -> None: + """Test storing a workflow (QGraph) object in run_item.""" + assert state_with_workflow.run_item is not None + assert state_with_workflow.run_item.name == "test_workflow" + assert state_with_workflow.run_item.snapshot_idx == 100 # type: ignore[union-attr] + + def test_run_item_can_be_any_object(self) -> None: + """Test that run_item can store any object (arbitrary_types_allowed). + + Note: When using model_construct, Pydantic bypasses validation + which allows storing any object type. + """ + + # Create a simple custom object + class CustomRunnable: + def __init__(self) -> None: + self.name = "custom" + + custom_obj = CustomRunnable() + state = State.model_construct(run_item=custom_obj) # type: ignore[arg-type] + + assert state.run_item is not None + assert state.run_item.name == "custom" + + +class TestStateEdgeCases: + """Tests for edge cases and special scenarios.""" + + def test_replace_last_run_while_not_running( + self, sample_last_run_finished: LastRun, sample_last_run_error: LastRun + ) -> None: + """Test replacing last_run when not running.""" + state = State(last_run=sample_last_run_finished) + + # Replace with different LastRun + state.last_run = sample_last_run_error + + assert state.last_run.status == RunStatusEnum.ERROR + + def test_is_running_check_is_safe_with_none(self) -> None: + """Test that is_running safely handles None last_run.""" + state = State() + + # Should not raise AttributeError + is_running = state.is_running + + assert is_running is False diff --git a/tests/unit/test_models_common.py b/tests/unit/test_models_common.py new file mode 100644 index 0000000..a5a45ab --- /dev/null +++ b/tests/unit/test_models_common.py @@ -0,0 +1,130 @@ +""" +Tests for common models: RunError and StateUpdate. + +These models are used to capture error information and state changes +during calibration execution. +""" + +from __future__ import annotations + +from qualibrate_runner.core.models.common import RunError, StateUpdate + + +class TestRunError: + """Tests for the RunError model.""" + + def test_serialization(self, sample_run_error: RunError) -> None: + """Test that RunError can be serialized to dict.""" + data = sample_run_error.model_dump() + + assert data["error_class"] == "ValueError" + assert data["message"] == "Invalid data" + assert isinstance(data["traceback"], list) + assert len(data["traceback"]) == 2 + + def test_deserialization(self, sample_traceback: list[str]) -> None: + """Test that RunError can be deserialized from dict.""" + data = { + "error_class": "RuntimeError", + "message": "Something went wrong", + "traceback": sample_traceback, + } + + error = RunError(**data) + + assert error.error_class == "RuntimeError" + assert error.message == "Something went wrong" + assert error.traceback == sample_traceback + + def test_serialization_with_optional_fields( + self, sample_traceback: list[str] + ) -> None: + """Test serialization includes optional fields when present.""" + error = RunError( + error_class="RuntimeError", + message="Something failed", + traceback=sample_traceback, + details_headline="Execution Error", + details="Check the logs for more information", + ) + + serialized = error.model_dump() + assert "details_headline" in serialized + assert serialized["details_headline"] == "Execution Error" + assert "details" in serialized + assert serialized["details"] == "Check the logs for more information" + + def test_deserialization_with_optional_fields( + self, sample_traceback: list[str] + ) -> None: + """Test deserialization with optional fields.""" + data = { + "error_class": "ValueError", + "message": "Test error", + "traceback": sample_traceback, + "details_headline": "Test Headline", + "details": "Test Details", + } + + error = RunError(**data) + assert error.details_headline == "Test Headline" + assert error.details == "Test Details" + + def test_deserialization_without_optional_fields( + self, sample_traceback: list[str] + ) -> None: + """Test deserialization without optional fields.""" + data = { + "error_class": "ValueError", + "message": "Test error", + "traceback": sample_traceback, + } + + error = RunError(**data) + assert error.details_headline is None + assert error.details is None + + +class TestStateUpdate: + """Tests for the StateUpdate model.""" + + def test_nested_key_path(self) -> None: + """Test StateUpdate with nested key path (dot notation).""" + update = StateUpdate( + key="qubit_0.readout.resonator", + attr="frequency", + old=7.0e9, + new=7.1e9, + updated=True, + ) + + assert update.key == "qubit_0.readout.resonator" + assert "." in update.key + + def test_serialization(self, sample_state_update: StateUpdate) -> None: + """Test that StateUpdate can be serialized to dict.""" + data = sample_state_update.model_dump() + + assert data["key"] == "qubit_0" + assert data["attr"] == "resonance_frequency" + assert data["old"] == 5.0e9 + assert data["new"] == 5.1e9 + assert data["updated"] is True + + def test_deserialization(self) -> None: + """Test that StateUpdate can be deserialized from dict.""" + data = { + "key": "qubit_2", + "attr": "T1", + "old": 50e-6, + "new": 55e-6, + "updated": True, + } + + update = StateUpdate(**data) + + assert update.key == "qubit_2" + assert update.attr == "T1" + assert update.old == 50e-6 + assert update.new == 55e-6 + assert update.updated is True diff --git a/tests/unit/test_models_last_run.py b/tests/unit/test_models_last_run.py new file mode 100644 index 0000000..4cadee6 --- /dev/null +++ b/tests/unit/test_models_last_run.py @@ -0,0 +1,202 @@ +""" +Tests for the LastRun model. + +LastRun tracks complete execution information for calibration nodes and +workflows, including status, timing, results, and errors. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from qualibrate_runner.core.models.enums import RunnableType, RunStatusEnum +from qualibrate_runner.core.models.last_run import LastRun + + +class TestLastRunDuration: + """Tests for the run_duration computed field.""" + + def test_duration_for_completed_run( + self, aware_datetime: datetime, later_datetime: datetime + ) -> None: + """Test run_duration calculation for completed run.""" + # later_datetime is 5.5 seconds after aware_datetime + last_run = LastRun( + status=RunStatusEnum.FINISHED, + started_at=aware_datetime, + completed_at=later_datetime, + name="test_node", + idx=42, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + + # Should be 5.5 seconds + assert last_run.run_duration == 5.5 # type: ignore[comparison-overlap] + + def test_duration_for_running_execution(self) -> None: + """Test run_duration for still-running execution (live duration).""" + # Started a moment ago + started = datetime.now(timezone.utc) - timedelta(milliseconds=100) + + last_run = LastRun( + status=RunStatusEnum.RUNNING, + started_at=started, + name="test_node", + idx=-1, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + + # Duration should be positive and small (less than 1 second) + assert 0 < last_run.run_duration < 1.0 # type: ignore[operator] + + def test_duration_for_very_short_run( + self, aware_datetime: datetime + ) -> None: + """Test duration for very short execution (milliseconds).""" + completed = aware_datetime + timedelta(milliseconds=150) + + last_run = LastRun( + status=RunStatusEnum.FINISHED, + started_at=aware_datetime, + completed_at=completed, + name="test_node", + idx=42, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + + assert last_run.run_duration == 0.15 # type: ignore[comparison-overlap] + + def test_duration_for_long_run(self, aware_datetime: datetime) -> None: + """Test duration for long execution (minutes).""" + # 5 minutes and 30 seconds + completed = aware_datetime + timedelta(minutes=5, seconds=30) + + last_run = LastRun( + status=RunStatusEnum.FINISHED, + started_at=aware_datetime, + completed_at=completed, + name="test_node", + idx=42, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + + assert last_run.run_duration == 330.0 # type: ignore[comparison-overlap] + + def test_duration_recalculated_on_access(self) -> None: + """Test that duration is recalculated each time for running jobs.""" + import time + + # Start a moment ago + started = datetime.now(timezone.utc) - timedelta(milliseconds=50) + + last_run = LastRun( + status=RunStatusEnum.RUNNING, + started_at=started, + name="test_node", + idx=-1, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + + # First access + duration1 = last_run.run_duration + assert duration1 > 0 # type: ignore[operator] + + # Wait a bit + time.sleep(0.1) + + # Second access should show increased duration + duration2 = last_run.run_duration + assert duration2 > duration1 # type: ignore[operator] + + +class TestLastRunSerialization: + """Tests for LastRun serialization and deserialization.""" + + def test_serialize_running_node( + self, sample_last_run_running: LastRun + ) -> None: + """Test serializing a running node to dict.""" + data = sample_last_run_running.model_dump() + + assert data["status"] == RunStatusEnum.RUNNING + assert data["name"] == "test_node" + assert data["idx"] == -1 + assert data["runnable_type"] == RunnableType.NODE + assert data["completed_at"] is None + assert data["run_result"] is None + assert data["error"] is None + + def test_serialize_finished_node( + self, sample_last_run_finished: LastRun + ) -> None: + """Test serializing a finished node to dict.""" + data = sample_last_run_finished.model_dump() + + assert data["status"] == RunStatusEnum.FINISHED + assert data["completed_at"] is not None + assert data["idx"] == 42 + assert "run_result" in data + assert "amplitude" in data["passed_parameters"] + + def test_serialize_error_node(self, sample_last_run_error: LastRun) -> None: + """Test serializing a failed node to dict.""" + data = sample_last_run_error.model_dump() + + assert data["status"] == RunStatusEnum.ERROR + assert data["error"] is not None + assert data["error"]["error_class"] == "ValueError" + assert data["error"]["message"] == "Invalid data" + + def test_deserialize_from_dict(self, aware_datetime: datetime) -> None: + """Test deserializing LastRun from dict.""" + data = { + "status": RunStatusEnum.RUNNING, + "started_at": aware_datetime.isoformat(), + "completed_at": None, + "name": "test_node", + "idx": -1, + "runnable_type": RunnableType.NODE, + "passed_parameters": {}, + "run_result": None, + "state_updates": {}, + "error": None, + } + + last_run = LastRun(**data) + + assert last_run.status == RunStatusEnum.RUNNING + assert last_run.name == "test_node" + + +class TestLastRunEdgeCases: + """Tests for edge cases and special scenarios.""" + + def test_node_vs_graph_runnable_types( + self, aware_datetime: datetime + ) -> None: + """Test both NODE and GRAPH runnable types.""" + node_run = LastRun( + status=RunStatusEnum.RUNNING, + started_at=aware_datetime, + name="test_node", + idx=-1, + runnable_type=RunnableType.NODE, + passed_parameters={}, + ) + + graph_run = LastRun( + status=RunStatusEnum.RUNNING, + started_at=aware_datetime, + name="test_workflow", + idx=-1, + runnable_type=RunnableType.GRAPH, + passed_parameters={}, + ) + + assert node_run.runnable_type == RunnableType.NODE + assert graph_run.runnable_type == RunnableType.GRAPH diff --git a/tests/unit/test_run_job.py b/tests/unit/test_run_job.py new file mode 100644 index 0000000..6ff7e32 --- /dev/null +++ b/tests/unit/test_run_job.py @@ -0,0 +1,573 @@ +""" +Tests for run_job.py orchestration layer (non-interactive mode only). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock, patch + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError + +from qualibrate_runner.config.models import State +from qualibrate_runner.core.models.common import StateUpdate +from qualibrate_runner.core.models.enums import RunnableType, RunStatusEnum +from qualibrate_runner.core.run_job import ( + get_active_library_or_error, + run_node, + run_workflow, + validate_input_parameters, +) + + +class TestValidateInputParameters: + """Tests for validate_input_parameters function.""" + + def test_valid_parameters_pass_validation( + self, sample_parameters_class: type[Any] + ) -> None: + """Test that valid parameters pass validation.""" + params = {"amplitude": 0.5, "frequency": 5.0e9, "num_averages": 100} + + result = validate_input_parameters(sample_parameters_class, params) + + assert result.amplitude == 0.5 # type: ignore[attr-defined] + assert result.frequency == 5.0e9 # type: ignore[attr-defined] + assert result.num_averages == 100 # type: ignore[attr-defined] + + def test_valid_parameters_with_defaults( + self, sample_parameters_class: type[Any] + ) -> None: + """Test that validation works with default values.""" + params = {"amplitude": 0.5, "frequency": 5.0e9} + + result = validate_input_parameters(sample_parameters_class, params) + + assert result.amplitude == 0.5 # type: ignore[attr-defined] + assert result.frequency == 5.0e9 # type: ignore[attr-defined] + assert result.num_averages == 100 # type: ignore[attr-defined] # Default value + + def test_invalid_parameter_raises_http_exception( + self, sample_parameters_class: type[Any] + ) -> None: + """Test that invalid parameters raise HTTPException with 422 status.""" + params = {"amplitude": 1.5, "frequency": 5.0e9} # amplitude > 1.0 + + with pytest.raises(HTTPException) as exc_info: + validate_input_parameters(sample_parameters_class, params) + + assert exc_info.value.status_code == 422 + assert exc_info.value.detail is not None + + def test_missing_required_field_raises_http_exception( + self, sample_parameters_class: type[Any] + ) -> None: + """Test that missing required field raises HTTPException.""" + params = {"amplitude": 0.5} # Missing required 'frequency' + + with pytest.raises(HTTPException) as exc_info: + validate_input_parameters(sample_parameters_class, params) + + assert exc_info.value.status_code == 422 + # Check that error mentions the missing field + errors = exc_info.value.detail + assert any("frequency" in str(error).lower() for error in errors) + + def test_type_coercion_works( + self, sample_parameters_class: type[Any] + ) -> None: + """Test that Pydantic type coercion works as expected.""" + params = { + "amplitude": "0.5", # String that can be coerced to float + "frequency": 5.0e9, + "num_averages": "50", # String that can be coerced to int + } + + result = validate_input_parameters(sample_parameters_class, params) + + assert result.amplitude == 0.5 # type: ignore[attr-defined] + assert result.frequency == 5.0e9 # type: ignore[attr-defined] + assert result.num_averages == 50 # type: ignore[attr-defined] + + +class TestGetActiveLibraryOrError: + """Tests for get_active_library_or_error function.""" + + @patch("qualibrate_runner.core.run_job.QualibrationLibrary") + def test_returns_library_when_exists( + self, mock_lib_class: Mock, mock_library: Mock + ) -> None: + """Test that function returns library when one exists.""" + mock_lib_class.get_active_library.return_value = mock_library + + result = get_active_library_or_error() + + mock_lib_class.get_active_library.assert_called_once_with(create=False) + assert result is mock_library + + @patch("qualibrate_runner.core.run_job.QualibrationLibrary") + def test_raises_exception_when_no_library( + self, mock_lib_class: Mock + ) -> None: + """Test that function raises exception when no library exists.""" + mock_lib_class.get_active_library.side_effect = RuntimeError( + "No active library" + ) + + with pytest.raises(RuntimeError, match="No active library"): + get_active_library_or_error() + + mock_lib_class.get_active_library.assert_called_once_with(create=False) + + +class TestRunNodeHappyPath: + """Tests for run_node function - happy path scenarios.""" + + def test_sets_run_item_to_node( + self, mock_node: Mock, fresh_state: State + ) -> None: + """Test that state.run_item is set to the node.""" + mock_node.run = Mock(return_value=None) + + run_node(mock_node, {}, fresh_state) + + assert fresh_state.run_item is mock_node + + def test_creates_last_run_with_running_status( + self, mock_node: Mock, fresh_state: State + ) -> None: + """Test that initial LastRun has RUNNING status.""" + # Track state during execution + last_run_during_execution = None + + def capture_state(*args: Any, **kwargs: Any) -> None: + nonlocal last_run_during_execution + last_run_during_execution = fresh_state.last_run + + mock_node.run = Mock(side_effect=capture_state) + + run_node(mock_node, {}, fresh_state) + + # During execution, status should have been RUNNING + assert last_run_during_execution is not None + assert last_run_during_execution.status == RunStatusEnum.RUNNING + assert last_run_during_execution.name == "test_node" + + def test_updates_state_with_finished_status( + self, mock_node: Mock, fresh_state: State + ) -> None: + """Test that final state has FINISHED status on success.""" + mock_node.run = Mock(return_value=None) + + run_node(mock_node, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.FINISHED + assert fresh_state.last_run.error is None + + def test_captures_snapshot_idx( + self, mock_node: Mock, fresh_state: State + ) -> None: + """Test that snapshot_idx is captured from node.""" + mock_node.run = Mock(return_value=None) + mock_node.snapshot_idx = 42 + + run_node(mock_node, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.idx == 42 + + def test_captures_run_summary( + self, mock_node: Mock, fresh_state: State + ) -> None: + """Test that run_summary is captured from node.""" + mock_node.run = Mock(return_value=None) + # run_summary is None by default in fixture + + run_node(mock_node, {}, fresh_state) + + # run_result should be None since run_summary is None + assert fresh_state.last_run is not None + assert fresh_state.last_run.run_result is None + + def test_captures_state_updates( + self, + mock_node: Mock, + fresh_state: State, + sample_state_update: StateUpdate, + ) -> None: + """Test that state_updates are captured from node.""" + + mock_node.run = Mock(return_value=None) + state_updates = {"qubit_0_frequency": sample_state_update} + mock_node.state_updates = state_updates + + run_node(mock_node, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.state_updates == state_updates + + def test_sets_completed_at_timestamp( + self, mock_node: Mock, fresh_state: State + ) -> None: + """Test that completed_at is set after execution.""" + mock_node.run = Mock(return_value=None) + + run_node(mock_node, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.completed_at is not None + assert ( + fresh_state.last_run.completed_at > fresh_state.last_run.started_at + ) + + +class TestRunNodeErrorPath: + """Tests for run_node function - error scenarios.""" + + def test_captures_exception_in_state( + self, mock_node: Mock, fresh_state: State + ) -> None: + """Test that exceptions are captured in state.last_run.error.""" + mock_node.run = Mock(side_effect=ValueError("Test error")) + + with pytest.raises(ValueError, match="Test error"): + run_node(mock_node, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.error is not None + assert fresh_state.last_run.error.error_class == "ValueError" + assert fresh_state.last_run.error.message == "Test error" + + def test_captures_traceback( + self, mock_node: Mock, fresh_state: State + ) -> None: + """Test that full traceback is captured.""" + mock_node.run = Mock(side_effect=RuntimeError("Node failed")) + + with pytest.raises(RuntimeError): + run_node(mock_node, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.error is not None + assert len(fresh_state.last_run.error.traceback) > 0 + # Traceback should be a list of strings + assert all( + isinstance(line, str) + for line in fresh_state.last_run.error.traceback + ) + + def test_re_raises_original_exception( + self, mock_node: Mock, fresh_state: State + ) -> None: + """Test that the original exception is re-raised.""" + original_error = ValueError("Original error") + mock_node.run = Mock(side_effect=original_error) + + with pytest.raises(ValueError) as exc_info: + run_node(mock_node, {}, fresh_state) + + # Should be the same exception object + assert exc_info.value is original_error + + def test_state_updated_even_on_error( + self, mock_node: Mock, fresh_state: State + ) -> None: + """Test that state is updated in finally block even on error.""" + mock_node.run = Mock(side_effect=RuntimeError("Error")) + + with pytest.raises(RuntimeError): + run_node(mock_node, {}, fresh_state) + + # State should still be updated with error info + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.completed_at is not None + assert fresh_state.last_run.name == "test_node" + + def test_snapshot_idx_negative_one_on_error( + self, mock_node: Mock, fresh_state: State + ) -> None: + """Test that snapshot_idx remains -1 on error.""" + mock_node.run = Mock(side_effect=ValueError("Error")) + mock_node.snapshot_idx = 42 # Should not be used on error + + with pytest.raises(ValueError): + run_node(mock_node, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.idx == -1 + + +class TestRunWorkflowHappyPath: + """Tests for run_workflow function - happy path scenarios.""" + + @patch("qualibrate_runner.core.run_job.get_active_library_or_error") + def test_retrieves_fresh_workflow_from_library( + self, + mock_get_library: Mock, + mock_library: Mock, + mock_workflow: Mock, + fresh_state: State, + ) -> None: + """Test that workflow is retrieved fresh from library.""" + mock_get_library.return_value = mock_library + + # Create a fresh copy mock + fresh_workflow = Mock() + fresh_workflow.name = "test_workflow" + fresh_workflow.snapshot_idx = 100 + fresh_workflow.run_summary = None # Must be None or real RunSummary + fresh_workflow.run = Mock(return_value=None) + + # Setup full_parameters_class + mock_params = Mock() + mock_params.nodes.model_dump.return_value = {} + mock_params.parameters.model_dump.return_value = {"frequency": 5.0e9} + fresh_workflow.full_parameters_class.return_value = mock_params + + mock_library.graphs["test_workflow"] = fresh_workflow + + run_workflow( + mock_workflow, {"parameters": {"frequency": 5.0e9}}, fresh_state + ) + + # Should retrieve fresh workflow from library + mock_get_library.assert_called_once() + fresh_workflow.run.assert_called_once() + + @patch("qualibrate_runner.core.run_job.get_active_library_or_error") + def test_creates_last_run_with_graph_type( + self, + mock_get_library: Mock, + mock_library: Mock, + mock_workflow: Mock, + fresh_state: State, + ) -> None: + """Test that LastRun is created with GRAPH runnable_type.""" + mock_get_library.return_value = mock_library + + # Setup workflow + mock_workflow.run = Mock(return_value=None) + mock_params = Mock() + mock_params.nodes.model_dump.return_value = {} + mock_params.parameters.model_dump.return_value = {} + mock_workflow.full_parameters_class.return_value = mock_params + + # Track state during execution + last_run_during_execution = None + + def capture_state(*args: Any, **kwargs: Any) -> None: + nonlocal last_run_during_execution + last_run_during_execution = fresh_state.last_run + + mock_workflow.run.side_effect = capture_state + + run_workflow(mock_workflow, {}, fresh_state) + + assert last_run_during_execution is not None + assert last_run_during_execution.runnable_type == RunnableType.GRAPH + + @patch("qualibrate_runner.core.run_job.get_active_library_or_error") + def test_validates_parameters_with_full_parameters_class( + self, + mock_get_library: Mock, + mock_library: Mock, + mock_workflow: Mock, + fresh_state: State, + ) -> None: + """Test that parameters are validated using full_parameters_class.""" + mock_get_library.return_value = mock_library + + # Setup workflow + mock_workflow.run = Mock(return_value=None) + mock_params = Mock() + mock_params.nodes.model_dump.return_value = {} + mock_params.parameters.model_dump.return_value = {"frequency": 5.0e9} + mock_workflow.full_parameters_class.return_value = mock_params + + input_params = {"parameters": {"frequency": 5.0e9}, "nodes": {}} + + run_workflow(mock_workflow, input_params, fresh_state) + + # Should call full_parameters_class with input parameters + mock_workflow.full_parameters_class.assert_called_once_with( + **input_params + ) + + @patch("qualibrate_runner.core.run_job.get_active_library_or_error") + def test_splits_parameters_into_nodes_and_params( + self, + mock_get_library: Mock, + mock_library: Mock, + mock_workflow: Mock, + fresh_state: State, + ) -> None: + """Test that parameters are split into nodes and parameters.""" + mock_get_library.return_value = mock_library + + # Setup workflow + mock_workflow.run = Mock(return_value=None) + mock_params = Mock() + mock_params.nodes.model_dump.return_value = { + "node1": {"amplitude": 0.5} + } + mock_params.parameters.model_dump.return_value = {"frequency": 5.0e9} + mock_workflow.full_parameters_class.return_value = mock_params + + input_params = { + "parameters": {"frequency": 5.0e9}, + "nodes": {"node1": {"amplitude": 0.5}}, + } + + run_workflow(mock_workflow, input_params, fresh_state) + + # Should call workflow.run with separated params + mock_workflow.run.assert_called_once_with( + nodes={"node1": {"amplitude": 0.5}}, frequency=5.0e9 + ) + + @patch("qualibrate_runner.core.run_job.get_active_library_or_error") + def test_updates_state_with_finished_status( + self, + mock_get_library: Mock, + mock_library: Mock, + mock_workflow: Mock, + fresh_state: State, + ) -> None: + """Test that final state has FINISHED status on success.""" + mock_get_library.return_value = mock_library + + # Setup workflow + mock_workflow.run = Mock(return_value=None) + mock_params = Mock() + mock_params.nodes.model_dump.return_value = {} + mock_params.parameters.model_dump.return_value = {} + mock_workflow.full_parameters_class.return_value = mock_params + + run_workflow(mock_workflow, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.FINISHED + assert fresh_state.last_run.error is None + + +class TestRunWorkflowErrorPath: + """Tests for run_workflow function - error scenarios.""" + + @patch("qualibrate_runner.core.run_job.get_active_library_or_error") + def test_library_not_found_raises_exception( + self, mock_get_library: Mock, mock_workflow: Mock, fresh_state: State + ) -> None: + """Test that missing library raises exception.""" + mock_get_library.side_effect = RuntimeError("No active library") + + with pytest.raises(RuntimeError, match="No active library"): + run_workflow(mock_workflow, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + + @patch("qualibrate_runner.core.run_job.get_active_library_or_error") + def test_workflow_not_in_library_raises_exception( + self, + mock_get_library: Mock, + mock_library: Mock, + mock_workflow: Mock, + fresh_state: State, + ) -> None: + """Test that missing workflow in library raises exception.""" + mock_get_library.return_value = mock_library + mock_library.graphs = {} # Empty, no workflows + + with pytest.raises(KeyError): + run_workflow(mock_workflow, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + + @patch("qualibrate_runner.core.run_job.get_active_library_or_error") + def test_parameter_validation_errors_raise_exception( + self, + mock_get_library: Mock, + mock_library: Mock, + mock_workflow: Mock, + fresh_state: State, + ) -> None: + """Test that parameter validation errors are raised.""" + mock_get_library.return_value = mock_library + + # Setup workflow to raise validation error + mock_workflow.full_parameters_class.side_effect = ( + ValidationError.from_exception_data( + "test", + [ + { + "type": "missing", + "loc": ("frequency",), + "input": {}, + } + ], + ) + ) + + with pytest.raises(ValidationError): + run_workflow(mock_workflow, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + + @patch("qualibrate_runner.core.run_job.get_active_library_or_error") + def test_runtime_errors_captured_and_reraised( + self, + mock_get_library: Mock, + mock_library: Mock, + mock_workflow: Mock, + fresh_state: State, + ) -> None: + """Test that runtime errors during workflow.run are captured.""" + mock_get_library.return_value = mock_library + + # Setup workflow to raise error during run + mock_workflow.run = Mock(side_effect=RuntimeError("Workflow failed")) + mock_params = Mock() + mock_params.nodes.model_dump.return_value = {} + mock_params.parameters.model_dump.return_value = {} + mock_workflow.full_parameters_class.return_value = mock_params + + with pytest.raises(RuntimeError, match="Workflow failed"): + run_workflow(mock_workflow, {}, fresh_state) + + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.error is not None + assert fresh_state.last_run.error.error_class == "RuntimeError" + + @patch("qualibrate_runner.core.run_job.get_active_library_or_error") + def test_state_updated_even_on_error( + self, + mock_get_library: Mock, + mock_library: Mock, + mock_workflow: Mock, + fresh_state: State, + ) -> None: + """Test that state is updated in finally block even on error.""" + mock_get_library.return_value = mock_library + + # Setup workflow to raise error + mock_workflow.run = Mock(side_effect=ValueError("Error")) + mock_params = Mock() + mock_params.nodes.model_dump.return_value = {} + mock_params.parameters.model_dump.return_value = {} + mock_workflow.full_parameters_class.return_value = mock_params + + with pytest.raises(ValueError): + run_workflow(mock_workflow, {}, fresh_state) + + # State should still be updated + assert fresh_state.last_run is not None + assert fresh_state.last_run.status == RunStatusEnum.ERROR + assert fresh_state.last_run.completed_at is not None