diff --git a/.github/workflows/check_pr.yml b/.github/workflows/check_pr.yml index f268a52..f1150b4 100644 --- a/.github/workflows/check_pr.yml +++ b/.github/workflows/check_pr.yml @@ -20,10 +20,10 @@ jobs: steps: - uses: actions/checkout@v4 - run: pipx install poetry - - name: Set up Python 3.8 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: "3.12" - name: Install dependencies run: poetry install --with dev,docs - name: Build project documentation @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] + python-version: [ "3.11", "3.12", "3.13", "3.14" ] steps: - uses: actions/checkout@v4 - run: pipx install poetry @@ -52,10 +52,10 @@ jobs: steps: - uses: actions/checkout@v4 - run: pipx install poetry - - name: Set up Python 3.8 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.12" cache: "poetry" - name: Install dependencies run: poetry install --with dev @@ -70,15 +70,17 @@ jobs: client_id: ${{ secrets.STAGING_PYTHON_SDK_CLIENT_ID }} client_secret: ${{ secrets.STAGING_PYTHON_SDK_SECRET }} validate_cert: "true" + # Flip to "true" once the integration test console accepts asset communications + communications: "false" lints: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: pipx install poetry - - name: Set up Python 3.8 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.12" cache: "poetry" - name: Install dependencies run: poetry install --with dev diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 80e8ca1..57e6cc4 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -21,10 +21,10 @@ jobs: steps: - uses: actions/checkout@v4 - run: pipx install poetry - - name: Set up Python 3.8 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: "3.12" - name: Install dependencies run: poetry install --with dev,docs - name: Build project documentation @@ -47,7 +47,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.12" - name: Build project documentation uses: ./.github/actions/docsbuild - name: Setup Pages diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3bec3ed..8fee1d2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,10 +23,10 @@ jobs: steps: - uses: actions/checkout@v4 - run: pipx install poetry - - name: Set up Python 3.8 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: "3.12" - name: Install dependencies run: poetry install --with dev,docs - name: Build project documentation @@ -40,7 +40,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.12" cache: "poetry" - name: Install dependencies run: poetry install --with dev @@ -72,7 +72,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.12" - name: Install dependencies run: poetry install --no-interaction --no-root --without dev - name: Build package distributions @@ -100,7 +100,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.12" - name: Build project documentation uses: ./.github/actions/docsbuild - name: Setup Pages diff --git a/CHANGELOG.md b/CHANGELOG.md index 8411b6d..0d47231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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 this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +- The minimum supported Python version is now 3.11. Python 3.11 through 3.14 are supported and tested. Python 3.8 reached end of life in October 2024 and current tooling no longer builds environments for it. The package API is unchanged, so this is a direct upgrade on a supported interpreter. +- `pydantic` now requires 1.10.25 or newer, the first 1.x release that runs on Python 3.14. +- `runzero.Client` retries a request the server refuses with `429 Too Many Requests`, honoring the `Retry-After` header or backing off exponentially, before raising `RateLimitError`. `rate_limit_retries` and `rate_limit_backoff_seconds` on the constructor tune this; `rate_limit_retries=0` restores the previous raise-immediately behavior. +- `RateLimitError` is raised for every 429 response and now subclasses `ClientError`, so existing `except ClientError` handlers keep catching rate limits. It carries the server's `retry_after` seconds when sent. +- `runzero.types.ImportAsset` now supports associating aggregated traffic summaries with an asset by adding `runzero.types.Communication` values to the `ImportAsset.communications` field. +- Naive `datetime` values on `ImportAsset`, `Software`, `Vulnerability` and `Communication` are now treated as local time and serialized with an offset. The API requires RFC 3339 timestamps with an offset and previously rejected these records at import. + ## [0.8.3] - 2024-05-22 - Support for longer-form CVE identifies. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 17a495d..52363e1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -112,7 +112,7 @@ Poetry is an incredibly powerful toolchain and I recommend you [read its docs](h 1. First we need to install the codegen dependency * `poetry install --with codegen` 2. Next, we can run the command to generate the pydantic models from the openapi spec - * `poetry run datamodel-codegen --input ./api/runzero-api.yml --field-constraints --output ./models/asset.py --target-python-version 3.8` + * `poetry run datamodel-codegen --input ./api/runzero-api.yml --field-constraints --output ./models/asset.py --target-python-version 3.11` ### Preparing a release diff --git a/Makefile b/Makefile index 1487c52..5db5b9e 100644 --- a/Makefile +++ b/Makefile @@ -116,4 +116,5 @@ init-test-configs: 'org_id = "" # UUID of organization associated with the above token \n' \ 'client_id = "" # OAuth client ID for the test account \n' \ 'client_secret = '' # OAuth client secret for the test account - enclose in single quotes \n' \ - 'validate_cert = false # bool for whether to require a valid tsl cert (should be false for dev instance) \n' >> ./test_configs.toml + 'validate_cert = false # bool for whether to require a valid tsl cert (should be false for dev instance) \n' \ + 'communications = false # bool for whether the console accepts asset communications on import \n' >> ./test_configs.toml diff --git a/examples/create_asset_in_custom_integration.py b/examples/create_asset_in_custom_integration.py index e5bc730..123d7d7 100644 --- a/examples/create_asset_in_custom_integration.py +++ b/examples/create_asset_in_custom_integration.py @@ -4,7 +4,7 @@ import runzero from runzero.api import CustomIntegrationsAdmin -from runzero.types import ImportAsset, Tag +from runzero.types import Communication, ImportAsset, Tag # API keys are required for using the runZero sdk. See https://www.runzero.com/docs/leveraging-the-api/ MY_CLIENT_ID = "" # OAuth client id. See https://console.runzero.com/account/api/clients @@ -52,6 +52,19 @@ def main(): "ipAddresses": "192.168.86.1\t10.10.10.1", "ipAddressesExtra": "10.10.10.4", }, + # Aggregated traffic summaries, one per role and protocol over a window. + # Naive datetimes are treated as local time and gain an offset on upload. + communications=[ + Communication( + role="client", + protocol="HTTPS", + ports=[443], + start_ts=datetime.datetime.now() - datetime.timedelta(days=1), + end_ts=datetime.datetime.now(), + bytes_tx=1500, + bytes_rx=300, + ), + ], ), ) print(f"created asset {asset_id}") diff --git a/poetry.lock b/poetry.lock index 20e889f..76a7c91 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -6,6 +6,7 @@ version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" optional = false python-versions = ">=3.6" +groups = ["docs"] files = [ {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, @@ -17,6 +18,7 @@ version = "0.3.2" description = "Unicode to ASCII transliteration" optional = false python-versions = ">=3.3" +groups = ["docs"] files = [ {file = "anyascii-0.3.2-py3-none-any.whl", hash = "sha256:3b3beef6fc43d9036d3b0529050b0c48bfad8bc960e9e562d7223cfb94fe45d4"}, {file = "anyascii-0.3.2.tar.gz", hash = "sha256:9d5d32ef844fe225b8bc7cba7f950534fae4da27a9bf3a6bea2cb0ea46ce4730"}, @@ -28,20 +30,19 @@ version = "4.2.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" +groups = ["codegen"] files = [ {file = "anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee"}, {file = "anyio-4.2.0.tar.gz", hash = "sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f"}, ] [package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] trio = ["trio (>=0.23)"] [[package]] @@ -50,6 +51,7 @@ version = "3.2.2" description = "Bash tab completion for argparse" optional = false python-versions = ">=3.8" +groups = ["codegen"] files = [ {file = "argcomplete-3.2.2-py3-none-any.whl", hash = "sha256:e44f4e7985883ab3e73a103ef0acd27299dbfe2dfed00142c35d4ddd3005901d"}, {file = "argcomplete-3.2.2.tar.gz", hash = "sha256:f3e49e8ea59b4026ee29548e24488af46e30c9de57d48638e24f54a1ea1000a2"}, @@ -64,28 +66,24 @@ version = "3.1.0" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.8.0" +groups = ["dev", "docs"] files = [ {file = "astroid-3.1.0-py3-none-any.whl", hash = "sha256:951798f922990137ac090c53af473db7ab4e70c770e6d7fae0cec59f74411819"}, {file = "astroid-3.1.0.tar.gz", hash = "sha256:ac248253bfa4bd924a0de213707e7ebeeb3138abeb48d798784ead1e56d419d4"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} - [[package]] name = "babel" version = "2.14.0" description = "Internationalization utilities" optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, ] -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] @@ -95,6 +93,7 @@ version = "4.12.3" description = "Screen-scraping library" optional = false python-versions = ">=3.6.0" +groups = ["docs"] files = [ {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, @@ -116,6 +115,7 @@ version = "24.4.2" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" +groups = ["codegen", "dev"] files = [ {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, @@ -147,12 +147,10 @@ mypy-extensions = ">=0.4.3" packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -162,6 +160,7 @@ version = "5.3.2" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" +groups = ["devlocal"] files = [ {file = "cachetools-5.3.2-py3-none-any.whl", hash = "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1"}, {file = "cachetools-5.3.2.tar.gz", hash = "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2"}, @@ -173,6 +172,7 @@ version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main", "codegen", "docs"] files = [ {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, @@ -184,6 +184,7 @@ version = "5.2.0" description = "Universal encoding detector for Python 3" optional = false python-versions = ">=3.7" +groups = ["dev", "devlocal"] files = [ {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, @@ -195,6 +196,7 @@ version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["main", "docs"] files = [ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, @@ -294,6 +296,7 @@ version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["codegen", "dev"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -308,10 +311,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 = ["codegen", "dev", "devlocal", "docs"] 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 = {codegen = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", docs = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -319,6 +324,7 @@ version = "7.4.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7"}, {file = "coverage-7.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61"}, @@ -374,11 +380,8 @@ files = [ {file = "coverage-7.4.1.tar.gz", hash = "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04"}, ] -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "datamodel-code-generator" @@ -386,6 +389,7 @@ version = "0.24.2" description = "Datamodel Code Generator" optional = false python-versions = ">=3.7,<4.0" +groups = ["codegen"] files = [ {file = "datamodel_code_generator-0.24.2-py3-none-any.whl", hash = "sha256:582c30466def12600d7165c5f624bb63a7e944eeaf8320f282518daf9ccb566c"}, {file = "datamodel_code_generator-0.24.2.tar.gz", hash = "sha256:d278c751038c8911efc82856ec549ac1e3e13134567387a4bb5ab7ddc6543162"}, @@ -401,13 +405,10 @@ isort = ">=4.3.21,<6.0" jinja2 = ">=2.10.1,<4.0" packaging = "*" pydantic = [ - {version = ">=1.5.1,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version < \"3.10\""}, - {version = ">=1.9.0,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.10\" and python_version < \"3.11\""}, - {version = ">=1.10.0,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, - {version = ">=1.10.0,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.4.0 || >2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, + {version = ">=1.10.0,!=2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.11\" and python_version < \"4.0\""}, + {version = ">=1.10.0,!=2.0.0,!=2.0.1,!=2.4.0,<3.0", extras = ["email"], markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, ] pyyaml = ">=6.0.1" -toml = {version = ">=0.10.0,<1.0.0", markers = "python_version < \"3.11\""} [package.extras] debug = ["PySnooper (>=0.4.1,<2.0.0)"] @@ -420,6 +421,7 @@ version = "0.14.0" description = "A command line utility to check for unused, missing and transitive dependencies in a Python project." optional = false python-versions = ">=3.8, <4.0" +groups = ["dev"] files = [ {file = "deptry-0.14.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:951d40e40cca6b538d8b1992e9532c082757598c73249469d3f6f90cf3344c9f"}, {file = "deptry-0.14.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:59e0808507ffb57a0ce9b8c40ecda7b4a235a0627f00485d8e6104bf71dc99f8"}, @@ -434,7 +436,6 @@ chardet = ">=4.0.0" click = ">=8.0.0,<9" colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} pathspec = ">=0.9.0" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [[package]] name = "dill" @@ -442,6 +443,7 @@ version = "0.3.8" description = "serialize all of Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, @@ -457,6 +459,7 @@ version = "0.3.8" description = "Distribution utilities" optional = false python-versions = "*" +groups = ["devlocal"] files = [ {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, @@ -468,6 +471,7 @@ version = "2.6.1" description = "DNS toolkit" optional = false python-versions = ">=3.8" +groups = ["codegen"] files = [ {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, @@ -488,6 +492,7 @@ version = "0.20.1" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, @@ -499,6 +504,7 @@ version = "2.1.0.post1" description = "A robust email address syntax and deliverability validation library." optional = false python-versions = ">=3.8" +groups = ["codegen"] files = [ {file = "email_validator-2.1.0.post1-py3-none-any.whl", hash = "sha256:c973053efbeddfef924dc0bd93f6e77a1ea7ee0fce935aea7103c7a3d6d2d637"}, {file = "email_validator-2.1.0.post1.tar.gz", hash = "sha256:a4b0bd1cf55f073b924258d19321b1f3aa74b4b5a71a42c305575dba920e1a44"}, @@ -508,26 +514,13 @@ files = [ dnspython = ">=2.0.0" idna = ">=2.0.0" -[[package]] -name = "exceptiongroup" -version = "1.2.0" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, -] - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "filelock" version = "3.13.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" +groups = ["devlocal"] files = [ {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, @@ -536,7 +529,7 @@ files = [ [package.extras] docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] -typing = ["typing-extensions (>=4.8)"] +typing = ["typing-extensions (>=4.8) ; python_version < \"3.11\""] [[package]] name = "furo" @@ -544,6 +537,7 @@ version = "2024.5.6" description = "A clean customisable Sphinx documentation theme." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "furo-2024.5.6-py3-none-any.whl", hash = "sha256:490a00d08c0a37ecc90de03ae9227e8eb5d6f7f750edf9807f398a2bdf2358de"}, {file = "furo-2024.5.6.tar.gz", hash = "sha256:81f205a6605ebccbb883350432b4831c0196dd3d1bc92f61e1f459045b3d2b0b"}, @@ -553,7 +547,7 @@ files = [ beautifulsoup4 = "*" pygments = ">=2.7" sphinx = ">=6.0,<8.0" -sphinx-basic-ng = ">=1.0.0.beta2" +sphinx-basic-ng = ">=1.0.0b2" [[package]] name = "genson" @@ -561,6 +555,7 @@ version = "1.2.2" description = "GenSON is a powerful, user-friendly JSON Schema generator." optional = false python-versions = "*" +groups = ["codegen"] files = [ {file = "genson-1.2.2.tar.gz", hash = "sha256:8caf69aa10af7aee0e1a1351d1d06801f4696e005f06cedef438635384346a16"}, ] @@ -571,6 +566,7 @@ version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.7" +groups = ["codegen"] files = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, @@ -582,6 +578,7 @@ version = "1.0.2" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["codegen"] files = [ {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"}, {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"}, @@ -603,6 +600,7 @@ version = "0.26.0" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["codegen"] files = [ {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"}, {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"}, @@ -616,7 +614,7 @@ idna = "*" sniffio = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -627,6 +625,7 @@ version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" +groups = ["main", "codegen", "docs"] files = [ {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, @@ -638,36 +637,19 @@ version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["docs"] files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] -[[package]] -name = "importlib-metadata" -version = "7.0.1" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, - {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, -] - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] - [[package]] name = "inflect" version = "5.6.2" description = "Correctly generate plurals, singular nouns, ordinals, indefinite articles; convert numbers to words" optional = false python-versions = ">=3.7" +groups = ["codegen"] files = [ {file = "inflect-5.6.2-py3-none-any.whl", hash = "sha256:b45d91a4a28a4e617ff1821117439b06eaa86e2a4573154af0149e9be6687238"}, {file = "inflect-5.6.2.tar.gz", hash = "sha256:aadc7ed73928f5e014129794bbac03058cca35d0a973a5fc4eb45c7fa26005f9"}, @@ -675,7 +657,7 @@ files = [ [package.extras] docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] -testing = ["pygments", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +testing = ["pygments", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\""] [[package]] name = "iniconfig" @@ -683,6 +665,7 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -694,6 +677,7 @@ version = "5.13.2" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.8.0" +groups = ["codegen", "dev"] files = [ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, @@ -708,6 +692,7 @@ version = "3.1.4" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["codegen", "docs"] files = [ {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, @@ -725,6 +710,7 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -749,6 +735,7 @@ version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" +groups = ["codegen", "docs"] files = [ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, @@ -818,6 +805,7 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -829,6 +817,7 @@ version = "0.4.0" description = "Collection of plugins for markdown-it-py" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "mdit_py_plugins-0.4.0-py3-none-any.whl", hash = "sha256:b51b3bb70691f57f974e257e367107857a93b36f322a9e6d44ca5bf28ec2def9"}, {file = "mdit_py_plugins-0.4.0.tar.gz", hash = "sha256:d8ab27e9aed6c38aa716819fedfde15ca275715955f8a185a8e1cf90fb1d2c1b"}, @@ -848,6 +837,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -859,6 +849,7 @@ version = "1.10.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2"}, {file = "mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99"}, @@ -891,7 +882,6 @@ files = [ [package.dependencies] mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=4.1.0" [package.extras] @@ -906,6 +896,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["codegen", "dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -917,6 +908,7 @@ version = "2.0.0" description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "myst_parser-2.0.0-py3-none-any.whl", hash = "sha256:7c36344ae39c8e740dad7fdabf5aa6fc4897a813083c6cc9990044eb93656b14"}, {file = "myst_parser-2.0.0.tar.gz", hash = "sha256:ea929a67a6a0b1683cdbe19b8d2e724cd7643f8aa3e7bb18dd65beac3483bead"}, @@ -933,7 +925,7 @@ sphinx = ">=6,<8" [package.extras] code-style = ["pre-commit (>=3.0,<4.0)"] linkify = ["linkify-it-py (>=2.0,<3.0)"] -rtd = ["ipython", "pydata-sphinx-theme (==v0.13.0rc4)", "sphinx-autodoc2 (>=0.4.2,<0.5.0)", "sphinx-book-theme (==1.0.0rc2)", "sphinx-copybutton", "sphinx-design2", "sphinx-pyscript", "sphinx-tippy (>=0.3.1)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.8.2,<0.9.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] +rtd = ["ipython", "pydata-sphinx-theme (==0.13.0rc4)", "sphinx-autodoc2 (>=0.4.2,<0.5.0)", "sphinx-book-theme (==1.0.0rc2)", "sphinx-copybutton", "sphinx-design2", "sphinx-pyscript", "sphinx-tippy (>=0.3.1)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.8.2,<0.9.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=7,<8)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx-pytest"] testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4,<0.4.0)"] @@ -943,6 +935,7 @@ version = "23.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" +groups = ["codegen", "dev", "devlocal", "docs"] files = [ {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, @@ -954,6 +947,7 @@ version = "2.3" description = "Pandoc Documents for Python" optional = false python-versions = "*" +groups = ["docs"] files = [ {file = "pandoc-2.3.tar.gz", hash = "sha256:e772c2c6d871146894579828dbaf1efd538eb64fc7e71d4a6b3a11a18baef90d"}, ] @@ -968,6 +962,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["codegen", "dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -979,6 +974,7 @@ version = "4.2.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.8" +groups = ["codegen", "dev", "devlocal"] files = [ {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, @@ -994,6 +990,7 @@ version = "1.4.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev", "devlocal"] files = [ {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, @@ -1009,6 +1006,7 @@ version = "1.8.2" description = "Plumbum: shell combinators library" optional = false python-versions = ">=3.6" +groups = ["docs"] files = [ {file = "plumbum-1.8.2-py3-none-any.whl", hash = "sha256:3ad9e5f56c6ec98f6f7988f7ea8b52159662ea9e915868d369dbccbfca0e367e"}, {file = "plumbum-1.8.2.tar.gz", hash = "sha256:9e6dc032f4af952665f32f3206567bc23b7858b1413611afe603a3f8ad9bfd75"}, @@ -1028,6 +1026,7 @@ version = "3.11" description = "Python Lex & Yacc" optional = false python-versions = "*" +groups = ["docs"] files = [ {file = "ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce"}, {file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"}, @@ -1035,47 +1034,49 @@ files = [ [[package]] name = "pydantic" -version = "1.10.15" +version = "1.10.26" description = "Data validation and settings management using python type hints" optional = false python-versions = ">=3.7" -files = [ - {file = "pydantic-1.10.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:22ed12ee588b1df028a2aa5d66f07bf8f8b4c8579c2e96d5a9c1f96b77f3bb55"}, - {file = "pydantic-1.10.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:75279d3cac98186b6ebc2597b06bcbc7244744f6b0b44a23e4ef01e5683cc0d2"}, - {file = "pydantic-1.10.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50f1666a9940d3d68683c9d96e39640f709d7a72ff8702987dab1761036206bb"}, - {file = "pydantic-1.10.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82790d4753ee5d00739d6cb5cf56bceb186d9d6ce134aca3ba7befb1eedbc2c8"}, - {file = "pydantic-1.10.15-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:d207d5b87f6cbefbdb1198154292faee8017d7495a54ae58db06762004500d00"}, - {file = "pydantic-1.10.15-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e49db944fad339b2ccb80128ffd3f8af076f9f287197a480bf1e4ca053a866f0"}, - {file = "pydantic-1.10.15-cp310-cp310-win_amd64.whl", hash = "sha256:d3b5c4cbd0c9cb61bbbb19ce335e1f8ab87a811f6d589ed52b0254cf585d709c"}, - {file = "pydantic-1.10.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c3d5731a120752248844676bf92f25a12f6e45425e63ce22e0849297a093b5b0"}, - {file = "pydantic-1.10.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c365ad9c394f9eeffcb30a82f4246c0006417f03a7c0f8315d6211f25f7cb654"}, - {file = "pydantic-1.10.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3287e1614393119c67bd4404f46e33ae3be3ed4cd10360b48d0a4459f420c6a3"}, - {file = "pydantic-1.10.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be51dd2c8596b25fe43c0a4a59c2bee4f18d88efb8031188f9e7ddc6b469cf44"}, - {file = "pydantic-1.10.15-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6a51a1dd4aa7b3f1317f65493a182d3cff708385327c1c82c81e4a9d6d65b2e4"}, - {file = "pydantic-1.10.15-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4e316e54b5775d1eb59187f9290aeb38acf620e10f7fd2f776d97bb788199e53"}, - {file = "pydantic-1.10.15-cp311-cp311-win_amd64.whl", hash = "sha256:0d142fa1b8f2f0ae11ddd5e3e317dcac060b951d605fda26ca9b234b92214986"}, - {file = "pydantic-1.10.15-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7ea210336b891f5ea334f8fc9f8f862b87acd5d4a0cbc9e3e208e7aa1775dabf"}, - {file = "pydantic-1.10.15-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3453685ccd7140715e05f2193d64030101eaad26076fad4e246c1cc97e1bb30d"}, - {file = "pydantic-1.10.15-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bea1f03b8d4e8e86702c918ccfd5d947ac268f0f0cc6ed71782e4b09353b26f"}, - {file = "pydantic-1.10.15-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:005655cabc29081de8243126e036f2065bd7ea5b9dff95fde6d2c642d39755de"}, - {file = "pydantic-1.10.15-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:af9850d98fc21e5bc24ea9e35dd80a29faf6462c608728a110c0a30b595e58b7"}, - {file = "pydantic-1.10.15-cp37-cp37m-win_amd64.whl", hash = "sha256:d31ee5b14a82c9afe2bd26aaa405293d4237d0591527d9129ce36e58f19f95c1"}, - {file = "pydantic-1.10.15-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5e09c19df304b8123938dc3c53d3d3be6ec74b9d7d0d80f4f4b5432ae16c2022"}, - {file = "pydantic-1.10.15-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7ac9237cd62947db00a0d16acf2f3e00d1ae9d3bd602b9c415f93e7a9fc10528"}, - {file = "pydantic-1.10.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:584f2d4c98ffec420e02305cf675857bae03c9d617fcfdc34946b1160213a948"}, - {file = "pydantic-1.10.15-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbc6989fad0c030bd70a0b6f626f98a862224bc2b1e36bfc531ea2facc0a340c"}, - {file = "pydantic-1.10.15-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d573082c6ef99336f2cb5b667b781d2f776d4af311574fb53d908517ba523c22"}, - {file = "pydantic-1.10.15-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6bd7030c9abc80134087d8b6e7aa957e43d35714daa116aced57269a445b8f7b"}, - {file = "pydantic-1.10.15-cp38-cp38-win_amd64.whl", hash = "sha256:3350f527bb04138f8aff932dc828f154847fbdc7a1a44c240fbfff1b57f49a12"}, - {file = "pydantic-1.10.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:51d405b42f1b86703555797270e4970a9f9bd7953f3990142e69d1037f9d9e51"}, - {file = "pydantic-1.10.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a980a77c52723b0dc56640ced396b73a024d4b74f02bcb2d21dbbac1debbe9d0"}, - {file = "pydantic-1.10.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f1a1fb467d3f49e1708a3f632b11c69fccb4e748a325d5a491ddc7b5d22383"}, - {file = "pydantic-1.10.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:676ed48f2c5bbad835f1a8ed8a6d44c1cd5a21121116d2ac40bd1cd3619746ed"}, - {file = "pydantic-1.10.15-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:92229f73400b80c13afcd050687f4d7e88de9234d74b27e6728aa689abcf58cc"}, - {file = "pydantic-1.10.15-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2746189100c646682eff0bce95efa7d2e203420d8e1c613dc0c6b4c1d9c1fde4"}, - {file = "pydantic-1.10.15-cp39-cp39-win_amd64.whl", hash = "sha256:394f08750bd8eaad714718812e7fab615f873b3cdd0b9d84e76e51ef3b50b6b7"}, - {file = "pydantic-1.10.15-py3-none-any.whl", hash = "sha256:28e552a060ba2740d0d2aabe35162652c1459a0b9069fe0db7f4ee0e18e74d58"}, - {file = "pydantic-1.10.15.tar.gz", hash = "sha256:ca832e124eda231a60a041da4f013e3ff24949d94a01154b137fc2f2a43c3ffb"}, +groups = ["main", "codegen"] +files = [ + {file = "pydantic-1.10.26-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f7ae36fa0ecef8d39884120f212e16c06bb096a38f523421278e2f39c1784546"}, + {file = "pydantic-1.10.26-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d95a76cf503f0f72ed7812a91de948440b2bf564269975738a4751e4fadeb572"}, + {file = "pydantic-1.10.26-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a943ce8e00ad708ed06a1d9df5b4fd28f5635a003b82a4908ece6f24c0b18464"}, + {file = "pydantic-1.10.26-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:465ad8edb29b15c10b779b16431fe8e77c380098badf6db367b7a1d3e572cf53"}, + {file = "pydantic-1.10.26-cp310-cp310-win_amd64.whl", hash = "sha256:80e6be6272839c8a7641d26ad569ab77772809dd78f91d0068dc0fc97f071945"}, + {file = "pydantic-1.10.26-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:116233e53889bcc536f617e38c1b8337d7fa9c280f0fd7a4045947515a785637"}, + {file = "pydantic-1.10.26-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3cfdd361addb6eb64ccd26ac356ad6514cee06a61ab26b27e16b5ed53108f77"}, + {file = "pydantic-1.10.26-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e4451951a9a93bf9a90576f3e25240b47ee49ab5236adccb8eff6ac943adf0f"}, + {file = "pydantic-1.10.26-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9858ed44c6bea5f29ffe95308db9e62060791c877766c67dd5f55d072c8612b5"}, + {file = "pydantic-1.10.26-cp311-cp311-win_amd64.whl", hash = "sha256:ac1089f723e2106ebde434377d31239e00870a7563245072968e5af5cc4d33df"}, + {file = "pydantic-1.10.26-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:468d5b9cacfcaadc76ed0a4645354ab6f263ec01a63fb6d05630ea1df6ae453f"}, + {file = "pydantic-1.10.26-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2c1b0b914be31671000ca25cf7ea17fcaaa68cfeadf6924529c5c5aa24b7ab1f"}, + {file = "pydantic-1.10.26-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15b13b9f8ba8867095769e1156e0d7fbafa1f65b898dd40fd1c02e34430973cb"}, + {file = "pydantic-1.10.26-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad7025ca324ae263d4313998e25078dcaec5f9ed0392c06dedb57e053cc8086b"}, + {file = "pydantic-1.10.26-cp312-cp312-win_amd64.whl", hash = "sha256:4482b299874dabb88a6c3759e3d85c6557c407c3b586891f7d808d8a38b66b9c"}, + {file = "pydantic-1.10.26-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ae7913bb40a96c87e3d3f6fe4e918ef53bf181583de4e71824360a9b11aef1c"}, + {file = "pydantic-1.10.26-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8154c13f58d4de5d3a856bb6c909c7370f41fb876a5952a503af6b975265f4ba"}, + {file = "pydantic-1.10.26-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8af0507bf6118b054a9765fb2e402f18a8b70c964f420d95b525eb711122d62"}, + {file = "pydantic-1.10.26-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dcb5a7318fb43189fde6af6f21ac7149c4bcbcfffc54bc87b5becddc46084847"}, + {file = "pydantic-1.10.26-cp313-cp313-win_amd64.whl", hash = "sha256:71cde228bc0600cf8619f0ee62db050d1880dcc477eba0e90b23011b4ee0f314"}, + {file = "pydantic-1.10.26-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6b40730cc81d53d515dc0b8bb5c9b43fadb9bed46de4a3c03bd95e8571616dba"}, + {file = "pydantic-1.10.26-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c3bbb9c0eecdf599e4db9b372fa9cc55be12e80a0d9c6d307950a39050cb0e37"}, + {file = "pydantic-1.10.26-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc2e3fe7bc4993626ef6b6fa855defafa1d6f8996aa1caef2deb83c5ac4d043a"}, + {file = "pydantic-1.10.26-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:36d9e46b588aaeb1dcd2409fa4c467fe0b331f3cc9f227b03a7a00643704e962"}, + {file = "pydantic-1.10.26-cp314-cp314-win_amd64.whl", hash = "sha256:81ce3c8616d12a7be31b4aadfd3434f78f6b44b75adbfaec2fe1ad4f7f999b8c"}, + {file = "pydantic-1.10.26-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc5c91a3b3106caf07ac6735ec6efad8ba37b860b9eb569923386debe65039ad"}, + {file = "pydantic-1.10.26-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dde599e0388e04778480d57f49355c9cc7916de818bf674de5d5429f2feebfb6"}, + {file = "pydantic-1.10.26-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8be08b5cfe88e58198722861c7aab737c978423c3a27300911767931e5311d0d"}, + {file = "pydantic-1.10.26-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0141f4bafe5eda539d98c9755128a9ea933654c6ca4306b5059fc87a01a38573"}, + {file = "pydantic-1.10.26-cp38-cp38-win_amd64.whl", hash = "sha256:eb664305ffca8a9766a8629303bb596607d77eae35bb5f32ff9245984881b638"}, + {file = "pydantic-1.10.26-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:502b9d30d18a2dfaf81b7302f6ba0e5853474b1c96212449eb4db912cb604b7d"}, + {file = "pydantic-1.10.26-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0d8f6087bf697dec3bf7ffcd7fe8362674f16519f3151789f33cbe8f1d19fc15"}, + {file = "pydantic-1.10.26-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dd40a99c358419910c85e6f5d22f9c56684c25b5e7abc40879b3b4a52f34ae90"}, + {file = "pydantic-1.10.26-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ce3293b86ca9f4125df02ff0a70be91bc7946522467cbd98e7f1493f340616ba"}, + {file = "pydantic-1.10.26-cp39-cp39-win_amd64.whl", hash = "sha256:1a4e3062b71ab1d5df339ba12c48f9ed5817c5de6cb92a961dd5c64bb32e7b96"}, + {file = "pydantic-1.10.26-py3-none-any.whl", hash = "sha256:c43ad70dc3ce7787543d563792426a16fd7895e14be4b194b5665e36459dd917"}, + {file = "pydantic-1.10.26.tar.gz", hash = "sha256:8c6aa39b494c5af092e690127c283d84f363ac36017106a9e66cb33a22ac412e"}, ] [package.dependencies] @@ -1092,13 +1093,14 @@ version = "2.17.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, ] [package.extras] -plugins = ["importlib-metadata"] +plugins = ["importlib-metadata ; python_version < \"3.8\""] windows-terminal = ["colorama (>=0.4.6)"] [[package]] @@ -1107,25 +1109,23 @@ version = "3.1.0" description = "python code static checker" optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "pylint-3.1.0-py3-none-any.whl", hash = "sha256:507a5b60953874766d8a366e8e8c7af63e058b26345cfcb5f91f89d987fd6b74"}, {file = "pylint-3.1.0.tar.gz", hash = "sha256:6a69beb4a6f63debebaab0a3477ecd0f559aa726af4954fc948c51f7a2549e23"}, ] [package.dependencies] -astroid = ">=3.1.0,<=3.2.0-dev0" +astroid = ">=3.1.0,<=3.2.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ - {version = ">=0.2", markers = "python_version < \"3.11\""}, - {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, + {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, ] -isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" +isort = ">=4.2.5,!=5.13.0,<6" mccabe = ">=0.6,<0.8" platformdirs = ">=2.2.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} tomlkit = ">=0.10.1" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [package.extras] spelling = ["pyenchant (>=3.2,<4.0)"] @@ -1137,6 +1137,7 @@ version = "1.6.1" description = "API to interact with the python pyproject.toml based projects" optional = false python-versions = ">=3.8" +groups = ["devlocal"] files = [ {file = "pyproject_api-1.6.1-py3-none-any.whl", hash = "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675"}, {file = "pyproject_api-1.6.1.tar.gz", hash = "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538"}, @@ -1144,7 +1145,6 @@ files = [ [package.dependencies] packaging = ">=23.1" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [package.extras] docs = ["furo (>=2023.8.19)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"] @@ -1156,6 +1156,7 @@ version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, @@ -1163,11 +1164,9 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] @@ -1178,6 +1177,7 @@ version = "4.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, @@ -1196,6 +1196,7 @@ version = "0.2.3" description = "Organizing pytests by integration or not" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "pytest_integration-0.2.3-py3-none-any.whl", hash = "sha256:7f59ed1fa1cc8cb240f9495b68bc02c0421cce48589f78e49b7b842231604b12"}, {file = "pytest_integration-0.2.3.tar.gz", hash = "sha256:b00988a5de8a6826af82d4c7a3485b43fbf32c11235e9f4a8b7225eef5fbcf65"}, @@ -1207,6 +1208,7 @@ version = "0.6.3" description = "It helps to use fixtures in pytest.mark.parametrize" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "pytest-lazy-fixture-0.6.3.tar.gz", hash = "sha256:0e7d0c7f74ba33e6e80905e9bfd81f9d15ef9a790de97993e34213deb5ad10ac"}, {file = "pytest_lazy_fixture-0.6.3-py3-none-any.whl", hash = "sha256:e0b379f38299ff27a653f03eaa69b08a6fd4484e46fd1c9907d984b9f9daeda6"}, @@ -1215,23 +1217,14 @@ files = [ [package.dependencies] pytest = ">=3.2.5" -[[package]] -name = "pytz" -version = "2024.1" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, - {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, -] - [[package]] name = "pywin32" version = "306" description = "Python for Window Extensions" optional = false python-versions = "*" +groups = ["docs"] +markers = "platform_system == \"Windows\" and platform_python_implementation != \"PyPy\"" files = [ {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, @@ -1255,6 +1248,7 @@ version = "6.0.1" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.6" +groups = ["codegen", "docs"] files = [ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, @@ -1315,6 +1309,7 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["main", "docs"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -1336,6 +1331,7 @@ version = "1.3.0" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["codegen"] files = [ {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, @@ -1347,6 +1343,7 @@ version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." optional = false python-versions = "*" +groups = ["docs"] files = [ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, @@ -1358,6 +1355,7 @@ version = "2.5" description = "A modern CSS selector implementation for Beautiful Soup." optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, @@ -1369,6 +1367,7 @@ version = "7.1.2" description = "Python documentation generator" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe"}, {file = "sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f"}, @@ -1380,7 +1379,6 @@ babel = ">=2.9" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} docutils = ">=0.18.1,<0.21" imagesize = ">=1.3" -importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} Jinja2 = ">=3.0" packaging = ">=21.0" Pygments = ">=2.13" @@ -1404,6 +1402,7 @@ version = "3.0.0" description = "Sphinx API documentation generator" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "sphinx-autoapi-3.0.0.tar.gz", hash = "sha256:09ebd674a32b44467222b0fb8a917b97c89523f20dbf05b52cb8a3f0e15714de"}, {file = "sphinx_autoapi-3.0.0-py2.py3-none-any.whl", hash = "sha256:ea207793cba1feff7b2ded0e29364f2995a4d157303a98603cee0ce94cea2688"}, @@ -1428,6 +1427,7 @@ version = "1.0.0b2" description = "A modern skeleton for Sphinx themes." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"}, {file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"}, @@ -1445,6 +1445,7 @@ version = "0.15.1" description = "Automatic links from code examples to reference documentation." optional = false python-versions = ">=3.7" +groups = ["docs"] files = [ {file = "sphinx_codeautolink-0.15.1-py3-none-any.whl", hash = "sha256:c5e9d1c41463af31216aaaed3be149c2122b338bd0cfe87292ac47c970189986"}, {file = "sphinx_codeautolink-0.15.1.tar.gz", hash = "sha256:c4f88aa4a315ee26c8c1d7ffc11b3759764fadf7d8f0a861d780d4423eff93e6"}, @@ -1463,6 +1464,7 @@ version = "1.0.4" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, @@ -1478,6 +1480,7 @@ version = "1.0.2" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." optional = false python-versions = ">=3.5" +groups = ["docs"] files = [ {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, @@ -1493,6 +1496,7 @@ version = "2.0.1" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = false python-versions = ">=3.8" +groups = ["docs"] files = [ {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, @@ -1508,6 +1512,7 @@ version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" optional = false python-versions = ">=3.5" +groups = ["docs"] files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, @@ -1522,6 +1527,7 @@ version = "1.0.3" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." optional = false python-versions = ">=3.5" +groups = ["docs"] files = [ {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, @@ -1537,6 +1543,7 @@ version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." optional = false python-versions = ">=3.5" +groups = ["docs"] files = [ {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, @@ -1552,28 +1559,19 @@ version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["dev"] files = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - [[package]] name = "tomlkit" version = "0.12.3" description = "Style preserving TOML library" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "tomlkit-0.12.3-py3-none-any.whl", hash = "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba"}, {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"}, @@ -1585,6 +1583,7 @@ version = "4.15.0" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" +groups = ["devlocal"] files = [ {file = "tox-4.15.0-py3-none-any.whl", hash = "sha256:300055f335d855b2ab1b12c5802de7f62a36d4fd53f30bd2835f6a201dda46ea"}, {file = "tox-4.15.0.tar.gz", hash = "sha256:7a0beeef166fbe566f54f795b4906c31b428eddafc0102ac00d20998dd1933f6"}, @@ -1599,12 +1598,11 @@ packaging = ">=23.2" platformdirs = ">=4.1" pluggy = ">=1.3" pyproject-api = ">=1.6.1" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} virtualenv = ">=20.25" [package.extras] docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.25.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] -testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=8.0.2)", "distlib (>=0.3.8)", "flaky (>=3.7)", "hatch-vcs (>=0.4)", "hatchling (>=1.21)", "psutil (>=5.9.7)", "pytest (>=7.4.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-xdist (>=3.5)", "re-assert (>=1.1)", "time-machine (>=2.13)", "wheel (>=0.42)"] +testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=8.0.2)", "distlib (>=0.3.8)", "flaky (>=3.7)", "hatch-vcs (>=0.4)", "hatchling (>=1.21)", "psutil (>=5.9.7)", "pytest (>=7.4.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-xdist (>=3.5)", "re-assert (>=1.1)", "time-machine (>=2.13) ; implementation_name != \"pypy\"", "wheel (>=0.42)"] [[package]] name = "types-requests" @@ -1612,6 +1610,7 @@ version = "2.32.0.20240521" description = "Typing stubs for requests" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types-requests-2.32.0.20240521.tar.gz", hash = "sha256:c5c4a0ae95aad51f1bf6dae9eed04a78f7f2575d4b171da37b622e08b93eb5d3"}, {file = "types_requests-2.32.0.20240521-py3-none-any.whl", hash = "sha256:ab728ba43ffb073db31f21202ecb97db8753ded4a9dc49cb480d8a5350c5c421"}, @@ -1626,6 +1625,7 @@ version = "4.9.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main", "codegen", "dev"] files = [ {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, @@ -1637,13 +1637,14 @@ version = "2.2.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" +groups = ["main", "dev", "docs"] files = [ {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1654,6 +1655,7 @@ version = "20.26.6" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" +groups = ["devlocal"] files = [ {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, @@ -1666,24 +1668,9 @@ platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] - -[[package]] -name = "zipp" -version = "3.19.1" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "zipp-3.19.1-py3-none-any.whl", hash = "sha256:2828e64edb5386ea6a52e7ba7cdb17bb30a73a858f5eb6eb93d8d36f5ea26091"}, - {file = "zipp-3.19.1.tar.gz", hash = "sha256:35427f6d5594f4acf82d25541438348c26736fa9b3afa2754bcd63cdb99d8e8f"}, -] - -[package.extras] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [metadata] -lock-version = "2.0" -python-versions = "^3.8" -content-hash = "df1b029ed8b60a169f7e88c266c88230a3f2e80b252107e78fc200d2896af8db" +lock-version = "2.1" +python-versions = "^3.11" +content-hash = "21b9f6e0eaa51e4b56906e28c1359243534611f1092599f19aa4e41e2b00dd92" diff --git a/pyproject.toml b/pyproject.toml index 458c18e..555402f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,11 +15,10 @@ classifiers = [ "Framework :: Pydantic", "Intended Audience :: Developers", "Intended Audience :: System Administrators", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Security", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Systems Administration", @@ -32,8 +31,9 @@ classifiers = [ optional = true [tool.poetry.dependencies] -python = "^3.8" -pydantic = ">=1.10.5,<2.0.0" +python = "^3.11" +# 1.10.25 is the first 1.x release that runs on Python 3.14 +pydantic = ">=1.10.25,<2.0.0" requests = "^2.32.3" # no standard mechanism to pin transitive deps # https://github.com/python-poetry/poetry/issues/4991 @@ -111,14 +111,14 @@ preview = true field-constraints = true snake-case-field = true strip-default-none = false -target-python-version = "3.8" +target-python-version = "3.11" [tool.isort] profile = "black" multi_line_output = 3 [tool.mypy] -python_version = 3.8 +python_version = "3.11" exclude = "tests" plugins = [ "pydantic.mypy" @@ -160,10 +160,10 @@ legacy_tox_ini = """ [tox] isolated_build = true env_list = - py38 - py39 - py310 py311 + py312 + py313 + py314 [testenv] # This runs tests using all in-scope envs on your local source tree files. diff --git a/runzero/client/_http/io.py b/runzero/client/_http/io.py index 306a3d7..7596d9f 100644 --- a/runzero/client/_http/io.py +++ b/runzero/client/_http/io.py @@ -2,6 +2,8 @@ io contains classes which wrap network communication and handle errors in a consistent fashion. """ +import random +import time from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional from requests import JSONDecodeError, PreparedRequest @@ -54,6 +56,29 @@ def __init__(self, response: RequestsResponse): self.json_obj = None +# Sleep indirection so tests can observe backoff without waiting. +_sleep = time.sleep + +# Defaults for retrying a request the server refused with 429 Too Many Requests. +DEFAULT_RATE_LIMIT_RETRIES = 3 +DEFAULT_RATE_LIMIT_BACKOFF_SECONDS = 1.0 +MAX_RATE_LIMIT_WAIT_SECONDS = 120.0 + + +def rate_limit_wait_seconds(attempt: int, retry_after: Optional[int], backoff_seconds: float) -> float: + """Returns how long to wait before retrying a rate-limited request. + + The server's Retry-After value wins when present. Otherwise the wait doubles per attempt from + backoff_seconds with a little jitter. Either way the wait is capped at MAX_RATE_LIMIT_WAIT_SECONDS. + """ + if retry_after is not None and retry_after >= 0: + wait = float(retry_after) + else: + wait = backoff_seconds * (2**attempt) + wait += random.uniform(0, wait / 4) if wait > 0 else 0 # nosec B311 - jitter, not security + return min(wait, MAX_RATE_LIMIT_WAIT_SECONDS) + + class Request: """A wrapper around API http requests to keep all callers in-bounds. @@ -65,6 +90,10 @@ class Request: :param data: The data to send in form body (POST, PATCH, PUT) :param files: For multipart form data or file uploads. Format varies. :param multipart: True if using a multipart form data (combination file[s] and form data) + :param rate_limit_retries: How many times to retry after a 429 Too Many Requests response before + raising RateLimitError. Default is DEFAULT_RATE_LIMIT_RETRIES; 0 disables retries. + :param rate_limit_backoff_seconds: Base wait between retries when the server sends no + Retry-After header. Doubles per attempt. """ @@ -80,6 +109,8 @@ def __init__( data: Optional[Any] = None, files: Optional[Any] = None, multipart: Optional[bool] = None, + rate_limit_retries: Optional[int] = None, + rate_limit_backoff_seconds: Optional[float] = None, ): """Class constructor""" self.url = url @@ -89,6 +120,10 @@ def __init__( else: self.handlers = handlers self.token = token + self.rate_limit_retries = DEFAULT_RATE_LIMIT_RETRIES if rate_limit_retries is None else rate_limit_retries + self.rate_limit_backoff_seconds = ( + DEFAULT_RATE_LIMIT_BACKOFF_SECONDS if rate_limit_backoff_seconds is None else rate_limit_backoff_seconds + ) self.params = params self.timeout: Optional[int] = timeout if validate_certificate is None: @@ -114,7 +149,6 @@ def _prepare(self) -> PreparedRequest: # with boundaries is discouraged. 'requests' handles automatically. headers = DEFAULT_CONTENT_HEADERS - self.handlers.append(_error_handler) req = RequestsRequest( method=self.method, url=self.url, @@ -123,18 +157,30 @@ def _prepare(self) -> PreparedRequest: data=self.data, files=self.files, ) - for handler in self.handlers: + # The error handler runs last and is added per prepared request so a retry does not stack it. + for handler in [*self.handlers, _error_handler]: req.register_hook("response", handler) return BearerToken(self.token)(req.prepare()) def execute(self) -> Response: - """Sends prepared request. + """Sends prepared request, retrying with backoff when the server answers 429 Too Many Requests. Returns (Response) The HTTP Response from an API Request to the server. """ + attempt = 0 + while True: + try: + return self._send_once() + except RateLimitError as exc: + if attempt >= self.rate_limit_retries: + raise + _sleep(rate_limit_wait_seconds(attempt, exc.retry_after, self.rate_limit_backoff_seconds)) + attempt += 1 + + def _send_once(self) -> Response: prepared_request = self._prepare() session = Session() try: @@ -203,10 +249,11 @@ def _error_handler(response: RequestsResponse, **kwargs: Any) -> RequestsRespons if 400 <= response.status_code <= 499: if response.status_code == 429: - rate_limit = RateLimitInformation.from_headers(response.headers) - remaining = rate_limit.usage_remaining - if isinstance(remaining, int) and remaining < 1: - raise RateLimitError(rate_limit_information=rate_limit) + raise RateLimitError( + rate_limit_information=RateLimitInformation.from_headers(response.headers), + unparsed_response=response.text, + retry_after=_retry_after_seconds(response), + ) raise ClientError( unparsed_response=response.json(), message=f"The request was rejected by the server: {error_message}", @@ -221,3 +268,14 @@ def _error_handler(response: RequestsResponse, **kwargs: Any) -> RequestsRespons ) return response + + +def _retry_after_seconds(response: RequestsResponse) -> Optional[int]: + """Returns the Retry-After header as whole seconds, or None when absent or not a delay.""" + value = response.headers.get("Retry-After") + if value is None: + return None + try: + return max(0, int(float(value))) + except (TypeError, ValueError): + return None diff --git a/runzero/client/client.py b/runzero/client/client.py index a47b70f..2edba76 100644 --- a/runzero/client/client.py +++ b/runzero/client/client.py @@ -19,7 +19,12 @@ from runzero.types import RateLimitInformation from ._http.auth import OAuthToken, RegisteredAPIClient -from ._http.io import Request, Response +from ._http.io import ( + DEFAULT_RATE_LIMIT_BACKOFF_SECONDS, + DEFAULT_RATE_LIMIT_RETRIES, + Request, + Response, +) from .errors import AuthError @@ -58,6 +63,16 @@ class Client: Ignoring certificate validation errors can result in credential theft or other bad outcomes. :type validate_certificate: bool + + :param rate_limit_retries: Optional number of times a request is retried after the + server answers 429 Too Many Requests, waiting for the server's Retry-After value or an + exponential backoff between attempts. Default is 3. Set to 0 to raise + :class:`.RateLimitError` on the first 429 instead. + :type rate_limit_retries: int + + :param rate_limit_backoff_seconds: Optional base wait, in seconds, between retries when + the server sends no Retry-After header. The wait doubles per attempt. Default is 1. + :type rate_limit_backoff_seconds: float """ __default_timeout__ = 180 @@ -81,6 +96,8 @@ def __init__( server_url: Optional[str] = None, timeout_seconds: Optional[int] = None, validate_certificate: Optional[bool] = None, + rate_limit_retries: Optional[int] = None, + rate_limit_backoff_seconds: Optional[float] = None, ): """Constructor method""" self.__account_key: Optional[str] = account_key @@ -104,6 +121,14 @@ def __init__( else: self._validate_cert = validate_certificate self._rate_limit_information: Optional[RateLimitInformation] = None + if rate_limit_retries is not None and rate_limit_retries < 0: + raise ValueError("Rate limit retries must not be negative") + if rate_limit_backoff_seconds is not None and rate_limit_backoff_seconds < 0: + raise ValueError("Rate limit backoff must not be negative") + self.rate_limit_retries: int = DEFAULT_RATE_LIMIT_RETRIES if rate_limit_retries is None else rate_limit_retries + self.rate_limit_backoff_seconds: float = ( + DEFAULT_RATE_LIMIT_BACKOFF_SECONDS if rate_limit_backoff_seconds is None else rate_limit_backoff_seconds + ) @property def oauth_token_is_expired(self) -> bool: @@ -300,6 +325,8 @@ def execute( data=form_data, files=files, multipart=multipart, + rate_limit_retries=self.rate_limit_retries, + rate_limit_backoff_seconds=self.rate_limit_backoff_seconds, ).execute() self._rate_limit_information = resp.rate_limit_information return resp diff --git a/runzero/client/errors.py b/runzero/client/errors.py index 6078d3c..27b00f1 100644 --- a/runzero/client/errors.py +++ b/runzero/client/errors.py @@ -121,15 +121,13 @@ class AuthError(APIError): pass -class RateLimitError(APIError): +class RateLimitError(ClientError): """ - RateLimitError is a named Exception class errors resulting from API rate limiting. + RateLimitError is a named Exception class for every 429 Too Many Requests response. It is raised + after the client's own retries are exhausted. See https://www.runzero.com/docs/leveraging-the-api/#api-client-credentials for details. - Consider an exponential backoff retry, or a more calculated approach by examining the returned - numbers. - :param message: A top-level error description. The default value None provides a reasonable message. @@ -139,6 +137,10 @@ class RateLimitError(APIError): :param rate_limit_information: a RateLimitInformation object which holds the rate limit data :type rate_limit_information: RateLimitInformation + :param retry_after: seconds the server asked the caller to wait, from the Retry-After header, + when present. + :type retry_after: int, optional + """ def __init__( @@ -146,6 +148,7 @@ def __init__( rate_limit_information: RateLimitInformation, message: Optional[str] = None, unparsed_response: Optional[str] = None, + retry_after: Optional[int] = None, ): """Constructor method""" if not message: @@ -153,10 +156,10 @@ def __init__( "Too many API requests for licensed rate limit. See runZero documentation for details on API " "rate limiting." ) - super().__init__(message) + super().__init__(unparsed_response=unparsed_response, message=message) self.message = message - self.unparsed_response: Optional[str] = unparsed_response self.rate_limit_information: RateLimitInformation = rate_limit_information + self.retry_after: Optional[int] = retry_after def __str__(self) -> str: return f"{self.message} Rate limit information: {self.rate_limit_information}" diff --git a/runzero/types/__init__.py b/runzero/types/__init__.py index fde7e8f..4672458 100644 --- a/runzero/types/__init__.py +++ b/runzero/types/__init__.py @@ -29,6 +29,7 @@ ) from runzero.types._rate_limit_information import RateLimitInformation from runzero.types._wrapped import ( + Communication, CustomAttribute, CustomIntegration, Hostname, @@ -47,6 +48,7 @@ __all__ = [ "AddressValueError", "BaseCustomIntegration", + "Communication", "CustomAttribute", "CustomIntegration", "Explorer", diff --git a/runzero/types/_data_models_gen.py b/runzero/types/_data_models_gen.py index adfc56c..d6e8e34 100644 --- a/runzero/types/_data_models_gen.py +++ b/runzero/types/_data_models_gen.py @@ -1026,6 +1026,44 @@ class Config: """ +class Communication(BaseModel): + """ + An aggregated summary of traffic an asset took part in, for one role and protocol over a time window. + """ + + class Config: + allow_population_by_field_name = True + + role: str = Field(..., example="client", regex="^(client|server)$") + """ + The asset's side of the traffic: "client" (outbound) or "server" (inbound). + """ + protocol: str = Field(..., example="HTTPS", max_length=64) + """ + The application or transport protocol name. Stored upper-cased. + """ + ports: Optional[List[int]] = Field(None, example=[443], max_items=256) + """ + The ports observed during the window. + """ + start_ts: datetime = Field(..., alias="startTS", example="2023-03-06T18:14:50.52Z") + """ + The start of the collection window, using a date string as defined by RFC 3339, section 5.6. + """ + end_ts: datetime = Field(..., alias="endTS", example="2023-03-07T18:14:50.52Z") + """ + The end of the collection window, using a date string as defined by RFC 3339, section 5.6. + """ + bytes_tx: Optional[int] = Field(None, alias="bytesTx", example=1500, ge=0) + """ + The number of bytes the asset transmitted during the window. + """ + bytes_rx: Optional[int] = Field(None, alias="bytesRx", example=300, ge=0) + """ + The number of bytes the asset received during the window. + """ + + class ImportAsset(BaseModel): """ Represents a custom asset to be created or merged after import. @@ -1093,6 +1131,10 @@ class Config: """ The vulnerabilities associated with an asset. """ + communications: Optional[List[Communication]] = Field(None, max_items=1000) + """ + Aggregated traffic summaries for an asset. Records sharing a role and protocol are summed. An asset without communications keeps whatever was stored before. + """ custom_attributes: Optional[Dict[str, str]] = Field(None, alias="customAttributes") """ Flat map of arbitrary string key/value pairs representing custom attribute data not described in properties above. Note the maximum number of keys and length of values. Additionally, property names may only be 256 characters long. diff --git a/runzero/types/_wrapped.py b/runzero/types/_wrapped.py index 3f5a866..f0b40be 100644 --- a/runzero/types/_wrapped.py +++ b/runzero/types/_wrapped.py @@ -9,6 +9,7 @@ was insufficient. """ +from datetime import datetime from ipaddress import IPv4Address, IPv6Address, ip_address from typing import Any, Dict, Iterable, List, Optional, Union from warnings import warn @@ -16,6 +17,7 @@ # Note: `validator` has been replaced with `field_validator` in v2+ from pydantic import BaseModel, Field, ValidationError, validator +from ._data_models_gen import Communication as RESTCommunication from ._data_models_gen import CustomIntegration as RESTCustomIntegration from ._data_models_gen import Hostname as RESTHostname from ._data_models_gen import ImportAsset as RESTImportAsset @@ -30,6 +32,84 @@ from ._data_models_gen import Vulnerability as RESTVulnerability +def _ensure_timezone(value: Optional[datetime]) -> Optional[datetime]: + """ + Attaches the local offset to a naive datetime. The runZero API requires + RFC 3339 timestamps with an offset, and Python treats a naive datetime + as local time, so this follows the stdlib rule instead of guessing UTC. + """ + if value is None or value.tzinfo is not None: + return value + return value.astimezone() + + +class Communication(RESTCommunication): + """ + Represents aggregated traffic an asset took part in, for one role and protocol over a time window. + """ + + # The REST type accepts any protocol casing and any port value; the server + # normalizes the role and protocol and rejects ports outside 1..65535 and + # an inverted window, so the checks run here to fail before upload. + + __MIN_PORT = 1 + __MAX_PORT = 65535 + + def __int__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + @validator("start_ts", "end_ts") + def _timezone_aware(cls, value: Optional[datetime]) -> Optional[datetime]: # pylint: disable=E0213 + # disabled pylint because @validator turns the method into a classmethod + """ + Attaches the local offset to a naive datetime so the API accepts it. + """ + return _ensure_timezone(value) + + @validator("role", pre=True) + def _lower_case_role(cls, attr: str) -> str: # pylint: disable=E0213 + # disabled pylint because @validator turns the method into a classmethod + """ + Lower-cases the role so "Client" and "SERVER" are accepted. + """ + return str(attr).strip().lower() + + @validator("protocol", pre=True) + def _upper_case_protocol(cls, attr: str) -> str: # pylint: disable=E0213 + # disabled pylint because @validator turns the method into a classmethod + """ + Upper-cases the protocol to match how the console stores it. + """ + return str(attr).strip().upper() + + @validator("ports") + def _port_range(cls, ports: Optional[List[int]]) -> Optional[List[int]]: # pylint: disable=E0213 + # disabled pylint because @validator turns the method into a classmethod + """ + Rejects ports outside 1..65535. Validates the whole list because + each_item validators do not run on an Optional list in pydantic v1. + """ + for port in ports or []: + if port < cls.__MIN_PORT or port > cls.__MAX_PORT: + raise ValueError(f"port {port} must be between {cls.__MIN_PORT} and {cls.__MAX_PORT}") + return ports + + @validator("end_ts") + def _window_order(cls, end_ts: datetime, values: Dict[str, Any]) -> datetime: # pylint: disable=E0213 + # disabled pylint because @validator turns the method into a classmethod + """ + Rejects a window that ends before it starts. + """ + start_ts = values.get("start_ts") + if start_ts is None: + return end_ts + aware_start = _ensure_timezone(start_ts) + aware_end = _ensure_timezone(end_ts) + if aware_start is not None and aware_end is not None and aware_start > aware_end: + raise ValueError("start_ts must not be after end_ts") + return end_ts + + class CustomIntegration(RESTCustomIntegration): """CustomIntegration represents a custom asset data source for custom integrations use""" @@ -115,6 +195,14 @@ class ImportAsset(RESTImportAsset): def __int__(self, **kwargs: Any) -> None: super().__init__(**kwargs) + @validator("first_seen_ts") + def _timezone_aware(cls, value: Optional[datetime]) -> Optional[datetime]: # pylint: disable=E0213 + # disabled pylint because @validator turns the method into a classmethod + """ + Attaches the local offset to a naive datetime so the API accepts it. + """ + return _ensure_timezone(value) + @validator("hostnames", pre=True) def _hostnames_str_conversion(cls, hosts: List[Union[str, Hostname]]) -> List[Hostname]: # pylint: disable=E0213 """ @@ -414,6 +502,14 @@ class Software(RESTSoftware): def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) + @validator("installed_at") + def _timezone_aware(cls, value: Optional[datetime]) -> Optional[datetime]: # pylint: disable=E0213 + # disabled pylint because @validator turns the method into a classmethod + """ + Attaches the local offset to a naive datetime so the API accepts it. + """ + return _ensure_timezone(value) + @validator("service_transport", pre=True) def _lower_case_service_transport(cls, attr: str) -> str: # pylint: disable=E0213 # disabled pylint because @validator turns the method into a classmethod @@ -498,6 +594,14 @@ class Vulnerability(RESTVulnerability): def __int__(self, **kwargs: Any) -> None: super().__init__(**kwargs) + @validator("published_ts", "first_detected_ts", "last_detected_ts") + def _timezone_aware(cls, value: Optional[datetime]) -> Optional[datetime]: # pylint: disable=E0213 + # disabled pylint because @validator turns the method into a classmethod + """ + Attaches the local offset to a naive datetime so the API accepts it. + """ + return _ensure_timezone(value) + @validator("service_transport", pre=True) def _lower_case_service_transport(cls, attr: str) -> str: # pylint: disable=E0213 # disabled pylint because @validator turns the method into a classmethod diff --git a/tests/README.md b/tests/README.md index 57046e9..3319c66 100644 --- a/tests/README.md +++ b/tests/README.md @@ -35,6 +35,14 @@ make test-integration from the repository root. +The integration config accepts an optional `communications` flag (`communications = true` in +`test_configs.toml`, or `communications=true` in the environment). Tests that upload asset +communications skip unless it is set, so a console that does not accept them yet still passes. + +Live tests share one account rate limit. The client retries a `429 Too Many Requests` response +with backoff before raising `RateLimitError`, so a burst of parallel test runs slows down rather +than fails. + To validate changes via unit test against a fully built package that is installed into each supported Python version, run: diff --git a/tests/conftest.py b/tests/conftest.py index 34dc5c8..fa008ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -257,6 +257,7 @@ def __init__(self): self.client_id: str = t.get("client_id") self.client_secret: str = t.get("client_secret") self.validate_cert: bool = t.get("validate_cert") + self.communications: bool = bool(t.get("communications", False)) else: self.url: str = os.environ.get("url") self.account_token: str = os.environ.get("account_token") @@ -267,8 +268,16 @@ def __init__(self): self.client_id: str = os.environ.get("client_id") self.client_secret: str = os.environ.get("client_secret") self.validate_cert: bool = os.environ.get("validate_cert", "true").lower() == "true" + self.communications: bool = os.environ.get("communications", "false").lower() == "true" self._validate_config() + def skip_unless_communications(self) -> None: + """Skips the calling test until the integration test console accepts asset communications.""" + if not self.communications: + pytest.skip( + "asset communications are not enabled on the integration test console (set communications=true)" + ) + def _validate_config(self): try: if all( diff --git a/tests/runzero/test_custom_asset_import.py b/tests/runzero/test_custom_asset_import.py index f76474e..a6f445b 100644 --- a/tests/runzero/test_custom_asset_import.py +++ b/tests/runzero/test_custom_asset_import.py @@ -1,7 +1,11 @@ +import time +from datetime import datetime, timedelta, timezone + import pytest +from runzero.api import Tasks from runzero.api.imports import CustomAssets -from runzero.types import ImportAsset, ImportTask, Tag +from runzero.types import Communication, ImportAsset, ImportTask, Tag @pytest.mark.integration_test @@ -43,3 +47,58 @@ def test_client_custom_asset_import(client, integration_config, temp_custom_inte assert task.id is not None assert task.name == "task name" assert task.description == "task description" + + +@pytest.mark.integration_test +def test_client_custom_asset_import_with_communications( + account_client, integration_config, temp_custom_integration, temp_site +): + """ + This test demonstrates loading custom asset data that carries aggregated communications. + It is skipped until the integration test console accepts them (communications=true in the test config). + """ + integration_config.skip_unless_communications() + + window_end = datetime.now(timezone.utc) - timedelta(minutes=1) + import_assets = [ + ImportAsset( + id="asset with communications", + communications=[ + Communication( + role="client", + protocol="https", + ports=[443], + start_ts=window_end - timedelta(days=1), + end_ts=window_end, + bytes_tx=1500, + bytes_rx=300, + ), + Communication( + role="server", + protocol="modbus", + ports=[502], + start_ts=window_end - timedelta(days=1), + end_ts=window_end, + bytes_rx=64, + ), + ], + ), + ] + + c = account_client + task = CustomAssets(c).upload_assets( + org_id=integration_config.org_id, + site_id=temp_site.id, + custom_integration_id=temp_custom_integration.id, + assets=import_assets, + task_info=ImportTask(name="communications import"), + ) + assert task.id is not None + + # The console processes the upload asynchronously; a rejected communications record fails the task. + status = task.status + deadline = time.monotonic() + 300 + while status not in ("processed", "failed", "error") and time.monotonic() < deadline: + time.sleep(6) + status = Tasks(client=c).get_status(integration_config.org_id, task.id) + assert status == "processed" diff --git a/tests/runzero/test_custom_integrations.py b/tests/runzero/test_custom_integrations.py index 8d65a40..83b2ab7 100644 --- a/tests/runzero/test_custom_integrations.py +++ b/tests/runzero/test_custom_integrations.py @@ -130,7 +130,9 @@ def test_custom_integrations_admin_icon_creation(account_client, integration_con integrations.create(name=str(integration_name), icon=b"not a 256x256 png") assert str(exc.value).startswith("The request was rejected by the server") - assert "pixels" in exc.value.error_info.detail + # The console rejects the bytes as not being a PNG before it checks dimensions + detail = exc.value.error_info.detail.lower() + assert "png" in detail or "pixels" in detail @pytest.mark.integration_test diff --git a/tests/runzero/test_rate_limit_retry.py b/tests/runzero/test_rate_limit_retry.py new file mode 100644 index 0000000..b7abab7 --- /dev/null +++ b/tests/runzero/test_rate_limit_retry.py @@ -0,0 +1,117 @@ +"""Unit tests for 429 handling: retry with backoff, Retry-After, and RateLimitError.""" + +import json + +import pytest +from requests import Response as RequestsResponse +from requests.hooks import dispatch_hook + +import runzero.client._http.io as io +from runzero.client import Client +from runzero.client.errors import ClientError, RateLimitError + + +def _response(status: int, body: dict, headers: dict | None = None) -> RequestsResponse: + resp = RequestsResponse() + resp.status_code = status + resp.reason = "Too Many Requests" if status == 429 else "OK" + resp.headers.update({"Content-Type": "application/json"}) + resp.headers.update(headers or {}) + resp._content = json.dumps(body).encode() # pylint: disable=protected-access + return resp + + +class _FakeSession: + """Serves canned responses in order and runs the prepared request's response hooks like requests does.""" + + queue: list = [] + sent: int = 0 + + def send(self, prepared, **kwargs): + _FakeSession.sent += 1 + resp = _FakeSession.queue.pop(0) + resp.request = prepared + return dispatch_hook("response", prepared.hooks, resp, **kwargs) + + +@pytest.fixture +def fake_http(monkeypatch): + waits: list = [] + _FakeSession.queue = [] + _FakeSession.sent = 0 + monkeypatch.setattr(io, "Session", _FakeSession) + monkeypatch.setattr(io, "_sleep", waits.append) + yield _FakeSession, waits + + +def _client(**kwargs) -> Client: + return Client(org_key="OT" + "0" * 28, server_url="https://console.local", **kwargs) + + +def test_retries_then_succeeds_honoring_retry_after(fake_http): + session, waits = fake_http + session.queue = [ + _response(429, {"error": "slow down"}, {"Retry-After": "7"}), + _response(429, {"error": "slow down"}, {"Retry-After": "2"}), + _response(200, {"ok": True}), + ] + resp = _client().execute("GET", "api/v1.0/org") + assert resp.status_code == 200 + assert resp.json_obj == {"ok": True} + assert session.sent == 3 + assert waits == [7.0, 2.0], "the server's Retry-After wins over backoff" + + +def test_backoff_doubles_without_retry_after(fake_http, monkeypatch): + session, waits = fake_http + monkeypatch.setattr(io.random, "uniform", lambda *_: 0.0) + session.queue = [ + _response(429, {"error": "slow down"}), + _response(429, {"error": "slow down"}), + _response(200, {"ok": True}), + ] + _client(rate_limit_backoff_seconds=0.5).execute("GET", "api/v1.0/org") + assert waits == [0.5, 1.0] + + +def test_raises_rate_limit_error_after_retries(fake_http): + session, waits = fake_http + session.queue = [_response(429, {"error": "slow down"}, {"Retry-After": "1"}) for _ in range(3)] + with pytest.raises(RateLimitError) as exc: + _client(rate_limit_retries=2).execute("GET", "api/v1.0/org") + assert session.sent == 3 + assert waits == [1.0, 1.0] + assert exc.value.retry_after == 1 + assert isinstance(exc.value, ClientError), "existing ClientError handlers still catch rate limits" + + +def test_zero_retries_raises_immediately(fake_http): + session, waits = fake_http + session.queue = [_response(429, {"error": "slow down"})] + with pytest.raises(RateLimitError): + _client(rate_limit_retries=0).execute("GET", "api/v1.0/org") + assert session.sent == 1 + assert waits == [] + + +def test_retry_after_is_capped(): + assert io.rate_limit_wait_seconds(0, 10_000, 1.0) == io.MAX_RATE_LIMIT_WAIT_SECONDS + assert io.rate_limit_wait_seconds(0, 3, 1.0) == 3.0 + assert io.rate_limit_wait_seconds(20, None, 1.0) == io.MAX_RATE_LIMIT_WAIT_SECONDS + + +def test_client_rejects_negative_retry_settings(): + with pytest.raises(ValueError): + _client(rate_limit_retries=-1) + with pytest.raises(ValueError): + _client(rate_limit_backoff_seconds=-1) + assert _client().rate_limit_retries == io.DEFAULT_RATE_LIMIT_RETRIES + + +def test_error_handler_does_not_stack_across_retries(fake_http): + """A retried request registers the error handler once per attempt, not cumulatively.""" + session, _ = fake_http + session.queue = [_response(429, {"error": "slow down"}, {"Retry-After": "0"}), _response(200, {"ok": True})] + request = io.Request(url="https://console.local/x", token="t", method="GET", rate_limit_retries=1) + request.execute() + assert request.handlers == [] diff --git a/tests/runzero/test_type_validation.py b/tests/runzero/test_type_validation.py index 70a85d3..b3d2091 100644 --- a/tests/runzero/test_type_validation.py +++ b/tests/runzero/test_type_validation.py @@ -1,7 +1,11 @@ +import json +from datetime import datetime, timedelta, timezone + import pytest from runzero.types import ( AddressValueError, + Communication, CustomAttribute, Hostname, ImportAsset, @@ -469,3 +473,133 @@ def test_network_interfaces_ipv6(): with pytest.raises(ValidationError): NetworkInterface(ipv6_addresses=[valid, valid_str, invalid_str]) + + +_WINDOW_START = datetime(2026, 10, 1, tzinfo=timezone.utc) +_WINDOW_END = _WINDOW_START + timedelta(days=1) + + +def _communication(**overrides): + values = {"role": "client", "protocol": "https", "start_ts": _WINDOW_START, "end_ts": _WINDOW_END} + values.update(overrides) + return Communication(**values) + + +def test_communication_normalizes_role_and_protocol(): + """ + This test ensures the role is lower-cased and the protocol upper-cased to match the console. + """ + comm = _communication(role=" Server ", protocol=" modbus ") + assert comm.role == "server" + assert comm.protocol == "MODBUS" + + +def test_communication_rejects_unknown_role(): + with pytest.raises(ValidationError): + _communication(role="peer") + + +def test_communication_requires_window(): + with pytest.raises(ValidationError): + Communication(role="client", protocol="https") + + +def test_communication_rejects_inverted_window(): + with pytest.raises(ValidationError): + _communication(start_ts=_WINDOW_END, end_ts=_WINDOW_START) + + +def test_communication_port_range(): + comm = _communication(ports=[1, 443, 65535]) + assert comm.ports == [1, 443, 65535] + + with pytest.raises(ValidationError): + _communication(ports=[0]) + with pytest.raises(ValidationError): + _communication(ports=[65536]) + with pytest.raises(ValidationError): + _communication(ports=list(range(1, 258))) + + +def test_communication_rejects_negative_bytes(): + with pytest.raises(ValidationError): + _communication(bytes_tx=-1) + with pytest.raises(ValidationError): + _communication(bytes_rx=-1) + + +def test_communication_serializes_with_api_aliases(): + """ + This test ensures the upload payload uses the field names the console expects. + """ + comm = _communication(ports=[443], bytes_tx=10, bytes_rx=20) + payload = json.loads(ImportAsset(id="foo", communications=[comm]).json(by_alias=True, exclude_none=True)) + assert payload["communications"] == [ + { + "role": "client", + "protocol": "HTTPS", + "ports": [443], + "startTS": "2026-10-01T00:00:00+00:00", + "endTS": "2026-10-02T00:00:00+00:00", + "bytesTx": 10, + "bytesRx": 20, + } + ] + + +def test_communication_accepts_api_aliases(): + comm = Communication(role="client", protocol="https", startTS=_WINDOW_START, endTS=_WINDOW_END, bytesTx=5) + assert comm.bytes_tx == 5 + + +def test_import_asset_communications_length_limit(): + """ + This test ensures that only 1000 communications can be associated with a given asset + """ + valid = [_communication() for _ in range(1000)] + invalid = [_communication() for _ in range(1001)] + + asset = ImportAsset(id="valid", communications=valid) + assert len(asset.communications) == 1000 + + with pytest.raises(ValidationError): + ImportAsset(id="invalid", communications=invalid) + + +def test_naive_datetimes_gain_local_offset(): + """ + This test ensures naive datetimes serialize with an offset, which the API requires. + Python treats a naive datetime as local time, so the local offset is attached. + """ + naive = datetime(2026, 10, 1, 12, 0, 0) + expected = naive.astimezone() + + asset = ImportAsset(id="foo", first_seen_ts=naive) + assert asset.first_seen_ts == expected + assert asset.first_seen_ts.tzinfo is not None + assert json.loads(asset.json(by_alias=True))["firstSeenTS"] == expected.isoformat() + + sw = Software(id="sw", installed_at=naive) + assert sw.installed_at == expected + + vuln = Vulnerability(id="v", published_ts=naive, first_detected_ts=naive, last_detected_ts=naive) + assert vuln.published_ts == expected + assert vuln.first_detected_ts == expected + assert vuln.last_detected_ts == expected + + comm = Communication(role="client", protocol="https", start_ts=naive, end_ts=naive + timedelta(hours=1)) + assert comm.start_ts == expected + assert comm.end_ts == expected + timedelta(hours=1) + + +def test_aware_datetimes_are_unchanged(): + aware = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=9))) + asset = ImportAsset(id="foo", first_seen_ts=aware) + assert asset.first_seen_ts is aware + assert json.loads(asset.json(by_alias=True))["firstSeenTS"] == "2026-10-01T12:00:00+09:00" + + +def test_communication_window_order_with_naive_values(): + naive = datetime(2026, 10, 1, 12, 0, 0) + with pytest.raises(ValidationError): + Communication(role="client", protocol="https", start_ts=naive + timedelta(hours=1), end_ts=naive) diff --git a/tests/runzero/test_wrapped_types.py b/tests/runzero/test_wrapped_types.py index ea78898..87b3f92 100644 --- a/tests/runzero/test_wrapped_types.py +++ b/tests/runzero/test_wrapped_types.py @@ -1,6 +1,9 @@ +from datetime import datetime, timezone + import pytest from runzero.types import ( + Communication, CustomAttribute, Hostname, ImportAsset, @@ -63,6 +66,17 @@ def test_wrapped_vuln(): assert len(asset.vulnerabilities) == 1 +def test_wrapped_communication(): + comm = Communication( + role="client", + protocol="https", + start_ts=datetime(2026, 10, 1, tzinfo=timezone.utc), + end_ts=datetime(2026, 10, 2, tzinfo=timezone.utc), + ) + asset = ImportAsset(id="foo", communications=[comm]) + assert len(asset.communications) == 1 + + def test_wrapped_software(): sw = Software(id="foo") asset = ImportAsset(id="bar", software=[sw])