diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e570edc9..3a3856bc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,7 +41,7 @@ repos: exclude: ^docs/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.0 + rev: v0.16.1 hooks: - id: ruff - id: ruff-format diff --git a/AGENTS.md b/AGENTS.md index 9035bb49..9d9775ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -598,12 +598,17 @@ with ResourceEditor(node) as editor: ## Critical Constraints -### Test Execution Prohibition +### Test Execution Requirements (MUST) -AI must NEVER run tests directly (`pytest`, `uv run pytest`). Tests require live clusters, provider connections, and credentials. +Tests interact with live OpenShift clusters and source providers. Before running tests: -AI can: Read/analyze/write/fix tests, suggest improvements, review structure -AI cannot: Execute tests, validate by running +- **Cluster access:** A dedicated test cluster with `kubeadmin` or equivalent credentials is required +- **Provider credentials:** A valid `.providers.json` with source provider connection details +- **Isolation:** Tests create unique namespaces per session (`session_uuid`) to prevent OCP resource collisions between parallel runs +- **Parallel safety:** Namespace isolation does not prevent source-provider VM conflicts when multiple + runs share the same source VM names — use separate provider configurations or VM cloning for true + parallel safety +- **Cleanup:** Tests clean up resources via `fixture_store` teardown — use `--skip-teardown` to preserve resources for debugging ### No Module-Level Provider Loading (MUST) @@ -708,6 +713,20 @@ namespace = Namespace(client=ocp_admin_client, name="my-namespace") namespace.deploy() ``` +When intentionally deleting a resource mid-test (e.g., testing plan archive+delete), unregister it +from teardown to prevent session cleanup from operating on a missing resource: + +```python +# Assumes Plan, Migration, archive_plan, get_migration_for_plan are already imported +from utilities.resources import unregister_teardown_resource + +migration_name = get_migration_for_plan(plan).name +archive_plan(plan=self.plan_resource) +self.plan_resource.clean_up(wait=True) +unregister_teardown_resource(fixture_store=fixture_store, kind=Plan.kind, name=self.plan_resource.name) +unregister_teardown_resource(fixture_store=fixture_store, kind=Migration.kind, name=migration_name) +``` + ## Test Structure Pattern All tests follow a class-based structure with 5 base test methods: @@ -1070,12 +1089,14 @@ When multiple issues exist, address them in this order: ## Parallel Execution (pytest-xdist) -Tests are parallel-safe because: +Tests are parallel-safe for OpenShift resources because: - Unique namespaces per session via `session_uuid` - Each worker has isolated `fixture_store` - `create_and_store_resource()` generates unique names +**Note:** Namespace isolation does not prevent source-provider VM conflicts — see Test Execution Requirements above. + Rules: - Always use fixtures for namespaces (never hardcode) diff --git a/conftest.py b/conftest.py index 43afd653..a30e4b7e 100644 --- a/conftest.py +++ b/conftest.py @@ -329,11 +329,7 @@ def pytest_collection_modifyitems(session, config, items): if source_provider_type != Provider.ProviderType.VSPHERE: vsphere_only_skip = pytest.mark.skip(reason="Test is only applicable to vSphere source providers") for item in items: - if ( - "copyoffload" in item.keywords - or "shared_disk" in item.keywords - or "deep_inspection" in item.keywords - ): + if any(kw in item.keywords for kw in ("copyoffload", "shared_disk", "deep_inspection", "aap")): item.add_marker(vsphere_only_skip) # Skip CA cert tests for providers that don't use CA certificates. diff --git a/pytest.ini b/pytest.ini index e9477790..38a4194e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -32,6 +32,7 @@ markers = openshift: OpenShift provider-specific tests deep_inspection: Deep Inspection / Conversion CR tests (vSphere only) ca_crt: CA certificate field (ca.crt) provider secret tests + aap: AAP (Ansible Automation Platform) hook integration tests (vSphere only) upgrade: MTV operator upgrade tests junit_logging = all diff --git a/tests/hooks/conftest.py b/tests/hooks/conftest.py new file mode 100644 index 00000000..475dc359 --- /dev/null +++ b/tests/hooks/conftest.py @@ -0,0 +1,301 @@ +""" +Fixtures for AAP (Ansible Automation Platform) hook integration tests. + +Session-scoped fixtures deploy AWX on the cluster and configure MTV +to use it. Class-scoped fixtures create AAP Hook CRs for each test class. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any, Generator + +import filelock +import pytest +from ocp_resources.forklift_controller import ForkliftController +from ocp_resources.resource import ResourceEditor +from simple_logger.logger import get_logger + +from utilities.aap import ( + AAP_TEST_PLAYBOOKS_REPO, + AWX_ADMIN_USERNAME, + AWX_NAMESPACE, + AWX_POSTHOOK_PLAYBOOK, + AWX_POSTHOOK_TEMPLATE_NAME, + AWX_PREHOOK_PLAYBOOK, + AWX_PREHOOK_TEMPLATE_NAME, + AWX_PROJECT_NAME, + create_aap_token_secret, + create_awx_auth_token, + create_awx_instance, + create_awx_inventory, + create_awx_job_template, + create_awx_project, + deploy_awx_via_helm, + get_awx_admin_password, + is_awx_installed, + get_awx_route_url, + teardown_awx, + wait_for_awx_api_ready, + wait_for_awx_project_sync, + wait_for_awx_ready, +) +from utilities.forklift_controller_populator import ensure_secure_shared_lock_dir +from utilities.hooks import create_hook_for_plan + +if TYPE_CHECKING: + from kubernetes.dynamic import DynamicClient + +LOGGER = get_logger(__name__) + +FORKLIFT_CONTROLLER_NAME: str = ( + "forklift-controller" # Well-known name of the ForkliftController CR created by MTV operator +) +AAP_FORKLIFT_CONTROLLER_LOCK_TIMEOUT: int = 3600 # seconds; covers entire session-scoped fixture + + +def _get_aap_forklift_controller_lock_path() -> Path: + """Return the cross-worker lock path for ForkliftController AAP setting changes. + + Returns: + Path: File lock path under a secured shared temp directory. + """ + lock_dir = Path(tempfile.gettempdir()) / "pytest-shared-forklift" + ensure_secure_shared_lock_dir(lock_dir=lock_dir) + return lock_dir / "aap-settings.lock" + + +@pytest.fixture(scope="session") +def awx_deployment( + ocp_admin_client: "DynamicClient", + fixture_store: dict[str, Any], +) -> Generator[str, None, None]: + """Deploy AWX via Helm and return the route URL. + + Installs the AWX Operator, creates an AWX instance with CephFS storage, + and waits for all pods to be ready. AWX is a third-party CRD installed + at runtime — cleanup is handled by namespace deletion in teardown_awx(). + + Args: + ocp_admin_client: OpenShift admin client. + fixture_store: Fixture store for resource tracking. + + Yields: + str: AWX web route URL (https://...). + """ + helm_installed_by_us = not is_awx_installed() + awx_cr_created_by_us = False + try: + deploy_awx_via_helm() + awx_cr_created_by_us = create_awx_instance( + ocp_admin_client=ocp_admin_client, fixture_store=fixture_store, namespace=AWX_NAMESPACE + ) + wait_for_awx_ready(ocp_admin_client=ocp_admin_client, namespace=AWX_NAMESPACE) + awx_url = get_awx_route_url(ocp_admin_client=ocp_admin_client, namespace=AWX_NAMESPACE) + password = get_awx_admin_password(ocp_admin_client=ocp_admin_client, namespace=AWX_NAMESPACE) + wait_for_awx_api_ready(awx_url=awx_url, username=AWX_ADMIN_USERNAME, password=password) + yield awx_url + finally: + if helm_installed_by_us or awx_cr_created_by_us: + teardown_awx() + + +@pytest.fixture(scope="session") +def awx_api_token( + ocp_admin_client: "DynamicClient", + awx_deployment: str, +) -> str: + """Create an AWX OAuth2 API token. + + Args: + ocp_admin_client: OpenShift admin client. + awx_deployment: AWX route URL. + + Returns: + str: OAuth2 token for AWX API authentication. + """ + password = get_awx_admin_password( + ocp_admin_client=ocp_admin_client, + namespace=AWX_NAMESPACE, + ) + return create_awx_auth_token( + awx_url=awx_deployment, + username=AWX_ADMIN_USERNAME, + password=password, + ) + + +@pytest.fixture(scope="session") +def awx_job_templates( + awx_deployment: str, + awx_api_token: str, +) -> dict[str, int]: + """Create AWX project and job templates for pre/post hooks. + + Creates a project from the mtv-aap-test-playbooks git repo, + waits for SCM sync, then creates two job templates. + + Args: + awx_deployment: AWX route URL. + awx_api_token: AWX API token. + + Returns: + dict[str, int]: Mapping of hook type to template ID: + ``{"pre_hook": , "post_hook": }`` + """ + project_id = create_awx_project( + awx_url=awx_deployment, + token=awx_api_token, + name=AWX_PROJECT_NAME, + scm_url=AAP_TEST_PLAYBOOKS_REPO, + ) + wait_for_awx_project_sync( + awx_url=awx_deployment, + token=awx_api_token, + project_id=project_id, + ) + + inventory_id = create_awx_inventory( + awx_url=awx_deployment, + token=awx_api_token, + ) + + pre_hook_id = create_awx_job_template( + awx_url=awx_deployment, + token=awx_api_token, + name=AWX_PREHOOK_TEMPLATE_NAME, + project_id=project_id, + playbook=AWX_PREHOOK_PLAYBOOK, + inventory_id=inventory_id, + ) + post_hook_id = create_awx_job_template( + awx_url=awx_deployment, + token=awx_api_token, + name=AWX_POSTHOOK_TEMPLATE_NAME, + project_id=project_id, + playbook=AWX_POSTHOOK_PLAYBOOK, + inventory_id=inventory_id, + ) + + return {"pre_hook": pre_hook_id, "post_hook": post_hook_id} + + +@pytest.fixture(scope="session") +def aap_mtv_settings( + ocp_admin_client: "DynamicClient", + fixture_store: dict[str, Any], + mtv_namespace: str, + session_uuid: str, + awx_deployment: str, + awx_api_token: str, +) -> Generator[None, None, None]: + """Configure MTV to use AWX for AAP hooks. + + Creates a session-unique AWX token Secret in the MTV namespace and patches + the ForkliftController with ``aap_token_secret_name``, ``aap_url``, and + ``aap_insecure_skip_verify``. A file lock serializes ForkliftController + changes across pytest-xdist workers for the entire session duration. + + Args: + ocp_admin_client: OpenShift admin client. + fixture_store: Fixture store for resource tracking. + mtv_namespace: MTV operator namespace (e.g., openshift-mtv). + session_uuid: Session UUID for unique resource naming. + awx_deployment: AWX route URL. + awx_api_token: AWX OAuth2 token. + + Yields: + None + + Raises: + TimeoutError: If the cross-worker file lock cannot be acquired. + """ + lock_path = _get_aap_forklift_controller_lock_path() + try: + with filelock.FileLock(lock_path, timeout=AAP_FORKLIFT_CONTROLLER_LOCK_TIMEOUT): + token_secret_name = f"{session_uuid}-awx-aap" + create_aap_token_secret( + ocp_admin_client=ocp_admin_client, + fixture_store=fixture_store, + namespace=mtv_namespace, + token=awx_api_token, + name=token_secret_name, + ) + + forklift_controller = ForkliftController( + client=ocp_admin_client, + name=FORKLIFT_CONTROLLER_NAME, + namespace=mtv_namespace, + ensure_exists=True, + ) + + LOGGER.info( + f"Patching ForkliftController with aap_url='{awx_deployment}', " + f"aap_token_secret_name='{token_secret_name}', and aap_insecure_skip_verify=true" + ) + editor = ResourceEditor( + patches={ + forklift_controller: { + "spec": { + "aap_url": awx_deployment, + "aap_token_secret_name": token_secret_name, + "aap_insecure_skip_verify": "true", + } + } + } + ) + editor.update(backup_resources=True) + try: + forklift_controller.wait_for_condition( + status=forklift_controller.Condition.Status.TRUE, + condition=forklift_controller.Condition.Type.SUCCESSFUL, + timeout=300, + ) + yield + finally: + editor.restore() + except filelock.Timeout as err: + raise TimeoutError( + f"Timeout ({AAP_FORKLIFT_CONTROLLER_LOCK_TIMEOUT}s) waiting for ForkliftController " + f"AAP settings lock at {lock_path}. Another worker may be running AAP hook tests." + ) from err + + +@pytest.fixture(scope="class") +def aap_hook_refs( + prepared_plan: dict[str, Any], + fixture_store: dict[str, Any], + ocp_admin_client: "DynamicClient", + target_namespace: str, + awx_job_templates: dict[str, int], + aap_mtv_settings: None, +) -> None: + """Create AAP Hook CRs and store references in prepared_plan. + + Creates pre-hook and post-hook Hook CRs with ``spec.aap.jobTemplateId`` + pointing to AWX job templates. Stores ``_pre_hook_name``, + ``_pre_hook_namespace``, ``_post_hook_name``, ``_post_hook_namespace`` + in the prepared_plan dict for ``test_create_plan`` to use. + + Args: + prepared_plan: The prepared migration plan dict. + fixture_store: Fixture store for resource tracking. + ocp_admin_client: OpenShift admin client. + target_namespace: Target namespace for Hook CR creation. + awx_job_templates: AWX job template IDs. + aap_mtv_settings: Ensures MTV is configured with AAP token. + """ + for hook_type, template_key in [("pre", "pre_hook"), ("post", "post_hook")]: + hook_config: dict[str, Any] = { + "aap_job_template_id": awx_job_templates[template_key], + } + hook_name, hook_namespace = create_hook_for_plan( + hook_config=hook_config, + hook_type=hook_type, + fixture_store=fixture_store, + ocp_admin_client=ocp_admin_client, + target_namespace=target_namespace, + ) + prepared_plan[f"_{hook_type}_hook_name"] = hook_name + prepared_plan[f"_{hook_type}_hook_namespace"] = hook_namespace diff --git a/tests/hooks/test_aap_hook_migration.py b/tests/hooks/test_aap_hook_migration.py new file mode 100644 index 00000000..33e9c40a --- /dev/null +++ b/tests/hooks/test_aap_hook_migration.py @@ -0,0 +1,233 @@ +""" +AAP hook integration test — migration with AWX PreHook and PostHook. + +Validates that Hook CRs with ``spec.aap.jobTemplateId`` correctly trigger +AWX job templates during migration. The test deploys AWX, creates job +templates from the mtv-aap-test-playbooks repo, configures MTV with AAP +settings, and runs a cold migration with both PreHook and PostHook. +A successful migration confirms Forklift correctly launched the AWX jobs +and waited for their completion before proceeding through the pipeline. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +from ocp_resources.network_map import NetworkMap +from ocp_resources.plan import Plan +from ocp_resources.storage_map import StorageMap +from pytest_testconfig import config as py_config + +from utilities.mtv_migration import ( + create_plan_resource, + execute_migration, + get_network_migration_map, + get_storage_migration_map, +) +from utilities.post_migration import check_vms +from utilities.utils import populate_vm_ids + +if TYPE_CHECKING: + from kubernetes.dynamic import DynamicClient + + from libs.base_provider import BaseProvider + from libs.forklift_inventory import ForkliftInventory + from libs.ocp_provider import OCPProvider + from utilities.ssh_utils import SSHConnectionManager + + +@pytest.mark.vsphere +@pytest.mark.tier1 +@pytest.mark.aap +@pytest.mark.incremental +@pytest.mark.parametrize( + "class_plan_config", + [pytest.param(py_config["tests_params"]["test_aap_hook_migration"])], + indirect=True, + ids=["aap-hook-migration"], +) +@pytest.mark.usefixtures("cleanup_migrated_vms", "aap_hook_refs") +class TestAapHookMigration: + """Test AAP hook integration — cold migration with AWX PreHook and PostHook. + + Follows the standard 5-step migration pattern. The AAP hooks are created + by the ``aap_hook_refs`` fixture which injects Hook CR references into + ``prepared_plan``. A successful migration confirms PreHook ran before + disk transfer and PostHook ran after VM creation. + """ + + storage_map: StorageMap + network_map: NetworkMap + plan_resource: Plan + + def test_create_storagemap( + self, + prepared_plan: dict[str, Any], + fixture_store: dict[str, Any], + ocp_admin_client: "DynamicClient", + source_provider: "BaseProvider", + destination_provider: "OCPProvider", + source_provider_inventory: "ForkliftInventory", + target_namespace: str, + ) -> None: + """Create StorageMap resource for migration. + + Args: + prepared_plan (dict[str, Any]): The prepared migration plan. + fixture_store (dict[str, Any]): Fixture store for resource tracking. + ocp_admin_client (DynamicClient): OpenShift admin client. + source_provider (BaseProvider): Source provider instance. + destination_provider (OCPProvider): Destination provider instance. + source_provider_inventory (ForkliftInventory): Source provider inventory. + target_namespace (str): Target namespace for migration. + """ + vms = [vm["name"] for vm in prepared_plan["virtual_machines"]] + self.__class__.storage_map = get_storage_migration_map( + fixture_store=fixture_store, + source_provider=source_provider, + destination_provider=destination_provider, + source_provider_inventory=source_provider_inventory, + ocp_admin_client=ocp_admin_client, + target_namespace=target_namespace, + vms=vms, + ) + assert self.storage_map + + def test_create_networkmap( + self, + prepared_plan: dict[str, Any], + fixture_store: dict[str, Any], + ocp_admin_client: "DynamicClient", + source_provider: "BaseProvider", + destination_provider: "OCPProvider", + source_provider_inventory: "ForkliftInventory", + target_namespace: str, + multus_network_name: dict[str, str], + ) -> None: + """Create NetworkMap resource for migration. + + Args: + prepared_plan (dict[str, Any]): The prepared migration plan. + fixture_store (dict[str, Any]): Fixture store for resource tracking. + ocp_admin_client (DynamicClient): OpenShift admin client. + source_provider (BaseProvider): Source provider instance. + destination_provider (OCPProvider): Destination provider instance. + source_provider_inventory (ForkliftInventory): Source provider inventory. + target_namespace (str): Target namespace for migration. + multus_network_name (dict[str, str]): Name of the multus network. + """ + vms = [vm["name"] for vm in prepared_plan["virtual_machines"]] + self.__class__.network_map = get_network_migration_map( + fixture_store=fixture_store, + source_provider=source_provider, + destination_provider=destination_provider, + source_provider_inventory=source_provider_inventory, + ocp_admin_client=ocp_admin_client, + target_namespace=target_namespace, + multus_network_name=multus_network_name, + vms=vms, + ) + assert self.network_map + + def test_create_plan( + self, + prepared_plan: dict[str, Any], + fixture_store: dict[str, Any], + ocp_admin_client: "DynamicClient", + source_provider: "BaseProvider", + destination_provider: "OCPProvider", + target_namespace: str, + source_provider_inventory: "ForkliftInventory", + ) -> None: + """Create MTV Plan CR with AAP PreHook and PostHook. + + The hook references (``_pre_hook_name``, ``_post_hook_name``, etc.) + are injected into ``prepared_plan`` by the ``aap_hook_refs`` fixture. + + Args: + prepared_plan (dict[str, Any]): The prepared migration plan. + fixture_store (dict[str, Any]): Fixture store for resource tracking. + ocp_admin_client (DynamicClient): OpenShift admin client. + source_provider (BaseProvider): Source provider instance. + destination_provider (OCPProvider): Destination provider instance. + target_namespace (str): Target namespace for migration. + source_provider_inventory (ForkliftInventory): Source provider inventory. + """ + populate_vm_ids(prepared_plan, source_provider_inventory) + self.__class__.plan_resource = create_plan_resource( + ocp_admin_client=ocp_admin_client, + fixture_store=fixture_store, + source_provider=source_provider, + destination_provider=destination_provider, + storage_map=self.storage_map, + network_map=self.network_map, + virtual_machines_list=prepared_plan["virtual_machines"], + target_namespace=target_namespace, + warm_migration=prepared_plan.get("warm_migration", False), + target_power_state=prepared_plan.get("target_power_state"), + pre_hook_name=prepared_plan["_pre_hook_name"], + pre_hook_namespace=prepared_plan["_pre_hook_namespace"], + after_hook_name=prepared_plan["_post_hook_name"], + after_hook_namespace=prepared_plan["_post_hook_namespace"], + ) + assert self.plan_resource + + def test_migrate_vms( + self, + fixture_store: dict[str, Any], + ocp_admin_client: "DynamicClient", + target_namespace: str, + ) -> None: + """Execute migration — both AAP hooks should succeed. + + A successful migration confirms the full AAP hook pipeline: + Initialize → PreHook (AWX job) → DiskAllocation → ImageConversion → + DiskTransfer → VirtualMachineCreation → PostHook (AWX job) → Completed. + + Args: + fixture_store (dict[str, Any]): Fixture store for resource tracking. + ocp_admin_client (DynamicClient): OpenShift admin client. + target_namespace (str): Target namespace for migration. + """ + execute_migration( + ocp_admin_client=ocp_admin_client, + fixture_store=fixture_store, + plan=self.plan_resource, + target_namespace=target_namespace, + ) + + def test_check_vms( + self, + prepared_plan: dict[str, Any], + source_provider: "BaseProvider", + destination_provider: "OCPProvider", + source_provider_data: dict[str, Any], + target_namespace: str, + source_vms_namespace: str, + source_provider_inventory: "ForkliftInventory", + vm_ssh_connections: "SSHConnectionManager | None", + ) -> None: + """Validate migrated VMs post-migration. + + Args: + prepared_plan (dict[str, Any]): The prepared migration plan. + source_provider (BaseProvider): Source provider instance. + destination_provider (OCPProvider): Destination provider instance. + source_provider_data (dict[str, Any]): Source provider configuration data. + target_namespace (str): Target namespace for migration. + source_vms_namespace (str): Namespace of source VMs. + source_provider_inventory (ForkliftInventory): Source provider inventory. + vm_ssh_connections (SSHConnectionManager | None): SSH connections to migrated VMs. + """ + check_vms( + plan=prepared_plan, + source_provider=source_provider, + destination_provider=destination_provider, + network_map_resource=self.network_map, + storage_map_resource=self.storage_map, + source_provider_data=source_provider_data, + source_vms_namespace=source_vms_namespace, + source_provider_inventory=source_provider_inventory, + vm_ssh_connections=vm_ssh_connections, + ) diff --git a/tests/plan_lifecycle/__init__.py b/tests/plan_lifecycle/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/plan_lifecycle/test_plan_archive_pvc_cleanup.py b/tests/plan_lifecycle/test_plan_archive_pvc_cleanup.py new file mode 100644 index 00000000..ece3088c --- /dev/null +++ b/tests/plan_lifecycle/test_plan_archive_pvc_cleanup.py @@ -0,0 +1,338 @@ +"""MTV-5663: Verify PVC cleanup after archiving and deleting a failed migration plan. + +Regression test for MTV-5564: archiving and deleting a failed plan left +orphan PVCs (both regular and prime PVCs) in the target namespace. + +This test induces failure via a post-hook (not mid-transfer like the original +bug) to create PVCs and then fail the migration. Both paths exercise the same +Forklift plan archive+delete cleanup mechanism. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +from ocp_resources.datavolume import DataVolume +from ocp_resources.migration import Migration +from ocp_resources.network_map import NetworkMap +from ocp_resources.persistent_volume_claim import PersistentVolumeClaim +from ocp_resources.plan import Plan +from ocp_resources.storage_map import StorageMap +from ocp_resources.virtual_machine import VirtualMachine +from pytest_testconfig import config as py_config +from timeout_sampler import TimeoutExpiredError, TimeoutSampler + +from exceptions.exceptions import MigrationPlanExecError +from utilities.hooks import validate_hook_failure_and_check_vms +from utilities.migration_utils import archive_plan +from utilities.mtv_migration import ( + create_plan_resource, + execute_migration, + get_migration_for_plan, + get_network_migration_map, + get_storage_migration_map, +) +from utilities.naming import resolve_destination_vm_name +from utilities.resources import unregister_teardown_resource +from utilities.utils import populate_vm_ids + +if TYPE_CHECKING: + from kubernetes.dynamic import DynamicClient + + from libs.base_provider import BaseProvider + from libs.forklift_inventory import ForkliftInventory + from libs.providers.openshift import OCPProvider + + +_ORPHAN_RESOURCE_WAIT_TIMEOUT = 120 # Seconds to wait for async DV/PVC garbage collection +_ORPHAN_RESOURCE_POLL_INTERVAL = 5 # Seconds between polls + + +def _get_orphan_resource_names(client: DynamicClient, namespace: str) -> list[str]: + """List remaining DV and PVC names in a namespace. + + The target namespace is unique per session (named after session_uuid), + so all PVCs/DVs in it belong to this test run. + + Args: + client (DynamicClient): OpenShift admin client. + namespace (str): Namespace to check. + + Returns: + list[str]: Prefixed names (PVC/name, DV/name) of remaining resources, empty if none. + """ + remaining_pvcs = list(PersistentVolumeClaim.get(client=client, namespace=namespace)) + remaining_dvs = list(DataVolume.get(client=client, namespace=namespace)) + return [f"PVC/{pvc.name}" for pvc in remaining_pvcs] + [f"DV/{dv.name}" for dv in remaining_dvs] + + +@pytest.mark.vsphere +@pytest.mark.rhv +@pytest.mark.openstack +@pytest.mark.openshift +@pytest.mark.tier1 +@pytest.mark.incremental +@pytest.mark.parametrize( + "class_plan_config", + [pytest.param(py_config["tests_params"]["test_plan_archive_pvc_cleanup"])], + indirect=True, + ids=["MTV-5663-plan-archive-pvc-cleanup"], +) +@pytest.mark.usefixtures("cleanup_migrated_vms") +class TestPlanArchivePvcCleanup: + """MTV-5663: Verify PVC cleanup after archiving and deleting a failed migration plan. + + Regression test for MTV-5564: archiving and deleting a failed plan left + orphan PVCs (both regular and prime PVCs) in the target namespace. + + Test steps: + 1. Create StorageMap resource. + 2. Create NetworkMap resource. + 3. Create Plan with a post-hook configured to fail. + 4. Execute migration — migration runs far enough to create PVCs, + then fails due to post-hook failure (MigrationPlanExecError). + 5. Archive the failed plan, then delete it. + 6. Delete retained destination VMs, then verify all DVs and PVCs + from this test session in the effective VM target namespace are + cleaned up — no matching orphan resources remain. + """ + + storage_map: StorageMap + network_map: NetworkMap + plan_resource: Plan + + def test_create_storagemap( + self, + prepared_plan: dict[str, Any], + fixture_store: dict[str, Any], + ocp_admin_client: DynamicClient, + source_provider: BaseProvider, + destination_provider: OCPProvider, + source_provider_inventory: ForkliftInventory, + target_namespace: str, + ) -> None: + """Create StorageMap resource. + + Args: + prepared_plan (dict[str, Any]): The prepared migration plan. + fixture_store (dict[str, Any]): Fixture store for resource tracking. + ocp_admin_client (DynamicClient): OpenShift admin client. + source_provider (BaseProvider): Source provider instance. + destination_provider (OCPProvider): Destination provider instance. + source_provider_inventory (ForkliftInventory): Source provider inventory. + target_namespace (str): Target namespace for migration. + + Raises: + AssertionError: If StorageMap creation fails. + """ + vms = [vm["name"] for vm in prepared_plan["virtual_machines"]] + self.__class__.storage_map = get_storage_migration_map( + fixture_store=fixture_store, + source_provider=source_provider, + destination_provider=destination_provider, + source_provider_inventory=source_provider_inventory, + ocp_admin_client=ocp_admin_client, + target_namespace=target_namespace, + vms=vms, + ) + assert self.storage_map, "StorageMap creation failed" + + def test_create_networkmap( + self, + prepared_plan: dict[str, Any], + fixture_store: dict[str, Any], + ocp_admin_client: DynamicClient, + source_provider: BaseProvider, + destination_provider: OCPProvider, + source_provider_inventory: ForkliftInventory, + target_namespace: str, + multus_network_name: dict[str, str], + ) -> None: + """Create NetworkMap resource. + + Args: + prepared_plan (dict[str, Any]): The prepared migration plan. + fixture_store (dict[str, Any]): Fixture store for resource tracking. + ocp_admin_client (DynamicClient): OpenShift admin client. + source_provider (BaseProvider): Source provider instance. + destination_provider (OCPProvider): Destination provider instance. + source_provider_inventory (ForkliftInventory): Source provider inventory. + target_namespace (str): Target namespace for migration. + multus_network_name (dict[str, str]): Name of the multus network. + + Raises: + AssertionError: If NetworkMap creation fails. + """ + vms = [vm["name"] for vm in prepared_plan["virtual_machines"]] + self.__class__.network_map = get_network_migration_map( + fixture_store=fixture_store, + source_provider=source_provider, + destination_provider=destination_provider, + source_provider_inventory=source_provider_inventory, + ocp_admin_client=ocp_admin_client, + target_namespace=target_namespace, + multus_network_name=multus_network_name, + vms=vms, + ) + assert self.network_map, "NetworkMap creation failed" + + def test_create_plan( + self, + prepared_plan: dict[str, Any], + fixture_store: dict[str, Any], + ocp_admin_client: DynamicClient, + source_provider: BaseProvider, + destination_provider: OCPProvider, + target_namespace: str, + source_provider_inventory: ForkliftInventory, + ) -> None: + """Create MTV Plan CR with a post-hook configured to fail. + + Args: + prepared_plan (dict[str, Any]): The prepared migration plan. + fixture_store (dict[str, Any]): Fixture store for resource tracking. + ocp_admin_client (DynamicClient): OpenShift admin client. + source_provider (BaseProvider): Source provider instance. + destination_provider (OCPProvider): Destination provider instance. + target_namespace (str): Target namespace for migration. + source_provider_inventory (ForkliftInventory): Source provider inventory. + + Raises: + AssertionError: If Plan creation fails. + """ + populate_vm_ids(prepared_plan, source_provider_inventory) + + self.__class__.plan_resource = create_plan_resource( + ocp_admin_client=ocp_admin_client, + fixture_store=fixture_store, + source_provider=source_provider, + destination_provider=destination_provider, + storage_map=self.storage_map, + network_map=self.network_map, + virtual_machines_list=prepared_plan["virtual_machines"], + target_namespace=target_namespace, + warm_migration=prepared_plan.get("warm_migration", False), + target_power_state=prepared_plan["target_power_state"], + after_hook_name=prepared_plan["_post_hook_name"], + after_hook_namespace=prepared_plan["_post_hook_namespace"], + ) + assert self.plan_resource, "Plan creation failed" + + def test_migrate_vms( + self, + prepared_plan: dict[str, Any], + fixture_store: dict[str, Any], + ocp_admin_client: DynamicClient, + target_namespace: str, + ) -> None: + """Execute migration — expected to fail due to post-hook failure. + + The migration runs far enough to create PVCs for the VM disks, then + the post-hook triggers a failure. This leaves PVCs in the target + namespace that should be cleaned up when the plan is archived and deleted. + + Args: + prepared_plan (dict[str, Any]): The prepared migration plan. + fixture_store (dict[str, Any]): Fixture store for resource tracking. + ocp_admin_client (DynamicClient): OpenShift admin client. + target_namespace (str): Target namespace for migration. + + Raises: + AssertionError: If migration does not fail at PostHook as expected. + """ + with pytest.raises(MigrationPlanExecError): + execute_migration( + ocp_admin_client=ocp_admin_client, + fixture_store=fixture_store, + plan=self.plan_resource, + target_namespace=target_namespace, + ) + + validate_hook_failure_and_check_vms(self.plan_resource, prepared_plan) + + # Verify migration created resources before we archive+delete. + # The target namespace is unique per session (named after session_uuid), + # so all PVCs/DVs in it belong to this test run. Forklift creates PVCs + # using source disk UUIDs (not session_uuid), so name filtering is wrong. + vm_namespace = prepared_plan.get("_vm_target_namespace", target_namespace) + migration_pvcs = list(PersistentVolumeClaim.get(client=ocp_admin_client, namespace=vm_namespace)) + migration_dvs = list(DataVolume.get(client=ocp_admin_client, namespace=vm_namespace)) + assert migration_pvcs or migration_dvs, ( + f"No PVCs or DataVolumes found in namespace '{vm_namespace}' after " + "post-hook failure — the archive+delete cleanup assertion would be vacuous" + ) + + def test_archive_and_delete_plan(self, fixture_store: dict[str, Any]) -> None: + """Archive and delete the failed migration plan. + + Args: + fixture_store (dict[str, Any]): Fixture store for resource tracking. + + Raises: + AssertionError: If plan is not archived or deletion fails. + """ + plan = self.plan_resource + migration_name = get_migration_for_plan(plan).name + + archive_plan(plan=plan) + conditions = plan.instance.status.conditions or [] + assert any( + condition["type"] == plan.Condition.ARCHIVED and condition["status"] == plan.Condition.Status.TRUE + for condition in conditions + ), f"Plan '{plan.name}' did not reach Archived condition" + + assert plan.clean_up(wait=True), f"Failed to delete plan '{plan.name}' after archiving" + + # Plan was deleted intentionally; unregister so session_teardown does not + # call archive_plan() on a missing Plan and abort the rest of cleanup. + unregister_teardown_resource(fixture_store=fixture_store, kind=Plan.kind, name=plan.name) + unregister_teardown_resource(fixture_store=fixture_store, kind=Migration.kind, name=migration_name) + + def test_verify_pvc_cleanup( + self, + prepared_plan: dict[str, Any], + ocp_admin_client: DynamicClient, + target_namespace: str, + ) -> None: + """Verify all PVCs are cleaned up after plan archive and deletion. + + Destination VMs may still exist if post-hook failure retained them. + Any remaining VMs are deleted so the orphan DV/PVC check below is not + masked by VM-owned resources. + Polls for up to 120s because DV/PVC garbage collection is async. + + Args: + prepared_plan (dict[str, Any]): The prepared migration plan. + ocp_admin_client (DynamicClient): OpenShift admin client. + target_namespace (str): Target namespace for migration. + + Raises: + AssertionError: If orphan resources remain after 120s timeout. + """ + vm_namespace = prepared_plan.get("_vm_target_namespace", target_namespace) + for vm in prepared_plan["virtual_machines"]: + vm_name = resolve_destination_vm_name(vm) + vm_obj = VirtualMachine(client=ocp_admin_client, name=vm_name, namespace=vm_namespace) + if vm_obj.exists: + assert vm_obj.clean_up(wait=True), ( + f"Failed to delete destination VM '{vm_name}' in namespace '{vm_namespace}'" + ) + + try: + for sample in TimeoutSampler( + wait_timeout=_ORPHAN_RESOURCE_WAIT_TIMEOUT, + sleep=_ORPHAN_RESOURCE_POLL_INTERVAL, + func=_get_orphan_resource_names, + client=ocp_admin_client, + namespace=vm_namespace, + ): + if not sample: + return + except TimeoutExpiredError: + orphan_names = _get_orphan_resource_names(client=ocp_admin_client, namespace=vm_namespace) + if not orphan_names: + return + raise AssertionError( + f"Orphan resources remain in namespace '{vm_namespace}' after plan archive+delete: {orphan_names}" + ) diff --git a/tests/tests_config/config.py b/tests/tests_config/config.py index 0f5cf7df..3c844a62 100644 --- a/tests/tests_config/config.py +++ b/tests/tests_config/config.py @@ -704,6 +704,17 @@ "post_hook": {"expected_result": "fail"}, "expected_migration_result": "fail", }, + "test_aap_hook_migration": { + "virtual_machines": [ + { + "name": "mtv-tests-rhel8", + "source_vm_power": "on", + "guest_agent": True, + }, + ], + "warm_migration": False, + "target_power_state": "off", + }, "test_shared_disk_rhel_migration": { "virtual_machines": [ { @@ -811,6 +822,14 @@ "expected_output": "crc=0", }, }, + "test_plan_archive_pvc_cleanup": { + "virtual_machines": [ + {"name": "mtv-tests-rhel8", "source_vm_power": "on", "guest_agent": True}, + ], + "warm_migration": False, + "target_power_state": "off", + "post_hook": {"expected_result": "fail"}, + }, "test_standalone_di_vsphere": { "virtual_machines": [ { diff --git a/utilities/aap.py b/utilities/aap.py new file mode 100644 index 00000000..1d90636c --- /dev/null +++ b/utilities/aap.py @@ -0,0 +1,658 @@ +""" +AWX/AAP deployment and API utility functions for MTV hook integration testing. + +This module provides functions to deploy AWX on OpenShift via Helm, +interact with the AWX REST API (projects, job templates, tokens), +and verify AAP hook execution after migration. +""" + +from __future__ import annotations + +import base64 +import subprocess +import time +import urllib.parse +from typing import TYPE_CHECKING, Any + +import requests +from ocp_resources.pod import Pod +from ocp_resources.resource import Resource +from ocp_resources.route import Route +from ocp_resources.secret import Secret +from simple_logger.logger import get_logger + +from utilities.resources import create_and_store_resource + +if TYPE_CHECKING: + from kubernetes.dynamic import DynamicClient + +LOGGER = get_logger(__name__) + +AWX_NAMESPACE: str = "awx" +AWX_INSTANCE_NAME: str = "awx-openshift" +AWX_ADMIN_USERNAME: str = "admin" +AWX_DEFAULT_ORGANIZATION_ID: int = 1 # AWX auto-creates a "Default" organization with ID 1 +AAP_TEST_PLAYBOOKS_REPO: str = "https://github.com/gwencasey96/mtv-aap-test-playbooks" +AAP_TEST_PLAYBOOKS_BRANCH: str = "main" +AWX_PROJECT_NAME: str = "mtv-aap-test-playbooks" +AWX_PREHOOK_TEMPLATE_NAME: str = "mtv-pre-hook" +AWX_POSTHOOK_TEMPLATE_NAME: str = "mtv-post-hook" +AWX_PREHOOK_PLAYBOOK: str = "pre_hook_integration_example.yml" +AWX_POSTHOOK_PLAYBOOK: str = "post_hook_integration_example.yml" +AWX_HELM_REPO_NAME: str = "awx-operator-helm" +AWX_HELM_REPO_URL: str = "https://ansible-community.github.io/awx-operator-helm/" +AWX_HELM_RELEASE_NAME: str = "awx-operator" +# CephFS required for AWX — projects PVC needs RWX (multiple pods), postgres needs filesystem mode. +# Ceph RBD (used for VM migration storage_class) does not support RWX for filesystem volumes. +AWX_PROJECTS_STORAGE_CLASS: str = "ocs-storagecluster-cephfs" +AWX_POSTGRES_STORAGE_CLASS: str = "ocs-storagecluster-cephfs" +AAP_TOKEN_SECRET_NAME: str = "awx-aap" # Arbitrary name; referenced by ForkliftController aap_token_secret_name + + +def _awx_api_request( + method: str, + awx_url: str, + endpoint: str, + token: str | None = None, + auth: tuple[str, str] | None = None, + json_data: dict[str, Any] | None = None, # AWX REST API accepts arbitrary JSON request bodies +) -> dict[str, Any]: # AWX REST API returns dynamic JSON responses + """Make an authenticated request to the AWX REST API. + + Args: + method: HTTP method (GET, POST, etc.). + awx_url: AWX base URL (https://...). + endpoint: API endpoint path (e.g., "/api/v2/tokens/"). + token: OAuth2 bearer token for authentication. + auth: Basic auth tuple (username, password) as alternative to token. + json_data: JSON body for POST/PUT requests. + + Returns: + dict[str, Any]: Parsed JSON response. + + Raises: + requests.HTTPError: If the API returns a non-2xx status. + """ + headers: dict[str, str] = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + + url = f"{awx_url}{endpoint}" + response = requests.request( + method=method, + url=url, + headers=headers, + auth=auth, + json=json_data, + verify=False, + timeout=30, + ) + if not response.ok: + body = response.text[:500] if response.text else "(empty)" + LOGGER.error(f"AWX API {method} {url} failed ({response.status_code}): {body}") + response.raise_for_status() + if response.content: + return response.json() + return {} + + +def _run_shell(command: str, timeout: int = 600) -> str: + """Run a shell command and return stdout. + + Args: + command: Shell command string. + timeout: Timeout in seconds. + + Returns: + str: Command stdout. + + Raises: + subprocess.CalledProcessError: If command exits with non-zero status. + subprocess.TimeoutExpired: If command exceeds timeout. + """ + LOGGER.info(f"Running: {command}") + result = subprocess.run( + command, + shell=True, + check=True, + capture_output=True, + text=True, + timeout=timeout, + ) + return result.stdout.strip() + + +def is_awx_installed() -> bool: + """Check if AWX Operator is already installed via Helm. + + Returns: + bool: True if the AWX Helm release exists in the awx namespace. + + Raises: + subprocess.CalledProcessError: If Helm command fails. + """ + output = _run_shell(f"helm list -n {AWX_NAMESPACE} -q") + return AWX_HELM_RELEASE_NAME in output + + +def deploy_awx_via_helm() -> None: + """Deploy AWX Operator via Helm chart if not already installed. + + Skips installation if the Helm release already exists. + + Raises: + subprocess.CalledProcessError: If Helm installation fails. + """ + if is_awx_installed(): + LOGGER.info("AWX Operator already installed via Helm, skipping deployment") + return + + LOGGER.info("Adding AWX Operator Helm repository") + _run_shell(f"helm repo add {AWX_HELM_REPO_NAME} {AWX_HELM_REPO_URL} --force-update") + _run_shell("helm repo update") + + LOGGER.info(f"Installing AWX Operator into namespace '{AWX_NAMESPACE}'") + _run_shell( + f"helm install {AWX_HELM_RELEASE_NAME} {AWX_HELM_REPO_NAME}/awx-operator " + f"--namespace {AWX_NAMESPACE} --create-namespace --wait --timeout 5m" + ) + + +def create_awx_instance( + ocp_admin_client: "DynamicClient", + fixture_store: dict[str, Any], # pytest fixture_store has dynamic structure + namespace: str, +) -> bool: + """Create an AWX custom resource instance if not already present. + + Uses Resource with kind_dict because AWX is a third-party CRD + (awx.ansible.com/v1beta1) installed at runtime by the Helm operator. + Tracked via fixture_store for teardown. + + Args: + ocp_admin_client: OpenShift admin client. + fixture_store: Fixture store for resource tracking. + namespace: Namespace where AWX operator is installed. + + Returns: + bool: True if the AWX instance was created, False if it already existed. + """ + awx_resource = Resource( + client=ocp_admin_client, + kind_dict={ # AWX CR spec structure is defined by third-party CRD + "apiVersion": "awx.ansible.com/v1beta1", + "kind": "AWX", + "metadata": { + "name": AWX_INSTANCE_NAME, + "namespace": namespace, + }, + "spec": { + "ingress_type": "Route", + "postgres_storage_class": AWX_POSTGRES_STORAGE_CLASS, + "postgres_storage_requirement": "4Gi", + "projects_persistence": True, + "projects_storage_class": AWX_PROJECTS_STORAGE_CLASS, + "projects_storage_size": "4Gi", + }, + }, + ) + if awx_resource.exists: + LOGGER.info(f"AWX instance '{AWX_INSTANCE_NAME}' already exists, skipping creation") + return False + + LOGGER.info(f"Creating AWX instance '{AWX_INSTANCE_NAME}' in namespace '{namespace}'") + awx_resource.deploy(wait=True) + fixture_store.setdefault("teardown", {}).setdefault("AWX", []).append({ + "name": AWX_INSTANCE_NAME, + "namespace": namespace, + }) + return True + + +def wait_for_awx_ready( + ocp_admin_client: "DynamicClient", + namespace: str, + timeout: int = 900, +) -> None: + """Wait for all AWX pods to be in Running state. + + Args: + ocp_admin_client: OpenShift admin client. + namespace: AWX namespace. + timeout: Maximum wait time in seconds. + + Raises: + TimeoutError: If AWX pods are not ready within timeout. + """ + LOGGER.info(f"Waiting for AWX pods to be ready in namespace '{namespace}' (timeout={timeout}s)") + expected_prefixes = (f"{AWX_INSTANCE_NAME}-postgres", f"{AWX_INSTANCE_NAME}-task", f"{AWX_INSTANCE_NAME}-web") + deadline = time.time() + timeout + + while time.time() < deadline: + pods = list(Pod.get(client=ocp_admin_client, namespace=namespace)) + all_components_ready = all( + any(pod.name.startswith(prefix) and pod.instance.status.phase == "Running" for pod in pods) + for prefix in expected_prefixes + ) + if all_components_ready: + LOGGER.info("All AWX components (postgres, task, web) are Running") + return + time.sleep(15) + + raise TimeoutError(f"AWX pods not ready within {timeout}s in namespace '{namespace}'") + + +def get_awx_admin_password( + ocp_admin_client: "DynamicClient", + namespace: str, +) -> str: + """Retrieve the AWX admin password from the auto-generated Secret. + + Args: + ocp_admin_client: OpenShift admin client. + namespace: AWX namespace. + + Returns: + str: The admin password. + + Raises: + ValueError: If the secret does not exist or has no password field. + """ + secret_name = f"{AWX_INSTANCE_NAME}-admin-password" + secret = Secret( + client=ocp_admin_client, + name=secret_name, + namespace=namespace, + ensure_exists=True, + ) + password_b64 = secret.instance.data.get("password") + if not password_b64: + raise ValueError(f"Secret '{secret_name}' in namespace '{namespace}' has no 'password' field") + return base64.b64decode(password_b64).decode("utf-8") + + +def get_awx_route_url( + ocp_admin_client: "DynamicClient", + namespace: str, +) -> str: + """Get the AWX web UI route URL. + + Args: + ocp_admin_client: OpenShift admin client. + namespace: AWX namespace. + + Returns: + str: The AWX route URL (https://...). + + Raises: + ValueError: If no AWX route exists. + """ + routes = list(Route.get(client=ocp_admin_client, namespace=namespace)) + for route in routes: + if AWX_INSTANCE_NAME in route.name: + host = route.instance.spec.host + LOGGER.info(f"AWX route URL: https://{host}") + return f"https://{host}" + + raise ValueError(f"No AWX route found in namespace '{namespace}'") + + +def wait_for_awx_api_ready( + awx_url: str, + username: str, + password: str, + timeout: int = 600, +) -> None: + """Wait for AWX API to be fully operational. + + AWX pods may be Running before the API can handle write operations. + The ping endpoint responds early but database migrations may still + be running. This function polls the organizations endpoint with + authentication to verify the API is fully ready. + + Args: + awx_url: AWX base URL. + username: AWX admin username. + password: AWX admin password. + timeout: Maximum wait time in seconds. + + Raises: + TimeoutError: If AWX API is not ready within timeout. + """ + LOGGER.info(f"Waiting for AWX API to be fully ready at {awx_url} (timeout={timeout}s)") + deadline = time.time() + timeout + + while time.time() < deadline: + try: + response = requests.get( + f"{awx_url}/api/v2/organizations/", + auth=(username, password), + verify=False, + timeout=10, + ) + if response.ok and response.json().get("count", 0) > 0: + LOGGER.info("AWX API is fully ready (organizations endpoint responding)") + return + except (requests.ConnectionError, requests.Timeout, ValueError): + pass + time.sleep(15) + + raise TimeoutError(f"AWX API not fully ready within {timeout}s at {awx_url}") + + +def _find_awx_resource_by_name( + awx_url: str, + token: str, + endpoint: str, + name: str, +) -> dict[str, Any] | None: # AWX API resource schema varies by endpoint + """Find an AWX resource by name. + + Args: + awx_url: AWX base URL. + token: AWX API auth token. + endpoint: API endpoint (e.g., "/api/v2/projects/"). + name: Resource name to search for. + + Returns: + dict[str, Any] | None: The resource dict if found, None otherwise. + """ + encoded_name = urllib.parse.quote(name, safe="") + response = _awx_api_request( + method="GET", + awx_url=awx_url, + endpoint=f"{endpoint}?name={encoded_name}", + token=token, + ) + results = response.get("results", []) + if results: + return results[0] + return None + + +def create_awx_auth_token( + awx_url: str, + username: str, + password: str, +) -> str: + """Create an AWX OAuth2 personal access token. + + Args: + awx_url: AWX base URL. + username: AWX admin username. + password: AWX admin password. + + Returns: + str: The OAuth2 token string. + + Raises: + requests.HTTPError: If token creation fails. + """ + LOGGER.info("Creating AWX OAuth2 token") + response = _awx_api_request( + method="POST", + awx_url=awx_url, + endpoint="/api/v2/tokens/", + auth=(username, password), + json_data={"description": "mtv-api-tests", "scope": "write"}, + ) + token = response["token"] + LOGGER.info("AWX OAuth2 token created successfully") + return token + + +def create_awx_project( + awx_url: str, + token: str, + name: str, + scm_url: str, + retries: int = 20, + retry_interval: int = 15, +) -> int: + """Create an AWX project from a git SCM URL, or return existing project ID. + + Retries on 500 errors because AWX may still be initializing after deployment. + + Args: + awx_url: AWX base URL. + token: AWX API auth token. + name: Project name. + scm_url: Git repository URL for playbooks. + retries: Number of retry attempts on 500 errors. + retry_interval: Seconds between retries. + + Returns: + int: The project ID (created or existing). + + Raises: + requests.HTTPError: If project creation fails after all retries. + """ + existing = _find_awx_resource_by_name(awx_url=awx_url, token=token, endpoint="/api/v2/projects/", name=name) + if existing: + LOGGER.info(f"AWX project '{name}' already exists with ID {existing['id']}") + return existing["id"] + + LOGGER.info(f"Creating AWX project '{name}' from '{scm_url}'") + for attempt in range(1, retries + 1): + try: + response = _awx_api_request( + method="POST", + awx_url=awx_url, + endpoint="/api/v2/projects/", + token=token, + json_data={ + "name": name, + "scm_type": "git", + "scm_url": scm_url, + "scm_branch": AAP_TEST_PLAYBOOKS_BRANCH, + "organization": AWX_DEFAULT_ORGANIZATION_ID, + }, + ) + project_id: int = response["id"] + LOGGER.info(f"AWX project created with ID {project_id}") + return project_id + except requests.HTTPError as e: + if e.response is not None and e.response.status_code == 500 and attempt < retries: + LOGGER.warning(f"AWX project creation returned 500, retrying ({attempt}/{retries})...") + time.sleep(retry_interval) + existing = _find_awx_resource_by_name( + awx_url=awx_url, token=token, endpoint="/api/v2/projects/", name=name + ) + if existing: + LOGGER.info(f"AWX project '{name}' found after retry with ID {existing['id']}") + return existing["id"] + elif e.response is not None and e.response.status_code == 400: + existing = _find_awx_resource_by_name( + awx_url=awx_url, token=token, endpoint="/api/v2/projects/", name=name + ) + if existing: + LOGGER.info(f"AWX project '{name}' already exists with ID {existing['id']}") + return existing["id"] + raise + else: + raise + + raise requests.HTTPError( + f"AWX project '{name}' creation failed after {retries} retries (url={awx_url}, scm_url={scm_url})" + ) + + +def wait_for_awx_project_sync( + awx_url: str, + token: str, + project_id: int, + timeout: int = 300, +) -> None: + """Wait for an AWX project to finish its initial SCM sync. + + Args: + awx_url: AWX base URL. + token: AWX API auth token. + project_id: AWX project ID. + timeout: Timeout in seconds. + + Raises: + TimeoutError: If project sync does not complete within timeout. + ValueError: If project sync fails. + """ + LOGGER.info(f"Waiting for AWX project {project_id} SCM sync (timeout={timeout}s)") + deadline = time.time() + timeout + + while time.time() < deadline: + response = _awx_api_request( + method="GET", + awx_url=awx_url, + endpoint=f"/api/v2/projects/{project_id}/", + token=token, + ) + status = response.get("status", "") + if status == "successful": + LOGGER.info(f"AWX project {project_id} sync completed successfully") + return + if status == "failed": + raise ValueError(f"AWX project {project_id} sync failed: {response.get('summary_fields', {})}") + time.sleep(10) + + raise TimeoutError(f"AWX project {project_id} sync did not complete within {timeout}s") + + +def create_awx_inventory( + awx_url: str, + token: str, + name: str = "Default", +) -> int: + """Create an AWX inventory, or return existing inventory ID. + + AWX job templates require an inventory even if the playbook only + runs on localhost. + + Args: + awx_url: AWX base URL. + token: AWX API auth token. + name: Inventory name. + + Returns: + int: The inventory ID (created or existing). + + Raises: + requests.HTTPError: If inventory creation fails. + """ + existing = _find_awx_resource_by_name(awx_url=awx_url, token=token, endpoint="/api/v2/inventories/", name=name) + if existing: + LOGGER.info(f"AWX inventory '{name}' already exists with ID {existing['id']}") + return existing["id"] + + LOGGER.info(f"Creating AWX inventory '{name}'") + response = _awx_api_request( + method="POST", + awx_url=awx_url, + endpoint="/api/v2/inventories/", + token=token, + json_data={ + "name": name, + "organization": AWX_DEFAULT_ORGANIZATION_ID, + }, + ) + inventory_id: int = response["id"] + LOGGER.info(f"AWX inventory '{name}' created with ID {inventory_id}") + return inventory_id + + +def create_awx_job_template( + awx_url: str, + token: str, + name: str, + project_id: int, + playbook: str, + inventory_id: int, +) -> int: + """Create an AWX job template for a playbook, or return existing template ID. + + Args: + awx_url: AWX base URL. + token: AWX API auth token. + name: Job template name. + project_id: Project ID containing the playbook. + playbook: Playbook filename within the project. + inventory_id: AWX inventory ID (required by AWX API). + + Returns: + int: The job template ID (created or existing). + + Raises: + requests.HTTPError: If job template creation fails. + """ + existing = _find_awx_resource_by_name(awx_url=awx_url, token=token, endpoint="/api/v2/job_templates/", name=name) + if existing: + LOGGER.info(f"AWX job template '{name}' already exists with ID {existing['id']}") + return existing["id"] + + LOGGER.info(f"Creating AWX job template '{name}' for playbook '{playbook}'") + response = _awx_api_request( + method="POST", + awx_url=awx_url, + endpoint="/api/v2/job_templates/", + token=token, + json_data={ + "name": name, + "project": project_id, + "playbook": playbook, + "inventory": inventory_id, + "organization": AWX_DEFAULT_ORGANIZATION_ID, + "ask_variables_on_launch": True, + }, + ) + template_id: int = response["id"] + LOGGER.info(f"AWX job template '{name}' created with ID {template_id}") + return template_id + + +def create_aap_token_secret( + ocp_admin_client: "DynamicClient", + fixture_store: dict[str, Any], # pytest fixture_store has dynamic structure + namespace: str, + token: str, + name: str = AAP_TOKEN_SECRET_NAME, +) -> Secret: + """Create a Kubernetes Secret with the AWX OAuth2 token for MTV. + + The ForkliftController uses this secret to authenticate with AWX + when triggering AAP hooks during migration. + + Args: + ocp_admin_client: OpenShift admin client. + fixture_store: Fixture store for resource tracking. + namespace: MTV operator namespace (e.g., openshift-mtv). + token: AWX OAuth2 token string. + name: Secret name. Defaults to AAP_TOKEN_SECRET_NAME. + + Returns: + Secret: The created Kubernetes Secret. + """ + LOGGER.info(f"Creating AAP token secret '{name}' in namespace '{namespace}'") + return create_and_store_resource( + client=ocp_admin_client, + fixture_store=fixture_store, + resource=Secret, + name=name, + namespace=namespace, + string_data={"token": token}, + ) + + +def teardown_awx() -> None: + """Remove AWX deployment via Helm and delete namespace. + + Attempts both Helm uninstall and namespace deletion. Logs + failures but does not raise — teardown errors should not + mark passing tests as failures. + """ + LOGGER.info("Tearing down AWX deployment") + try: + _run_shell(f"helm uninstall {AWX_HELM_RELEASE_NAME} --namespace {AWX_NAMESPACE}") + except subprocess.CalledProcessError as e: + LOGGER.warning(f"Helm uninstall failed (stderr: {e.stderr}): {e}") + try: + _run_shell(f"oc delete namespace {AWX_NAMESPACE} --wait=false") + except subprocess.CalledProcessError as e: + LOGGER.warning(f"Failed to delete namespace '{AWX_NAMESPACE}' (stderr: {e.stderr}): {e}") diff --git a/utilities/hooks.py b/utilities/hooks.py index 8f9baf0c..a9e942b2 100644 --- a/utilities/hooks.py +++ b/utilities/hooks.py @@ -64,29 +64,43 @@ def validate_hook_config(hook_config: dict[str, Any], hook_type: str) -> None: hook_type (str): "pre" or "post" for error messages Raises: - TypeError: If hook_config is not a dict - ValueError: If expected_result and playbook_base64 are both specified, - or if neither is specified + TypeError: If hook_config is not a dict. + ValueError: If more than one of 'expected_result', 'playbook_base64', + or 'aap_job_template_id' is specified, or if none is specified, + or if 'aap_job_template_id' is not a positive integer. """ if not isinstance(hook_config, dict): raise TypeError(f"Invalid {hook_type} hook config: expected dict, got {type(hook_config).__name__}") expected_result = hook_config.get("expected_result") custom_playbook = hook_config.get("playbook_base64") + aap_job_template_id = hook_config.get("aap_job_template_id") + + modes_specified = sum(x is not None for x in (expected_result, custom_playbook, aap_job_template_id)) - # Validate mutual exclusivity - if expected_result is not None and custom_playbook is not None: + if modes_specified > 1: raise ValueError( - f"Invalid {hook_type} hook config: 'expected_result' and 'playbook_base64' are " - f"mutually exclusive. Use 'expected_result' for predefined playbooks, or " - f"'playbook_base64' for custom playbooks." + f"Invalid {hook_type} hook config: 'expected_result', 'playbook_base64', and " + f"'aap_job_template_id' are mutually exclusive. Specify exactly one." ) - if expected_result is None and custom_playbook is None: + if modes_specified == 0: raise ValueError( - f"Invalid {hook_type} hook config: must specify either 'expected_result' or 'playbook_base64'." + f"Invalid {hook_type} hook config: must specify exactly one of 'expected_result', " + f"'playbook_base64', or 'aap_job_template_id'." ) + if aap_job_template_id is not None: + if ( + isinstance(aap_job_template_id, bool) + or not isinstance(aap_job_template_id, int) + or aap_job_template_id <= 0 + ): + raise ValueError( + f"Invalid {hook_type} hook config: 'aap_job_template_id' must be a positive integer, " + f"got: {aap_job_template_id!r}" + ) + # Reject empty strings for both expected_result and custom_playbook if isinstance(expected_result, str) and expected_result.strip() == "": raise ValueError(f"Invalid {hook_type} hook config: 'expected_result' cannot be empty or whitespace-only.") @@ -140,9 +154,14 @@ def create_hook_for_plan( ) -> tuple[str, str]: """Create a Hook CR based on plan configuration. + Supports three mutually exclusive modes: + - expected_result: predefined success/fail playbooks + - playbook_base64: custom base64-encoded Ansible playbook + - aap_job_template_id: AWX job template reference (AAP hook) + Args: - hook_config (dict[str, Any]): Hook configuration with either - 'expected_result' OR 'playbook_base64' (mutually exclusive) + hook_config (dict[str, Any]): Hook configuration with exactly one of + 'expected_result', 'playbook_base64', or 'aap_job_template_id' hook_type (str): "pre" or "post" for logging fixture_store (dict[str, Any]): Fixture store for resource tracking ocp_admin_client (DynamicClient): OpenShift admin client @@ -153,22 +172,31 @@ def create_hook_for_plan( Raises: TypeError: If hook_config is not a dict - ValueError: If expected_result is invalid or playbook is invalid base64 + ValueError: If expected_result is invalid, playbook is invalid base64, + or aap_job_template_id is not a positive integer """ - # Validate configuration validate_hook_config(hook_config, hook_type) + aap_job_template_id = hook_config.get("aap_job_template_id") + if aap_job_template_id is not None: + LOGGER.info(f"Creating AAP {hook_type} hook with job template ID {aap_job_template_id}") + hook = create_and_store_resource( + client=ocp_admin_client, + fixture_store=fixture_store, + resource=Hook, + namespace=target_namespace, + aap={"jobTemplateId": aap_job_template_id}, + ) + return hook.name, hook.namespace + expected_result = hook_config.get("expected_result") custom_playbook = hook_config.get("playbook_base64") - # Determine playbook based on mode if custom_playbook: - # Custom playbook mode - validate base64, UTF-8, and YAML syntax validate_custom_playbook(custom_playbook, hook_type) playbook = custom_playbook LOGGER.info(f"Using custom {hook_type} hook playbook") else: - # Predefined playbook mode if expected_result not in ("succeed", "fail"): raise ValueError( f"Invalid {hook_type} hook 'expected_result': must be 'succeed' or 'fail', got: '{expected_result}'" @@ -176,14 +204,13 @@ def create_hook_for_plan( playbook = HOOK_PLAYBOOK_FAIL if expected_result == "fail" else HOOK_PLAYBOOK_SUCCESS LOGGER.info(f"Using predefined {hook_type} hook playbook for expected_result='{expected_result}'") - # Create the Hook CR hook = create_and_store_resource( client=ocp_admin_client, fixture_store=fixture_store, resource=Hook, namespace=target_namespace, - playbook=playbook, image="quay.io/konveyor/hook-runner:latest", + playbook=playbook, ) return hook.name, hook.namespace @@ -218,7 +245,7 @@ def validate_all_vms_same_step(plan_name: str, failed_steps: dict[str, str]) -> raise VmMigrationStepMismatchError(plan_name, failed_steps) common_step = unique_steps.pop() - LOGGER.info("All VMs failed at step '%s'", common_step) + LOGGER.info(f"All VMs failed at step '{common_step}'") return common_step @@ -263,7 +290,7 @@ def validate_expected_hook_failure( f"Migration failed at step '{actual_failed_step}' but expected to fail at '{expected_step}'" ) - LOGGER.info("Migration correctly failed at expected step '%s'", expected_step) + LOGGER.info(f"Migration correctly failed at expected step '{expected_step}'") def validate_hook_failure_and_check_vms( diff --git a/utilities/resources.py b/utilities/resources.py index 58f092a5..98db98bf 100644 --- a/utilities/resources.py +++ b/utilities/resources.py @@ -70,6 +70,26 @@ def create_and_store_resource( return _resource +def unregister_teardown_resource(fixture_store: dict[str, Any], kind: str, name: str) -> None: + """Remove a resource entry from fixture_store teardown tracking. + + Use after intentionally deleting a resource mid-test so session teardown + does not operate on a missing object. Safe to call even if the entry + does not exist — logs a warning instead of raising. + + Args: + fixture_store (dict[str, Any]): Fixture store for resource tracking. + kind (str): Resource kind key in fixture_store["teardown"]. + name (str): Resource name to unregister. + """ + resources = fixture_store["teardown"].get(kind, []) + remaining = [resource for resource in resources if resource["name"] != name] + if len(remaining) == len(resources): + LOGGER.warning(f"Resource '{name}' of kind '{kind}' not found in fixture_store teardown — already removed?") + else: + fixture_store["teardown"][kind] = remaining + + def get_or_create_namespace( fixture_store: dict[str, Any], ocp_admin_client: "DynamicClient", diff --git a/uv.lock b/uv.lock index f800d0fa6..0b2ccb37 100644 --- a/uv.lock +++ b/uv.lock @@ -9,14 +9,14 @@ resolution-markers = [ [[package]] name = "aiofile" -version = "3.11.1" +version = "3.12.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "caio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/31/edb06aabd8f8f0b56d659f30800795f40b93cba96be946ce179f6931e3a5/aiofile-3.12.3.tar.gz", hash = "sha256:caa6aa746b5e47e2165f7abd741b6415e49cf4d44fddc0f61844612cc3924d41", size = 21600, upload-time = "2026-08-04T22:59:27.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, + { url = "https://files.pythonhosted.org/packages/4e/79/6e45e778c4c3cab39e0937b007b720c15f76c50c6453d153282d0fcc3588/aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017", size = 22122, upload-time = "2026-08-04T22:59:25.838Z" }, ] [[package]] @@ -217,19 +217,22 @@ wheels = [ [[package]] name = "caio" -version = "0.9.25" +version = "0.12.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/c8/82b3c760141a1076408164b03e8789b51809add6aecd48aa9d7651cf6b59/caio-0.12.2.tar.gz", hash = "sha256:87a67c0dccc60e432888bd532ec504b66e124a5d8b391aab894583b55abd39ea", size = 80927, upload-time = "2026-08-04T14:43:33.726Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, - { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, - { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, - { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, - { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, + { url = "https://files.pythonhosted.org/packages/60/bc/b62bf048a6e11870291a24319ed027bdf658df9ba77d1ad762aa138e066b/caio-0.12.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2097cc0d19fa95e8d55aad770597bb0f76e4f70ed48278c965aa7c5b0b8c3bf5", size = 84702, upload-time = "2026-08-04T14:43:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/f7/be/b40d55d793afcfa5bcdb32ade9289d9588e14e3026c2c87522e303cc6e8c/caio-0.12.2-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:2122dccbd1959b922543fc9f8a9d2af47bd5b59190d1ece2445d3d1b4d1be45f", size = 198292, upload-time = "2026-08-04T14:43:05.238Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/9bd2bca72bfa478337618eae88942c43c891ae225e11baeae275e5e5c6ab/caio-0.12.2-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:107e56554c179749de9440e1b5e5a19813572eebf3166e9dc3e5228b16966beb", size = 196207, upload-time = "2026-08-04T14:43:06.494Z" }, + { url = "https://files.pythonhosted.org/packages/48/9b/65f95efdd68b50b7a9f2555c93d9edc7da7aa5ae5e153163c41cf6fd5cd9/caio-0.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adc7785e61ff7cf372318f67ec65617eaa06975e20da177522665dca8be6ea5d", size = 195748, upload-time = "2026-08-04T14:43:07.893Z" }, + { url = "https://files.pythonhosted.org/packages/3c/16/6a5c010ca435a5184d11ca350874694ac19db249560126dc8df0f25791ce/caio-0.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07942d3b5999127ecb96256c38d5dbf49ed2864c087ed2a80b783901d0aa3ba1", size = 195835, upload-time = "2026-08-04T14:43:09.19Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9b/31f0b49a2542ffa2f9d6140267e2b568e722a1feeb05cfbffea97666c62b/caio-0.12.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:40ebea9ebe3a3a66ae85fa00d4112d163654a33c82dcf9b26a99f7d30de13317", size = 84656, upload-time = "2026-08-04T14:43:10.513Z" }, + { url = "https://files.pythonhosted.org/packages/99/bc/62568d688af9712a34fe3f958d7a98c53bb2017e263260cd5deae67a90e9/caio-0.12.2-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:6003ec389a68d5ec8f089df82b2dc8915293dd630a4d11322d7e3455045981fd", size = 198443, upload-time = "2026-08-04T14:43:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e4/5ed627860285612e5307f06c109913c5918c947fbc223b55599e484c64b0/caio-0.12.2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:eee9376d0e2af25b6defc5bce39f6efa90521c803aaf12eba931bd898a397cfc", size = 196356, upload-time = "2026-08-04T14:43:13.206Z" }, + { url = "https://files.pythonhosted.org/packages/81/e2/2a8cfc6ba3ef3f19e7c778e9fb6f98600f0971cca78bbdfc23a413a66349/caio-0.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:78e3ccafc98e009fcb00a97ad441585551e52c0ae7ecc50427a3ccd9b11502fd", size = 195893, upload-time = "2026-08-04T14:43:14.649Z" }, + { url = "https://files.pythonhosted.org/packages/d1/87/77c40fb2301d0b5bb27c2e79ae42fce718ed75396d5fe3e1c09d8e1400b1/caio-0.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f2355db8917f5a0f3638bf332fe0d87549c80e978fca01db84a8a14b9df56a05", size = 195969, upload-time = "2026-08-04T14:43:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b5/0ceca97eb546fe6bbace3399c8b11dfc503efcc7509d708a7a3f09ab50e9/caio-0.12.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8054cba5e7ee623bea34946e2b59eb7c7c2be8872d0a5d12215d6ff564938d5f", size = 78621, upload-time = "2026-08-04T14:43:17.316Z" }, + { url = "https://files.pythonhosted.org/packages/61/8a/71b0144f783468ba9f1bbf8a2f8e45c7d85ae31ec192f10650aa46f31702/caio-0.12.2-py3-none-any.whl", hash = "sha256:5233e797c9fe2b541914b1bc2e2df82677e2206b537e44e252188f3c2cbb0ea9", size = 62548, upload-time = "2026-08-04T14:43:32.394Z" }, ] [[package]] @@ -243,39 +246,39 @@ wheels = [ [[package]] name = "cffi" -version = "2.1.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, - { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, - { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, - { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, - { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, - { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, - { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, - { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, - { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, - { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, - { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, - { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, ] [[package]] @@ -406,7 +409,7 @@ wheels = [ [[package]] name = "cyclopts" -version = "4.22.4" +version = "4.22.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -414,9 +417,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/8b/fa4bfca58481ff7ef3d48ba706ccd4a7eaa1e27e7b1d9e10cbb3ae0f780f/cyclopts-4.22.4.tar.gz", hash = "sha256:d48c17e8d4a334b3f33b82920afabbf52c877a2e21edd55a172433c288bc7720", size = 194636, upload-time = "2026-08-02T14:04:17.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/05/689617b7e86503417c172f577d791524cb13b9697303d5d44409a971ba10/cyclopts-4.22.5.tar.gz", hash = "sha256:94044506317462cad90fb01a917dadce1f48a0915ba3605dc8d178dea1229e24", size = 195144, upload-time = "2026-08-04T13:53:00.303Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/49/f50d1ebb472902c952835d6141bfe920a16c402660a25c813151a861ca4c/cyclopts-4.22.4-py3-none-any.whl", hash = "sha256:90debd2468c5b33d7ca55ca64209a3bdf2667c11a2f75d973584198b58ca5e46", size = 234023, upload-time = "2026-08-02T14:04:16.282Z" }, + { url = "https://files.pythonhosted.org/packages/83/58/bcab9c33fb7a25a1f5970f357c5b19729bc81d50615d2f737b20c4255909/cyclopts-4.22.5-py3-none-any.whl", hash = "sha256:cf9ce285836053d156730ea4ea0ad0c75cf63beb3f3d8edf222a795bc57666ab", size = 234557, upload-time = "2026-08-04T13:52:58.509Z" }, ] [[package]] @@ -550,19 +553,19 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.4.5" +version = "3.4.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/14/c1ffb91b7d1fece86c81e1f9df5474f30fd97e4cdaa398814bbbeee88568/fastmcp-3.4.5.tar.gz", hash = "sha256:a95f2bc876bef42e8b50f7872f24f3f2fe3b1d37408c734e8b9d9e03014b72d3", size = 28800521, upload-time = "2026-07-27T19:20:01.231Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/5a/e2c78e26233cd8a416b21513e1925435d54c008a0ec467dbdaa80369daf7/fastmcp-3.4.6.tar.gz", hash = "sha256:2287938da8364ad7071bec2d2393af6ae10fd4e836f06f506569f1456cc87eb4", size = 28808130, upload-time = "2026-08-05T14:54:42.177Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/4f/73450a436c963c0382d15a882fc5d08f15aadc329194df1b54495a7c8383/fastmcp-3.4.5-py3-none-any.whl", hash = "sha256:5d3d438eb2917e63e6faf53e8cb8fe26d887ec3232f848093a4eecad7fa34861", size = 8017, upload-time = "2026-07-27T19:19:57.942Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/c02275db111892388972edbb05fbcbfdf1e83cbd1fd03356b3a49b93f839/fastmcp-3.4.6-py3-none-any.whl", hash = "sha256:2a29967be9f68cdd1b4cefb413ede74f83adefd923919a37aaac3611eccdd749", size = 8017, upload-time = "2026-08-05T14:54:38.473Z" }, ] [[package]] name = "fastmcp-slim" -version = "3.4.5" +version = "3.4.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "platformdirs" }, @@ -572,9 +575,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/1d/f3e271fbcd01ce01a4cf623b336d8e1305c192aa5d5e8e0223b7167462e9/fastmcp_slim-3.4.5.tar.gz", hash = "sha256:5badc3bceee61f61297eeb9494f499325f3ce1cafabf4611b31f6c3e9d7dff59", size = 591622, upload-time = "2026-07-27T19:15:19.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/b6/b5b9e81e67a3f39534881d2af6aa3fbd2dc367eaa070c3c932770a0c062f/fastmcp_slim-3.4.6.tar.gz", hash = "sha256:6a1e6e42c697ba90abcb1be617a26947d07316f13c6fa138ae7b0de24558e32c", size = 594167, upload-time = "2026-08-05T14:54:15.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/3b/16d8aa8224094519f30b078138e725b8a731bf0a13f1f850e58b5f9b3cc4/fastmcp_slim-3.4.5-py3-none-any.whl", hash = "sha256:bc31217827c4999812543c83ee95ed9a47f3ed1e3fd0bd4f64371e375b748eca", size = 766478, upload-time = "2026-07-27T19:15:18.015Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/02c254c46b4323ae5262b76a29af73b50a863efc5739a3454d144d3605ee/fastmcp_slim-3.4.6-py3-none-any.whl", hash = "sha256:3e08e6acb03523a47aa17f4d2ab9943e648a41e20b24084da491a732c46dffdc", size = 769174, upload-time = "2026-08-05T14:54:14.663Z" }, ] [package.optional-dependencies] @@ -634,14 +637,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.57" +version = "3.1.58" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/0d/132ed135c871b6bf91adf16a0e43797cd535b81d4973b5d09291c54fc5ee/gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488", size = 225898, upload-time = "2026-07-26T07:33:26.351Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/d6/5f358ff283325580c2003a6d953aea18cfe10ae87b46f5ebc80fa3a386dc/gitpython-3.1.58.tar.gz", hash = "sha256:621416df10ef3fd0e19fabf9172ddeed0fa704d353d04f194eec56a625a95b22", size = 228498, upload-time = "2026-08-04T15:05:49.47Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/6e/2139de986d9c7c3ac86f1f8be43858ce90bdfe2f7175e6c80c650ba15242/gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf", size = 217151, upload-time = "2026-07-26T07:33:24.838Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/9d8752098bc442f0726e64aa6135940b3a96809915d1aa4206c1bb97881d/gitpython-3.1.58-py3-none-any.whl", hash = "sha256:d331e722577f0fd7fc1f857419b3ecc07af66282b933d2a4d95f84a042fdd50f", size = 220183, upload-time = "2026-08-04T15:05:48.025Z" }, ] [[package]] @@ -762,7 +765,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.16.0" +version = "9.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -776,9 +779,9 @@ dependencies = [ { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/49/04360f83b4d110195751b4171b75dc1cd7b97ba122b18da34b5828172d59/ipython-9.16.0.tar.gz", hash = "sha256:d2f92587b1ef51d84f934dffe05fabb9255f0038ed0a21426f2ea761e39ad09a", size = 4515375, upload-time = "2026-07-31T08:02:51.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/96/b150fe7e25a5a29ae9ac1374e71488639605d39a1ea4abb74c9ce33af235/ipython-9.16.1.tar.gz", hash = "sha256:5a3d1f9a47ff216d6cf9cf863124f6a2c1a198d1354c546a4d24a370a283b64c", size = 4515302, upload-time = "2026-08-03T08:36:15.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/82/d30656b9eb33b8ed4e421ca55c13c7fff412086f0405bbe53c39a7ee4a3b/ipython-9.16.0-py3-none-any.whl", hash = "sha256:3d02b96de2a59074d153b1ac1c3865de738df114e430e879e6e5ef100a4d470c", size = 625973, upload-time = "2026-07-31T08:02:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8e/1239df488393d61076653bfb29f759d0f60cab8e030abdf7c17c31539b51/ipython-9.16.1-py3-none-any.whl", hash = "sha256:4acae635506f6d352d94c4899a19d5f85f8bc4d230932342dca556fdab1c69b4", size = 625974, upload-time = "2026-08-03T08:36:13.654Z" }, ] [[package]] @@ -1092,11 +1095,11 @@ wheels = [ [[package]] name = "marshmallow" -version = "4.3.0" +version = "4.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/7e/1dbd4096eb7c148cd2841841916f78820bb85a4d80a0c25c02d30815a7fb/marshmallow-4.3.0.tar.gz", hash = "sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880", size = 224485, upload-time = "2026-04-03T21:46:32.72Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/d7/611e68d57e6a903c29cb33b5afec0f93b4baecacc6c6c62e33cde9eb9dcb/marshmallow-4.3.1.tar.gz", hash = "sha256:fb6b8048af08d4ab061610d5b7d3696a7e4c95337dbda880edb9f95812cabc20", size = 218308, upload-time = "2026-08-08T14:27:29.517Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/e0/ff24e25218bb59eb6290a530cea40651b14068b6e3659b20f9c175179632/marshmallow-4.3.0-py3-none-any.whl", hash = "sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46", size = 49148, upload-time = "2026-04-03T21:46:31.241Z" }, + { url = "https://files.pythonhosted.org/packages/43/57/4526ca0e214a3d158690e3393f6d547a2f8070f88d6388ab21d5b0aac8a1/marshmallow-4.3.1-py3-none-any.whl", hash = "sha256:e65accfbe277546df92ed7996a678c90e063e9a7c2a2f5e03f7d0b90e3768c42", size = 49219, upload-time = "2026-08-08T14:27:28.078Z" }, ] [[package]] @@ -1260,32 +1263,32 @@ wheels = [ [[package]] name = "numpy" -version = "2.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, - { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, - { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, - { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, - { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, - { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, - { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, - { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, - { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, - { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, - { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, - { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, - { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, - { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, - { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, ] [[package]] @@ -1330,7 +1333,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/11/8c/5a03b7c28670dd355 [[package]] name = "openshift-python-wrapper" -version = "11.0.138" +version = "11.0.139" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloup" }, @@ -1350,7 +1353,7 @@ dependencies = [ { name = "timeout-sampler" }, { name = "xmltodict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e5/6a683c68262f56a13cda611691cd5cda3caadd54decf54d1efec8c1455ab/openshift_python_wrapper-11.0.138.tar.gz", hash = "sha256:a1f073e0217ccab93e671ee569e5b5917eae9d555c194a937994e566b83e932d", size = 8160531, upload-time = "2026-07-22T11:00:19.859Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/40/55c080ae49440e4e5be1f3f0c7bed5d8df6790c80081a73ff1adf8f8426c/openshift_python_wrapper-11.0.139.tar.gz", hash = "sha256:63baddad7a8880eee3b1b88ada798b242265d96d886277a9b8c32336c8f17102", size = 8167523, upload-time = "2026-08-05T18:34:38.902Z" } [[package]] name = "openshift-python-wrapper-data-collector" @@ -1365,7 +1368,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/3b/5d/7d0652c649da05ffb [[package]] name = "openstacksdk" -version = "4.17.0" +version = "4.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, @@ -1381,9 +1384,9 @@ dependencies = [ { name = "psutil" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/1d/0238c57f7eb64170be6ff1703c3f87afa44a648f0672c91de7f66a58a11e/openstacksdk-4.17.0.tar.gz", hash = "sha256:827e1ade488db6116f59af1da6c97dbdfeeb879d3fc96bca99b19351f15df8ba", size = 1399124, upload-time = "2026-06-24T09:25:09.839Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/d0/514c38d0b7f4d3652321baf0c5136ac29ce9360a2094fcdcd78f865cb7c9/openstacksdk-4.18.0.tar.gz", hash = "sha256:466f2f869bcf6dec717a5e6c65c0522b1bd061d53310c7bf11982e0d9244f70c", size = 1403478, upload-time = "2026-08-03T12:27:08.152Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/b8/7c48faf004219cba8cd160800f11fd8440a5ae18e621679da2d4607fa1fd/openstacksdk-4.17.0-py3-none-any.whl", hash = "sha256:3c968f8b97b38b7d73ddb88d16c6a7d80bcc554c5fbfcc12638a7b51bdae144e", size = 1988252, upload-time = "2026-06-24T09:25:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/1c68b79bc71b1d9ea1d7acfd9a90f52bddfc2485ac3f8e1d1be488fcbcb8/openstacksdk-4.18.0-py3-none-any.whl", hash = "sha256:17d033c19a07df90116b67c0750b2218acdc45cdcf659d4c137f084667b26993", size = 1993600, upload-time = "2026-08-03T12:27:06.589Z" }, ] [[package]] @@ -1409,15 +1412,15 @@ wheels = [ [[package]] name = "os-service-types" -version = "1.8.2" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pbr" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/62/31e39aa8f2ac5bff0b061ce053f0610c9fe659e12aeca20bfb26d1665024/os_service_types-1.8.2.tar.gz", hash = "sha256:ab7648d7232849943196e1bb00a30e2e25e600fa3b57bb241d15b7f521b5b575", size = 27476, upload-time = "2025-11-21T13:55:47.726Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/ae/fe7ac23155ae0b4b9779e06e9c5bb4070f2315dc4ca886a88fa3230d344b/os_service_types-1.9.0.tar.gz", hash = "sha256:1f2e5fb71d1f6f4ff31d8992674f2368465bc2f25cd94018015c3ddbfc5c617f", size = 28087, upload-time = "2026-08-04T13:07:43.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/26/0937af7b4383f1eba5bca789b8d191c0e09e59bb64962b18f4a14534ce41/os_service_types-1.8.2-py3-none-any.whl", hash = "sha256:f78890d71814deffabf0ed4358288ec2ced579bc4d0bb87a79ae806cbb4deb6e", size = 24876, upload-time = "2025-11-21T13:55:46.093Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/f8ae99ae9bb54cd63e7ab89251b2a4ff4ff07f02847403948b6a6f50f816/os_service_types-1.9.0-py3-none-any.whl", hash = "sha256:f268545c896434177bf55c43f7d5129e65210d5d456df3f00a1c9832a71c3a4b", size = 25282, upload-time = "2026-08-04T13:07:42.258Z" }, ] [[package]] @@ -1471,11 +1474,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/bb/15/c9ddf8c863aafd198 [[package]] name = "packaging" -version = "26.2" +version = "26.3" 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, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -1567,11 +1570,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.0" +version = "4.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/0a/062135c9a98dac804265073cc3afdbec5ae1aa37980bb354f461bafe81b4/platformdirs-4.11.1.tar.gz", hash = "sha256:bb1af68078f25e2f3e111e2d43b8d536df41b73c8a684b40bb018223b66fae27", size = 32396, upload-time = "2026-08-07T23:06:48.516Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, + { url = "https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl", hash = "sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386", size = 23261, upload-time = "2026-08-07T23:06:47.219Z" }, ] [[package]] @@ -1778,16 +1781,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.2" +version = "2.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] [[package]] @@ -2298,27 +2301,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, - { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, - { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, - { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, - { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, - { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, - { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, - { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, - { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, - { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, - { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, ] [[package]] @@ -2345,11 +2348,11 @@ wheels = [ [[package]] name = "setuptools" -version = "83.0.0" +version = "84.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] [[package]] @@ -2390,24 +2393,24 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.9.1" +version = "2.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, ] [[package]] name = "sse-starlette" -version = "3.4.6" +version = "3.4.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, ] [[package]] @@ -2426,15 +2429,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.3.1" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] [[package]] @@ -2467,16 +2470,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/df/76/36d7b0653dbfec2c5 [[package]] name = "traitlets" -version = "5.16.0" +version = "5.16.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/a1/d7e7d9f461575d8bb77e3c3bd78a6cdfdd2bb4a06bfbbb8a0e1f51ab7bc2/traitlets-5.16.0.tar.gz", hash = "sha256:7de0a3fabaf5971ff15c8905545f9febfa850309fb8e86e1b42bdb5b46b293ed", size = 165946, upload-time = "2026-07-31T12:23:49.785Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/2e/a7fbfe268c8a3b32546930c0297c101d65a4a14c304ad5790a9f478f0e4e/traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1", size = 166137, upload-time = "2026-08-03T08:32:36.848Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/bd/f8607e908605262e4926cbfd2560094bc5d04ef7f8aff1340e7fff503016/traitlets-5.16.0-py3-none-any.whl", hash = "sha256:94a9967ba45e89e837cf9934029c8d019bea9149cfffa115ed8c1900f679beba", size = 86093, upload-time = "2026-07-31T12:23:47.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" }, ] [[package]] name = "typer" -version = "0.27.0" +version = "0.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -2484,9 +2487,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" }, ] [[package]]