diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd70e3..1bc13c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] - 2026-02-20 +## [0.1.0] - 2026-02-20 ### Added - **Dual-Threshold Detection:** Logic to capture the start and end of signals, not just the peak. @@ -15,4 +15,22 @@ ### Fixed - Prevented redundant `_annotated` suffixes in file naming patterns. -- Simplified internal math to increase processing speed and precision. \ No newline at end of file +- Simplified internal math to increase processing speed and precision. +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- +## [0.1.1] - 2026-03-20 + +### Added + +- **Campaign orchestration** — new `orchestration` module that manages the full lifecycle of an RF data collection campaign: SDR capture, automatic labeling, QA checks, and dataset packaging. +- **HTTP inference server** — `ria-server` command starts a REST API server for deploying campaigns and controlling live inference from external systems such as the RIA Hub platform. +- **Campaign CLI** — `ria campaign` commands for starting, monitoring, and managing campaigns from the terminal. + +### Changed + +- **Visualization layout** — recording and dataset views have been reformatted with improved sizing, repositioned titles, and updated Qoherent branding. + +--- diff --git a/poetry.lock b/poetry.lock index db83521..43af2ce 100644 --- a/poetry.lock +++ b/poetry.lock @@ -12,13 +12,37 @@ files = [ {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = false +python-versions = ">=3.8" +groups = ["server", "test"] +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["server", "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"}, +] + [[package]] name = "anyio" version = "4.13.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.10" -groups = ["docs"] +groups = ["docs", "server", "test"] files = [ {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, @@ -76,42 +100,48 @@ dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)" [[package]] name = "black" -version = "24.10.0" +version = "26.3.1" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, - {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, - {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, - {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, - {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, - {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, - {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, - {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, - {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, - {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, - {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, - {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, - {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, - {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, - {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, - {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, - {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, - {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, - {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, - {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, - {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, - {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, + {file = "black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2"}, + {file = "black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b"}, + {file = "black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac"}, + {file = "black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a"}, + {file = "black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a"}, + {file = "black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff"}, + {file = "black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c"}, + {file = "black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5"}, + {file = "black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e"}, + {file = "black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5"}, + {file = "black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1"}, + {file = "black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f"}, + {file = "black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7"}, + {file = "black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983"}, + {file = "black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb"}, + {file = "black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54"}, + {file = "black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f"}, + {file = "black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56"}, + {file = "black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839"}, + {file = "black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2"}, + {file = "black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78"}, + {file = "black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568"}, + {file = "black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f"}, + {file = "black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c"}, + {file = "black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1"}, + {file = "black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b"}, + {file = "black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07"}, ] [package.dependencies] click = ">=8.0.0" mypy-extensions = ">=0.4.3" packaging = ">=22.0" -pathspec = ">=0.9.0" +pathspec = ">=1.0.0" platformdirs = ">=2" +pytokens = ">=0.4.0,<0.5.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} @@ -119,7 +149,7 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} colorama = ["colorama (>=0.4.3)"] d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] +uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] [[package]] name = "cachetools" @@ -139,7 +169,7 @@ version = "2026.2.25" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["docs"] +groups = ["agent", "docs", "test"] files = [ {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, @@ -249,7 +279,7 @@ version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["docs"] +groups = ["agent", "docs"] files = [ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, @@ -388,7 +418,7 @@ version = "8.3.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["main", "dev", "docs"] +groups = ["main", "dev", "docs", "server", "test"] files = [ {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, @@ -403,12 +433,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", "docs", "test"] +groups = ["main", "dev", "docs", "server", "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\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", server = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "contourpy" @@ -643,7 +673,7 @@ version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["docs", "test"] +groups = ["docs", "server", "test"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, @@ -656,6 +686,30 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "fastapi" +version = "0.135.3" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.10" +groups = ["server", "test"] +files = [ + {file = "fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98"}, + {file = "fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +pydantic = ">=2.9.0" +starlette = ">=0.46.0" +typing-extensions = ">=4.8.0" +typing-inspection = ">=0.4.2" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + [[package]] name = "filelock" version = "3.25.2" @@ -685,6 +739,17 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.14.0,<2.15.0" pyflakes = ">=3.4.0,<3.5.0" +[[package]] +name = "flatbuffers" +version = "25.12.19" +description = "The FlatBuffers serialization format for Python" +optional = false +python-versions = "*" +groups = ["server", "test"] +files = [ + {file = "flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4"}, +] + [[package]] name = "fonttools" version = "4.62.1" @@ -764,7 +829,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 = ["docs"] +groups = ["docs", "server", "test"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -1271,6 +1336,24 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mpmath" +version = "1.3.0" +description = "Python library for arbitrary-precision floating-point arithmetic" +optional = false +python-versions = "*" +groups = ["server", "test"] +files = [ + {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, + {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, +] + +[package.extras] +develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] +docs = ["sphinx"] +gmpy = ["gmpy2 (>=2.1.0a4) ; platform_python_implementation != \"PyPy\""] +tests = ["pytest (>=4.6)"] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -1315,7 +1398,7 @@ version = "1.26.4" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "server", "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"}, @@ -1355,13 +1438,97 @@ files = [ {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, ] +[[package]] +name = "onnxruntime" +version = "1.24.3" +description = "ONNX Runtime is a runtime accelerator for Machine Learning models" +optional = false +python-versions = ">=3.10" +groups = ["server", "test"] +markers = "python_version == \"3.10\"" +files = [ + {file = "onnxruntime-1.24.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3e6456801c66b095c5cd68e690ca25db970ea5202bd0c5b84a2c3ef7731c5a3c"}, + {file = "onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b2ebc54c6d8281dccff78d4b06e47d4cf07535937584ab759448390a70f4978"}, + {file = "onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb56575d7794bf0781156955610c9e651c9504c64d42ec880784b6106244882d"}, + {file = "onnxruntime-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:c958222ef9eff54018332beecd32d5d94a3ab079d8821937b333811bf4da0d39"}, + {file = "onnxruntime-1.24.3-cp311-cp311-win_arm64.whl", hash = "sha256:a8f761857ebaf58a85b9e42422d03207f1d39e6bb8fecfdbf613bac5b9710723"}, + {file = "onnxruntime-1.24.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0d244227dc5e00a9ae15a7ac1eba4c4460d7876dfecafe73fb00db9f1d914d91"}, + {file = "onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a9847b870b6cb462652b547bc98c49e0efb67553410a082fde1918a38707452"}, + {file = "onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b354afce3333f2859c7e8706d84b6c552beac39233bcd3141ce7ab77b4cabb5d"}, + {file = "onnxruntime-1.24.3-cp312-cp312-win_amd64.whl", hash = "sha256:44ea708c34965439170d811267c51281d3897ecfc4aa0087fa25d4a4c3eb2e4a"}, + {file = "onnxruntime-1.24.3-cp312-cp312-win_arm64.whl", hash = "sha256:48d1092b44ca2ba6f9543892e7c422c15a568481403c10440945685faf27a8d8"}, + {file = "onnxruntime-1.24.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:34a0ea5ff191d8420d9c1332355644148b1bf1a0d10c411af890a63a9f662aa7"}, + {file = "onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fd2ec7bb0fabe42f55e8337cfc9b1969d0d14622711aac73d69b4bd5abb5ed7"}, + {file = "onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df8e70e732fe26346faaeec9147fa38bef35d232d2495d27e93dd221a2d473a9"}, + {file = "onnxruntime-1.24.3-cp313-cp313-win_amd64.whl", hash = "sha256:2d3706719be6ad41d38a2250998b1d87758a20f6ea4546962e21dc79f1f1fd2b"}, + {file = "onnxruntime-1.24.3-cp313-cp313-win_arm64.whl", hash = "sha256:b082f3ba9519f0a1a1e754556bc7e635c7526ef81b98b3f78da4455d25f0437b"}, + {file = "onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72f956634bc2e4bd2e8b006bef111849bd42c42dea37bd0a4c728404fdaf4d34"}, + {file = "onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d1f25eed4ab9959db70a626ed50ee24cf497e60774f59f1207ac8556399c4d"}, + {file = "onnxruntime-1.24.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a6b4bce87d96f78f0a9bf5cefab3303ae95d558c5bfea53d0bf7f9ea207880a8"}, + {file = "onnxruntime-1.24.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d48f36c87b25ab3b2b4c88826c96cf1399a5631e3c2c03cc27d6a1e5d6b18eb4"}, + {file = "onnxruntime-1.24.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e104d33a409bf6e3f30f0e8198ec2aaf8d445b8395490a80f6e6ad56da98e400"}, + {file = "onnxruntime-1.24.3-cp314-cp314-win_amd64.whl", hash = "sha256:e785d73fbd17421c2513b0bb09eb25d88fa22c8c10c3f5d6060589efa5537c5b"}, + {file = "onnxruntime-1.24.3-cp314-cp314-win_arm64.whl", hash = "sha256:951e897a275f897a05ffbcaa615d98777882decaeb80c9216c68cdc62f849f53"}, + {file = "onnxruntime-1.24.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d4e70ce578aa214c74c7a7a9226bc8e229814db4a5b2d097333b81279ecde36"}, + {file = "onnxruntime-1.24.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02aaf6ddfa784523b6873b4176a79d508e599efe12ab0ea1a3a6e7314408b7aa"}, +] + +[package.dependencies] +flatbuffers = "*" +numpy = ">=1.21.6" +packaging = "*" +protobuf = "*" +sympy = "*" + +[[package]] +name = "onnxruntime" +version = "1.24.4" +description = "ONNX Runtime is a runtime accelerator for Machine Learning models" +optional = false +python-versions = ">=3.11" +groups = ["server", "test"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "onnxruntime-1.24.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2"}, + {file = "onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7"}, + {file = "onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330"}, + {file = "onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153"}, + {file = "onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b"}, + {file = "onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78"}, + {file = "onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5"}, + {file = "onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c"}, + {file = "onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb"}, + {file = "onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90"}, + {file = "onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0"}, + {file = "onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13"}, + {file = "onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f"}, + {file = "onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93"}, + {file = "onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19"}, + {file = "onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee"}, + {file = "onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36"}, + {file = "onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4"}, + {file = "onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1"}, + {file = "onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177"}, + {file = "onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858"}, + {file = "onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d"}, + {file = "onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661"}, + {file = "onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731"}, +] + +[package.dependencies] +flatbuffers = "*" +numpy = ">=1.21.6" +packaging = "*" +protobuf = "*" +sympy = "*" + [[package]] name = "packaging" version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "docs", "test"] +groups = ["main", "dev", "docs", "server", "test"] files = [ {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, @@ -1646,6 +1813,24 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "protobuf" +version = "7.34.1" +description = "" +optional = false +python-versions = ">=3.10" +groups = ["server", "test"] +files = [ + {file = "protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7"}, + {file = "protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b"}, + {file = "protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a"}, + {file = "protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4"}, + {file = "protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a"}, + {file = "protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c"}, + {file = "protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11"}, + {file = "protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280"}, +] + [[package]] name = "pycodestyle" version = "2.14.0" @@ -1671,6 +1856,162 @@ files = [ {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, ] +[[package]] +name = "pydantic" +version = "2.12.5" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["server", "test"] +files = [ + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["server", "test"] +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + [[package]] name = "pyflakes" version = "3.4.0" @@ -1822,6 +2163,76 @@ platformdirs = ">=4.3.6,<5" docs = ["furo (>=2025.12.19)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.3)", "sphinxcontrib-mermaid (>=2)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] +[[package]] +name = "python-dotenv" +version = "1.2.2" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.10" +groups = ["server", "test"] +files = [ + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pytokens" +version = "0.4.1" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, + {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, + {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, + {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, + {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, + {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, + {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, + {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, + {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, + {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, + {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, + {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, + {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, + {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, + {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, + {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, + {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, + {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, + {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, + {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, + {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, + {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, + {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, + {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, + {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, + {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + [[package]] name = "pytz" version = "2026.1.post1" @@ -1840,7 +2251,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "server", "test"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -2057,7 +2468,7 @@ version = "2.33.1" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" -groups = ["docs"] +groups = ["agent", "docs"] files = [ {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, @@ -2505,7 +2916,7 @@ version = "1.0.0" description = "The little ASGI library that shines." optional = false python-versions = ">=3.10" -groups = ["docs"] +groups = ["docs", "server", "test"] files = [ {file = "starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b"}, {file = "starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149"}, @@ -2518,6 +2929,24 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\"" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +[[package]] +name = "sympy" +version = "1.14.0" +description = "Computer algebra system (CAS) in Python" +optional = false +python-versions = ">=3.9" +groups = ["server", "test"] +files = [ + {file = "sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"}, + {file = "sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517"}, +] + +[package.dependencies] +mpmath = ">=1.1.0,<1.4" + +[package.extras] +dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] + [[package]] name = "tomli" version = "2.4.1" @@ -2635,12 +3064,27 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev", "docs", "test"] +groups = ["main", "dev", "docs", "server", "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"}, ] -markers = {main = "python_version <= \"3.12\"", dev = "python_version == \"3.10\"", docs = "python_version <= \"3.12\"", test = "python_version == \"3.10\""} +markers = {main = "python_version <= \"3.12\"", dev = "python_version == \"3.10\"", docs = "python_version <= \"3.12\""} + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["server", "test"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" [[package]] name = "tzdata" @@ -2660,7 +3104,7 @@ version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["agent", "docs"] files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, @@ -2678,7 +3122,7 @@ version = "0.42.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.10" -groups = ["docs"] +groups = ["docs", "server", "test"] files = [ {file = "uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359"}, {file = "uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775"}, @@ -2686,12 +3130,84 @@ files = [ [package.dependencies] click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} h11 = ">=0.8" +httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} +uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.20", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} [package.extras] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=10.4)"] +[[package]] +name = "uvloop" +version = "0.22.1" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.1" +groups = ["server", "test"] +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7"}, + {file = "uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f"}, +] + +[package.extras] +dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx_rtd_theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=25.3.0,<25.4.0)", "pycodestyle (>=2.11.0,<2.12.0)"] + [[package]] name = "virtualenv" version = "21.2.0" @@ -2717,7 +3233,7 @@ version = "1.1.1" description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["docs", "server", "test"] files = [ {file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"}, {file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"}, @@ -2839,7 +3355,7 @@ version = "16.0" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.10" -groups = ["docs"] +groups = ["docs", "server", "test"] files = [ {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, @@ -2907,4 +3423,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "561f5c2944eccf993252e21d130ed541e8b409ee702ff08281e8da715228fcac" +content-hash = "b1e5ddd7284aecf49624e51740b7a4c31bc8d0e703c255126ba5d9b2a4a0e519" diff --git a/pyproject.toml b/pyproject.toml index 13d8ec3..8db3469 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,15 +85,25 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.group.test.dependencies] pytest = "^8.0.0" tox = "^4.19.0" +fastapi = ">=0.111,<1.0" +uvicorn = {version = ">=0.29,<1.0", extras = ["standard"]} +onnxruntime = ">=1.17,<2.0" +httpx = ">=0.27,<1.0" [tool.poetry.group.docs.dependencies] sphinx = "^7.2.6" sphinx-rtd-theme = "^2.0.0" sphinx-autobuild = "^2024.2.4" +[tool.poetry.group.agent] +optional = true + +[tool.poetry.group.agent.dependencies] +requests = ">=2.28,<3.0" + [tool.poetry.group.dev.dependencies] flake8 = "^7.1.0" -black = "^24.3.0" +black = "^26.3.1" isort = "^5.13.2" pylint = "^3.2.6" # For pyreverse, to automate the creation of UML diagrams @@ -105,6 +115,13 @@ pylint = "^3.2.6" # For pyreverse, to automate the creation of UML diagrams [tool.poetry.scripts] ria = "ria_toolkit_oss_cli.cli:cli" ria-tools = "ria_toolkit_oss_cli.cli:cli" +ria-server = "ria_toolkit_oss.server.cli:serve" +ria-agent = "ria_toolkit_oss.agent:main" + +[tool.poetry.group.server.dependencies] +fastapi = ">=0.111,<1.0" +uvicorn = {version = ">=0.29,<1.0", extras = ["standard"]} +onnxruntime = ">=1.17,<2.0" [tool.black] line-length = 119 @@ -127,5 +144,8 @@ exclude = ''' )/ ''' +[tool.pytest.ini_options] +pythonpath = ["src"] + [tool.isort] profile = "black" diff --git a/src/ria_toolkit_oss/agent.py b/src/ria_toolkit_oss/agent.py new file mode 100644 index 0000000..bd4b3fc --- /dev/null +++ b/src/ria_toolkit_oss/agent.py @@ -0,0 +1,489 @@ +"""RT-OSS Node Agent — connects to RIA Hub and dispatches work to local hardware. + +The agent runs on any machine with an SDR attached and connects **outbound** to +RIA Hub. No inbound ports need to be opened on the user's machine, and the +connection works identically through NAT, corporate firewalls, or a Pi on a +cellular link. + +Usage:: + + ria-agent \\ + --hub https://riahub.company.com \\ + --key \\ + --name lab-bench-1 \\ + [--device plutosdr] \\ + [--insecure] + +The agent: + 1. Registers with RIA Hub and receives a ``node_id``. + 2. Sends a heartbeat every 30 s so the hub knows it is online. + 3. Long-polls ``GET /orchestrator/nodes/{id}/commands`` (30 s timeout). + 4. Executes received campaigns via :class:`ria_toolkit_oss.orchestration.executor.CampaignExecutor`. + 5. Uploads recordings to the hub via chunked POST, keeping each request + under 50 MB so it passes through Cloudflare without needing the bypass + subdomain. + 6. Deregisters cleanly on SIGINT / SIGTERM. +""" + +from __future__ import annotations + +import logging +import math +import os +import signal +import sys +import threading +import time +import uuid +from typing import Any + +logger = logging.getLogger("ria_agent") + +# --------------------------------------------------------------------------- +# Tuneable constants +# --------------------------------------------------------------------------- + +_HEARTBEAT_INTERVAL = 30 # seconds between heartbeats +_POLL_TIMEOUT = 30 # server-side long-poll duration +_POLL_CLIENT_TIMEOUT = 40 # client read timeout — slightly longer than server +_RECONNECT_PAUSE = 5 # seconds to wait after a poll error before retrying +_CHUNK_SIZE = 50 * 1024 * 1024 # 50 MB — well below Cloudflare's 100 MB limit +_DIRECT_THRESHOLD = 90 * 1024 * 1024 # files above this use chunked upload + + +# --------------------------------------------------------------------------- +# Agent +# --------------------------------------------------------------------------- + + +class NodeAgent: + """Outbound-connecting agent that bridges RIA Hub to local SDR hardware. + + All network I/O is initiated by the agent (outbound). RIA Hub never opens + a connection back to the agent's machine. + """ + + def __init__( + self, + hub_url: str, + api_key: str, + name: str, + sdr_device: str = "unknown", + insecure: bool = False, + ) -> None: + self.hub_url = hub_url.rstrip("/") + self.api_key = api_key + self.name = name + self.sdr_device = sdr_device + self.insecure = insecure + + self.node_id: str | None = None + self._stop = threading.Event() + + try: + import ria_toolkit_oss + + self._ria_version: str = getattr(ria_toolkit_oss, "__version__", "unknown") + except Exception: + self._ria_version = "unknown" + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def run(self) -> None: + """Register, start the heartbeat thread, and enter the command loop. + + Blocks until SIGINT or SIGTERM is received. + """ + self._register() + + def _shutdown(sig: int, _frame: Any) -> None: + logger.info("Shutdown signal received — stopping agent") + self._stop.set() + + signal.signal(signal.SIGINT, _shutdown) + signal.signal(signal.SIGTERM, _shutdown) + + hb = threading.Thread(target=self._heartbeat_loop, daemon=True, name="ria-agent-heartbeat") + hb.start() + + logger.info("Agent %r online (node_id=%s, hub=%s)", self.name, self.node_id, self.hub_url) + + try: + self._command_loop() + finally: + self._stop.set() + self._deregister() + + # ------------------------------------------------------------------ + # Registration + # ------------------------------------------------------------------ + + def _register(self) -> None: + resp = self._post( + "/orchestrator/nodes/register", + json={ + "name": self.name, + "sdr_device": self.sdr_device, + "ria_toolkit_version": self._ria_version, + "capabilities": ["inference", "campaign"], + }, + timeout=15, + ) + resp.raise_for_status() + self.node_id = resp.json()["node_id"] + logger.info("Registered as %r (node_id=%s)", self.name, self.node_id) + + def _deregister(self) -> None: + if not self.node_id: + return + try: + self._delete(f"/orchestrator/nodes/{self.node_id}", timeout=10) + logger.info("Deregistered %s", self.node_id) + except Exception as exc: + logger.debug("Deregister failed (ignored on shutdown): %s", exc) + + # ------------------------------------------------------------------ + # Heartbeat thread + # ------------------------------------------------------------------ + + def _heartbeat_loop(self) -> None: + while not self._stop.wait(_HEARTBEAT_INTERVAL): + try: + resp = self._post(f"/orchestrator/nodes/{self.node_id}/heartbeat", timeout=10) + if resp.status_code == 404: + logger.warning("Heartbeat got 404 — hub lost registration, re-registering") + self._register() + except Exception as exc: + logger.warning("Heartbeat failed: %s", exc) + + # ------------------------------------------------------------------ + # Command poll loop + # ------------------------------------------------------------------ + + def _command_loop(self) -> None: + while not self._stop.is_set(): + try: + resp = self._get( + f"/orchestrator/nodes/{self.node_id}/commands", + timeout=_POLL_CLIENT_TIMEOUT, + ) + if resp.status_code == 204: + # No command within the timeout window — loop immediately. + continue + if resp.status_code == 404: + logger.warning("Command poll got 404 — re-registering") + self._register() + continue + resp.raise_for_status() + cmd = resp.json() + logger.info("Received command: %s", cmd.get("command")) + self._dispatch(cmd) + except Exception as exc: + if not self._stop.is_set(): + logger.warning("Command poll error: %s — retrying in %ds", exc, _RECONNECT_PAUSE) + time.sleep(_RECONNECT_PAUSE) + + # ------------------------------------------------------------------ + # Command dispatch + # ------------------------------------------------------------------ + + def _dispatch(self, cmd: dict) -> None: + command = cmd.get("command") + if command == "run_campaign": + campaign_id: str = cmd.get("campaign_id") or str(uuid.uuid4()) + config_dict: dict = cmd.get("payload") or {} + threading.Thread( + target=self._run_campaign, + args=(campaign_id, config_dict), + daemon=True, + name=f"campaign-{campaign_id[:8]}", + ).start() + else: + logger.warning("Unknown command %r — ignored", command) + + # ------------------------------------------------------------------ + # Campaign execution + # ------------------------------------------------------------------ + + def _run_campaign(self, campaign_id: str, config_dict: dict) -> None: + try: + from ria_toolkit_oss.orchestration.campaign import CampaignConfig + from ria_toolkit_oss.orchestration.executor import CampaignExecutor + except ImportError as exc: + logger.error( + "Campaign %s cannot start — ria_toolkit_oss not fully installed: %s", + campaign_id[:8], + exc, + ) + return + + logger.info("Campaign %s starting", campaign_id[:8]) + try: + config = CampaignConfig.from_dict(config_dict) + executor = CampaignExecutor(config) + result = executor.run() + logger.info("Campaign %s completed — uploading recordings", campaign_id[:8]) + self._upload_recordings(campaign_id, config, result) + result_dict = result.to_dict() if hasattr(result, "to_dict") else None + self._report_campaign_status(campaign_id, "completed", result=result_dict) + except Exception as exc: + logger.error("Campaign %s failed: %s", campaign_id[:8], exc) + self._report_campaign_status(campaign_id, "failed", error=str(exc)) + + # ------------------------------------------------------------------ + # Recording upload (chunked for large files) + # ------------------------------------------------------------------ + + def _upload_recordings(self, campaign_id: str, config: Any, result: Any) -> None: + output_repo: str | None = getattr(getattr(config, "output", None), "repo", None) + if not output_repo or "/" not in output_repo: + logger.warning("Campaign %s: no output.repo — skipping upload", campaign_id[:8]) + return + + repo_owner, repo_name = output_repo.split("/", 1) + base_url = f"{self.hub_url}/datasets/upload" + steps = getattr(result, "steps", None) or [] + + for step in steps: + output_path: str | None = getattr(step, "output_path", None) + if not output_path: + continue + device_id: str = getattr(step, "transmitter_id", "") or "" + for fpath in _sigmf_files(output_path): + filename = os.path.basename(fpath) + metadata = { + "filename": filename, + "repo_owner": repo_owner, + "repo_name": repo_name, + "device_id": device_id, + "campaign_id": campaign_id, + } + try: + resp_data = self._upload_file(base_url, fpath, metadata) + logger.info( + "Campaign %s: uploaded %s (oid=%s)", + campaign_id[:8], + filename, + resp_data.get("oid", "?"), + ) + except Exception as exc: + logger.warning("Campaign %s: upload of %s failed: %s", campaign_id[:8], filename, exc) + + def _report_campaign_status( + self, + campaign_id: str, + status: str, + result: "dict | None" = None, + error: "str | None" = None, + ) -> None: + """POST campaign completion/failure back to the hub so GET /status/{id} resolves.""" + payload: dict = {"campaign_id": campaign_id, "status": status} + if result is not None: + payload["result"] = result + if error is not None: + payload["error"] = error + try: + resp = self._post( + f"/orchestrator/nodes/{self.node_id}/campaign-status", + json=payload, + timeout=15, + ) + resp.raise_for_status() + logger.info("Campaign %s: reported status=%s to hub", campaign_id[:8], status) + except Exception as exc: + logger.warning("Campaign %s: failed to report status to hub: %s", campaign_id[:8], exc) + + def _upload_file(self, base_url: str, file_path: str, metadata: dict) -> dict: + """Upload *file_path*, choosing chunked or direct path based on file size.""" + import requests as _requests + + size = os.path.getsize(file_path) + filename = os.path.basename(file_path) + headers = {"X-API-Key": self.api_key} + verify = not self.insecure + + # Small files: single POST (unchanged endpoint, no assembly needed server-side). + if size <= _DIRECT_THRESHOLD: + with open(file_path, "rb") as fh: + resp = _requests.post( + base_url, + headers=headers, + files={"file": (filename, fh)}, + data=metadata, + timeout=300, + verify=verify, + ) + resp.raise_for_status() + return resp.json() + + # Large files: chunked upload — each request is ≤ 50 MB. + total_chunks = math.ceil(size / _CHUNK_SIZE) + upload_id = str(uuid.uuid4()) + chunk_url = base_url + "/chunk" + + logger.info( + "Chunked upload: %s (%d bytes, %d × %d MB chunks)", + filename, + size, + total_chunks, + _CHUNK_SIZE // (1024 * 1024), + ) + + resp_data: dict = {} + with open(file_path, "rb") as fh: + for i in range(total_chunks): + chunk = fh.read(_CHUNK_SIZE) + resp = _requests.post( + chunk_url, + headers=headers, + files={"file": (filename, chunk, "application/octet-stream")}, + data={ + **metadata, + "upload_id": upload_id, + "chunk_index": i, + "total_chunks": total_chunks, + }, + timeout=120, + verify=verify, + ) + if not resp.ok: + raise RuntimeError( + f"Chunk {i + 1}/{total_chunks} failed: " f"HTTP {resp.status_code}: {resp.text[:300]}" + ) + resp_data = resp.json() + logger.debug("Chunk %d/%d uploaded", i + 1, total_chunks) + + return resp_data + + # ------------------------------------------------------------------ + # HTTP helpers + # ------------------------------------------------------------------ + + def _get(self, path: str, **kwargs: Any): + import requests as _requests + + return _requests.get( + f"{self.hub_url}{path}", + headers={"X-API-Key": self.api_key}, + verify=not self.insecure, + **kwargs, + ) + + def _post(self, path: str, **kwargs: Any): + import requests as _requests + + return _requests.post( + f"{self.hub_url}{path}", + headers={"X-API-Key": self.api_key}, + verify=not self.insecure, + **kwargs, + ) + + def _delete(self, path: str, **kwargs: Any): + import requests as _requests + + return _requests.delete( + f"{self.hub_url}{path}", + headers={"X-API-Key": self.api_key}, + verify=not self.insecure, + **kwargs, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _sigmf_files(data_path: str) -> list[str]: + """Return paths to both SigMF files (.sigmf-data and .sigmf-meta) for a recording.""" + candidates = [data_path] + if data_path.endswith(".sigmf-data"): + candidates.append(data_path[: -len(".sigmf-data")] + ".sigmf-meta") + return [p for p in candidates if os.path.exists(p)] + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser( + prog="ria-agent", + description=( + "RT-OSS Node Agent — connects outbound to RIA Hub and executes " + "campaigns / inference on local SDR hardware." + ), + ) + parser.add_argument( + "--hub", + required=True, + metavar="URL", + help="RIA Hub base URL, e.g. https://riahub.company.com", + ) + parser.add_argument( + "--key", + required=True, + metavar="API_KEY", + help="Shared API key (must match [wac] API_KEY in the hub's app.ini)", + ) + parser.add_argument( + "--name", + required=True, + metavar="NAME", + help='Human-readable name shown in the Target Node dropdown, e.g. "lab-bench-1"', + ) + parser.add_argument( + "--device", + default="unknown", + metavar="SDR", + help=( + "SDR device type reported to the hub (informational only). " + "Examples: plutosdr, usrp_b210, rtlsdr, mock. Default: unknown" + ), + ) + parser.add_argument( + "--insecure", + action="store_true", + help="Disable TLS certificate verification (dev/self-signed certs only)", + ) + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + help="Logging verbosity (default: INFO)", + ) + + args = parser.parse_args() + + logging.basicConfig( + level=getattr(logging, args.log_level), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + stream=sys.stderr, + ) + + # Warn loudly if --insecure is used outside of development. + if args.insecure: + logger.warning( + "--insecure disables TLS certificate verification. " + "Only use this for local development with self-signed certs." + ) + + agent = NodeAgent( + hub_url=args.hub, + api_key=args.key, + name=args.name, + sdr_device=args.device, + insecure=args.insecure, + ) + agent.run() + + +if __name__ == "__main__": + main() diff --git a/src/ria_toolkit_oss/datatypes/datasets/dataset_builder.py b/src/ria_toolkit_oss/datatypes/datasets/dataset_builder.py index 241bbdf..fa34130 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/dataset_builder.py +++ b/src/ria_toolkit_oss/datatypes/datasets/dataset_builder.py @@ -21,7 +21,8 @@ class DatasetBuilder(ABC): """ _url: str = abstract_attribute() - _SHA256: str # SHA256 checksum. + _SHA256: Optional[str] = None # SHA256 checksum. + _MD5: Optional[str] = None # MD5 checksum. _name: str = abstract_attribute() _author: str = abstract_attribute() _license: DatasetLicense = abstract_attribute() diff --git a/src/ria_toolkit_oss/datatypes/datasets/h5helpers.py b/src/ria_toolkit_oss/datatypes/datasets/h5helpers.py index f990025..d35a771 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/h5helpers.py +++ b/src/ria_toolkit_oss/datatypes/datasets/h5helpers.py @@ -109,13 +109,10 @@ def copy_file(original_source: str | os.PathLike, new_source: str | os.PathLike) :return: None """ - original_file = h5py.File(original_source, "r") - - with h5py.File(new_source, "w") as new_file: - for key in original_file.keys(): - original_file.copy(key, new_file) - - original_file.close() + with h5py.File(original_source, "r") as original_file: + with h5py.File(new_source, "w") as new_file: + for key in original_file.keys(): + original_file.copy(key, new_file) def make_empty_clone(original_source: str | os.PathLike, new_source: str | os.PathLike, example_length: int) -> None: @@ -172,8 +169,10 @@ def delete_example_inplace(source: str | os.PathLike, idx: int) -> None: with h5py.File(source, "a") as f: ds, md = f["data"], f["metadata/metadata"] m, c, n = ds.shape - assert 0 <= idx <= m - 1 - assert len(ds) == len(md) + if not (0 <= idx <= m - 1): + raise IndexError(f"Index {idx} out of range [0, {m - 1}]") + if len(ds) != len(md): + raise ValueError("Data and metadata array lengths do not match") new_ds = f.create_dataset( "data.temp", @@ -218,4 +217,3 @@ def overwrite_file(source: str | os.PathLike, new_data: np.ndarray) -> None: ds_name = tuple(f.keys())[0] del f[ds_name] f.create_dataset(ds_name, data=new_data) - f.close() diff --git a/src/ria_toolkit_oss/datatypes/datasets/iq_dataset.py b/src/ria_toolkit_oss/datatypes/datasets/iq_dataset.py index 992b7d0..bb7164c 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/iq_dataset.py +++ b/src/ria_toolkit_oss/datatypes/datasets/iq_dataset.py @@ -169,8 +169,10 @@ class IQDataset(RadioDataset, ABC): """ if split_factor is not None and example_length is not None: - # Raise warning and use split factor - raise Warning("split_factor and example_length should not both be specified.") + # Warn and use split factor + import warnings + + warnings.warn("split_factor and example_length should not both be specified.") if not inplace: # ds = self.create_new_dataset(example_length=example_length) diff --git a/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py b/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py index 7a70589..1ee9646 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py +++ b/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py @@ -255,7 +255,9 @@ class RadioDataset(ABC): else: classes_to_augment = classes_to_augment.encode("utf-8") if classes_to_augment not in class_sizes: - raise ValueError(f"class name of {i} does not belong to the class key of {class_key}") + raise ValueError( + f"class name of {classes_to_augment} does not belong to the class key of {class_key}" + ) result_sizes = get_result_sizes( level=level, target_size=target_size, classes_to_augment=classes_to_augment, class_sizes=class_sizes @@ -375,7 +377,7 @@ class RadioDataset(ABC): counters[key] = counters.get(key, 0) idx = 0 - with h5py.File(self.source, "a") as f: + with h5py.File(self.source, "r") as f: while idx < len(self): labels = f["metadata/metadata"][class_key] current_class = labels[idx] @@ -514,7 +516,7 @@ class RadioDataset(ABC): idx = 0 - with h5py.File(self.source, "a") as f: + with h5py.File(self.source, "r") as f: while idx < len(self): labels = f["metadata/metadata"][class_key] current_class = labels[idx] diff --git a/src/ria_toolkit_oss/datatypes/datasets/split.py b/src/ria_toolkit_oss/datatypes/datasets/split.py index d70b9a9..4ef7faf 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/split.py +++ b/src/ria_toolkit_oss/datatypes/datasets/split.py @@ -247,7 +247,7 @@ def _validate_sublists(list_of_lists: list[list[str]], ids: list[str]) -> None: """Ensure that each ID is present in one and only one sublist.""" all_elements = [item for sublist in list_of_lists for item in sublist] - assert len(all_elements) == len(set(all_elements)) and list(set(ids)).sort() == list(set(all_elements)).sort() + assert len(all_elements) == len(set(all_elements)) and sorted(set(ids)) == sorted(set(all_elements)) def _generate_split_source_filenames( diff --git a/src/ria_toolkit_oss/datatypes/recording.py b/src/ria_toolkit_oss/datatypes/recording.py index b282d9d..11989f9 100644 --- a/src/ria_toolkit_oss/datatypes/recording.py +++ b/src/ria_toolkit_oss/datatypes/recording.py @@ -146,7 +146,7 @@ class Recording: self._metadata["timestamp"] = time.time() else: if not isinstance(self._metadata["timestamp"], (int, float)): - raise ValueError("timestamp must be int or float, not ", type(self._metadata["timestamp"])) + raise ValueError(f"timestamp must be int or float, not {type(self._metadata['timestamp'])}") if "rec_id" not in self.metadata: self._metadata["rec_id"] = generate_recording_id(data=self.data, timestamp=self._metadata["timestamp"]) @@ -393,6 +393,7 @@ class Recording: """ if key not in self.metadata: self.add_to_metadata(key=key, value=value) + return if not _is_jsonable(value): raise ValueError("Value must be JSON serializable.") @@ -444,7 +445,7 @@ class Recording: 'rec_id': 'fda0f41...'} # Example value """ if key not in PROTECTED_KEYS: - self._metadata.pop(key) + self._metadata.pop(key, None) else: raise ValueError(f"Key {key} is protected and cannot be modified or removed.") @@ -702,7 +703,14 @@ class Recording: data = self.data[:, start_sample:end_sample] new_annotations = copy.deepcopy(self.annotations) + trimmed_annotations = [] for annotation in new_annotations: + # skip annotations entirely outside the trim window + if annotation.sample_start + annotation.sample_count <= start_sample: + continue + if annotation.sample_start >= end_sample: + continue + # trim annotation if it goes outside the trim boundaries if annotation.sample_start < start_sample: annotation.sample_count = annotation.sample_count - (start_sample - annotation.sample_start) @@ -713,8 +721,9 @@ class Recording: # shift annotation to align with the new start point annotation.sample_start = annotation.sample_start - start_sample + trimmed_annotations.append(annotation) - return Recording(data=data, metadata=self.metadata, annotations=new_annotations) + return Recording(data=data, metadata=self.metadata, annotations=trimmed_annotations) def normalize(self) -> Recording: """Scale the recording data, relative to its maximum value, so that the magnitude of the maximum sample is 1. @@ -743,7 +752,10 @@ class Recording: >>> print(numpy.max(numpy.abs(normalized_recording.data))) 1 """ - scaled_data = self.data / np.max(abs(self.data)) + max_val = np.max(abs(self.data)) + if max_val == 0: + raise ValueError("Cannot normalize a recording with all-zero data.") + scaled_data = self.data / max_val return Recording(data=scaled_data, metadata=self.metadata, annotations=self.annotations) def __len__(self) -> int: diff --git a/src/ria_toolkit_oss/io/recording.py b/src/ria_toolkit_oss/io/recording.py index 3f0f403..1a81a04 100644 --- a/src/ria_toolkit_oss/io/recording.py +++ b/src/ria_toolkit_oss/io/recording.py @@ -4,10 +4,12 @@ Utilities for input/output operations on the ria_toolkit_oss.datatypes.Recording import datetime import datetime as dt +import json import numbers import os import re import struct +import warnings from datetime import timezone from typing import Any, List, Optional @@ -91,15 +93,35 @@ def to_npy( metadata = recording.metadata annotations = recording.annotations - with open(file=fullpath, mode="wb") as f: - np.save(f, data) - np.save(f, metadata) - np.save(f, annotations) + # Serialize metadata and annotations as JSON to avoid pickle-based deserialization. + # JSON is safe; pickle allows arbitrary code execution when loading untrusted files. + metadata_bytes = json.dumps(convert_to_serializable(metadata)).encode() + annotations_bytes = json.dumps([a.__dict__ for a in annotations]).encode() + + with open(file=fullpath, mode="wb") as f: + # Write format version marker first so from_npy can detect the safe JSON format. + np.save(f, np.array("ria-toolkit-oss-v2")) + np.save(f, data) + np.save(f, np.frombuffer(metadata_bytes, dtype=np.uint8)) + np.save(f, np.frombuffer(annotations_bytes, dtype=np.uint8)) - # print(f"Saved recording to {os.getcwd()}/{fullpath}") return str(fullpath) +_NPY_MAGIC = b"\x93NUMPY" + + +def _check_npy_magic(filepath: str) -> None: + """Raise ValueError if the file does not start with the NumPy magic bytes.""" + try: + with open(filepath, "rb") as f: + header = f.read(6) + except OSError as e: + raise IOError(f"Cannot open file for validation: {filepath}") from e + if header != _NPY_MAGIC: + raise ValueError(f"File does not appear to be a valid NumPy .npy file (bad magic bytes): {filepath}") + + def from_npy(file: os.PathLike | str, legacy: bool = False) -> Recording: """Load a recording from a ``.npy`` binary file. @@ -126,35 +148,37 @@ def from_npy(file: os.PathLike | str, legacy: bool = False) -> Recording: if legacy: return from_npy_legacy(filename) + _check_npy_magic(filename) + with open(file=filename, mode="rb") as f: - data = np.load(f, allow_pickle=True) - metadata = np.load(f, allow_pickle=True) - metadata = metadata.tolist() - try: - annotations = list(np.load(f, allow_pickle=True)) - except EOFError: - annotations = [] - except ModuleNotFoundError: - # File was pickled with utils.data.Annotation — remap to ria_toolkit_oss - import sys - import types + first = np.load(f, allow_pickle=False) - import ria_toolkit_oss.datatypes.annotation as _ann_mod + if first.ndim == 0 and first.dtype.kind in ("U", "S") and str(first) == "ria-toolkit-oss-v2": + # Safe JSON format written by current to_npy. + data = np.load(f, allow_pickle=False) + raw_meta = np.load(f, allow_pickle=False) + metadata = json.loads(raw_meta.tobytes().decode()) + try: + raw_ann = np.load(f, allow_pickle=False) + ann_list = json.loads(raw_ann.tobytes().decode()) + from ria_toolkit_oss.datatypes.annotation import Annotation - utils_shim = types.ModuleType("utils") - utils_data = types.ModuleType("utils.data") - utils_data_annotation = types.ModuleType("utils.data.annotation") - utils_data_annotation.Annotation = _ann_mod.Annotation - utils_shim.data = utils_data - utils_data.annotation = utils_data_annotation - sys.modules.setdefault("utils", utils_shim) - sys.modules.setdefault("utils.data", utils_data) - sys.modules.setdefault("utils.data.annotation", utils_data_annotation) - - f.seek(0) - np.load(f, allow_pickle=True) # skip data - np.load(f, allow_pickle=True) # skip metadata - annotations = list(np.load(f, allow_pickle=True)) + annotations = [Annotation(**a) for a in ann_list] + except EOFError: + annotations = [] + else: + # Legacy pickle-based format. Only load files from trusted sources. + warnings.warn( + "Loading .npy file in legacy pickle format — only load files from trusted sources. " + "Re-save with to_npy() to upgrade to the safe JSON format.", + stacklevel=2, + ) + data = first # already loaded without pickle (numeric array) + metadata = np.load(f, allow_pickle=True).tolist() + try: + annotations = list(np.load(f, allow_pickle=True)) + except EOFError: + annotations = [] recording = Recording(data=data, metadata=metadata, annotations=annotations) return recording @@ -192,14 +216,20 @@ def from_npy_legacy(file: os.PathLike | str) -> Recording: # Rebuild with .npy extension. filename = str(filename) + ".npy" + warnings.warn( + "from_npy_legacy uses pickle deserialization for extended metadata — only load files from trusted sources.", + stacklevel=2, + ) + _check_npy_magic(filename) + with open(filename, "rb") as f: # Read IQ data (2, N) format - iqdata = np.load(f) + iqdata = np.load(f, allow_pickle=False) # Read basic metadata array [center_freq, rec_length, decimation, sample_rate] - meta = np.load(f) + meta = np.load(f, allow_pickle=False) - # Read extended metadata dict + # Read extended metadata dict (legacy format requires pickle) extended_meta = np.load(f, allow_pickle=True)[0] # Convert IQ data from (2, N) to (N,) complex format @@ -300,7 +330,7 @@ def to_sigmf( converted_metadata = { sigmf_key: metadata[metadata_key] for sigmf_key, metadata_key in SIGMF_KEY_CONVERSION.items() - if metadata_key in metadata + if metadata_key in metadata and sigmf_key != SigMFFile.HASH_KEY } # Merge dictionaries, giving priority to sigmf_meta @@ -355,9 +385,8 @@ def from_sigmf(file: os.PathLike | str) -> Recording: """ file = str(file) - if len(file) > 11: - if file[-11:-5] != ".sigmf": - file = file + ".sigmf-data" + if not file.endswith((".sigmf-data", ".sigmf-meta", ".sigmf")): + file = file + ".sigmf-data" sigmf_file = sigmffile.fromfile(file) @@ -370,7 +399,7 @@ def from_sigmf(file: os.PathLike | str) -> Recording: # Process core keys if key.startswith("core:"): base_key = key[5:] # Remove 'core:' prefix - converted_key = SIGMF_KEY_CONVERSION.get(base_key, base_key) + converted_key = SIGMF_KEY_CONVERSION.get(key, base_key) # Process ria keys elif key.startswith("ria:"): converted_key = key[4:] # Remove 'ria:' prefix diff --git a/src/ria_toolkit_oss/orchestration/__init__.py b/src/ria_toolkit_oss/orchestration/__init__.py new file mode 100644 index 0000000..2a05ad3 --- /dev/null +++ b/src/ria_toolkit_oss/orchestration/__init__.py @@ -0,0 +1,26 @@ +"""Orchestration layer for automated RF capture campaigns.""" + +from .campaign import ( + CampaignConfig, + CaptureStep, + QAConfig, + RecorderConfig, + TransmitterConfig, +) +from .executor import CampaignExecutor, CampaignResult, StepResult +from .labeler import label_recording +from .qa import QAResult, check_recording + +__all__ = [ + "CampaignConfig", + "CaptureStep", + "QAConfig", + "RecorderConfig", + "TransmitterConfig", + "CampaignExecutor", + "CampaignResult", + "StepResult", + "label_recording", + "QAResult", + "check_recording", +] diff --git a/src/ria_toolkit_oss/orchestration/campaign.py b/src/ria_toolkit_oss/orchestration/campaign.py new file mode 100644 index 0000000..9d96c96 --- /dev/null +++ b/src/ria_toolkit_oss/orchestration/campaign.py @@ -0,0 +1,490 @@ +"""Campaign configuration schema and YAML parser for orchestrated RF captures.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import yaml + +# Allowed characters in campaign names when used as filename components. +_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_\-]") + +# Reasonable RF bounds for consumer/research SDR hardware. +_FREQ_MIN_HZ = 1.0 # 1 Hz +_FREQ_MAX_HZ = 300e9 # 300 GHz +_GAIN_MIN_DB = -30.0 +_GAIN_MAX_DB = 120.0 + +# --------------------------------------------------------------------------- +# Parsing helpers +# --------------------------------------------------------------------------- + + +def parse_duration(value: str | float | int) -> float: + """Parse a duration string to seconds. + + Accepts: + "30s" → 30.0 + "1.5m" or "1.5min" → 90.0 + "2h" → 7200.0 + 30 (numeric) → 30.0 + """ + if isinstance(value, (int, float)): + return float(value) + value = str(value).strip() + match = re.fullmatch(r"([\d.]+)\s*(s|sec|m|min|h|hr)?", value, re.IGNORECASE) + if not match: + raise ValueError(f"Cannot parse duration: '{value}'") + amount = float(match.group(1)) + unit = (match.group(2) or "s").lower() + if unit in ("h", "hr"): + return amount * 3600 + if unit in ("m", "min"): + return amount * 60 + return amount + + +def parse_frequency(value: str | float | int) -> float: + """Parse a frequency string to Hz. + + Accepts: + "2.45GHz" → 2_450_000_000.0 + "40MHz" → 40_000_000.0 + "915e6" → 915_000_000.0 + 2.45e9 (numeric) → 2_450_000_000.0 + """ + if isinstance(value, (int, float)): + result = float(value) + if not (_FREQ_MIN_HZ <= result <= _FREQ_MAX_HZ): + raise ValueError( + f"Frequency {result:.3g} Hz is outside the supported range " + f"({_FREQ_MIN_HZ:.0f} Hz – {_FREQ_MAX_HZ:.3g} Hz)" + ) + return result + value = str(value).strip() + + # Try bare numeric first (handles scientific notation like "915e6") + try: + result = float(value) + except ValueError: + pass + else: + if not (_FREQ_MIN_HZ <= result <= _FREQ_MAX_HZ): + raise ValueError( + f"Frequency {result:.3g} Hz is outside the supported range " + f"({_FREQ_MIN_HZ:.0f} Hz – {_FREQ_MAX_HZ:.3g} Hz): '{value}'" + ) + return result + + # Handle suffix notation: "2.45GHz", "40MHz", "40M", "433k" + match = re.fullmatch(r"([\d.]+)\s*(k|M|G)(?:\s*Hz?)?", value, re.IGNORECASE) + if match: + amount = float(match.group(1)) + suffix = match.group(2).upper() + result = amount * {"K": 1e3, "M": 1e6, "G": 1e9}[suffix] + if not (_FREQ_MIN_HZ <= result <= _FREQ_MAX_HZ): + raise ValueError( + f"Frequency {result:.3g} Hz is outside the supported range " + f"({_FREQ_MIN_HZ:.0f} Hz – {_FREQ_MAX_HZ:.3g} Hz): '{value}'" + ) + return result + + raise ValueError(f"Cannot parse frequency: '{value}'") + + +def parse_gain(value: str | float | int) -> float | str: + """Parse a gain string. + + Accepts: + "40dB" or "40 dB" → 40.0 + "auto" → "auto" + 40 (numeric) → 40.0 + """ + if isinstance(value, (int, float)): + result = float(value) + if not (_GAIN_MIN_DB <= result <= _GAIN_MAX_DB): + raise ValueError(f"Gain {result} dB is outside the supported range ({_GAIN_MIN_DB} – {_GAIN_MAX_DB} dB)") + return result + value = str(value).strip() + if value.lower() == "auto": + return "auto" + match = re.fullmatch(r"([\d.+\-]+)\s*dB?", value, re.IGNORECASE) + if not match: + raise ValueError(f"Cannot parse gain: '{value}'") + result = float(match.group(1)) + if not (_GAIN_MIN_DB <= result <= _GAIN_MAX_DB): + raise ValueError( + f"Gain {result} dB is outside the supported range ({_GAIN_MIN_DB} – {_GAIN_MAX_DB} dB): '{value}'" + ) + return result + + +def parse_bandwidth_mhz(value: str | float | int | None) -> Optional[float]: + """Parse a bandwidth string to MHz. + + Accepts: + "20MHz" → 20.0 + "40MHz" → 40.0 + 20 (numeric, assumed MHz) → 20.0 + None → None + """ + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + value = str(value).strip() + match = re.fullmatch(r"([\d.]+)\s*MHz?", value, re.IGNORECASE) + if match: + return float(match.group(1)) + match = re.fullmatch(r"([\d.]+)", value) + if match: + return float(match.group(1)) + raise ValueError(f"Cannot parse bandwidth: '{value}'") + + +# --------------------------------------------------------------------------- +# Config dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class RecorderConfig: + """SDR recorder configuration.""" + + device: str + center_freq: float # Hz + sample_rate: float # Hz + gain: float | str # dB float, or "auto" + bandwidth: Optional[float] = None # Hz, None = match sample_rate + + @classmethod + def from_dict(cls, d: dict) -> "RecorderConfig": + gain = parse_gain(d.get("gain", "auto")) + bandwidth_raw = d.get("bandwidth") or d.get("bandwidth_hz") + bandwidth = parse_frequency(bandwidth_raw) if bandwidth_raw else None + return cls( + device=str(d["device"]), + center_freq=parse_frequency(d["center_freq"]), + sample_rate=parse_frequency(d["sample_rate"]), + gain=gain, + bandwidth=bandwidth, + ) + + +@dataclass +class CaptureStep: + """A single timed capture within a transmitter schedule.""" + + duration: float # seconds + label: str # used as filename component + + # WiFi-specific + channel: Optional[int] = None + bandwidth_mhz: Optional[float] = None # MHz + traffic: Optional[str] = None + + # Bluetooth-specific + connection_interval_ms: Optional[float] = None + + # Power (dBm), optional + power_dbm: Optional[float] = None + + @classmethod + def from_dict(cls, d: dict, auto_label: bool = True) -> "CaptureStep": + duration = parse_duration(d["duration"]) + label = d.get("label", "") + if not label and auto_label: + parts = [] + if d.get("channel"): + parts.append(f"ch{d['channel']:02d}") + if d.get("bandwidth"): + bw = parse_bandwidth_mhz(d["bandwidth"]) + parts.append(f"{int(bw)}mhz") + if d.get("traffic"): + parts.append(str(d["traffic"]).replace(" ", "_")) + label = "_".join(parts) if parts else "capture" + return cls( + duration=duration, + label=label, + channel=d.get("channel"), + bandwidth_mhz=parse_bandwidth_mhz(d.get("bandwidth")), + traffic=d.get("traffic"), + connection_interval_ms=d.get("connection_interval_ms"), + power_dbm=float(d["power"].removesuffix("dBm").strip()) if d.get("power") else None, + ) + + +@dataclass +class TransmitterConfig: + """Configuration for a single transmitter device in the campaign.""" + + id: str + type: str # "wifi", "bluetooth", "sdr", "external" + control_method: str # "external_script" | "sdr" + schedule: list[CaptureStep] + + # For external_script control + script: Optional[str] = None # path to control script + device: Optional[str] = None # e.g. "/dev/wlan0" + + @classmethod + def from_dict(cls, d: dict) -> "TransmitterConfig": + schedule = [CaptureStep.from_dict(s) for s in d.get("schedule", [])] + return cls( + id=str(d["id"]), + type=str(d["type"]), + control_method=str(d.get("control_method", "external_script")), + schedule=schedule, + script=d.get("script"), + device=d.get("device"), + ) + + +@dataclass +class QAConfig: + """Quality assurance thresholds.""" + + snr_threshold_db: float = 10.0 + min_duration_s: float = 25.0 + flag_for_review: bool = True + + @classmethod + def from_dict(cls, d: dict) -> "QAConfig": + return cls( + snr_threshold_db=float(str(d.get("snr_threshold", "10")).rstrip("dB").strip()), + min_duration_s=parse_duration(d.get("min_duration", "25s")), + flag_for_review=bool(d.get("flag_for_review", True)), + ) + + +@dataclass +class OutputConfig: + """Where to save captured recordings.""" + + format: str = "sigmf" + path: str = "recordings" + device_id: Optional[str] = None # for device-profile campaigns + repo: Optional[str] = None + + @classmethod + def from_dict(cls, d: dict) -> "OutputConfig": + return cls( + format=str(d.get("format", "sigmf")), + path=str(d.get("path", "recordings")), + device_id=d.get("device_id"), + repo=d.get("repo"), + ) + + +@dataclass +class CampaignConfig: + """Full campaign configuration parsed from YAML.""" + + name: str + recorder: RecorderConfig + transmitters: list[TransmitterConfig] + qa: QAConfig = field(default_factory=QAConfig) + output: OutputConfig = field(default_factory=OutputConfig) + mode: str = "controlled_testbed" + + # --------------------------------------------------------------------------- + # Loaders + # --------------------------------------------------------------------------- + + @classmethod + def from_dict(cls, raw: dict) -> "CampaignConfig": + """Build a CampaignConfig from a parsed dictionary. + + Accepts the same structure as the campaign YAML, already loaded into + a Python dict (e.g. from a JSON HTTP request body). + + Raises: + ValueError: If required fields are missing or malformed. + KeyError: If ``recorder`` key is absent. + """ + campaign_meta = raw.get("campaign", {}) + transmitters = [TransmitterConfig.from_dict(t) for t in raw.get("transmitters", [])] + if not transmitters: + raise ValueError("Campaign config must define at least one transmitter") + if "recorder" not in raw: + raise ValueError("Campaign config is missing required 'recorder' section") + raw_name = str(campaign_meta.get("name", "unnamed")) + safe_name = _SAFE_NAME_RE.sub("_", raw_name) + return cls( + name=safe_name, + mode=str(campaign_meta.get("mode", "controlled_testbed")), + recorder=RecorderConfig.from_dict(raw["recorder"]), + transmitters=transmitters, + qa=QAConfig.from_dict(raw.get("qa", {})), + output=OutputConfig.from_dict(raw.get("output", {})), + ) + + @classmethod + def from_yaml(cls, path: str | Path) -> "CampaignConfig": + """Load a full campaign config YAML. + + Expected format:: + + campaign: + name: "wifi_capture_001" + mode: "controlled_testbed" + + transmitters: + - id: "laptop_wifi" + type: "wifi" + control_method: "external_script" + script: "./scripts/wifi_control.sh" + device: "/dev/wlan0" + schedule: + - channel: 6 + bandwidth: "20MHz" + traffic: "iperf_udp" + duration: "30s" + + recorder: + device: "usrp_b210" + center_freq: "2.45GHz" + sample_rate: "40MHz" + gain: "40dB" + + qa: + snr_threshold: "10dB" + min_duration: "25s" + flag_for_review: true + + output: + format: "sigmf" + path: "./recordings" + """ + path = Path(path) + try: + with open(path) as f: + raw = yaml.safe_load(f) + except FileNotFoundError: + raise FileNotFoundError(f"Campaign config not found: {path}") + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML in {path}: {e}") + + campaign_meta = raw.get("campaign", {}) + transmitters = [TransmitterConfig.from_dict(t) for t in raw.get("transmitters", [])] + if not transmitters: + raise ValueError("Campaign config must define at least one transmitter") + if "recorder" not in raw: + raise ValueError(f"Campaign config is missing required 'recorder' section in {path}") + raw_name = str(campaign_meta.get("name", path.stem)) + safe_name = _SAFE_NAME_RE.sub("_", raw_name) + + return cls( + name=safe_name, + mode=str(campaign_meta.get("mode", "controlled_testbed")), + recorder=RecorderConfig.from_dict(raw["recorder"]), + transmitters=transmitters, + qa=QAConfig.from_dict(raw.get("qa", {})), + output=OutputConfig.from_dict(raw.get("output", {})), + ) + + @classmethod + def from_device_profile(cls, path: str | Path) -> "CampaignConfig": + """Build a campaign config from an App 1 device profile YAML. + + Expected format:: + + device: + name: "iPhone_13_WiFi" + type: "wifi" + protocol: "wifi_24ghz" + + capture: + channels: [1, 6, 11] # WiFi only + bandwidth: "20MHz" # WiFi only + traffic_patterns: ["idle", "ping", "iperf_udp"] + duration_per_config: "30s" + + recorder: + device: "usrp_b210" + center_freq: "2.45GHz" + sample_rate: "40MHz" + gain: "auto" + + output: + path: "./recordings" + device_id: "iphone13_wifi_001" + + For WiFi devices, schedule is expanded as channels × traffic_patterns. + For Bluetooth devices (no channels), schedule is traffic_patterns only. + """ + path = Path(path) + try: + with open(path) as f: + raw = yaml.safe_load(f) + except FileNotFoundError: + raise FileNotFoundError(f"Device profile not found: {path}") + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML in {path}: {e}") + + device = raw.get("device", {}) + capture = raw.get("capture", {}) + device_type = str(device.get("type", "wifi")).lower() + device_name = str(device.get("name", path.stem)) + duration = parse_duration(capture.get("duration_per_config", "30s")) + traffic_patterns = capture.get("traffic_patterns", ["idle"]) + + # Build capture schedule + schedule: list[CaptureStep] = [] + + if device_type in ("wifi", "wifi_24ghz", "wifi_5ghz"): + channels = capture.get("channels", [6]) + bw_str = capture.get("bandwidth", "20MHz") + bw_mhz = parse_bandwidth_mhz(bw_str) + for ch in channels: + for traffic in traffic_patterns: + label = f"ch{ch:02d}_{int(bw_mhz)}mhz_{traffic}" + schedule.append( + CaptureStep( + duration=duration, + label=label, + channel=ch, + bandwidth_mhz=bw_mhz, + traffic=traffic, + ) + ) + else: + # Bluetooth / generic — no channels + for traffic in traffic_patterns: + schedule.append( + CaptureStep( + duration=duration, + label=traffic, + traffic=traffic, + ) + ) + + device_id = raw.get("output", {}).get("device_id", device_name.lower().replace(" ", "_")) + transmitter = TransmitterConfig( + id=device_id, + type=device_type, + control_method=str(capture.get("control_method", "external_script")), + schedule=schedule, + script=capture.get("script"), + device=capture.get("device"), + ) + + return cls( + name=f"enroll_{device_id}", + mode="controlled_testbed", + recorder=RecorderConfig.from_dict(raw["recorder"]), + transmitters=[transmitter], + qa=QAConfig.from_dict(raw.get("qa", {})), + output=OutputConfig.from_dict(raw.get("output", {})), + ) + + def total_capture_time_s(self) -> float: + """Sum of all step durations across all transmitters.""" + return sum(step.duration for tx in self.transmitters for step in tx.schedule) + + def total_steps(self) -> int: + """Total number of capture steps across all transmitters.""" + return sum(len(tx.schedule) for tx in self.transmitters) diff --git a/src/ria_toolkit_oss/orchestration/executor.py b/src/ria_toolkit_oss/orchestration/executor.py new file mode 100644 index 0000000..629c0d8 --- /dev/null +++ b/src/ria_toolkit_oss/orchestration/executor.py @@ -0,0 +1,444 @@ +"""Campaign executor: runs a capture campaign end-to-end.""" + +from __future__ import annotations + +import json +import logging +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Optional + +from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.io.recording import to_sigmf + +from .campaign import CampaignConfig, CaptureStep, TransmitterConfig +from .labeler import build_output_filename, label_recording +from .qa import QAResult, check_recording + +logger = logging.getLogger(__name__) + +# Device name aliases: campaign YAML names → get_sdr_device() names +_DEVICE_ALIASES = { + "usrp_b210": "usrp", + "usrp_b200": "usrp", + "usrp": "usrp", + "plutosdr": "pluto", + "pluto": "pluto", + "hackrf": "hackrf", + "hackrf_one": "hackrf", + "bladerf": "bladerf", + "rtlsdr": "rtlsdr", + "rtl_sdr": "rtlsdr", + "thinkrf": "thinkrf", + # Simulated device — no hardware required + "mock": "mock", + "sim": "mock", +} + + +@dataclass +class StepResult: + """Outcome of a single capture step.""" + + transmitter_id: str + step_label: str + output_path: Optional[str] + qa: QAResult + capture_timestamp: float + error: Optional[str] = None + + @property + def ok(self) -> bool: + return self.error is None and self.qa.passed + + def to_dict(self) -> dict: + return { + "transmitter_id": self.transmitter_id, + "step_label": self.step_label, + "output_path": self.output_path, + "capture_timestamp": self.capture_timestamp, + "qa": self.qa.to_dict(), + "error": self.error, + } + + +@dataclass +class CampaignResult: + """Aggregate outcome of a full campaign.""" + + campaign_name: str + steps: list[StepResult] = field(default_factory=list) + start_time: float = field(default_factory=time.time) + end_time: Optional[float] = None + + @property + def total_steps(self) -> int: + return len(self.steps) + + @property + def passed(self) -> int: + return sum(1 for s in self.steps if s.ok) + + @property + def flagged(self) -> int: + return sum(1 for s in self.steps if not s.error and s.qa.flagged) + + @property + def failed(self) -> int: + return sum(1 for s in self.steps if s.error or not s.qa.passed) + + @property + def duration_s(self) -> float: + if self.end_time: + return self.end_time - self.start_time + return time.time() - self.start_time + + def to_dict(self) -> dict: + return { + "campaign_name": self.campaign_name, + "total_steps": self.total_steps, + "passed": self.passed, + "flagged": self.flagged, + "failed": self.failed, + "duration_s": round(self.duration_s, 1), + "steps": [s.to_dict() for s in self.steps], + } + + def write_report(self, path: str | Path) -> None: + """Write a JSON QA report to disk.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + json.dump(self.to_dict(), f, indent=2) + logger.info(f"QA report written to {path}") + + +# --------------------------------------------------------------------------- +# External script interface +# --------------------------------------------------------------------------- + + +def _run_script(script: str, *args: str, timeout: float = 15.0) -> str: + """Run an external control script and return stdout. + + The script is called as:: + +