diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index b4eb2dc..449ffd7 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -3,6 +3,7 @@ name: Python Test on: push: branches: + - main - master - release/* pull_request: @@ -12,16 +13,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - python-version: '3.10' + enable-cache: true + - name: Set up Python + run: uv python install 3.10 - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install . + run: uv sync --frozen --extra dev - name: Run tests - run: | - cd tests/release/integration - python -m unittest test_wrapper.py + run: uv run --project ${{ github.workspace }} python -m pytest tests -v diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..60b7022 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,63 @@ +# Development + +This project uses [`uv`](https://docs.astral.sh/uv/) for dependency management and packaging. + +## Prerequisites + +Install uv (see the [official installation guide](https://docs.astral.sh/uv/getting-started/installation/)): + +```sh +# macOS / Linux +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Windows (PowerShell) +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +## Set up a dev environment + +```sh +# Installs runtime + dev dependencies into a managed virtual environment +uv sync --extra dev +``` + +## Build + +The wheel packages the `digitalai` tree. + +```sh +# Build both sdist and wheel into ./dist +uv build + +# Or build a single artifact +uv build --wheel +uv build --sdist +``` + +## Run tests + +```sh +# Run the tests +cd tests/release/integration +uv run python -m unittest test_wrapper.py +``` + +## Publish to PyPI + +```sh +# Publish the distribution (uploads everything in dist/ to PyPI) +uv publish --token +``` + +## Dependency management + +```sh +# Add a runtime dependency +uv add + +# Add a development-only dependency +uv add --dev + +# Update the lockfile +uv lock +``` diff --git a/LICENSE b/LICENSE index 2134e88..c1bcccd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,21 @@ -Copyright 2023 Digital.ai +MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Copyright (c) 2026 Digital.ai -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index fd8f44c..e053419 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ The **Digital.ai Release Python SDK** (`digitalai-release-sdk`) provides a set o ## Features - Define custom tasks using the `BaseTask` abstract class. +- Subclass `ApiBaseTask` to access the Release APIs (`releaseApi`, `phaseApi`, `taskApi`, ...) through a ready-to-use client. - Easily manage input and output properties. - Interact with the Digital.ai Release environment seamlessly. -- Simplified API client for efficient communication with Release API. ## Installation @@ -16,6 +16,8 @@ Install the SDK using `pip`: pip install digitalai-release-sdk ``` +> **Note:** The SDK depends on [`digitalai-release-api-client`](https://pypi.org/project/digitalai-release-api-client/), which is installed automatically. + ## Getting Started ### Example Task: `hello.py` @@ -43,27 +45,46 @@ class Hello(BaseTask): self.set_output_property('greeting', greeting) ``` -## Changelog +### Example Task using the Release API: `ApiBaseTask` -### Version 26.1.0 +Subclass `ApiBaseTask` to call the Release v1 REST API without building a client +yourself. Every API is exposed as a lazily created, cached property, all sharing +a single client built from the task's "Run as user" context: -#### ๐Ÿ› ๏ธ Enhancements +```python +from digitalai.release.integration.api_base_task import ApiBaseTask -- Updated minimum Python version requirement to **3.10**. -- Updated dependency versions to enhance compatibility and security. -- Added support for the **scriptLocation** hidden property to explicitly define the task script path, improving performance and file organization. ---- +class ShowVersion(ApiBaseTask): + + def execute(self) -> None: + release = self.releaseApi.getRelease(self.get_release_id()) + self.add_comment(f"Working on {release.title}") +``` ## ๐Ÿ”— Related Resources -- ๐Ÿงช **Python Template Project**: [release-integration-template-python](https://github.com/digital-ai/release-integration-template-python) +- **[Digital.ai Python SDK Documentation](https://docs.digital.ai/release/docs/how-to/overview-python-sdk)**: + Comprehensive guide to using the Python SDK and building custom tasks. + +- **[SDK Template Project for integration plugins](https://github.com/digital-ai/release-integration-template-python)**: A starting point for building custom integrations using Digital.ai Release and Python. -- ๐Ÿ“˜ **Official Documentation**: [Digital.ai Release Python SDK Docs](https://docs.digital.ai/release/docs/category/python-sdk) - Comprehensive guide to using the Python SDK and building custom tasks. +- **[Digital.ai Release Python SDK](https://pypi.org/project/digitalai-release-sdk/)**: + The official SDK package for integrating with Digital.ai Release on Pypi. + + +## Changelog -- ๐Ÿ“ฆ **Digital.ai Release Python SDK**: [digitalai-release-sdk on PyPI](https://pypi.org/project/digitalai-release-sdk/) - The official SDK package for integrating with Digital.ai Release. +### Version 26.3.0 (Beta) + +#### ๐Ÿš€ Features + +- Added the `ApiBaseTask` base class, exposing every Release v1 API as a lazily created, cached property. +- `get_release_api_client()` now supports optional credentials/server URL and `requests` library arguments. +- Added `get_phase_id()` and `get_folder_id()` helper methods to `BaseTask`. + +#### ๐Ÿ› ๏ธ Enhancements +- Improved stability and error handling for API requests and Kubernetes tasks. diff --git a/digitalai/release/integration/__init__.py b/digitalai/release/integration/__init__.py index 12ef7ad..dd7a247 100644 --- a/digitalai/release/integration/__init__.py +++ b/digitalai/release/integration/__init__.py @@ -1,4 +1,5 @@ from .base_task import BaseTask +from .api_base_task import ApiBaseTask from .input_context import InputContext from .output_context import OutputContext from .exceptions import AbortException diff --git a/digitalai/release/integration/api_base_task.py b/digitalai/release/integration/api_base_task.py new file mode 100644 index 0000000..ad6a1f4 --- /dev/null +++ b/digitalai/release/integration/api_base_task.py @@ -0,0 +1,528 @@ +from typing import Any, Dict, Type, TypeVar + +from digitalai.release.integration.base_task import BaseTask + +from digitalai.release.release_api_client import ReleaseAPIClient +from com.xebialabs.xlrelease.domain.folder import Folder +from com.xebialabs.xlrelease.domain.phase import Phase +from com.xebialabs.xlrelease.domain.release import Release +from com.xebialabs.xlrelease.domain.task import Task +from com.xebialabs.xlrelease.domain.variable import Variable + +from com.xebialabs.xlrelease.api.v1.activity_logs_api import ActivityLogsApi +from com.xebialabs.xlrelease.api.v1.application_api import ApplicationApi +from com.xebialabs.xlrelease.api.v1.archive_api import ArchiveApi +from com.xebialabs.xlrelease.api.v1.attachment_api import AttachmentApi +from com.xebialabs.xlrelease.api.v1.category_api import CategoryApi +from com.xebialabs.xlrelease.api.v1.configuration_api import ConfigurationApi +from com.xebialabs.xlrelease.api.v1.delivery_api import DeliveryApi +from com.xebialabs.xlrelease.api.v1.delivery_pattern_api import DeliveryPatternApi +from com.xebialabs.xlrelease.api.v1.dsl_api import DslApi +from com.xebialabs.xlrelease.api.v1.environment_api import EnvironmentApi +from com.xebialabs.xlrelease.api.v1.environment_label_api import EnvironmentLabelApi +from com.xebialabs.xlrelease.api.v1.environment_reservation_api import ( + EnvironmentReservationApi, +) +from com.xebialabs.xlrelease.api.v1.environment_stage_api import EnvironmentStageApi +from com.xebialabs.xlrelease.api.v1.folder_api import FolderApi +from com.xebialabs.xlrelease.api.v1.folder_versioning_api import FolderVersioningApi +from com.xebialabs.xlrelease.api.v1.permissions_api import PermissionsApi +from com.xebialabs.xlrelease.api.v1.phase_api import PhaseApi +from com.xebialabs.xlrelease.api.v1.release_api import ReleaseApi +from com.xebialabs.xlrelease.api.v1.report_api import ReportApi +from com.xebialabs.xlrelease.api.v1.risk_api import RiskApi +from com.xebialabs.xlrelease.api.v1.roles_api import RolesApi +from com.xebialabs.xlrelease.api.v1.search_api import SearchApi +from com.xebialabs.xlrelease.api.v1.settings_api import SettingsApi +from com.xebialabs.xlrelease.api.v1.task_api import TaskApi +from com.xebialabs.xlrelease.api.v1.task_reporting_api import TaskReportingApi +from com.xebialabs.xlrelease.api.v1.team_api import TeamApi +from com.xebialabs.xlrelease.api.v1.template_api import TemplateApi +from com.xebialabs.xlrelease.api.v1.triggers_api import TriggersApi +from com.xebialabs.xlrelease.api.v1.user_api import UserApi +from com.xebialabs.xlrelease.api.v1.variable_api import VariableApi + +T = TypeVar("T") + + +class ApiBaseTask(BaseTask): + """ + Base class for Release container tasks that need the v1 REST API. + + Subclass this instead of :class:`BaseTask` to get a ready-to-use, lazily + created instance of every ``com.xebialabs.xlrelease.api.v1`` wrapper as a + property (``releaseApi``, ``phaseApi``, ``taskApi``, ...). All wrappers + share a single, pre-configured :class:`ReleaseAPIClient` built from the + task's "Run as user" context (credentials + server URL), so the client and + each API object are created only once and only when first accessed. + + Example:: + + class MyTask(ApiBaseTask): + def execute(self) -> None: + release = self.releaseApi.getRelease(self.get_release_id()) + self.add_comment(f"Working on {release.title}") + """ + + def __init__(self): + super().__init__() + self._api_client: ReleaseAPIClient = None + self._api_instances: Dict[Type, object] = {} + + # -- client / instance management --------------------------------------- + + @property + def apiClient(self) -> ReleaseAPIClient: + """The shared :class:`ReleaseAPIClient`, created on first access.""" + if self._api_client is None: + self._api_client = self.get_release_api_client() + return self._api_client + + def _api(self, api_class: Type[T]) -> T: + """Return a cached instance of ``api_class``, creating it on first use.""" + instance = self._api_instances.get(api_class) + if instance is None: + instance = api_class(self.apiClient) + self._api_instances[api_class] = instance + return instance + + def reset_api_clients(self) -> None: + """Drop the cached client and API instances (e.g. to re-authenticate).""" + self._api_client = None + self._api_instances = {} + + # -- API wrappers ------------------------------------------------------- + + @property + def activityLogsApi(self) -> ActivityLogsApi: + return self._api(ActivityLogsApi) + + @property + def applicationApi(self) -> ApplicationApi: + return self._api(ApplicationApi) + + @property + def archiveApi(self) -> ArchiveApi: + return self._api(ArchiveApi) + + @property + def attachmentApi(self) -> AttachmentApi: + return self._api(AttachmentApi) + + @property + def categoryApi(self) -> CategoryApi: + return self._api(CategoryApi) + + @property + def configurationApi(self) -> ConfigurationApi: + return self._api(ConfigurationApi) + + @property + def deliveryApi(self) -> DeliveryApi: + return self._api(DeliveryApi) + + @property + def deliveryPatternApi(self) -> DeliveryPatternApi: + return self._api(DeliveryPatternApi) + + @property + def dslApi(self) -> DslApi: + return self._api(DslApi) + + @property + def environmentApi(self) -> EnvironmentApi: + return self._api(EnvironmentApi) + + @property + def environmentLabelApi(self) -> EnvironmentLabelApi: + return self._api(EnvironmentLabelApi) + + @property + def environmentReservationApi(self) -> EnvironmentReservationApi: + return self._api(EnvironmentReservationApi) + + @property + def environmentStageApi(self) -> EnvironmentStageApi: + return self._api(EnvironmentStageApi) + + @property + def folderApi(self) -> FolderApi: + return self._api(FolderApi) + + @property + def folderVersioningApi(self) -> FolderVersioningApi: + return self._api(FolderVersioningApi) + + @property + def permissionsApi(self) -> PermissionsApi: + return self._api(PermissionsApi) + + @property + def phaseApi(self) -> PhaseApi: + return self._api(PhaseApi) + + @property + def releaseApi(self) -> ReleaseApi: + return self._api(ReleaseApi) + + @property + def reportApi(self) -> ReportApi: + return self._api(ReportApi) + + @property + def riskApi(self) -> RiskApi: + return self._api(RiskApi) + + @property + def rolesApi(self) -> RolesApi: + return self._api(RolesApi) + + @property + def searchApi(self) -> SearchApi: + return self._api(SearchApi) + + @property + def settingsApi(self) -> SettingsApi: + return self._api(SettingsApi) + + @property + def taskApi(self) -> TaskApi: + return self._api(TaskApi) + + @property + def taskReportingApi(self) -> TaskReportingApi: + return self._api(TaskReportingApi) + + @property + def teamApi(self) -> TeamApi: + return self._api(TeamApi) + + @property + def templateApi(self) -> TemplateApi: + return self._api(TemplateApi) + + @property + def triggersApi(self) -> TriggersApi: + return self._api(TriggersApi) + + @property + def userApi(self) -> UserApi: + return self._api(UserApi) + + @property + def variableApi(self) -> VariableApi: + return self._api(VariableApi) + + # -- current-context helpers -------------------------------------------- + # Convenience methods that resolve the release object the task is running + # in, mirroring the helpers the Jython script API offers on the server + # (XLReleaseApi.py). Each derives the relevant id from the task's context + # and fetches the object through the matching API wrapper above. + + def getCurrentTask(self) -> Task: + """ + Return the task that is running this code. + + Fetches the task via ``taskApi`` using the task's own id. + """ + return self.taskApi.getTask(self.get_task_id()) + + def getCurrentPhase(self) -> Phase: + """ + Return the phase that contains this task. + + The phase id is derived from the task id, then fetched via ``phaseApi``. + """ + return self.phaseApi.getPhase(self.get_phase_id()) + + def getCurrentRelease(self) -> Release: + """ + Return the release this task belongs to. + + Fetches the release via ``releaseApi`` using the task's release id, so + the caller does not need to know or substitute the id itself. + """ + return self.releaseApi.getRelease(self.get_release_id()) + + def getCurrentFolder(self) -> Folder: + """ + Return the folder that contains the current release. + + The folder id is derived from the release id, then fetched via + ``folderApi``. + """ + return self.folderApi.getFolder(self.get_folder_id()) + + def getReleaseVariable(self, name: str) -> Any: + """ + Return the value of a variable in the current release by name. + + The Python3 equivalent of the Jython script global + ``releaseVariables[name]``. Pass the bare variable name (e.g. + ``"JenkinsBuildNumber"``). The variable is looked up by its ``key`` via + ``releaseApi.getVariables`` and its stored value returned as-is. + + :param name: the variable name (e.g. ``"JenkinsBuildNumber"``). + :return: the variable's value. + :raises KeyError: if the release has no variable with that name. + """ + variables = self.releaseApi.getVariables(self.get_release_id()) + variable = next((v for v in variables if v.key == name), None) + if variable is None: + raise KeyError( + f"No variable named {name} in the current release; " + f"available: {sorted(v.key for v in variables)}") + return variable.value + + def setReleaseVariable(self, name: str, value: Any) -> Variable: + """ + Set the value of a variable in the current release by name. + + The Python3 equivalent of the Jython script assignment + ``releaseVariables[name] = value``. Pass the bare variable name (e.g. + ``"JenkinsBuildNumber"``). The variable is looked up by its ``key`` in + the current release; if it exists its value is persisted via + ``releaseApi.updateVariable``, otherwise a new variable is created via + ``releaseApi.createVariable``. The new variable's type is inferred from + ``value`` (see :meth:`_variable_type_for_value`). + + :param name: the variable name (e.g. ``"JenkinsBuildNumber"``). + :param value: the new value to assign. + :return: the updated (or newly created) variable. + """ + release_id = self.get_release_id() + variables = self.releaseApi.getVariables(release_id) + variable = next((v for v in variables if v.key == name), None) + if variable is None: + return self.releaseApi.createVariable( + release_id, self._new_variable(name, value)) + variable.value = self._coerce_value(value) + return self.releaseApi.updateVariable(variable.id, variable) + + def getFolderVariable(self, name: str) -> Any: + """ + Return the value of a variable in the current folder by name. + + Like :meth:`getReleaseVariable`, but scoped to the folder that contains + the current release. Inherited variables (from parent folders and global + variables) are included, mirroring what a release actually resolves. + + Folder variables are stored with a ``folder.`` prefix, which is + required here: pass the fully qualified name (e.g. ``"folder.foo"``). + + :param name: the variable name, including the ``folder.`` prefix. + :return: the variable's value. + :raises ValueError: if ``name`` does not start with ``folder.``. + :raises KeyError: if no such variable is visible to the folder. + """ + key = self._folder_key(name) + variables = self.folderApi.listVariables(self.get_folder_id()) + variable = next((v for v in variables if v.key == key), None) + if variable is None: + raise KeyError( + f"No variable named {key} visible to the current folder; " + f"available: {sorted(v.key for v in variables)}") + return variable.value + + def setFolderVariable(self, name: str, value: Any) -> Variable: + """ + Set the value of a variable owned by the current folder by name. + + Only variables the folder owns are matched: an inherited variable + (defined on a parent folder or as a global variable) is not. If the + folder owns the variable its value is persisted via + ``folderApi.updateVariable``; otherwise a new folder-owned variable is + created via ``folderApi.createVariable``. The new variable's type is + inferred from ``value`` (see :meth:`_variable_type_for_value`). Creating + a variable whose name matches an inherited one yields a folder-owned + variable that shadows the inherited value. + + The ``folder.`` prefix is required (see :meth:`getFolderVariable`). + + :param name: the variable name, including the ``folder.`` prefix. + :param value: the new value to assign. + :return: the updated (or newly created) variable. + :raises ValueError: if ``name`` does not start with ``folder.``. + """ + folder_id = self.get_folder_id() + key = self._folder_key(name) + variables = self.folderApi.listVariables(folder_id, folderOnly=True) + variable = next((v for v in variables if v.key == key), None) + if variable is None: + return self.folderApi.createVariable( + folder_id, self._new_variable(key, value)) + variable.value = self._coerce_value(value) + return self.folderApi.updateVariable(folder_id, variable.id, variable) + + def getGlobalVariable(self, name: str) -> Any: + """ + Return the value of a global variable by name. + + Global variables are stored with a ``global.`` prefix, which is + required here: pass the fully qualified name (e.g. ``"global.foo"``). + + :param name: the global variable name, including the ``global.`` prefix. + :return: the variable's value. + :raises ValueError: if ``name`` does not start with ``global.``. + :raises KeyError: if no global variable with that name exists. + """ + key = self._global_key(name) + variables = self.configurationApi.getGlobalVariables() + variable = next((v for v in variables if v.key == key), None) + if variable is None: + raise KeyError( + f"No global variable named {key}; " + f"available: {sorted(v.key for v in variables)}") + return variable.value + + def setGlobalVariable(self, name: str, value: Any) -> Variable: + """ + Set the value of a global variable by name. + + The ``global.`` prefix is required (see :meth:`getGlobalVariable`). If + the variable exists its value is persisted via + ``configurationApi.updateGlobalVariable``; otherwise a new global + variable is created via ``configurationApi.addGlobalVariable``, with its + type inferred from ``value`` (see :meth:`_variable_type_for_value`). The + task's run-as user must hold the permission to edit global variables. + + :param name: the global variable name, including the ``global.`` prefix. + :param value: the new value to assign. + :return: the updated (or newly created) variable. + :raises ValueError: if ``name`` does not start with ``global.``. + """ + key = self._global_key(name) + variables = self.configurationApi.getGlobalVariables() + variable = next((v for v in variables if v.key == key), None) + if variable is None: + return self.configurationApi.addGlobalVariable( + self._new_variable(key, value)) + variable.value = self._coerce_value(value) + return self.configurationApi.updateGlobalVariable(variable.id, variable) + + @staticmethod + def _global_key(name: str) -> str: + """ + Validate and return the stored ``key`` of a global variable. + + The ``global.`` prefix is required: ``name`` is returned unchanged, but + a :class:`ValueError` is raised when it is missing. + """ + if not name.startswith("global."): + raise ValueError( + f"Global variable name must include the 'global.' prefix; " + f"got {name!r}.") + return name + + @staticmethod + def _folder_key(name: str) -> str: + """ + Validate and return the stored ``key`` of a folder variable. + + The ``folder.`` prefix is required: ``name`` is returned unchanged, but + a :class:`ValueError` is raised when it is missing. + """ + if not name.startswith("folder."): + raise ValueError( + f"Folder variable name must include the 'folder.' prefix; " + f"got {name!r}.") + return name + + @staticmethod + def _coerce_value(value: Any) -> Any: + """ + Return ``value`` in a JSON-serializable form for a variable payload. + + Sets and tuples (natural Python types for a set-of-string variable) are + converted to lists; everything else is passed through unchanged. + """ + if isinstance(value, (set, tuple)): + return list(value) + return value + + @staticmethod + def _variable_type_for_value(value: Any) -> str: + """ + Infer the Release variable ``type`` to use for a new variable from the + Python type of ``value``. + + ==================== ====================================== + Python value Variable type + ==================== ====================================== + ``bool`` ``xlrelease.BooleanVariable`` + ``int`` ``xlrelease.IntegerVariable`` + ``dict`` ``xlrelease.MapStringStringVariable`` + ``list``/``set``/ ``xlrelease.SetStringVariable`` + ``tuple`` + anything else ``xlrelease.StringVariable`` + ==================== ====================================== + + ``bool`` is checked before ``int`` because ``bool`` is a subclass of + ``int`` in Python. + """ + if isinstance(value, bool): + return "xlrelease.BooleanVariable" + if isinstance(value, int): + return "xlrelease.IntegerVariable" + if isinstance(value, dict): + return "xlrelease.MapStringStringVariable" + if isinstance(value, (list, set, tuple)): + return "xlrelease.SetStringVariable" + return "xlrelease.StringVariable" + + @classmethod + def _new_variable(cls, key: str, value: Any) -> Variable: + """ + Build a :class:`Variable` to create for ``key``/``value``, inferring the + ``type`` from ``value`` and coercing the value to a serializable form. + """ + return Variable( + type=cls._variable_type_for_value(value), + key=key, + value=cls._coerce_value(value), + requiresValue=False, + showOnReleaseStart=False, + ) + + def getTasksByTitle(self, taskTitle: str, phaseTitle: str | None = None, + releaseId: str | None = None) -> list[Task]: + """ + Find tasks by title. + + :param taskTitle: the task title to search for. + :param phaseTitle: optional phase title to scope the search; searches the + whole release when omitted. + :param releaseId: optional release to search; the current release when + omitted. + :return: the matching tasks. + """ + return self.taskApi.searchTasksByTitle( + taskTitle, releaseId or self.get_release_id(), phaseTitle) + + def getPhasesByTitle(self, phaseTitle: str, + releaseId: str | None = None) -> list[Phase]: + """ + Find phases by title. + + :param phaseTitle: the phase title to search for. + :param releaseId: optional release to search; the current release when + omitted. + :return: the matching phases. + """ + return self.phaseApi.searchPhasesByTitle( + phaseTitle, releaseId or self.get_release_id()) + + def getReleasesByTitle(self, releaseTitle: str) -> list[Release]: + """ + Find releases by title. + + :param releaseTitle: the release title to search for. + :return: the matching releases. + """ + return self.releaseApi.searchReleasesByTitle(releaseTitle) + + def getVersion(self) -> str | None: + """ + Return the version of this Digital.ai Release instance. + """ + return self.settingsApi.getInstanceInformation().get('version') diff --git a/digitalai/release/integration/base_task.py b/digitalai/release/integration/base_task.py index 64e17b0..8164774 100644 --- a/digitalai/release/integration/base_task.py +++ b/digitalai/release/integration/base_task.py @@ -1,11 +1,11 @@ -import logging import sys from abc import ABC, abstractmethod -from typing import Any, Dict +from typing import Any, Dict, Optional from .input_context import AutomatedTaskAsUserContext from .output_context import OutputContext from .exceptions import AbortException +from .ids import Ids from .logger import dai_logger from digitalai.release.release_api_client import ReleaseAPIClient @@ -128,10 +128,13 @@ def get_release_server_url(self) -> str: """ return self.release_server_url - def get_task_user(self) -> AutomatedTaskAsUserContext: + def get_task_user(self) -> Optional[AutomatedTaskAsUserContext]: """ - Returns the user details that are executing the task. + Returns the user details that are executing the task, or ``None`` when no + release context is available. """ + if not self.release_context: + return None return self.release_context.automated_task_as_user def get_release_id(self) -> str: @@ -146,23 +149,70 @@ def get_task_id(self) -> str: """ return self.task_id - def get_release_api_client(self) -> ReleaseAPIClient: + def get_phase_id(self) -> str: """ - Returns a ReleaseAPIClient object with default configuration based on the task. + Returns the Phase ID of the task, derived from the task id. """ - self._validate_api_credentials() - return ReleaseAPIClient(self.get_release_server_url(), - self.get_task_user().username, - self.get_task_user().password) + return Ids.phase_id_from(self.get_task_id()) - def _validate_api_credentials(self) -> None: + def get_folder_id(self) -> str: + """ + Returns the ID of the folder that contains the release, derived from the + release id. + """ + return Ids.find_folder_id(self.get_release_id()) + + def get_release_api_client(self, + server_address: str = None, + username: str = None, + password: str = None, + personal_access_token: str = None, + timeout: float | tuple[float, float] | None = None, + **kwargs) -> ReleaseAPIClient: + """ + Returns a ReleaseAPIClient object. + + All arguments are optional. When omitted, the client is configured from the + task context (server URL and the 'Run as user' credentials). Any argument + that is provided overrides the corresponding task default. + + :param server_address: Optional Release server URL. Defaults to the task's server URL. + :param username: Optional username. Defaults to the task user's username. + :param password: Optional password. Defaults to the task user's password. + :param personal_access_token: Optional personal access token for authentication. + :param timeout: Optional default timeout (in seconds) applied to every request. + Accepts a single float or a (connect, read) tuple. Can be overridden per call. + :param kwargs: Additional session parameters (e.g., headers). + """ + task_user = self.get_task_user() + server_address = server_address or self.get_release_server_url() + + if personal_access_token: + if not server_address: + raise ValueError( + "Cannot connect to Release API without server URL. " + "Make sure that the release server URL is available." + ) + return ReleaseAPIClient(server_address, + personal_access_token=personal_access_token, + timeout=timeout, + **kwargs) + + username = username or (task_user and task_user.username) + password = password or (task_user and task_user.password) + self._validate_api_credentials(server_address, username, password) + return ReleaseAPIClient(server_address, username, password, timeout=timeout, **kwargs) + + def _validate_api_credentials(self, server_address: str = None, + username: str = None, password: str = None) -> None: """ Validates that the necessary credentials are available for connecting to the Release API. """ + task_user = self.get_task_user() if not all([ - self.get_release_server_url(), - self.get_task_user().username, - self.get_task_user().password + server_address or self.get_release_server_url(), + username or (task_user and task_user.username), + password or (task_user and task_user.password) ]): raise ValueError( "Cannot connect to Release API without server URL, username, or password. " diff --git a/digitalai/release/integration/ids.py b/digitalai/release/integration/ids.py new file mode 100644 index 0000000..8ce1576 --- /dev/null +++ b/digitalai/release/integration/ids.py @@ -0,0 +1,52 @@ +""" +Helpers for parsing Release object ids. + +Release object ids are slash-separated paths, e.g. + Applications/Folder.../Release.../Phase.../Task... +The server derives the enclosing phase/folder by walking up that path (see +com.xebialabs.xlrelease.repository.Ids). A task only receives its own task and +release ids, so we reproduce the same walk to resolve the phase and folder. +""" + +_ID_SEPARATOR = '/' +_PHASE_PREFIX = 'Phase' +_FOLDER_PREFIX = 'Folder' + + +class Ids: + """Path-based parsing of Release object ids (mirrors the server's Ids).""" + + @staticmethod + def segment_name(object_id: str) -> str: + """Return the last path segment of an id (Ids.getName).""" + if _ID_SEPARATOR not in object_id: + return object_id + return object_id[object_id.rfind(_ID_SEPARATOR) + 1:] + + @staticmethod + def parent_id(object_id: str) -> str: + """Return the id with its last path segment removed (Ids.getParentId).""" + return object_id[:object_id.rfind(_ID_SEPARATOR)] + + @staticmethod + def is_root(object_id: str) -> bool: + """True when the id has no parent, i.e. no separator (Ids.isRoot).""" + return _ID_SEPARATOR not in object_id + + @staticmethod + def phase_id_from(object_id: str) -> str: + """Return the enclosing phase id of ``object_id`` (Ids.phaseIdFrom).""" + ancestry = object_id + while not Ids.segment_name(ancestry).startswith(_PHASE_PREFIX): + if Ids.is_root(ancestry): + raise ValueError(f"No phase found in id '{object_id}'") + ancestry = Ids.parent_id(ancestry) + return ancestry + + @staticmethod + def find_folder_id(object_id: str) -> str: + """Return the enclosing folder id of ``object_id`` (Ids.findFolderId).""" + parent = object_id + while not Ids.segment_name(parent).startswith(_FOLDER_PREFIX) and not Ids.is_root(parent): + parent = Ids.parent_id(parent) + return parent diff --git a/digitalai/release/integration/k8s.py b/digitalai/release/integration/k8s.py index a89761e..09f11a3 100644 --- a/digitalai/release/integration/k8s.py +++ b/digitalai/release/integration/k8s.py @@ -43,7 +43,20 @@ def get_client(): def split_secret_resource_data(secret_entry: str) -> tuple: + """ + Split a ``namespace:name:key`` secret reference into its three parts. + + Raises: + ValueError: if ``secret_entry`` is empty or not in the expected + ``namespace:name:key`` form. Returning blanks silently here only + surfaces later as a confusing Kubernetes API error. + """ + if not secret_entry: + raise ValueError("Secret resource reference is empty") split = secret_entry.split(":") if len(split) != 3: - return "", "", "" + raise ValueError( + f"Invalid secret resource reference '{secret_entry}', " + f"expected format 'namespace:name:key'" + ) return tuple(split) diff --git a/digitalai/release/integration/masked_io.py b/digitalai/release/integration/masked_io.py index a4b2ff6..ce47226 100644 --- a/digitalai/release/integration/masked_io.py +++ b/digitalai/release/integration/masked_io.py @@ -45,9 +45,14 @@ def write(self, s): Args: s (str): The string to write. + + Returns: + int: The number of characters of the original string written, as + required by the ``TextIOBase.write`` contract. """ d = s for secret in self.secrets: if secret: - d = d.replace(secret, '********') + d = d.replace(str(secret), '********') self.buffer.write(d) + return len(s) diff --git a/digitalai/release/integration/watcher.py b/digitalai/release/integration/watcher.py index a60a7a1..f6ea3c1 100644 --- a/digitalai/release/integration/watcher.py +++ b/digitalai/release/integration/watcher.py @@ -24,24 +24,34 @@ def start_input_context_watcher(on_input_context_update_func): def start_input_secret_watcher(on_input_context_update_func, stop): dai_logger.info("Input secret watcher started") - kubernetes_client = k8s.get_client() - field_selector = "metadata.name=" + os.getenv("INPUT_CONTEXT_SECRET") + secret_name = os.getenv("INPUT_CONTEXT_SECRET") namespace = os.getenv("RUNNER_NAMESPACE") + if not secret_name or not namespace: + raise ValueError( + "INPUT_CONTEXT_SECRET and RUNNER_NAMESPACE must be set to watch the input context secret" + ) + + kubernetes_client = k8s.get_client() + field_selector = "metadata.name=" + secret_name old_session_key = None w = watch.Watch() - for event in w.stream(kubernetes_client.list_namespaced_secret, namespace, field_selector=field_selector): - secret = event['object'] - new_session_key = secret.data.get("session-key") - - # Checking if 'session-key' field has changed - if old_session_key and old_session_key != new_session_key: - dai_logger.info("Detected input context value change") - on_input_context_update_func() - - # Set old session-key value - old_session_key = new_session_key - - # Check if the watcher should be stopped - if stop.is_set(): - break + try: + for event in w.stream(kubernetes_client.list_namespaced_secret, namespace, field_selector=field_selector): + secret = event['object'] + new_session_key = secret.data.get("session-key") if secret.data else None + + # Checking if 'session-key' field has changed + if old_session_key and old_session_key != new_session_key: + dai_logger.info("Detected input context value change") + on_input_context_update_func() + + # Set old session-key value + old_session_key = new_session_key + + # Check if the watcher should be stopped + if stop.is_set(): + break + finally: + # Always release the underlying streaming connection. + w.stop() diff --git a/digitalai/release/integration/wrapper.py b/digitalai/release/integration/wrapper.py index 52761eb..a9898c5 100644 --- a/digitalai/release/integration/wrapper.py +++ b/digitalai/release/integration/wrapper.py @@ -8,9 +8,9 @@ import signal import sys import time +from typing import Any, Dict, Optional, Tuple import requests -import urllib3 from digitalai.release.integration import k8s, watcher from .base_task import BaseTask @@ -37,10 +37,20 @@ runner_namespace: str = os.getenv('RUNNER_NAMESPACE', '') execution_mode: str = os.getenv('EXECUTOR_EXECUTION_MODE', '') -input_context: InputContext = None +input_context: Optional[InputContext] = None size_of_1Mb = 1024 * 1024 +# HTTP timeouts (seconds). A missing timeout lets a hung server stall the runner forever. +HTTP_CONNECT_TIMEOUT = float(os.getenv('HTTP_CONNECT_TIMEOUT', '10')) +HTTP_READ_TIMEOUT = float(os.getenv('HTTP_READ_TIMEOUT', '60')) + +# A single Session reused across all callback requests so the underlying urllib3 +# connection pool is shared (instead of opening a fresh connection per call). +_http_session: requests.Session = requests.Session() +_HTTP_TIMEOUT = (HTTP_CONNECT_TIMEOUT, HTTP_READ_TIMEOUT) + + # Create the encryptor def get_encryptor(): if base64_session_key: @@ -51,7 +61,7 @@ def get_encryptor(): # Initialize the global task object -dai_task_object: BaseTask = None +dai_task_object: Optional[BaseTask] = None def abort_handler(signum, frame): @@ -73,7 +83,24 @@ def abort_handler(signum, frame): signal.signal(signal.SIGTERM, abort_handler) -def get_task_details(): +def _post_callback(url: str, encrypted_json) -> requests.Response: + """ + POST the encrypted result to the callback URL using the shared Session. + + Raises an exception on transport errors *and* on HTTP error status codes + (>= 400), so that the caller's retry logic is triggered in both cases. + """ + response = _http_session.post( + url, + headers={'Content-Type': 'application/json'}, + data=encrypted_json, + timeout=_HTTP_TIMEOUT, + ) + response.raise_for_status() + return response + + +def get_task_details() -> Tuple[Dict[str, Any], str, str]: """ Get the task details by reading the input context file or fetching from secret, decrypting the contents using the encryptor, and parsing the JSON data into an InputContext object. Then, set the secrets for the masked standard output @@ -84,40 +111,35 @@ def get_task_details(): dai_logger.info("Reading input context from file") with open(input_context_file) as data_input: input_content = data_input.read() - #dai_logger.info("Successfully loaded input context from file") else: k8s_client = k8s.get_client() dai_logger.info("Reading input context from secret") - secret =k8s_client.read_namespaced_secret(input_context_secret, runner_namespace) - #dai_logger.info("Successfully loaded input context from secret") + secret = k8s_client.read_namespaced_secret(input_context_secret, runner_namespace) global base64_session_key, callback_url base64_session_key = base64.b64decode(secret.data["session-key"]) callback_url = base64.b64decode(secret.data["url"]) input_content = secret.data["input"] - if not input_content or len(input_content) == 0: + if not input_content: fetch_url_base64 = secret.data["fetchUrl"] - if not fetch_url_base64 or len(fetch_url_base64) == 0: + if not fetch_url_base64: raise ValueError("Cannot find fetch URL for task") + # The fetch URL is double base64 encoded in the secret. fetch_url_bytes = base64.b64decode(fetch_url_base64) fetch_url = base64.b64decode(fetch_url_bytes).decode("UTF-8") try: - response = requests.get(fetch_url) + response = _http_session.get(fetch_url, timeout=_HTTP_TIMEOUT) response.raise_for_status() except requests.exceptions.RequestException as e: dai_logger.error("Failed to fetch data.", exc_info=True) raise e - if response.status_code != 200: - raise ValueError(f"Failed to fetch data, server returned status: {response.status_code}") - input_content = response.content else: input_content = base64.b64decode(input_content) decrypted_json = get_encryptor().decrypt(input_content) - #dai_logger.info("Successfully decrypted input context") global input_context input_context = InputContext.from_dict(json.loads(decrypted_json)) secrets = input_context.task.secrets() @@ -156,8 +178,7 @@ def update_output_context(output_context: OutputContext): dai_logger.info("Pushing result using HTTP") url = base64.b64decode(callback_url).decode("UTF-8") try: - urllib3.PoolManager().request("POST", url, headers={'Content-Type': 'application/json'}, - body=encrypted_json) + _post_callback(url, encrypted_json) except Exception: if should_retry_callback_request(encrypted_json): dai_logger.error("Cannot finish Callback request.", exc_info=True) @@ -180,29 +201,25 @@ def retry_push_result_infinitely(encrypted_json): backoff_factor = 2.0 while True: - try: - # If we can't read the secret, we should fail fast - secret = k8s.get_client().read_namespaced_secret(input_context_secret, runner_namespace) - except Exception: - raise + # If we can't read the secret we should fail fast (let the exception propagate). + secret = k8s.get_client().read_namespaced_secret(input_context_secret, runner_namespace) try: - callback_url = base64.b64decode(secret.data["url"]) - url = base64.b64decode(callback_url).decode("UTF-8") - response = urllib3.PoolManager().request("POST", url, headers={'Content-Type': 'application/json'}, body=encrypted_json) - return response + current_callback_url = base64.b64decode(secret.data["url"]) + url = base64.b64decode(current_callback_url).decode("UTF-8") + return _post_callback(url, encrypted_json) except Exception as e: dai_logger.warning(f"Cannot finish retried Callback request: {e}. Retrying in {retry_delay} seconds...") time.sleep(retry_delay) retry_delay = min(retry_delay * backoff_factor, max_backoff) -def should_retry_callback_request(encrypted_data): +def should_retry_callback_request(encrypted_data) -> bool: """ Checks if callback request should be retried on failure. It should be retried when result is too big for Secret and Output File handler is not used. """ - return len(encrypted_data) >= size_of_1Mb and len(input_context_file) == 0 + return len(encrypted_data) >= size_of_1Mb and not input_context_file def execute_task(task_object: BaseTask): @@ -219,7 +236,14 @@ def execute_task(task_object: BaseTask): except Exception: dai_logger.error("Unexpected error occurred.", exc_info=True) finally: - update_output_context(dai_task_object.get_output_context()) + # Guard against a task object that was never constructed or that failed + # before producing an output context, so the finally block does not raise + # a second exception that masks the original one. + if dai_task_object is not None and dai_task_object.get_output_context() is not None: + update_output_context(dai_task_object.get_output_context()) + else: + dai_logger.error("No output context available to report task result") + update_output_context(OutputContext(1, "Task produced no output context", {}, [])) def find_class_file(root_dir, class_name): @@ -227,11 +251,15 @@ def find_class_file(root_dir, class_name): for filename in files: if filename.endswith('.py'): filepath = os.path.join(root, filename) - with open(filepath) as file: - node = ast.parse(file.read()) - classes = [n.name for n in node.body if isinstance(n, ast.ClassDef)] - if class_name in classes: - return filepath + try: + with open(filepath, encoding="utf-8") as file: + node = ast.parse(file.read()) + except (SyntaxError, UnicodeDecodeError, OSError): + # Skip files that cannot be read or parsed instead of aborting the whole search. + continue + classes = [n.name for n in node.body if isinstance(n, ast.ClassDef)] + if class_name in classes: + return filepath return None @@ -240,6 +268,8 @@ def run(): # Get task details, parse the script file to get the task class, import the module, # create an instance of the task class, and execute the task task_props, task_type, script_path = get_task_details() + if "." not in task_type: + raise ValueError(f"Invalid task type '{task_type}', expected format '.'") task_class_name = task_type.split(".")[1] if script_path: diff --git a/digitalai/release/release_api_client.py b/digitalai/release/release_api_client.py index 8969057..1701c75 100644 --- a/digitalai/release/release_api_client.py +++ b/digitalai/release/release_api_client.py @@ -1,96 +1,17 @@ -import requests +from com.xebialabs.xlrelease.release_api_client import ( + ReleaseAPIClient as _ReleaseAPIClient, +) -class ReleaseAPIClient: +class ReleaseAPIClient(_ReleaseAPIClient): """ - A client for interacting with the Release API. - Supports authentication via username/password or personal access token. + Backwards-compatible import path for ``ReleaseAPIClient``. + + The implementation now lives in the standalone + ``digitalai-release-api-client`` package at + ``com.xebialabs.xlrelease.release_api_client``. This thin subclass keeps the + original ``digitalai.release.release_api_client.ReleaseAPIClient`` import + path working, so existing integrations continue to run unchanged. Class + name, constructor signature, methods, and behavior are identical to the + base class. """ - - def __init__(self, server_address, username=None, password=None, personal_access_token=None, **kwargs): - """ - Initializes the API client. - - :param server_address: Base URL of the Release API server. - :param username: Optional username for basic authentication. - :param password: Optional password for basic authentication. - :param personal_access_token: Optional personal access token for authentication. - :param kwargs: Additional session parameters (e.g., headers, timeout). - """ - if not server_address: - raise ValueError("server_address must not be empty.") - - self.server_address = server_address.rstrip('/') # Remove trailing slash if present - self.session = requests.Session() - self.session.headers.update({"Accept": "application/json"}) - - # Set authentication method - if username and password: - self.session.auth = (username, password) - elif personal_access_token: - self.session.headers.update({"x-release-personal-token": personal_access_token}) - else: - raise ValueError("Either username and password or a personal access token must be provided.") - - # Apply additional session configurations - for key, value in kwargs.items(): - if key == 'headers': - self.session.headers.update(value) # Merge custom headers - elif hasattr(self.session, key) and key != 'auth': # Skip 'auth' key - setattr(self.session, key, value) - - def _request(self, method, endpoint, params=None, json=None, data=None, **kwargs): - """ - Internal method to send an HTTP request. - - :param method: HTTP method (GET, POST, PUT, DELETE, PATCH). - :param endpoint: API endpoint (relative path). - :param params: Optional query parameters. - :param json: Optional JSON payload. - :param data: Optional raw data payload. - :param kwargs: Additional request options. - :return: Response object. - """ - if not endpoint: - raise ValueError("Endpoint must not be empty.") - - kwargs.pop('auth', None) # Remove 'auth' key if present to avoid conflicts - url = f"{self.server_address}/{endpoint.lstrip('/')}" # Construct full URL - - response = self.session.request( - method, url, params=params, data=data, json=json, **kwargs - ) - - return response - - def get(self, endpoint, params=None, **kwargs): - """Sends a GET request to the specified endpoint.""" - return self._request("GET", endpoint, params=params, **kwargs) - - def post(self, endpoint, json=None, data=None, **kwargs): - """Sends a POST request to the specified endpoint.""" - return self._request("POST", endpoint, data=data, json=json, **kwargs) - - def put(self, endpoint, json=None, data=None, **kwargs): - """Sends a PUT request to the specified endpoint.""" - return self._request("PUT", endpoint, data=data, json=json, **kwargs) - - def delete(self, endpoint, params=None, **kwargs): - """Sends a DELETE request to the specified endpoint.""" - return self._request("DELETE", endpoint, params=params, **kwargs) - - def patch(self, endpoint, json=None, data=None, **kwargs): - """Sends a PATCH request to the specified endpoint.""" - return self._request("PATCH", endpoint, data=data, json=json, **kwargs) - - def close(self): - """Closes the session.""" - self.session.close() - - def __enter__(self): - """Enables the use of 'with' statements for automatic resource management.""" - return self - - def __exit__(self, exc_type, exc_value, traceback): - """Ensures the session is closed when exiting a 'with' block.""" - self.close() \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7f36f13 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ +# Digital.ai Release Python SDK โ€” Documentation + +Reference documentation for the **Digital.ai Release Python SDK** +(`digitalai-release-sdk`) โ€” the toolkit for building container-based +integration tasks for Digital.ai Release. You define a task by subclassing a +base class and implementing `execute`; the SDK provides built-in helpers to read +inputs, write outputs, report progress, and call the Release REST API. + +## Examples + +New here? See **[Examples](examples.md)** for task-oriented, copy-pasteable +snippets covering defining tasks, handling inputs/outputs, and calling the +Release API. + +## Base Classes + +Subclass one of these to build a task. `ApiBaseTask` extends `BaseTask`, adding +ready-to-use Release API access. + +| Name | Description | Documentation | +|------|-------------|---------------| +| `BaseTask` | Abstract base class for a task that can be executed. Implement `execute` and use the input/output and reporting helpers. | [classes/base_task.md](classes/base_task.md) | +| `ApiBaseTask` | Extends `BaseTask` with a ready-to-use, cached client and every `com.xebialabs.xlrelease.api.v1` wrapper exposed as a property. | [classes/api_base_task.md](classes/api_base_task.md) | + +## Related + +- **[Release API Python Client](https://pypi.org/project/digitalai-release-api-client/)** โ€” the API wrappers (`releaseApi`, `taskApi`, โ€ฆ) and domain models (`Task`, `Release`, `Variable`, โ€ฆ) returned by [`ApiBaseTask`](classes/api_base_task.md) are provided by this dependency, installed automatically with the SDK. +- **[Python SDK Documentation](https://docs.digital.ai/release/docs/how-to/overview-python-sdk)** โ€” guide to using the SDK and building custom tasks. +- **[Integration Template Project](https://github.com/digital-ai/release-integration-template-python)** โ€” a starting point for building custom integrations. diff --git a/docs/classes/api_base_task.md b/docs/classes/api_base_task.md new file mode 100644 index 0000000..9fbee35 --- /dev/null +++ b/docs/classes/api_base_task.md @@ -0,0 +1,331 @@ +[๐Ÿ  Docs Home](../README.md) โ€บ [Base Classes](../README.md#base-classes) โ€บ **ApiBaseTask** + +# ApiBaseTask + +> Base class for Release container tasks that need the v1 REST API. + +| | | +|---|---| +| **Class** | `ApiBaseTask` | +| **Extends** | [`BaseTask`](base_task.md) | +| **Module** | `digitalai.release.integration.api_base_task` | +| **Source** | [`digitalai/release/integration/api_base_task.py`](../../digitalai/release/integration/api_base_task.py) | +| **Properties** | 31 | +| **Methods** | 16 | + +Subclass `ApiBaseTask` instead of [`BaseTask`](base_task.md) to get a +ready-to-use, lazily created instance of every `com.xebialabs.xlrelease.api.v1` +wrapper as a property (`releaseApi`, `phaseApi`, `taskApi`, โ€ฆ). All wrappers +share a single, pre-configured `ReleaseAPIClient` built from the task's "Run as +user" context (credentials + server URL), so the client and each API object are +created only once, and only when first accessed. + +Because it extends `BaseTask`, every method and attribute documented in +[BaseTask](base_task.md) is also available here. + +```python +from digitalai.release.integration.api_base_task import ApiBaseTask + + +class MyTask(ApiBaseTask): + def execute(self) -> None: + release = self.releaseApi.getRelease(self.get_release_id()) + self.add_comment(f"Working on {release.title}") +``` + +> **Note:** The API wrappers and the domain models they return (`Task`, `Phase`, +> `Release`, `Folder`, `Variable`, โ€ฆ) are provided by the +> [`digitalai-release-api-client`](https://pypi.org/project/digitalai-release-api-client/) +> package, which the SDK depends on. + +--- + +## API Properties + +Each property returns a cached API wrapper, created on first access and bound to +the shared [`apiClient`](#apiclient). See the API client reference for the +methods each wrapper exposes. + +| Property | Type | Description | +|----------|------|-------------| +| [`apiClient`](#apiclient) | `ReleaseAPIClient` | The shared client, created on first access from the task context. | +| `activityLogsApi` | `ActivityLogsApi` | Operations on activity logs. | +| `applicationApi` | `ApplicationApi` | Operations on applications. | +| `archiveApi` | `ArchiveApi` | Operations on archived releases. | +| `attachmentApi` | `AttachmentApi` | Operations on attachments. | +| `categoryApi` | `CategoryApi` | Operations on categories. | +| `configurationApi` | `ConfigurationApi` | Operations on shared configurations and global variables. | +| `deliveryApi` | `DeliveryApi` | Operations on deliveries. | +| `deliveryPatternApi` | `DeliveryPatternApi` | Operations on delivery patterns. | +| `dslApi` | `DslApi` | Operations with release DSL. | +| `environmentApi` | `EnvironmentApi` | Operations on environments. | +| `environmentLabelApi` | `EnvironmentLabelApi` | Operations on environment labels. | +| `environmentReservationApi` | `EnvironmentReservationApi` | Operations on environment reservations. | +| `environmentStageApi` | `EnvironmentStageApi` | Operations on environment stages. | +| `folderApi` | `FolderApi` | Operations on folders. | +| `folderVersioningApi` | `FolderVersioningApi` | Operations to store and synchronize folder contents. | +| `permissionsApi` | `PermissionsApi` | Operations on permissions. | +| `phaseApi` | `PhaseApi` | Operations on phases. | +| `releaseApi` | `ReleaseApi` | Operations on releases. | +| `reportApi` | `ReportApi` | Operations on reports. | +| `riskApi` | `RiskApi` | Operations on risk. | +| `rolesApi` | `RolesApi` | Operations on roles. | +| `searchApi` | `SearchApi` | Search operations across releases and templates. | +| `settingsApi` | `SettingsApi` | Operations on global settings. | +| `taskApi` | `TaskApi` | Operations on tasks. | +| `taskReportingApi` | `TaskReportingApi` | Operations on task reporting records. | +| `teamApi` | `TeamApi` | Operations on teams across releases, templates, and folders. | +| `templateApi` | `TemplateApi` | Operations on templates. | +| `triggersApi` | `TriggersApi` | Operations on triggers. | +| `userApi` | `UserApi` | Operations on users. | +| `variableApi` | `VariableApi` | Operations on variables across releases, templates, and folders. | + +### `apiClient` + +The shared `ReleaseAPIClient`, created on first access. Built from the task's +"Run as user" context (server URL + credentials) via +[`get_release_api_client`](base_task.md#get_release_api_client). Every API +wrapper property is bound to this same client. + +**Type:** `ReleaseAPIClient` + +[โ†‘ API properties](#api-properties) + +--- + +## Method Index + +| Method | Returns | Description | +|--------|---------|-------------| +| [`getCurrentTask`](#getcurrenttask) | `Task` | Returns the task that is running this code. | +| [`getCurrentPhase`](#getcurrentphase) | `Phase` | Returns the phase that contains this task. | +| [`getCurrentRelease`](#getcurrentrelease) | `Release` | Returns the release this task belongs to. | +| [`getCurrentFolder`](#getcurrentfolder) | `Folder` | Returns the folder that contains the current release. | +| [`getReleaseVariable`](#getreleasevariable) | `Any` | Returns the value of a variable in the current release by name. | +| [`setReleaseVariable`](#setreleasevariable) | `Variable` | Sets the value of a variable in the current release by name. | +| [`getFolderVariable`](#getfoldervariable) | `Any` | Returns the value of a variable in the current folder by name. | +| [`setFolderVariable`](#setfoldervariable) | `Variable` | Sets the value of a variable owned by the current folder by name. | +| [`getGlobalVariable`](#getglobalvariable) | `Any` | Returns the value of a global variable by name. | +| [`setGlobalVariable`](#setglobalvariable) | `Variable` | Sets the value of a global variable by name. | +| [`getTasksByTitle`](#gettasksbytitle) | `list[Task]` | Finds tasks by title. | +| [`getPhasesByTitle`](#getphasesbytitle) | `list[Phase]` | Finds phases by title. | +| [`getReleasesByTitle`](#getreleasesbytitle) | `list[Release]` | Finds releases by title. | +| [`getVersion`](#getversion) | `str \| None` | Returns the version of this Digital.ai Release instance. | +| [`reset_api_clients`](#reset_api_clients) | `None` | Drops the cached client and API instances. | + +--- + +## Methods + +### `getCurrentTask` + +Returns the task that is running this code. Fetches the task via `taskApi` using +the task's own id. + +_No parameters._ + +**Returns:** `Task` โ€” the current task. + +[โ†‘ Method index](#method-index) + +### `getCurrentPhase` + +Returns the phase that contains this task. The phase id is derived from the task +id, then fetched via `phaseApi`. + +_No parameters._ + +**Returns:** `Phase` โ€” the current phase. + +[โ†‘ Method index](#method-index) + +### `getCurrentRelease` + +Returns the release this task belongs to. Fetches the release via `releaseApi` +using the task's release id, so the caller does not need to know or substitute +the id itself. + +_No parameters._ + +**Returns:** `Release` โ€” the current release. + +[โ†‘ Method index](#method-index) + +### `getCurrentFolder` + +Returns the folder that contains the current release. The folder id is derived +from the release id, then fetched via `folderApi`. + +_No parameters._ + +**Returns:** `Folder` โ€” the current folder. + +[โ†‘ Method index](#method-index) + +### `getReleaseVariable` + +Returns the value of a variable in the current release by name. The Python3 +equivalent of the Jython script global `releaseVariables[name]`. Pass the bare +variable name; the variable is looked up by its `key` and its stored value is +returned as-is. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the variable name (e.g. `"JenkinsBuildNumber"`). | + +**Returns:** `Any` โ€” the variable's value. + +**Raises:** `KeyError` โ€” if the release has no variable with that name. + +[โ†‘ Method index](#method-index) + +### `setReleaseVariable` + +Sets the value of a variable in the current release by name. The Python3 +equivalent of the Jython script assignment `releaseVariables[name] = value`. If +the variable exists its value is updated; otherwise a new variable is created, +with its type inferred from `value`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the variable name (e.g. `"JenkinsBuildNumber"`). | +| `value` | `Any` | _required_ | the new value to assign. | + +**Returns:** `Variable` โ€” the updated (or newly created) variable. + +[โ†‘ Method index](#method-index) + +### `getFolderVariable` + +Returns the value of a variable in the current folder by name. Like +[`getReleaseVariable`](#getreleasevariable), but scoped to the folder that +contains the current release. Inherited variables (from parent folders and +global variables) are included. The `folder.` prefix is required โ€” pass the +fully qualified name (e.g. `"folder.foo"`). + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the variable name, including the `folder.` prefix. | + +**Returns:** `Any` โ€” the variable's value. + +**Raises:** `ValueError` โ€” if `name` does not start with `folder.`; `KeyError` โ€” if no such variable is visible to the folder. + +[โ†‘ Method index](#method-index) + +### `setFolderVariable` + +Sets the value of a variable owned by the current folder by name. Only variables +the folder owns are matched: an inherited variable (defined on a parent folder +or as a global variable) is not. If the folder owns the variable its value is +updated; otherwise a new folder-owned variable is created (which shadows any +inherited value of the same name). The `folder.` prefix is required. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the variable name, including the `folder.` prefix. | +| `value` | `Any` | _required_ | the new value to assign. | + +**Returns:** `Variable` โ€” the updated (or newly created) variable. + +**Raises:** `ValueError` โ€” if `name` does not start with `folder.`. + +[โ†‘ Method index](#method-index) + +### `getGlobalVariable` + +Returns the value of a global variable by name. Global variables are stored with +a `global.` prefix, which is required here โ€” pass the fully qualified name (e.g. +`"global.foo"`). + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the global variable name, including the `global.` prefix. | + +**Returns:** `Any` โ€” the variable's value. + +**Raises:** `ValueError` โ€” if `name` does not start with `global.`; `KeyError` โ€” if no global variable with that name exists. + +[โ†‘ Method index](#method-index) + +### `setGlobalVariable` + +Sets the value of a global variable by name. The `global.` prefix is required. +If the variable exists its value is updated; otherwise a new global variable is +created, with its type inferred from `value`. The task's run-as user must hold +the permission to edit global variables. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the global variable name, including the `global.` prefix. | +| `value` | `Any` | _required_ | the new value to assign. | + +**Returns:** `Variable` โ€” the updated (or newly created) variable. + +**Raises:** `ValueError` โ€” if `name` does not start with `global.`. + +[โ†‘ Method index](#method-index) + +### `getTasksByTitle` + +Finds tasks by title. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `taskTitle` | `str` | _required_ | the task title to search for. | +| `phaseTitle` | `str \| None` | `None` | optional phase title to scope the search; searches the whole release when omitted. | +| `releaseId` | `str \| None` | `None` | optional release to search; the current release when omitted. | + +**Returns:** `list[Task]` โ€” the matching tasks. + +[โ†‘ Method index](#method-index) + +### `getPhasesByTitle` + +Finds phases by title. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `phaseTitle` | `str` | _required_ | the phase title to search for. | +| `releaseId` | `str \| None` | `None` | optional release to search; the current release when omitted. | + +**Returns:** `list[Phase]` โ€” the matching phases. + +[โ†‘ Method index](#method-index) + +### `getReleasesByTitle` + +Finds releases by title. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `releaseTitle` | `str` | _required_ | the release title to search for. | + +**Returns:** `list[Release]` โ€” the matching releases. + +[โ†‘ Method index](#method-index) + +### `getVersion` + +Returns the version of this Digital.ai Release instance. + +_No parameters._ + +**Returns:** `str \| None` โ€” the instance version, or `None` if unavailable. + +[โ†‘ Method index](#method-index) + +### `reset_api_clients` + +Drops the cached client and API instances (e.g. to re-authenticate). The next +access to [`apiClient`](#apiclient) or any API wrapper property rebuilds them. + +_No parameters._ + +**Returns:** _None._ + +[โ†‘ Method index](#method-index) + +--- + +[๐Ÿ  Docs Home](../README.md) ยท [Base Classes](../README.md#base-classes) ยท [Examples](../examples.md) diff --git a/docs/classes/base_task.md b/docs/classes/base_task.md new file mode 100644 index 0000000..7acfdfe --- /dev/null +++ b/docs/classes/base_task.md @@ -0,0 +1,311 @@ +[๐Ÿ  Docs Home](../README.md) โ€บ [Base Classes](../README.md#base-classes) โ€บ **BaseTask** + +# BaseTask + +> An abstract base class representing a task that can be executed. + +| | | +|---|---| +| **Class** | `BaseTask` | +| **Module** | `digitalai.release.integration.base_task` | +| **Source** | [`digitalai/release/integration/base_task.py`](../../digitalai/release/integration/base_task.py) | +| **Methods** | 18 | + +Subclass `BaseTask` to build a container-based integration task. Implement the +abstract [`execute`](#execute) method with your task logic; the Release runtime +calls [`execute_task`](#execute_task), which wraps `execute` with error handling +and exit-code management. Inside `execute` you read the task's input through +[`get_input_properties`](#get_input_properties) (or the `input_properties` +attribute) and report results through `set_output_property`, `add_comment`, +`set_status_line`, and `add_reporting_record`. + +For tasks that need to call the Release v1 REST API, subclass +[`ApiBaseTask`](api_base_task.md) instead โ€” it extends `BaseTask` and exposes a +ready-to-use API client. + +```python +from digitalai.release.integration import BaseTask + + +class Hello(BaseTask): + + def execute(self) -> None: + name = self.input_properties.get("yourName") + if not name: + raise ValueError("The 'yourName' field cannot be empty") + greeting = f"Hello {name}" + self.add_comment(greeting) + self.set_output_property("greeting", greeting) +``` + +--- + +## Method Index + +| Method | Returns | Description | +|--------|---------|-------------| +| [`execute`](#execute) | `None` | Abstract method holding the task's main logic; implemented by subclasses. | +| [`execute_task`](#execute_task) | `None` | Runs `execute` with error handling and exit-code management; called by the runtime. | +| [`abort`](#abort) | `None` | Sets the exit code to 1 and exits the program. | +| [`get_input_properties`](#get_input_properties) | `dict[str, Any]` | Returns the task's input properties. | +| [`set_output_property`](#set_output_property) | `None` | Sets a named output property of the task. | +| [`get_output_properties`](#get_output_properties) | `dict[str, Any]` | Returns the output properties of the task. | +| [`get_output_context`](#get_output_context) | `OutputContext` | Returns the task's `OutputContext`. | +| [`set_exit_code`](#set_exit_code) | `None` | Sets the task's exit code. | +| [`set_error_message`](#set_error_message) | `None` | Sets the task's error message. | +| [`add_comment`](#add_comment) | `None` | Logs a comment shown in the task's comment section in the UI. | +| [`set_status_line`](#set_status_line) | `None` | Sets the status line of the task. | +| [`add_reporting_record`](#add_reporting_record) | `None` | Adds a reporting record to the output context. | +| [`get_release_server_url`](#get_release_server_url) | `str` | Returns the Release server URL of the task. | +| [`get_task_user`](#get_task_user) | `AutomatedTaskAsUserContext \| None` | Returns the "Run as user" details, or `None`. | +| [`get_release_id`](#get_release_id) | `str` | Returns the release id of the task. | +| [`get_task_id`](#get_task_id) | `str` | Returns the task id. | +| [`get_phase_id`](#get_phase_id) | `str` | Returns the phase id, derived from the task id. | +| [`get_folder_id`](#get_folder_id) | `str` | Returns the id of the folder that contains the release. | +| [`get_release_api_client`](#get_release_api_client) | `ReleaseAPIClient` | Builds a `ReleaseAPIClient`, by default from the task context. | + +--- + +## Methods + +### `execute` + +**Abstract** โ€” must be implemented by subclasses. Holds the main logic of the +task. The Release runtime invokes it through [`execute_task`](#execute_task), so +you normally do not call it yourself. + +_No parameters._ + +**Returns:** _None._ + +```python +class MyTask(BaseTask): + def execute(self) -> None: + ... # your task logic +``` + +[โ†‘ Method index](#method-index) + +### `execute_task` + +Executes the task by calling [`execute`](#execute). If an `AbortException` is +raised during execution, the task's exit code is set to `1` and the program +exits with status `1`. If any other exception is raised, the exit code is set to +`1` and its message is stored as the error message. This is the entry point the +runtime calls. + +_No parameters._ + +**Returns:** _None._ + +[โ†‘ Method index](#method-index) + +### `abort` + +Sets the task's exit code to `1` and exits the program with a status code of `1`. + +_No parameters._ + +**Returns:** _None._ + +[โ†‘ Method index](#method-index) + +### `get_input_properties` + +Returns the input properties dictionary of the task. + +_No parameters._ + +**Returns:** `dict[str, Any]` โ€” the task's input properties. + +**Raises:** `ValueError` โ€” if the input properties have not been set. + +[โ†‘ Method index](#method-index) + +### `set_output_property` + +Sets the name and value of an output property of the task. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | `str` | _required_ | the output property name. Cannot be empty. | +| `value` | `Any` | _required_ | the value to store. Accepted types are `str`, `int`, `list`, `dict`, `bool`. | + +**Returns:** _None._ + +**Raises:** `ValueError` โ€” if `name` is empty, or `value` is not one of the accepted data types. + +[โ†‘ Method index](#method-index) + +### `get_output_properties` + +Returns the output properties dictionary of the task's `OutputContext`. + +_No parameters._ + +**Returns:** `dict[str, Any]` โ€” the output properties set so far. + +[โ†‘ Method index](#method-index) + +### `get_output_context` + +Returns the `OutputContext` object associated with the task. + +_No parameters._ + +**Returns:** `OutputContext` โ€” the task's output context. + +[โ†‘ Method index](#method-index) + +### `set_exit_code` + +Sets the exit code of the task's `OutputContext`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `value` | `int` | _required_ | the exit code (`0` for success, non-zero for failure). | + +**Returns:** _None._ + +[โ†‘ Method index](#method-index) + +### `set_error_message` + +Sets the error message of the task's `OutputContext`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `value` | `str` | _required_ | the error message to record. | + +**Returns:** _None._ + +[โ†‘ Method index](#method-index) + +### `add_comment` + +Logs a comment for the task. The comment is shown in the task's comment section +in the Release UI. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `comment` | `str` | _required_ | the comment text. | + +**Returns:** _None._ + +[โ†‘ Method index](#method-index) + +### `set_status_line` + +Sets the status line of the task. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `status_line` | `str` | _required_ | the status text to display. | + +**Returns:** _None._ + +[โ†‘ Method index](#method-index) + +### `add_reporting_record` + +Adds a reporting record to the task's `OutputContext`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `reporting_record` | `Any` | _required_ | the reporting record to append. | + +**Returns:** _None._ + +[โ†‘ Method index](#method-index) + +### `get_release_server_url` + +Returns the Release server URL of the associated task. + +_No parameters._ + +**Returns:** `str` โ€” the Release server URL. + +[โ†‘ Method index](#method-index) + +### `get_task_user` + +Returns the user details that are executing the task (the "Run as user" +context), or `None` when no release context is available. + +_No parameters._ + +**Returns:** `AutomatedTaskAsUserContext \| None` โ€” the task user context, or `None`. + +[โ†‘ Method index](#method-index) + +### `get_release_id` + +Returns the release id of the task. + +_No parameters._ + +**Returns:** `str` โ€” the release identifier. + +[โ†‘ Method index](#method-index) + +### `get_task_id` + +Returns the task id. + +_No parameters._ + +**Returns:** `str` โ€” the task identifier. + +[โ†‘ Method index](#method-index) + +### `get_phase_id` + +Returns the phase id of the task, derived from the task id. + +_No parameters._ + +**Returns:** `str` โ€” the phase identifier. + +[โ†‘ Method index](#method-index) + +### `get_folder_id` + +Returns the id of the folder that contains the release, derived from the release +id. + +_No parameters._ + +**Returns:** `str` โ€” the folder identifier. + +[โ†‘ Method index](#method-index) + +### `get_release_api_client` + +Returns a `ReleaseAPIClient`. All arguments are optional: when omitted, the +client is configured from the task context (server URL and the "Run as user" +credentials). Any argument that is provided overrides the corresponding task +default. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `server_address` | `str` | `None` | Release server URL. Defaults to the task's server URL. | +| `username` | `str` | `None` | username. Defaults to the task user's username. | +| `password` | `str` | `None` | password. Defaults to the task user's password. | +| `personal_access_token` | `str` | `None` | personal access token for authentication. When set, it is used instead of username/password. | +| `timeout` | `float \| tuple[float, float] \| None` | `None` | default timeout (in seconds) applied to every request. Accepts a single float or a `(connect, read)` tuple. Can be overridden per call. | +| `**kwargs` | `Any` | โ€” | additional session parameters (e.g. `headers`, `verify`, `proxies`). | + +**Returns:** `ReleaseAPIClient` โ€” a configured client. + +**Raises:** `ValueError` โ€” if the server URL, username, or password cannot be resolved (e.g. the "Run as user" property is not set on the release). + +> **Tip:** if your task needs the API, subclass [`ApiBaseTask`](api_base_task.md) +> instead of calling this method directly โ€” it builds and caches the client and +> every API wrapper for you. + +[โ†‘ Method index](#method-index) + +--- + +[๐Ÿ  Docs Home](../README.md) ยท [Base Classes](../README.md#base-classes) ยท [Examples](../examples.md) diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..c75201e --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,276 @@ +[๐Ÿ  Docs Home](README.md) โ€บ **Examples** + +# Examples + +Task-oriented usage examples for the **Digital.ai Release Python SDK**. Each +snippet defines a task by subclassing [`BaseTask`](classes/base_task.md) or +[`ApiBaseTask`](classes/api_base_task.md) and implementing `execute`. The +Release runtime instantiates your task and calls `execute_task`, which runs +`execute` and handles errors and exit codes for you. + +## Contents + +- [Define a task with BaseTask](#define-a-task-with-basetask) +- [Read inputs and write outputs](#read-inputs-and-write-outputs) +- [Comments, status line, and reporting](#comments-status-line-and-reporting) +- [Fail or abort a task](#fail-or-abort-a-task) +- [Call the Release API with ApiBaseTask](#call-the-release-api-with-apibasetask) +- [Work with the current task, phase, release, and folder](#work-with-the-current-task-phase-release-and-folder) +- [Manage variables](#manage-variables) +- [Search by title](#search-by-title) +- [Build a client manually](#build-a-client-manually) + +--- + +## Define a task with BaseTask + +Subclass [`BaseTask`](classes/base_task.md) and implement the abstract +`execute` method. Input fields configured on the task are available through the +`input_properties` attribute (or [`get_input_properties`](classes/base_task.md#get_input_properties)). + +**Classes:** [`BaseTask`](classes/base_task.md) + +```python +from digitalai.release.integration import BaseTask + + +class Hello(BaseTask): + + def execute(self) -> None: + name = self.input_properties.get("yourName") + if not name: + raise ValueError("The 'yourName' field cannot be empty") + + greeting = f"Hello {name}" + self.add_comment(greeting) + self.set_output_property("greeting", greeting) +``` + +[โ†‘ Contents](#contents) + +## Read inputs and write outputs + +Read configured input fields and publish results as output properties. Output +values must be one of `str`, `int`, `list`, `dict`, or `bool`. Output properties +are returned to Release and can be consumed by downstream tasks. + +**Classes:** [`BaseTask`](classes/base_task.md) + +```python +from digitalai.release.integration import BaseTask + + +class BuildSummary(BaseTask): + + def execute(self) -> None: + inputs = self.get_input_properties() + version = inputs["version"] + artifacts = inputs.get("artifacts", []) + + self.set_output_property("version", version) + self.set_output_property("artifactCount", len(artifacts)) + self.set_output_property("succeeded", True) +``` + +[โ†‘ Contents](#contents) + +## Comments, status line, and reporting + +Surface progress to the Release UI: `add_comment` writes to the task's comment +section, `set_status_line` updates the one-line status, and +`add_reporting_record` attaches a reporting record to the output context. + +**Classes:** [`BaseTask`](classes/base_task.md) + +```python +from digitalai.release.integration import BaseTask + + +class Deploy(BaseTask): + + def execute(self) -> None: + self.set_status_line("Deployingโ€ฆ") + self.add_comment("Starting deployment to **prod**") + + # ... do the work ... + + self.set_status_line("Deployed") + self.add_comment("Deployment finished successfully") +``` + +[โ†‘ Contents](#contents) + +## Fail or abort a task + +Raising an exception from `execute` fails the task: `execute_task` records the +message and sets a non-zero exit code. To stop immediately, call `abort`, which +sets the exit code to `1` and exits the process. + +**Classes:** [`BaseTask`](classes/base_task.md) + +```python +from digitalai.release.integration import BaseTask + + +class GuardedTask(BaseTask): + + def execute(self) -> None: + if not self.input_properties.get("confirmed"): + # Fails the task with this message shown in Release + raise ValueError("Task was not confirmed") + + if self.input_properties.get("cancel"): + # Stop right away + self.abort() + + self.add_comment("Proceeding") +``` + +[โ†‘ Contents](#contents) + +## Call the Release API with ApiBaseTask + +Subclass [`ApiBaseTask`](classes/api_base_task.md) to call the Release v1 REST +API without building a client yourself. Every API is exposed as a lazily +created, cached property (`releaseApi`, `taskApi`, โ€ฆ), all sharing a single +client built from the task's "Run as user" context. + +**Classes:** [`ApiBaseTask`](classes/api_base_task.md) + +```python +from digitalai.release.integration.api_base_task import ApiBaseTask + + +class ShowVersion(ApiBaseTask): + + def execute(self) -> None: + release = self.releaseApi.getRelease(self.get_release_id()) + self.add_comment(f"Working on {release.title}") + + version = self.getVersion() + self.set_output_property("releaseVersion", version) +``` + +[โ†‘ Contents](#contents) + +## Work with the current task, phase, release, and folder + +The current-context helpers resolve the objects the task is running in, so you +do not have to derive ids yourself. + +**Classes:** [`ApiBaseTask`](classes/api_base_task.md) + +```python +from digitalai.release.integration.api_base_task import ApiBaseTask + + +class Context(ApiBaseTask): + + def execute(self) -> None: + task = self.getCurrentTask() + phase = self.getCurrentPhase() + release = self.getCurrentRelease() + folder = self.getCurrentFolder() + + self.add_comment( + f"Task '{task.title}' in phase '{phase.title}' " + f"of release '{release.title}' (folder '{folder.title}')" + ) +``` + +[โ†‘ Contents](#contents) + +## Manage variables + +Read and write release, folder, and global variables by name. Folder and global +names must include their `folder.` / `global.` prefix. When a variable does not +exist yet, the `setโ€ฆ` helpers create it, inferring the variable type from the +value. + +**Classes:** [`ApiBaseTask`](classes/api_base_task.md) + +```python +from digitalai.release.integration.api_base_task import ApiBaseTask + + +class Variables(ApiBaseTask): + + def execute(self) -> None: + # Release variables (bare name) + build = self.getReleaseVariable("JenkinsBuildNumber") + self.setReleaseVariable("JenkinsBuildNumber", build + 1) + + # Folder variables (require the 'folder.' prefix) + owner = self.getFolderVariable("folder.owner") + self.setFolderVariable("folder.owner", owner) + + # Global variables (require the 'global.' prefix) + self.setGlobalVariable("global.lastRunBy", "release-bot") +``` + +[โ†‘ Contents](#contents) + +## Search by title + +Find tasks, phases, and releases by title. + +**Classes:** [`ApiBaseTask`](classes/api_base_task.md) + +```python +from digitalai.release.integration.api_base_task import ApiBaseTask + + +class Search(ApiBaseTask): + + def execute(self) -> None: + # Tasks in the current release, optionally scoped to a phase + deploys = self.getTasksByTitle("Deploy", phaseTitle="Production") + + # Phases in the current release + phases = self.getPhasesByTitle("Production") + + # Releases by title across the instance + releases = self.getReleasesByTitle("Nightly") + + self.add_comment( + f"Found {len(deploys)} tasks, {len(phases)} phases, " + f"{len(releases)} releases" + ) +``` + +[โ†‘ Contents](#contents) + +## Build a client manually + +You can also build a `ReleaseAPIClient` directly from a `BaseTask` with +[`get_release_api_client`](classes/base_task.md#get_release_api_client). With no +arguments it uses the task context; pass arguments to override the server URL or +credentials (e.g. to authenticate with a personal access token, or to call a +different server). + +**Classes:** [`BaseTask`](classes/base_task.md) + +```python +from digitalai.release.integration import BaseTask + + +class CustomClient(BaseTask): + + def execute(self) -> None: + # Default: server URL + 'Run as user' credentials from the task context + client = self.get_release_api_client() + + # Override: a different server with a personal access token and a timeout + other = self.get_release_api_client( + server_address="https://release.example.com", + personal_access_token="your-token-here", + timeout=(5, 30), # (connect, read) seconds + ) + ... +``` + +[โ†‘ Contents](#contents) + +--- + +[๐Ÿ  Docs Home](README.md) ยท [Base Classes](README.md#base-classes) diff --git a/pyproject.toml b/pyproject.toml index 8813d11..651be14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,14 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build] -exclude = [ - ".gitignore", "tests/*", ".github/*" -] - -[tool.hatch.build.targets.wheel] -packages = ["digitalai"] - [project] name = "digitalai_release_sdk" -version = "26.1.0" -authors = [ - { name="Digital.ai", email="pypi-devops@digital.ai" }, -] +version = "26.3.0b4" description = "Digital.ai Release SDK" readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Digital.ai", email = "pypi-devops@digital.ai" }] requires-python = ">=3.10" -dependencies = [ - 'dataclasses-json>=0.6.7, <1.0.0', - 'pycryptodomex>=3.23.0, <4.0.0', - 'python-dateutil>=2.9.0, <3.0.0', - 'requests>=2.32.5, <3.0.0', - 'kubernetes>=35.0.0, <36.0.0' -] classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3", @@ -35,10 +16,58 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent" ] +dependencies = [ + "digitalai-release-api-client==26.3.0b4", + "dataclasses-json>=0.6.7, <1.0.0", + "pycryptodomex>=3.23.0, <4.0.0", + "python-dateutil>=2.9.0, <3.0.0", + "requests>=2.32.5, <3.0.0", + "kubernetes>=35.0.0, <36.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", +] [project.urls] Homepage = "https://digital.ai/" Documentation = "https://docs.digital.ai/release/docs/category/python-sdk" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# Files never shipped in any build artifact +[tool.hatch.build] +exclude = [ + "**/__pycache__", + "**/*.py[cod]", + ".coverage", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "tests", + "docs", + "DEVELOPMENT.md", + "uv.lock", + ".gitignore", + ".github", +] + +# Wheel ships only the digitalai package tree +[tool.hatch.build.targets.wheel] +packages = ["digitalai"] + +# Source distribution: package code + project metadata only (no tests) +[tool.hatch.build.targets.sdist] +include = [ + "/digitalai", + "/README.md", + "/pyproject.toml", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/release/integration/input.json b/tests/release/integration/input.json index 6bdc493..16bb5f4 100644 --- a/tests/release/integration/input.json +++ b/tests/release/integration/input.json @@ -1,38 +1 @@ -{ - "release": { - "id": "Applications/Folder061e/Releaseddb8", - "automatedTaskAsUser": { - "username": "admin", - "password": "admin" - } - }, - "task": { - "id": "Applications/Folder061e/Releaseddb8/Phase5a05/Task65e6", - "type": "example.Hello", - "properties": [ - { - "name": "capabilities", - "value": [ - "remote" - ], - "kind": "SET_OF_STRING", - "category": "input", - "password": false - }, - { - "name": "yourName", - "value": "World", - "kind": "STRING", - "category": "input", - "password": false - }, - { - "name": "greeting", - "value": null, - "kind": "STRING", - "category": "output", - "password": false - } - ] - } -} \ No newline at end of file +{"release": {"id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b", "automatedTaskAsUser": {"username": "admin", "password": "admin"}}, "task": {"id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b/Phase723a601c78804f7dbcaa8b05b83708f5/Task3a35b67b42b6428b854857fba470b39a", "type": "containerExamples.Hello1", "properties": [{"name": "capabilities", "value": ["remote"], "kind": "SET_OF_STRING", "category": "input", "password": false}, {"name": "yourName", "value": "World", "kind": "STRING", "category": "input", "password": false}, {"name": "greeting", "value": null, "kind": "STRING", "category": "output", "password": false}, {"name": "scriptLocation", "value": "sample/hello.py", "kind": "STRING", "category": "input", "password": false}]}} \ No newline at end of file diff --git a/tests/release/integration/test_api_base_task.py b/tests/release/integration/test_api_base_task.py new file mode 100644 index 0000000..b22e26b --- /dev/null +++ b/tests/release/integration/test_api_base_task.py @@ -0,0 +1,256 @@ +import socket +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock + +from digitalai.release.integration.input_context import ( + AutomatedTaskAsUserContext, + ReleaseContext, +) +from digitalai.release.integration.api_base_task import ApiBaseTask + +from digitalai.release.release_api_client import ReleaseAPIClient +from com.xebialabs.xlrelease.api.v1.activity_logs_api import ActivityLogsApi +from com.xebialabs.xlrelease.api.v1.application_api import ApplicationApi +from com.xebialabs.xlrelease.api.v1.archive_api import ArchiveApi +from com.xebialabs.xlrelease.api.v1.attachment_api import AttachmentApi +from com.xebialabs.xlrelease.api.v1.category_api import CategoryApi +from com.xebialabs.xlrelease.api.v1.configuration_api import ConfigurationApi +from com.xebialabs.xlrelease.api.v1.delivery_api import DeliveryApi +from com.xebialabs.xlrelease.api.v1.delivery_pattern_api import DeliveryPatternApi +from com.xebialabs.xlrelease.api.v1.dsl_api import DslApi +from com.xebialabs.xlrelease.api.v1.environment_api import EnvironmentApi +from com.xebialabs.xlrelease.api.v1.environment_label_api import EnvironmentLabelApi +from com.xebialabs.xlrelease.api.v1.environment_reservation_api import ( + EnvironmentReservationApi, +) +from com.xebialabs.xlrelease.api.v1.environment_stage_api import EnvironmentStageApi +from com.xebialabs.xlrelease.api.v1.folder_api import FolderApi +from com.xebialabs.xlrelease.api.v1.folder_versioning_api import FolderVersioningApi +from com.xebialabs.xlrelease.api.v1.permissions_api import PermissionsApi +from com.xebialabs.xlrelease.api.v1.phase_api import PhaseApi +from com.xebialabs.xlrelease.api.v1.release_api import ReleaseApi +from com.xebialabs.xlrelease.api.v1.report_api import ReportApi +from com.xebialabs.xlrelease.api.v1.risk_api import RiskApi +from com.xebialabs.xlrelease.api.v1.roles_api import RolesApi +from com.xebialabs.xlrelease.api.v1.search_api import SearchApi +from com.xebialabs.xlrelease.api.v1.settings_api import SettingsApi +from com.xebialabs.xlrelease.api.v1.task_api import TaskApi +from com.xebialabs.xlrelease.api.v1.task_reporting_api import TaskReportingApi +from com.xebialabs.xlrelease.api.v1.team_api import TeamApi +from com.xebialabs.xlrelease.api.v1.template_api import TemplateApi +from com.xebialabs.xlrelease.api.v1.triggers_api import TriggersApi +from com.xebialabs.xlrelease.api.v1.user_api import UserApi +from com.xebialabs.xlrelease.api.v1.variable_api import VariableApi + + +# Mapping of every ApiBaseTask property name to the wrapper class it must return. +API_PROPERTIES = { + "activityLogsApi": ActivityLogsApi, + "applicationApi": ApplicationApi, + "archiveApi": ArchiveApi, + "attachmentApi": AttachmentApi, + "categoryApi": CategoryApi, + "configurationApi": ConfigurationApi, + "deliveryApi": DeliveryApi, + "deliveryPatternApi": DeliveryPatternApi, + "dslApi": DslApi, + "environmentApi": EnvironmentApi, + "environmentLabelApi": EnvironmentLabelApi, + "environmentReservationApi": EnvironmentReservationApi, + "environmentStageApi": EnvironmentStageApi, + "folderApi": FolderApi, + "folderVersioningApi": FolderVersioningApi, + "permissionsApi": PermissionsApi, + "phaseApi": PhaseApi, + "releaseApi": ReleaseApi, + "reportApi": ReportApi, + "riskApi": RiskApi, + "rolesApi": RolesApi, + "searchApi": SearchApi, + "settingsApi": SettingsApi, + "taskApi": TaskApi, + "taskReportingApi": TaskReportingApi, + "teamApi": TeamApi, + "templateApi": TemplateApi, + "triggersApi": TriggersApi, + "userApi": UserApi, + "variableApi": VariableApi, +} + + +# The live-call test below talks to a real Release server. Locally that is the +# developer's instance on localhost:5516; CI has no such server, so the test is +# skipped automatically when the port is not reachable. +LIVE_SERVER_HOST = "localhost" +LIVE_SERVER_PORT = 5516 +LIVE_SERVER_URL = f"http://{LIVE_SERVER_HOST}:{LIVE_SERVER_PORT}" + + +def _server_reachable(host: str, port: int, timeout: float = 0.5) -> bool: + """Return True if a TCP connection to host:port succeeds within the timeout.""" + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +class _SampleApiTask(ApiBaseTask): + """Concrete ApiBaseTask used to exercise the base behaviour in tests.""" + + def execute(self) -> None: # pragma: no cover - never executed by the tests + pass + + +class TestApiBaseTask(unittest.TestCase): + """Integration tests for the ApiBaseTask base class.""" + + def setUp(self): + # Configure the task context the way the Release runtime would, so that + # get_release_api_client() builds a client from the "Run as user" details. + self.task = _SampleApiTask() + self.task.release_server_url = LIVE_SERVER_URL + self.task.release_context = ReleaseContext( + id="Applications/Release0000000000000000000000000000", + automated_task_as_user=AutomatedTaskAsUserContext( + username="admin", password="admin" + ), + ) + + def test_api_client_is_lazy_and_cached(self): + """apiClient is built on first access and the same instance is reused.""" + self.assertIsNone(self.task._api_client) + client = self.task.apiClient + self.assertIsInstance(client, ReleaseAPIClient) + self.assertIs(self.task.apiClient, client) + + def test_all_api_properties_return_correct_type(self): + """Every API property returns an instance of its wrapper class.""" + for name, api_class in API_PROPERTIES.items(): + with self.subTest(api=name): + instance = getattr(self.task, name) + self.assertIsInstance(instance, api_class) + + def test_api_properties_are_cached(self): + """Accessing a property twice returns the very same cached instance.""" + for name in API_PROPERTIES: + with self.subTest(api=name): + first = getattr(self.task, name) + second = getattr(self.task, name) + self.assertIs(first, second) + + def test_all_apis_share_single_client(self): + """All wrappers are built on the one shared apiClient.""" + client = self.task.apiClient + for name in API_PROPERTIES: + with self.subTest(api=name): + self.assertIs(getattr(self.task, name).api, client) + + def test_reset_api_clients_clears_caches(self): + """reset_api_clients drops the client and forces fresh instances.""" + old_client = self.task.apiClient + old_release_api = self.task.releaseApi + + self.task.reset_api_clients() + self.assertIsNone(self.task._api_client) + self.assertEqual(self.task._api_instances, {}) + + self.assertIsNot(self.task.apiClient, old_client) + self.assertIsNot(self.task.releaseApi, old_release_api) + + @unittest.skipUnless( + _server_reachable(LIVE_SERVER_HOST, LIVE_SERVER_PORT), + f"live Release server not available on {LIVE_SERVER_HOST}:{LIVE_SERVER_PORT}", + ) + def test_wired_client_performs_live_call(self): + """A property built by ApiBaseTask can talk to the live server.""" + info = self.task.settingsApi.getInstanceInformation() + self.assertIsInstance(info, dict) + self.assertIn("version", info) + print(f"ApiBaseTask wired client instance information: {info}") + + +# Realistic ids, matching the documented sample input context. The phase and +# folder ids are substrings the helpers derive from the task / release ids. +FOLDER_ID = "Applications/Folder1f65c7220b394afbb941154342fd9fc6" +RELEASE_ID = f"{FOLDER_ID}/Release31de09e95c8e4ebb95aaed29a8082d0b" +PHASE_ID = f"{RELEASE_ID}/Phase723a601c78804f7dbcaa8b05b83708f5" +TASK_ID = f"{PHASE_ID}/Task3a35b67b42b6428b854857fba470b39a" + + +class TestApiBaseTaskContextHelpers(unittest.TestCase): + """Tests for the getCurrent*/...ByTitle/getVersion convenience helpers.""" + + def _stub_task(self, release_id=RELEASE_ID, task_id=TASK_ID): + """Build an ApiBaseTask with every API wrapper replaced by a MagicMock. + + Returns the task plus the dict of stub APIs keyed by property name. The + class-level property overrides are removed again after the test. + """ + task = _SampleApiTask() + task.task_id = task_id + task.release_context = SimpleNamespace(id=release_id) + task._api_instances = {} + + apis = {name: MagicMock(name=name) for name in ( + "releaseApi", "taskApi", "phaseApi", "folderApi", "settingsApi")} + for name, stub in apis.items(): + setattr(type(task), name, property(lambda self, s=stub: s)) + self.addCleanup( + lambda: [delattr(type(task), name) for name in apis]) + return task, apis + + def test_get_current_release_and_task(self): + task, apis = self._stub_task() + apis["releaseApi"].getRelease.return_value = SimpleNamespace(title="My Release") + apis["taskApi"].getTask.return_value = SimpleNamespace(title="My Task") + + self.assertEqual(task.getCurrentRelease().title, "My Release") + self.assertEqual(task.getCurrentTask().title, "My Task") + apis["releaseApi"].getRelease.assert_called_once_with(RELEASE_ID) + apis["taskApi"].getTask.assert_called_once_with(TASK_ID) + + def test_get_current_phase_and_folder_derive_ids(self): + task, apis = self._stub_task() + apis["phaseApi"].getPhase.return_value = SimpleNamespace(title="My Phase") + apis["folderApi"].getFolder.return_value = SimpleNamespace(title="My Folder") + + self.assertEqual(task.getCurrentPhase().title, "My Phase") + self.assertEqual(task.getCurrentFolder().title, "My Folder") + apis["phaseApi"].getPhase.assert_called_once_with(PHASE_ID) + apis["folderApi"].getFolder.assert_called_once_with(FOLDER_ID) + + def test_search_helpers_default_to_current_release(self): + task, apis = self._stub_task() + apis["taskApi"].searchTasksByTitle.return_value = ["t"] + apis["phaseApi"].searchPhasesByTitle.return_value = ["p"] + apis["releaseApi"].searchReleasesByTitle.return_value = ["r"] + + self.assertEqual(task.getTasksByTitle("Deploy"), ["t"]) + self.assertEqual(task.getPhasesByTitle("Prod"), ["p"]) + self.assertEqual(task.getReleasesByTitle("Nightly"), ["r"]) + # taskApi signature is (taskTitle, releaseId, phaseTitle). + apis["taskApi"].searchTasksByTitle.assert_called_once_with("Deploy", RELEASE_ID, None) + apis["phaseApi"].searchPhasesByTitle.assert_called_once_with("Prod", RELEASE_ID) + apis["releaseApi"].searchReleasesByTitle.assert_called_once_with("Nightly") + + def test_search_helpers_honor_explicit_arguments(self): + task, apis = self._stub_task() + apis["taskApi"].searchTasksByTitle.return_value = [] + + task.getTasksByTitle("Deploy", "Prod", "Release/Other") + + apis["taskApi"].searchTasksByTitle.assert_called_once_with( + "Deploy", "Release/Other", "Prod") + + def test_get_version(self): + task, apis = self._stub_task() + apis["settingsApi"].getInstanceInformation.return_value = { + "product": "Digital.ai Release", "edition": "enterprise", "version": "25.3.0"} + + self.assertEqual(task.getVersion(), "25.3.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/integration/test_api_base_task_live.py b/tests/release/integration/test_api_base_task_live.py new file mode 100644 index 0000000..1409947 --- /dev/null +++ b/tests/release/integration/test_api_base_task_live.py @@ -0,0 +1,610 @@ +""" +Live integration tests for :class:`ApiBaseTask`. + +These tests run against a *real* Digital.ai Release server (by default the +developer instance on ``localhost:5516`` with ``admin``/``admin``). They build +an ``ApiBaseTask`` wired exactly the way the Release runtime wires it -- a task +context carrying the release id, the task id and the "Run as user" credentials +-- and then exercise the convenience helpers (``getCurrent*``, ``get*ByTitle``, +the variable getters/setters and ``getVersion``) end to end. + +The suite is fully self-contained: + +* ``setUpClass`` first verifies the server is reachable, authentication works + and the API responds within a bounded timeout. If any of those checks fail + the whole suite is skipped (CI has no Release server). +* It then creates every prerequisite object -- a folder, a template (moved into + that folder), a phase, a task, and release/folder/global variables -- and + spins up a release from the template so the task context points at real ids. +* ``tearDownClass`` removes everything again; deleting the folder cascades to + the release and template, so the server is returned to its original state. +""" + +import socket +import unittest +import uuid + +from digitalai.release.release_api_client import ReleaseAPIClient +from digitalai.release.integration.api_base_task import ApiBaseTask +from digitalai.release.integration.input_context import ( + AutomatedTaskAsUserContext, + ReleaseContext, +) + +from com.xebialabs.xlrelease.api.v1.configuration_api import ConfigurationApi +from com.xebialabs.xlrelease.api.v1.folder_api import FolderApi +from com.xebialabs.xlrelease.api.v1.phase_api import PhaseApi +from com.xebialabs.xlrelease.api.v1.release_api import ReleaseApi +from com.xebialabs.xlrelease.api.v1.task_api import TaskApi +from com.xebialabs.xlrelease.api.v1.template_api import TemplateApi +from com.xebialabs.xlrelease.domain.folder import Folder +from com.xebialabs.xlrelease.domain.forms import CreateRelease +from com.xebialabs.xlrelease.domain.phase import Phase +from com.xebialabs.xlrelease.domain.release import Release +from com.xebialabs.xlrelease.domain.task import Task +from com.xebialabs.xlrelease.domain.variable import Variable + + +# Live server connection details. Locally this is the developer's instance; +# CI has no such server, so the suite skips itself when it is not reachable. +LIVE_SERVER_HOST = "localhost" +LIVE_SERVER_PORT = 5516 +LIVE_SERVER_URL = f"http://{LIVE_SERVER_HOST}:{LIVE_SERVER_PORT}" +LIVE_SERVER_USER = "admin" +LIVE_SERVER_PASSWORD = "admin" + +# Bounded timeout (connect, read) so an unresponsive server skips rather than +# hangs the suite. +LIVE_SERVER_TIMEOUT = (3.05, 10) + + +def _server_reachable(host: str, port: int, timeout: float = 0.5) -> bool: + """Return True if a TCP connection to host:port succeeds within the timeout.""" + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +class _LiveApiTask(ApiBaseTask): + """Concrete ApiBaseTask used to drive the helpers against the live server.""" + + def execute(self) -> None: # pragma: no cover - never executed by the tests + pass + + +@unittest.skipUnless( + _server_reachable(LIVE_SERVER_HOST, LIVE_SERVER_PORT), + f"live Release server not available on {LIVE_SERVER_HOST}:{LIVE_SERVER_PORT}", +) +class TestApiBaseTaskLive(unittest.TestCase): + """Live integration tests for the ApiBaseTask convenience helpers.""" + + # Populated by setUpClass; defaults keep tearDownClass safe if setup aborts. + client = None + uid = None + folder_id = None + folder_title = None + template_id = None + release_id = None + release_title = None + task_id = None + global_var_id = None + global_var_name = None + # Global variables created by the "create when missing" tests; tracked here so + # _cleanup can delete them (they live outside the folder, so the folder-delete + # cascade does not remove them). + created_global_var_ids = [] + task = None + + # -- lifecycle ---------------------------------------------------------- + + @classmethod + def setUpClass(cls): + uid = uuid.uuid4().hex[:10] + cls.uid = uid + cls.created_global_var_ids = [] + cls.client = ReleaseAPIClient( + LIVE_SERVER_URL, + LIVE_SERVER_USER, + LIVE_SERVER_PASSWORD, + timeout=LIVE_SERVER_TIMEOUT, + ) + + # Skip gate: verify authentication works and the API responds. Any + # failure here (auth error, timeout, unreachable) skips the whole suite. + folder_api = FolderApi(cls.client) + try: + root_folders = folder_api.listRoot() + if not root_folders: + raise unittest.SkipTest("No root folders available on the server.") + except unittest.SkipTest: + raise + except Exception as exc: # noqa: BLE001 - any failure means "skip" + raise unittest.SkipTest( + f"Release server authentication/API check failed: {exc}" + ) + + try: + cls._create_test_data(uid, root_folders[0].id) + except Exception: + # Clean up whatever was created before re-raising; unittest does not + # call tearDownClass when setUpClass fails. + cls._cleanup() + raise + + # Build the ApiBaseTask exactly as the Release runtime would: server URL + # + "Run as user" credentials + the real release and task ids. + cls.task = _LiveApiTask() + cls.task.release_server_url = LIVE_SERVER_URL + cls.task.release_context = ReleaseContext( + id=cls.release_id, + automated_task_as_user=AutomatedTaskAsUserContext( + username=LIVE_SERVER_USER, password=LIVE_SERVER_PASSWORD + ), + ) + cls.task.task_id = cls.task_id + print(f"Live ApiBaseTask wired to release {cls.release_id}") + + @classmethod + def _create_test_data(cls, uid: str, root_id: str): + """Create the folder/template/release/variables the helpers operate on.""" + template_api = TemplateApi(cls.client) + folder_api = FolderApi(cls.client) + phase_api = PhaseApi(cls.client) + task_api = TaskApi(cls.client) + config_api = ConfigurationApi(cls.client) + + # Folder (under root) that will contain the release. + cls.folder_title = f"AbtLiveFolder_{uid}" + folder = folder_api.addFolder(root_id, Folder(title=cls.folder_title)) + cls.folder_id = folder.id + print(f"Created folder: {cls.folder_id}") + + # Template at root with a phase, a manual task and a release variable. + template_title = f"AbtLiveTemplate_{uid}" + payload = { + "id": f"Applications/Release_abt_live_{uid}", + "title": template_title, + "type": "xlrelease.Release", + "status": "TEMPLATE", + "scheduledStartDate": "2026-06-01T00:00:00+00:00", + "dueDate": "2026-07-01T00:00:00+00:00", + } + response = cls.client.post("/api/v1/templates", json=payload) + response.raise_for_status() + root_template = Release.from_dict(response.json()) + + phase = phase_api.addPhase( + root_template.id, Phase(title="Test Phase", type="xlrelease.Phase") + ) + task_api.addTask(phase.id, Task(title="Manual Task", type="xlrelease.Task")) + template_api.createVariable( + root_template.id, + Variable( + type="xlrelease.StringVariable", + key="relVar", + value="release-value", + requiresValue=False, + showOnReleaseStart=False, + ), + ) + # A set (set-of-string) release variable; its value round-trips as a list. + template_api.createVariable( + root_template.id, + Variable( + type="xlrelease.SetStringVariable", + key="relSetVar", + value=["Apples", "Pears"], + requiresValue=False, + showOnReleaseStart=False, + ), + ) + # A key/value-map release variable; its value round-trips as a dict. + template_api.createVariable( + root_template.id, + Variable( + type="xlrelease.MapStringStringVariable", + key="relMapVar", + value={"env": "dev", "region": "us"}, + requiresValue=False, + showOnReleaseStart=False, + ), + ) + + # Move the template into the folder so releases created from it live + # inside the folder (required for getCurrentFolder to resolve). + folder_api.moveTemplate(cls.folder_id, root_template.id) + moved = [ + t for t in folder_api.getTemplates(cls.folder_id) + if t.title == template_title + ] + if not moved: + raise RuntimeError("Template not found in folder after move.") + cls.template_id = moved[0].id + print(f"Created template in folder: {cls.template_id}") + + # A folder-owned variable. + folder_api.createVariable( + cls.folder_id, + Variable( + type="xlrelease.StringVariable", + key="folder.folderVar", + value="folder-value", + requiresValue=False, + showOnReleaseStart=False, + ), + ) + + # A global variable (lives outside the folder; deleted explicitly later). + cls.global_var_name = f"abtLiveGlobal_{uid}" + global_var = config_api.addGlobalVariable( + Variable( + type="xlrelease.StringVariable", + key=f"global.{cls.global_var_name}", + value="global-value", + requiresValue=False, + showOnReleaseStart=False, + ) + ) + cls.global_var_id = global_var.id + print(f"Created global variable: {cls.global_var_id}") + + # Create the release from the template; it inherits the phase, task and + # release variable, and lives inside the folder. + cls.release_title = f"AbtLiveRelease_{uid}" + release = template_api.create( + cls.template_id, CreateRelease(releaseTitle=cls.release_title) + ) + cls.release_id = release.id + print(f"Created release: {cls.release_id}") + + # Resolve the task id of the release's manual task -> the task context. + tasks = task_api.searchTasksByTitle("Manual Task", cls.release_id) + if not tasks: + raise RuntimeError("Manual Task not found in created release.") + cls.task_id = tasks[0].id + print(f"Resolved task id: {cls.task_id}") + + @classmethod + def _cleanup(cls): + """Best-effort removal of every object created by the suite.""" + client = cls.client + if client is None: + return + + if cls.release_id: + release_api = ReleaseApi(client) + try: + release_api.abort(cls.release_id, "Aborting for test cleanup") + except Exception: + pass + try: + release_api.delete(cls.release_id) + except Exception: + pass + + if cls.template_id: + try: + TemplateApi(client).deleteTemplate(cls.template_id) + except Exception: + pass + + if cls.global_var_id: + try: + ConfigurationApi(client).deleteGlobalVariable(cls.global_var_id) + except Exception: + pass + + # Global variables created by the "create when missing" tests. + for var_id in cls.created_global_var_ids: + try: + ConfigurationApi(client).deleteGlobalVariable(var_id) + except Exception: + pass + + # Deleting the folder cascades to anything still inside it (release, + # template), so this is the catch-all that restores the server state. + if cls.folder_id: + try: + FolderApi(client).delete(cls.folder_id) + except Exception: + pass + + @classmethod + def tearDownClass(cls): + cls._cleanup() + + # -- getVersion --------------------------------------------------------- + + def test_01_get_version(self): + """getVersion returns the server's version string.""" + version = self.task.getVersion() + self.assertIsInstance(version, str) + self.assertTrue(version) + print(f"getVersion -> {version}") + + # -- getCurrent* -------------------------------------------------------- + + def test_02_get_current_release(self): + """getCurrentRelease returns the release the task belongs to.""" + release = self.task.getCurrentRelease() + self.assertEqual(release.id, self.release_id) + self.assertEqual(release.title, self.release_title) + print(f"getCurrentRelease -> {release.title}") + + def test_03_get_current_task(self): + """getCurrentTask returns the task running the code.""" + task = self.task.getCurrentTask() + self.assertEqual(task.id, self.task_id) + self.assertEqual(task.title, "Manual Task") + print(f"getCurrentTask -> {task.title}") + + def test_04_get_current_phase(self): + """getCurrentPhase returns the phase that contains the task.""" + phase = self.task.getCurrentPhase() + self.assertEqual(phase.id, self.task.get_phase_id()) + self.assertEqual(phase.title, "Test Phase") + print(f"getCurrentPhase -> {phase.title}") + + def test_05_get_current_folder(self): + """getCurrentFolder returns the folder that contains the release.""" + folder = self.task.getCurrentFolder() + self.assertEqual(folder.id, self.folder_id) + self.assertEqual(folder.title, self.folder_title) + print(f"getCurrentFolder -> {folder.title}") + + # -- release variables -------------------------------------------------- + + def test_06_get_release_variable(self): + """getReleaseVariable returns the value of a current-release variable.""" + value = self.task.getReleaseVariable("relVar") + self.assertEqual(value, "release-value") + print(f"getReleaseVariable('relVar') -> {value}") + + def test_07_set_release_variable(self): + """setReleaseVariable persists a new value, readable back via the getter.""" + updated = self.task.setReleaseVariable("relVar", "release-value-updated") + self.assertEqual(updated.value, "release-value-updated") + self.assertEqual( + self.task.getReleaseVariable("relVar"), "release-value-updated" + ) + print("setReleaseVariable('relVar') -> release-value-updated") + + def test_08_get_release_variable_missing_raises(self): + """getReleaseVariable raises KeyError for an unknown variable name.""" + with self.assertRaises(KeyError): + self.task.getReleaseVariable("doesNotExist") + print("getReleaseVariable(unknown) correctly raised KeyError") + + # -- release variables: set (set-of-string) ----------------------------- + + def test_08a_get_release_set_variable(self): + """getReleaseVariable returns a set variable's value as a list.""" + value = self.task.getReleaseVariable("relSetVar") + # A set is unordered on the server, so compare membership, not order. + self.assertEqual(set(value), {"Apples", "Pears"}) + print(f"getReleaseVariable('relSetVar') -> {value}") + + def test_08b_set_release_set_variable(self): + """setReleaseVariable persists a new set value, readable back via the getter.""" + new_value = ["Oranges", "Bananas", "Grapes"] + updated = self.task.setReleaseVariable("relSetVar", new_value) + self.assertEqual(set(updated.value), set(new_value)) + self.assertEqual( + set(self.task.getReleaseVariable("relSetVar")), set(new_value) + ) + print(f"setReleaseVariable('relSetVar') -> {new_value}") + + # -- release variables: key/value map ----------------------------------- + + def test_08c_get_release_map_variable(self): + """getReleaseVariable returns a key/value-map variable's value as a dict.""" + value = self.task.getReleaseVariable("relMapVar") + self.assertEqual(value, {"env": "dev", "region": "us"}) + print(f"getReleaseVariable('relMapVar') -> {value}") + + def test_08d_set_release_map_variable(self): + """setReleaseVariable persists a new map value, readable back via the getter.""" + new_value = {"env": "prod", "region": "eu", "tier": "gold"} + updated = self.task.setReleaseVariable("relMapVar", new_value) + self.assertEqual(updated.value, new_value) + self.assertEqual(self.task.getReleaseVariable("relMapVar"), new_value) + print(f"setReleaseVariable('relMapVar') -> {new_value}") + + # -- release variables: create when missing ----------------------------- + + def test_08e_set_release_variable_creates_when_missing(self): + """setReleaseVariable creates a string variable when the key is unknown.""" + created = self.task.setReleaseVariable("relNewVar", "created-value") + self.assertEqual(created.key, "relNewVar") + self.assertEqual(created.type, "xlrelease.StringVariable") + self.assertEqual(created.value, "created-value") + self.assertEqual(self.task.getReleaseVariable("relNewVar"), "created-value") + print("setReleaseVariable('relNewVar') created -> created-value") + + def test_08f_set_release_set_variable_creates_when_missing(self): + """setReleaseVariable creates a set variable when the key is unknown.""" + new_value = ["red", "green", "blue"] + created = self.task.setReleaseVariable("relNewSetVar", new_value) + self.assertEqual(created.type, "xlrelease.SetStringVariable") + self.assertEqual(set(created.value), set(new_value)) + self.assertEqual( + set(self.task.getReleaseVariable("relNewSetVar")), set(new_value) + ) + print(f"setReleaseVariable('relNewSetVar') created -> {new_value}") + + def test_08g_set_release_map_variable_creates_when_missing(self): + """setReleaseVariable creates a map variable when the key is unknown.""" + new_value = {"stage": "qa", "owner": "team-a"} + created = self.task.setReleaseVariable("relNewMapVar", new_value) + self.assertEqual(created.type, "xlrelease.MapStringStringVariable") + self.assertEqual(created.value, new_value) + self.assertEqual(self.task.getReleaseVariable("relNewMapVar"), new_value) + print(f"setReleaseVariable('relNewMapVar') created -> {new_value}") + + # -- folder variables --------------------------------------------------- + + def test_09_get_folder_variable(self): + """getFolderVariable resolves a folder variable by its prefixed name.""" + self.assertEqual( + self.task.getFolderVariable("folder.folderVar"), "folder-value" + ) + print("getFolderVariable('folder.folderVar') -> folder-value") + + def test_09a_get_folder_variable_requires_prefix(self): + """getFolderVariable raises ValueError when the folder. prefix is missing.""" + with self.assertRaises(ValueError): + self.task.getFolderVariable("folderVar") + print("getFolderVariable('folderVar') correctly raised ValueError") + + def test_10_set_folder_variable(self): + """setFolderVariable persists a new value on the folder-owned variable.""" + updated = self.task.setFolderVariable( + "folder.folderVar", "folder-value-updated" + ) + self.assertEqual(updated.value, "folder-value-updated") + self.assertEqual( + self.task.getFolderVariable("folder.folderVar"), "folder-value-updated" + ) + print("setFolderVariable('folder.folderVar') -> folder-value-updated") + + def test_10_set_folder_variable_requires_prefix(self): + """setFolderVariable raises ValueError when the folder. prefix is missing.""" + with self.assertRaises(ValueError): + self.task.setFolderVariable("folderVar", "nope") + print("setFolderVariable('folderVar') correctly raised ValueError") + + # -- folder variables: create when missing ------------------------------ + + def test_10a_set_folder_variable_creates_when_missing(self): + """setFolderVariable creates a folder-owned variable when the key is unknown.""" + created = self.task.setFolderVariable("folder.folderNewVar", "folder-created") + self.assertEqual(created.key, "folder.folderNewVar") + self.assertEqual(created.type, "xlrelease.StringVariable") + self.assertEqual(created.value, "folder-created") + self.assertEqual( + self.task.getFolderVariable("folder.folderNewVar"), "folder-created" + ) + print("setFolderVariable('folder.folderNewVar') created -> folder-created") + + def test_10b_set_folder_set_variable_creates_when_missing(self): + """setFolderVariable creates a set variable when the key is unknown.""" + new_value = ["alpha", "beta"] + created = self.task.setFolderVariable("folder.folderNewSetVar", new_value) + self.assertEqual(created.type, "xlrelease.SetStringVariable") + self.assertEqual(set(created.value), set(new_value)) + self.assertEqual( + set(self.task.getFolderVariable("folder.folderNewSetVar")), set(new_value) + ) + print(f"setFolderVariable('folder.folderNewSetVar') created -> {new_value}") + + def test_10c_set_folder_map_variable_creates_when_missing(self): + """setFolderVariable creates a map variable when the key is unknown.""" + new_value = {"team": "infra", "tier": "silver"} + created = self.task.setFolderVariable("folder.folderNewMapVar", new_value) + self.assertEqual(created.type, "xlrelease.MapStringStringVariable") + self.assertEqual(created.value, new_value) + self.assertEqual( + self.task.getFolderVariable("folder.folderNewMapVar"), new_value + ) + print(f"setFolderVariable('folder.folderNewMapVar') created -> {new_value}") + + # -- global variables --------------------------------------------------- + + def test_11_get_global_variable(self): + """getGlobalVariable resolves a global variable by its prefixed name.""" + self.assertEqual( + self.task.getGlobalVariable(f"global.{self.global_var_name}"), + "global-value", + ) + print(f"getGlobalVariable('global.{self.global_var_name}') -> global-value") + + def test_11a_get_global_variable_requires_prefix(self): + """getGlobalVariable raises ValueError when the global. prefix is missing.""" + with self.assertRaises(ValueError): + self.task.getGlobalVariable(self.global_var_name) + print("getGlobalVariable(unprefixed) correctly raised ValueError") + + def test_12_set_global_variable(self): + """setGlobalVariable persists a new value on the global variable.""" + key = f"global.{self.global_var_name}" + updated = self.task.setGlobalVariable(key, "global-value-updated") + self.assertEqual(updated.value, "global-value-updated") + self.assertEqual(self.task.getGlobalVariable(key), "global-value-updated") + print(f"setGlobalVariable('{key}') -> global-value-updated") + + def test_12_set_global_variable_requires_prefix(self): + """setGlobalVariable raises ValueError when the global. prefix is missing.""" + with self.assertRaises(ValueError): + self.task.setGlobalVariable(self.global_var_name, "nope") + print("setGlobalVariable(unprefixed) correctly raised ValueError") + + # -- global variables: create when missing ------------------------------ + + def test_12a_set_global_variable_creates_when_missing(self): + """setGlobalVariable creates a global variable when the key is unknown.""" + name = f"global.abtLiveGlobalNew_{self.uid}" + created = self.task.setGlobalVariable(name, "global-created") + self.created_global_var_ids.append(created.id) + self.assertEqual(created.key, name) + self.assertEqual(created.type, "xlrelease.StringVariable") + self.assertEqual(created.value, "global-created") + self.assertEqual(self.task.getGlobalVariable(name), "global-created") + print(f"setGlobalVariable('{name}') created -> global-created") + + def test_12b_set_global_set_variable_creates_when_missing(self): + """setGlobalVariable creates a set variable when the key is unknown.""" + name = f"global.abtLiveGlobalNewSet_{self.uid}" + new_value = ["x", "y", "z"] + created = self.task.setGlobalVariable(name, new_value) + self.created_global_var_ids.append(created.id) + self.assertEqual(created.type, "xlrelease.SetStringVariable") + self.assertEqual(set(created.value), set(new_value)) + self.assertEqual(set(self.task.getGlobalVariable(name)), set(new_value)) + print(f"setGlobalVariable('{name}') created -> {new_value}") + + def test_12c_set_global_map_variable_creates_when_missing(self): + """setGlobalVariable creates a map variable when the key is unknown.""" + name = f"global.abtLiveGlobalNewMap_{self.uid}" + new_value = {"region": "ap", "tier": "bronze"} + created = self.task.setGlobalVariable(name, new_value) + self.created_global_var_ids.append(created.id) + self.assertEqual(created.type, "xlrelease.MapStringStringVariable") + self.assertEqual(created.value, new_value) + self.assertEqual(self.task.getGlobalVariable(name), new_value) + print(f"setGlobalVariable('{name}') created -> {new_value}") + + # -- ...ByTitle --------------------------------------------------------- + + def test_13_get_phases_by_title(self): + """getPhasesByTitle finds the phase by title in the current release.""" + phases = self.task.getPhasesByTitle("Test Phase") + self.assertGreaterEqual(len(phases), 1) + self.assertTrue(all(p.title == "Test Phase" for p in phases)) + print(f"getPhasesByTitle('Test Phase') -> {len(phases)} phase(s)") + + def test_14_get_tasks_by_title(self): + """getTasksByTitle finds the task by title, optionally scoped to a phase.""" + tasks = self.task.getTasksByTitle("Manual Task") + self.assertGreaterEqual(len(tasks), 1) + self.assertTrue(any(t.id == self.task_id for t in tasks)) + + scoped = self.task.getTasksByTitle("Manual Task", "Test Phase") + self.assertGreaterEqual(len(scoped), 1) + print( + f"getTasksByTitle('Manual Task') -> {len(tasks)} task(s), " + f"scoped to phase -> {len(scoped)} task(s)" + ) + + def test_15_get_releases_by_title(self): + """getReleasesByTitle finds the current release by its title.""" + releases = self.task.getReleasesByTitle(self.release_title) + self.assertGreaterEqual(len(releases), 1) + self.assertTrue(any(r.id == self.release_id for r in releases)) + print(f"getReleasesByTitle('{self.release_title}') -> {len(releases)} release(s)") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/integration/test_base_task.py b/tests/release/integration/test_base_task.py new file mode 100644 index 0000000..a2b5dd8 --- /dev/null +++ b/tests/release/integration/test_base_task.py @@ -0,0 +1,179 @@ +import unittest +from types import SimpleNamespace +from unittest import mock + +from digitalai.release.integration.base_task import BaseTask +from digitalai.release.integration.exceptions import AbortException +from digitalai.release.integration.ids import Ids +from digitalai.release.integration.input_context import ( + AutomatedTaskAsUserContext, + ReleaseContext, +) + +# Realistic ids, matching the sample input context. +FOLDER_ID = 'Applications/Folder1f65c7220b394afbb941154342fd9fc6' +RELEASE_ID = f'{FOLDER_ID}/Release31de09e95c8e4ebb95aaed29a8082d0b' +PHASE_ID = f'{RELEASE_ID}/Phase723a601c78804f7dbcaa8b05b83708f5' +TASK_ID = f'{PHASE_ID}/Task3a35b67b42b6428b854857fba470b39a' + + +class _StubTask(BaseTask): + """Minimal concrete task so BaseTask can be instantiated in tests.""" + + def execute(self) -> None: # pragma: no cover - never run + pass + + +class TestIdParsing(unittest.TestCase): + + def test_phase_id_from_task_id(self): + self.assertEqual(Ids.phase_id_from(TASK_ID), PHASE_ID) + + def test_find_folder_id_from_release_id(self): + self.assertEqual(Ids.find_folder_id(RELEASE_ID), FOLDER_ID) + + def test_phase_id_from_raises_when_absent(self): + with self.assertRaises(ValueError): + Ids.phase_id_from('Applications/Folder1/Release1') + + def test_segment_name_and_parent_id(self): + self.assertEqual(Ids.segment_name(TASK_ID), 'Task3a35b67b42b6428b854857fba470b39a') + self.assertEqual(Ids.parent_id(TASK_ID), PHASE_ID) + self.assertTrue(Ids.is_root('Applications')) + self.assertFalse(Ids.is_root(RELEASE_ID)) + + +class TestBaseTaskIds(unittest.TestCase): + + def _task(self): + task = _StubTask() + task.task_id = TASK_ID + task.release_context = SimpleNamespace(id=RELEASE_ID) + return task + + def test_get_phase_id_derives_from_task_id(self): + self.assertEqual(self._task().get_phase_id(), PHASE_ID) + + def test_get_folder_id_derives_from_release_id(self): + self.assertEqual(self._task().get_folder_id(), FOLDER_ID) + + +class TestBaseTaskOutput(unittest.TestCase): + """Robustness coverage for output handling and credential validation.""" + + def _task(self): + task = _StubTask() + task.task_id = TASK_ID + return task + + def test_execute_task_sets_success_output(self): + task = self._task() + task.execute_task() + ctx = task.get_output_context() + self.assertEqual(ctx.exit_code, 0) + self.assertEqual(ctx.job_error_message, "") + + def test_execute_task_captures_unexpected_error(self): + class _Boom(_StubTask): + def execute(self) -> None: + raise RuntimeError("boom") + + task = _Boom() + task.execute_task() + ctx = task.get_output_context() + self.assertEqual(ctx.exit_code, 1) + self.assertEqual(ctx.job_error_message, "boom") + + def test_execute_task_handles_abort(self): + class _Abort(_StubTask): + def execute(self) -> None: + raise AbortException() + + task = _Abort() + with self.assertRaises(SystemExit) as cm: + task.execute_task() + self.assertEqual(cm.exception.code, 1) + ctx = task.get_output_context() + self.assertEqual(ctx.exit_code, 1) + self.assertEqual(ctx.job_error_message, "Abort requested") + + def test_set_output_property_rejects_empty_name(self): + task = self._task() + task.execute_task() + with self.assertRaises(ValueError): + task.set_output_property("", "value") + + def test_set_output_property_rejects_unsupported_type(self): + task = self._task() + task.execute_task() + with self.assertRaises(ValueError): + task.set_output_property("name", object()) + + def test_get_input_properties_requires_value(self): + task = self._task() + with self.assertRaises(ValueError): + task.get_input_properties() + + def test_get_task_user_returns_none_without_release_context(self): + task = self._task() + self.assertIsNone(task.get_task_user()) + + def test_validate_api_credentials_raises_without_user(self): + task = self._task() + task.release_server_url = "http://localhost:5516" + with self.assertRaises(ValueError): + task.get_release_api_client() + + +class TestAutomatedTaskAsUser(unittest.TestCase): + """Coverage for the 'Run as user' (automatedTaskAsUser) username/password.""" + + SERVER_URL = "http://localhost:5516" + + def _task(self, username, password): + task = _StubTask() + task.task_id = TASK_ID + task.release_server_url = self.SERVER_URL + task.release_context = ReleaseContext( + id=RELEASE_ID, + automated_task_as_user=AutomatedTaskAsUserContext(username=username, password=password), + ) + return task + + def test_get_task_user_returns_credentials(self): + task = self._task("admin", "secret") + user = task.get_task_user() + self.assertEqual(user.username, "admin") + self.assertEqual(user.password, "secret") + + def test_get_release_api_client_passes_credentials(self): + task = self._task("admin", "secret") + with mock.patch("digitalai.release.integration.base_task.ReleaseAPIClient") as fake_client: + client = task.get_release_api_client() + fake_client.assert_called_once_with(self.SERVER_URL, "admin", "secret", timeout=None) + self.assertIs(client, fake_client.return_value) + + def test_get_release_api_client_raises_when_password_missing(self): + task = self._task("admin", None) + with self.assertRaises(ValueError): + task.get_release_api_client() + + def test_get_release_api_client_raises_when_username_missing(self): + task = self._task(None, "secret") + with self.assertRaises(ValueError): + task.get_release_api_client() + + def test_get_release_api_client_raises_when_credentials_blank(self): + task = self._task("", "") + with self.assertRaises(ValueError): + task.get_release_api_client() + + def test_get_release_api_client_raises_without_server_url(self): + task = self._task("admin", "secret") + task.release_server_url = None + with self.assertRaises(ValueError): + task.get_release_api_client() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/release/integration/test_wrapper.py b/tests/release/integration/test_wrapper.py index fac4da0..2e224f3 100644 --- a/tests/release/integration/test_wrapper.py +++ b/tests/release/integration/test_wrapper.py @@ -1,33 +1,103 @@ import json import os import subprocess +import sys import unittest +# Directory that holds the test fixtures (input.json, hello.py, src/...). +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Sample input context for a "Hello" container task (matches input.json). +SAMPLE_INPUT_CONTEXT = { + "release": { + "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b", + "automatedTaskAsUser": {"username": "admin", "password": "admin"}, + }, + "task": { + "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b/Phase723a601c78804f7dbcaa8b05b83708f5/Task3a35b67b42b6428b854857fba470b39a", + "type": "containerExamples.Hello", + "properties": [ + {"name": "capabilities", "value": ["remote"], "kind": "SET_OF_STRING", "category": "input", "password": False}, + {"name": "yourName", "value": "World", "kind": "STRING", "category": "input", "password": False}, + {"name": "greeting", "value": None, "kind": "STRING", "category": "output", "password": False}, + ], + }, +} + +# Same task, but with an explicit scriptLocation so the wrapper loads the class via +# the `if script_path:` branch (importlib) instead of walking the tree. Points at the +# src/sample/hello.py fixture, which defines the Hello1 class. +SAMPLE_INPUT_CONTEXT_WITH_SCRIPT = { + "release": SAMPLE_INPUT_CONTEXT["release"], + "task": { + "id": SAMPLE_INPUT_CONTEXT["task"]["id"], + "type": "containerExamples.Hello1", + "properties": SAMPLE_INPUT_CONTEXT["task"]["properties"] + [ + {"name": "scriptLocation", "value": "sample/hello.py", "kind": "STRING", "category": "input", "password": False}, + ], + }, +} + +EXPECTED_OUTPUT = { + "exitCode": 0, + "jobErrorMessage": "", + "outputProperties": {"greeting": "Hello World"}, + "reportingRecords": [], +} + class TestWrapper(unittest.TestCase): + """End-to-end test that runs the wrapper against a sample input context.""" - def test_wrapper(self): - """ - This test method sets the environment variables INPUT_LOCATION and OUTPUT_LOCATION, - then runs the integration wrapper script using subprocess.run. - The wrapper script will generate the output.json. - It then opens and reads the contents of the expected_output.json and output.json files, - and compares the contents using self.assertEqual to check if they are equal. - """ + def setUp(self): + self.input_path = os.path.join(THIS_DIR, "input.json") + self.output_path = os.path.join(THIS_DIR, "output.json") + # Start from a clean slate so a stale file can never mask a failure. + if os.path.exists(self.output_path): + os.remove(self.output_path) - os.environ['INPUT_LOCATION'] = "input.json" - os.environ['OUTPUT_LOCATION'] = "output.json" - os.environ['RELEASE_URL'] = "http://localhost:5516" + def _run_wrapper(self, input_context): + """Write the given input context, run the wrapper as a subprocess, and return the parsed output.""" + with open(self.input_path, "w") as f: + json.dump(input_context, f) - subprocess.run(["python", "-m", "digitalai.release.integration.wrapper"]) + env = dict(os.environ) + env["INPUT_LOCATION"] = "input.json" + env["OUTPUT_LOCATION"] = "output.json" + env["RELEASE_URL"] = "http://localhost:5516" - with open('expected_output.json', 'r') as json_file: - expected_output = json.load(json_file) + result = subprocess.run( + [sys.executable, "-m", "digitalai.release.integration.wrapper"], + cwd=THIS_DIR, + env=env, + capture_output=True, + text=True, + ) - with open('output.json', 'r') as json_file: - actual_output = json.load(json_file) + self.assertEqual( + result.returncode, 0, + msg=f"wrapper exited with {result.returncode}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) + self.assertTrue(os.path.exists(self.output_path), "wrapper did not produce output.json") - self.assertEqual(expected_output, actual_output) + with open(self.output_path, "r") as json_file: + return json.load(json_file) + + def test_wrapper(self): + """ + Runs the wrapper with a sample input context that has no scriptLocation, so the + task class is resolved via the find_class_file fallback (the `else` branch of run()). + """ + actual_output = self._run_wrapper(SAMPLE_INPUT_CONTEXT) + self.assertEqual(EXPECTED_OUTPUT, actual_output) + + def test_wrapper_with_script_location(self): + """ + Runs the wrapper with a scriptLocation set, so the task class is loaded via + importlib (the `if script_path:` branch of run()). + """ + actual_output = self._run_wrapper(SAMPLE_INPUT_CONTEXT_WITH_SCRIPT) + self.assertEqual(EXPECTED_OUTPUT, actual_output) if __name__ == '__main__': diff --git a/tests/release/integration/test_wrapper_k8s.py b/tests/release/integration/test_wrapper_k8s.py new file mode 100644 index 0000000..78c3f7e --- /dev/null +++ b/tests/release/integration/test_wrapper_k8s.py @@ -0,0 +1,283 @@ +""" +Tests for the wrapper's Kubernetes execution path. + +When the runner executes inside Kubernetes there is no INPUT_LOCATION file; the +input context is read from a Secret, and the result is written back to a Secret +and/or pushed to a callback URL. These tests exercise that path with a fully +mocked Kubernetes client and callback transport, so no real cluster or network +is required. + +Secret values in Kubernetes are base64-encoded strings, and the wrapper applies +its own (double) base64 encoding to the callback/fetch URLs. The helpers below +mirror that encoding so the fakes look exactly like a real Secret. +""" + +import base64 +import json +import unittest +from unittest import mock + +import digitalai.release.integration.wrapper as wrapper +from digitalai.release.integration import k8s +from digitalai.release.integration.output_context import OutputContext + +# Sample input context (Hello task) reused from the file-based wrapper test. +SAMPLE_INPUT_CONTEXT = { + "release": { + "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b", + "automatedTaskAsUser": {"username": "admin", "password": "admin"}, + }, + "task": { + "id": "Applications/Folder1f65c7220b394afbb941154342fd9fc6/Release31de09e95c8e4ebb95aaed29a8082d0b/Phase723a601c78804f7dbcaa8b05b83708f5/Task3a35b67b42b6428b854857fba470b39a", + "type": "containerExamples.Hello", + "properties": [ + {"name": "capabilities", "value": ["remote"], "kind": "SET_OF_STRING", "category": "input", "password": False}, + {"name": "yourName", "value": "World", "kind": "STRING", "category": "input", "password": False}, + {"name": "greeting", "value": None, "kind": "STRING", "category": "output", "password": False}, + ], + }, +} + + +def _b64(value) -> str: + """base64-encode a str/bytes the way a Kubernetes Secret stores its values.""" + if isinstance(value, str): + value = value.encode("UTF-8") + return base64.b64encode(value).decode("UTF-8") + + +def _double_b64_url(url: str) -> str: + """The wrapper expects callback/fetch URLs to be double base64-encoded.""" + return _b64(_b64(url)) + + +class FakeSecret: + """Minimal stand-in for a kubernetes V1Secret (only ``.data`` is used).""" + + def __init__(self, data): + self.data = dict(data) + + +class FakeCoreV1Api: + """Records secret reads/replacements so assertions can inspect them.""" + + def __init__(self, secrets): + self.secrets = secrets # name -> FakeSecret + self.replaced = [] # list of (name, namespace, body) + + def read_namespaced_secret(self, name, namespace): + return self.secrets[name] + + def replace_namespaced_secret(self, name, namespace, body): + self.replaced.append((name, namespace, body)) + self.secrets[name] = body + + +class _WrapperK8sTestBase(unittest.TestCase): + """Shared setup: patch the wrapper's module globals and the k8s client.""" + + INPUT_SECRET = "input-context-secret" + NAMESPACE = "runner-ns" + CALLBACK_URL = "http://release-server/callback" + + def _patch_global(self, name, value): + patcher = mock.patch.object(wrapper, name, value) + patcher.start() + self.addCleanup(patcher.stop) + + def _install_fake_client(self, secrets): + fake = FakeCoreV1Api(secrets) + patcher = mock.patch.object(k8s, "get_client", return_value=fake) + patcher.start() + self.addCleanup(patcher.stop) + return fake + + def setUp(self): + # Force the Kubernetes branch: no input/output files. + self._patch_global("input_context_file", "") + self._patch_global("output_context_file", "") + self._patch_global("input_context_secret", self.INPUT_SECRET) + self._patch_global("runner_namespace", self.NAMESPACE) + # A blank session-key decodes to b"" -> NoOp encryptor (plaintext I/O), + # which keeps the test independent of AES key material. + self._patch_global("base64_session_key", "") + + +class TestGetTaskDetailsFromSecret(_WrapperK8sTestBase): + + def test_reads_input_context_from_secret(self): + input_json = json.dumps(SAMPLE_INPUT_CONTEXT) + secret = FakeSecret({ + "session-key": _b64(""), # -> NoOp encryptor + "url": _double_b64_url(self.CALLBACK_URL), + "input": _b64(input_json), + }) + self._install_fake_client({self.INPUT_SECRET: secret}) + + task_properties, task_type, script_path = wrapper.get_task_details() + + self.assertEqual(task_type, "containerExamples.Hello") + self.assertEqual(task_properties["yourName"], "World") + self.assertEqual(script_path, "") + # callback_url global is set to the (single-decoded) callback bytes. + self.assertEqual(base64.b64decode(wrapper.callback_url).decode("UTF-8"), self.CALLBACK_URL) + # The "Run as user" password is registered as a secret to be masked. + self.assertIn("admin", wrapper.masked_std_out.secrets) + + def test_reads_input_context_via_fetch_url_when_input_empty(self): + input_json = json.dumps(SAMPLE_INPUT_CONTEXT) + fetch_url = "http://blob-store/large-input" + secret = FakeSecret({ + "session-key": _b64(""), + "url": _double_b64_url(self.CALLBACK_URL), + "input": "", # empty -> use fetchUrl + "fetchUrl": _double_b64_url(fetch_url), + }) + self._install_fake_client({self.INPUT_SECRET: secret}) + + fake_response = mock.Mock() + fake_response.content = input_json.encode("UTF-8") + fake_response.status_code = 200 + fake_response.raise_for_status = mock.Mock() + + with mock.patch.object(wrapper._http_session, "get", return_value=fake_response) as fake_get: + task_properties, task_type, _ = wrapper.get_task_details() + + fake_get.assert_called_once() + self.assertEqual(fake_get.call_args.args[0], fetch_url) + # A timeout must be supplied so a hung blob store cannot stall the runner. + self.assertIn("timeout", fake_get.call_args.kwargs) + fake_response.raise_for_status.assert_called_once() + self.assertEqual(task_type, "containerExamples.Hello") + self.assertEqual(task_properties["yourName"], "World") + + def test_missing_fetch_url_raises(self): + secret = FakeSecret({ + "session-key": _b64(""), + "url": _double_b64_url(self.CALLBACK_URL), + "input": "", + "fetchUrl": "", + }) + self._install_fake_client({self.INPUT_SECRET: secret}) + + with self.assertRaises(ValueError): + wrapper.get_task_details() + + def test_secret_read_failure_propagates(self): + # get_task_details does not swallow k8s errors: a failure to read the + # input secret must surface so run() can report the task as failed. + fake = mock.Mock() + fake.read_namespaced_secret.side_effect = RuntimeError("k8s unavailable") + patcher = mock.patch.object(k8s, "get_client", return_value=fake) + patcher.start() + self.addCleanup(patcher.stop) + + with self.assertRaises(RuntimeError): + wrapper.get_task_details() + + +class TestUpdateOutputContextToSecret(_WrapperK8sTestBase): + + RESULT_SECRET_NAME = "result-secret" + RESULT_KEY = "result" + + def setUp(self): + super().setUp() + self._patch_global("result_secret_key", f"{self.NAMESPACE}:{self.RESULT_SECRET_NAME}:{self.RESULT_KEY}") + # callback_url global as set by get_task_details: single base64 of the URL. + self._patch_global("callback_url", _b64(self.CALLBACK_URL).encode("UTF-8")) + + def test_writes_result_to_secret_and_pushes_callback(self): + result_secret = FakeSecret({self.RESULT_KEY: ""}) + fake = self._install_fake_client({self.RESULT_SECRET_NAME: result_secret}) + + output = OutputContext(0, "", {"greeting": "Hello World"}, []) + + with mock.patch.object(wrapper, "_post_callback") as fake_post: + wrapper.update_output_context(output) + + # Result written back to the secret (NoOp encryptor -> plaintext JSON). + self.assertEqual(len(fake.replaced), 1) + name, namespace, body = fake.replaced[0] + self.assertEqual(name, self.RESULT_SECRET_NAME) + self.assertEqual(namespace, self.NAMESPACE) + stored = json.loads(body.data[self.RESULT_KEY]) + self.assertEqual(stored["outputProperties"], {"greeting": "Hello World"}) + + # Callback pushed to the decoded URL with the encrypted body. + fake_post.assert_called_once() + pushed_url, pushed_body = fake_post.call_args.args + self.assertEqual(pushed_url, self.CALLBACK_URL) + self.assertEqual(json.loads(pushed_body)["exitCode"], 0) + + def test_result_too_large_skips_secret_but_still_pushes_callback(self): + result_secret = FakeSecret({self.RESULT_KEY: ""}) + fake = self._install_fake_client({self.RESULT_SECRET_NAME: result_secret}) + + # > 1Mb of output so it cannot be stored in a Secret. + big_value = "x" * (wrapper.size_of_1Mb + 1024) + output = OutputContext(0, "", {"big": big_value}, []) + + with mock.patch.object(wrapper, "_post_callback") as fake_post: + wrapper.update_output_context(output) + + # Secret write skipped because the payload is too big. + self.assertEqual(fake.replaced, []) + # Callback is still attempted. + fake_post.assert_called_once() + + def test_secret_write_failure_is_swallowed_and_logged(self): + # update_output_context must never raise: a failure to write the result + # secret is logged and swallowed so the runner exits cleanly. Because the + # failure happens before the callback step, no callback is attempted. + fake = mock.Mock() + fake.read_namespaced_secret.side_effect = RuntimeError("k8s write unavailable") + patcher = mock.patch.object(k8s, "get_client", return_value=fake) + patcher.start() + self.addCleanup(patcher.stop) + + output = OutputContext(0, "", {"greeting": "Hello World"}, []) + + with mock.patch.object(wrapper, "_post_callback") as fake_post: + # Must not raise. + wrapper.update_output_context(output) + + fake_post.assert_not_called() + + def test_callback_retries_when_too_big_and_no_output_file(self): + result_secret = FakeSecret({self.RESULT_KEY: ""}) + # The retry path re-reads the input-context secret for a fresh URL. + input_secret = FakeSecret({"url": _double_b64_url(self.CALLBACK_URL)}) + self._install_fake_client({ + self.RESULT_SECRET_NAME: result_secret, + self.INPUT_SECRET: input_secret, + }) + + big_value = "x" * (wrapper.size_of_1Mb + 1024) + output = OutputContext(0, "", {"big": big_value}, []) + + # First push fails; should_retry_callback_request() is True (too big, no + # output file) so retry_push_result_infinitely is invoked and succeeds. + with mock.patch.object(wrapper, "_post_callback", side_effect=[RuntimeError("boom"), mock.Mock()]) as fake_post: + wrapper.update_output_context(output) + + self.assertEqual(fake_post.call_count, 2) + + +class TestShouldRetryCallbackRequest(unittest.TestCase): + + def test_retries_when_too_big_and_no_output_file(self): + with mock.patch.object(wrapper, "input_context_file", ""): + self.assertTrue(wrapper.should_retry_callback_request("x" * (wrapper.size_of_1Mb + 1))) + + def test_no_retry_when_small(self): + with mock.patch.object(wrapper, "input_context_file", ""): + self.assertFalse(wrapper.should_retry_callback_request("small")) + + def test_no_retry_when_output_file_present(self): + with mock.patch.object(wrapper, "input_context_file", "output.json"): + self.assertFalse(wrapper.should_retry_callback_request("x" * (wrapper.size_of_1Mb + 1))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/release/test_release_api_client.py b/tests/release/test_release_api_client.py index c47ba03..ee5d2bc 100644 --- a/tests/release/test_release_api_client.py +++ b/tests/release/test_release_api_client.py @@ -1,73 +1,39 @@ import unittest +from com.xebialabs.xlrelease.release_api_client import ( + ReleaseAPIClient as ApiClientReleaseAPIClient, +) from digitalai.release.release_api_client import ReleaseAPIClient -class TestReleaseAPIClient(unittest.TestCase): +class TestReleaseAPIClientBackwardCompatibility(unittest.TestCase): + """The old ``digitalai.release.release_api_client`` import path must keep + working as a drop-in for the standalone api-client implementation.""" - @classmethod - def setUpClass(cls): - """Set up the API client before running tests.""" - cls.client = ReleaseAPIClient("http://localhost:5516", "admin", "admin") - cls.global_variable_id = None # Store ID at the class level + def test_is_subclass_of_api_client_class(self): + """The shim extends the standalone api-client class.""" + self.assertTrue(issubclass(ReleaseAPIClient, ApiClientReleaseAPIClient)) - @classmethod - def tearDownClass(cls): - """Close the API client session after all tests.""" - cls.client.close() + def test_instance_is_recognized_as_api_client_class(self): + """Instances created via the old path are instances of both classes.""" + client = ReleaseAPIClient("http://localhost:5516", "admin", "admin") + try: + self.assertIsInstance(client, ReleaseAPIClient) + self.assertIsInstance(client, ApiClientReleaseAPIClient) + finally: + client.close() - def test_01_create_global_variable(self): - """Test creating a new global variable.""" - global_variable = { - "id": None, - "key": "global.testVar", - "type": "xlrelease.StringVariable", - "requiresValue": "false", - "showOnReleaseStart": "false", - "value": "test value" - } - response = self.client.post("/api/v1/config/Configuration/variables/global", json=global_variable) - self.assertEqual(response.status_code, 200, f"Unexpected status code: {response.status_code}") + def test_constructor_behaves_like_base(self): + """Construction, URL normalization, and auth match the base class.""" + with ReleaseAPIClient("http://localhost:5516/", "admin", "secret") as client: + self.assertEqual(client.server_address, "http://localhost:5516") + self.assertEqual(client.session.auth, ("admin", "secret")) - # Store ID in class attribute - TestReleaseAPIClient.global_variable_id = response.json().get("id") - print(f"Created global variable ID: {TestReleaseAPIClient.global_variable_id}") + def test_constructor_requires_credentials(self): + """The base validation still applies through the shim.""" + with self.assertRaises(ValueError): + ReleaseAPIClient("http://localhost:5516") - def test_02_update_global_variable(self): - """Test updating an existing global variable.""" - if not TestReleaseAPIClient.global_variable_id: - self.skipTest("Global variable ID is not set. Run test_01_create_global_variable first.") - - updated_variable = { - "id": TestReleaseAPIClient.global_variable_id, - "key": "global.testVar", - "type": "xlrelease.StringVariable", - "requiresValue": "false", - "showOnReleaseStart": "false", - "value": "updated test value" - } - - response = self.client.put(f"/api/v1/config/{TestReleaseAPIClient.global_variable_id}", json=updated_variable) - self.assertEqual(response.status_code, 200, f"Unexpected status code: {response.status_code}") - print("Global variable updated successfully.") - - def test_03_get_global_variable(self): - """Test retrieving the global variable.""" - if not TestReleaseAPIClient.global_variable_id: - self.skipTest("Global variable ID is not set. Run test_01_create_global_variable first.") - - response = self.client.get(f"/api/v1/config/{TestReleaseAPIClient.global_variable_id}") - self.assertEqual(response.status_code, 200, f"Unexpected status code: {response.status_code}") - print(f"Retrieved global variable: {response.json()}") - - def test_04_delete_global_variable(self): - """Test deleting the global variable.""" - if not TestReleaseAPIClient.global_variable_id: - self.skipTest("Global variable ID is not set. Run test_01_create_global_variable first.") - - response = self.client.delete(f"/api/v1/config/{TestReleaseAPIClient.global_variable_id}") - self.assertEqual(response.status_code, 204, f"Unexpected status code: {response.status_code}") - print("Global variable deleted successfully.") if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1ff7be8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,702 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182 }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329 }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230 }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890 }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930 }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109 }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684 }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785 }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055 }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502 }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295 }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145 }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884 }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343 }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174 }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805 }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705 }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419 }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901 }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742 }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061 }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239 }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173 }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841 }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304 }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455 }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036 }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739 }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277 }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819 }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281 }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843 }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282 }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595 }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986 }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711 }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036 }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998 }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056 }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537 }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176 }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723 }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085 }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819 }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915 }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234 }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042 }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706 }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727 }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882 }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860 }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564 }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276 }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238 }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189 }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352 }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024 }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869 }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541 }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634 }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384 }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133 }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257 }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851 }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393 }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609 }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014 }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979 }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238 }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110 }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824 }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103 }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194 }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827 }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168 }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018 }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 }, +] + +[[package]] +name = "digitalai-release-api-client" +version = "26.3.0b4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/4a/d3921dfed14fb5a5b82a03d50e3f13275558423f5faca3d713e1ffeffdfc/digitalai_release_api_client-26.3.0b4.tar.gz", hash = "sha256:323e7cfdaef76bf01398e2d3877d0d06d2772948346a2b49b43df43efbc73ed9", size = 39114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/58/553b7861cf9eecea7a7cf8f62db2438439099efb79816447ed1c93e09d13/digitalai_release_api_client-26.3.0b4-py3-none-any.whl", hash = "sha256:28f23e157ab689af7c9d02310f4b4fd427dcc7b6b2caf7c41ec53971a5706a14", size = 67602 }, +] + +[[package]] +name = "digitalai-release-sdk" +version = "26.3.0b4" +source = { editable = "." } +dependencies = [ + { name = "dataclasses-json" }, + { name = "digitalai-release-api-client" }, + { name = "kubernetes" }, + { name = "pycryptodomex" }, + { name = "python-dateutil" }, + { name = "requests" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "dataclasses-json", specifier = ">=0.6.7,<1.0.0" }, + { name = "digitalai-release-api-client", specifier = "==26.3.0b4" }, + { name = "kubernetes", specifier = ">=35.0.0,<36.0.0" }, + { name = "pycryptodomex", specifier = ">=3.23.0,<4.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "python-dateutil", specifier = ">=2.9.0,<3.0.0" }, + { name = "requests", specifier = ">=2.32.5,<3.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "kubernetes" +version = "35.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/8f/85bf51ad4150f64e8c665daf0d9dfe9787ae92005efb9a4d1cba592bd79d/kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee", size = 1094642 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/70/05b685ea2dffcb2adbf3cdcea5d8865b7bc66f67249084cf845012a0ff13/kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d", size = 2017602 }, +] + +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764 }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012 }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643 }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762 }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012 }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856 }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523 }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825 }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078 }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656 }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172 }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240 }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042 }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227 }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578 }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166 }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467 }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104 }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038 }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969 }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124 }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161 }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695 }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772 }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083 }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056 }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478 }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146 }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769 }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958 }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118 }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876 }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703 }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042 }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231 }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388 }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769 }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312 }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817 }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085 }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311 }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872 }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255 }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827 }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051 }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314 }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146 }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685 }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420 }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122 }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573 }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139 }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433 }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513 }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114 }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298 }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158 }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724 }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742 }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418 }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274 }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940 }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516 }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854 }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306 }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044 }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133 }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464 }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823 }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919 }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604 }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782 }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146 }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492 }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604 }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828 }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000 }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286 }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616 }, +]