From 2e052b5a0571aece26d571e1e158c95108bf224c Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 17 Sep 2025 15:56:24 +0100 Subject: [PATCH 01/26] Ironic: add custom hardware manager to inject IPs --- .../understack_hardware_manager.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 python/ironic-understack/ironic_understack/understack_hardware_manager.py diff --git a/python/ironic-understack/ironic_understack/understack_hardware_manager.py b/python/ironic-understack/ironic_understack/understack_hardware_manager.py new file mode 100644 index 000000000..3f636554b --- /dev/null +++ b/python/ironic-understack/ironic_understack/understack_hardware_manager.py @@ -0,0 +1,64 @@ +from ironic_python_agent import hardware +from ironic_python_agent import inject_files +from oslo_log import log + +LOG = log.getLogger() + + +class UnderstackHardwareManager(hardware.HardwareManager): + """Hardware Manager that injects Undercloud specific metadata.""" + + HARDWARE_MANAGER_NAME = "UnderstackHardwareManager" + HARDWARE_MANAGER_VERSION = "1" + + def evaluate_hardware_support(self): + """Declare level of hardware support provided. + + Since this example is explicitly about enforcing business logic during + cleaning, we want to return a static value. + + :returns: HardwareSupport level for this manager. + """ + return hardware.HardwareSupport.SERVICE_PROVIDER + + def get_deploy_steps(self, node, ports): + return [ + { + "step": "write_storage_ips", + "priority": 10, + "interface": "deploy", + "reboot_requested": False, + } + ] + + # "Files to inject, a list of file structures with keys: 'path' " + # "(path to the file), 'partition' (partition specifier), " + # "'content' (base64 encoded string), 'mode' (new file mode) and " + # "'dirmode' (mode for the leaf directory, if created). " + # "Merged with the values from node.properties[inject_files]." + + def write_storage_ips(self, node, ports): + # If not specified, the agent will determine the partition based on the + # first part of the path. + partition = None + file_contents = """ + [ + { + "address": "100.126.0.30/30", + "mac": "D4:04:E6:4F:87:85" + }, + { + "address": "100.126.128.30/30", + "mac": "14:23:F3:F5:3B:A1" + } + ] + """ + files = [ + { + "path": "/config-2/somefile.txt", + "partition": partition, + "content": file_contents, + "mode": "644", + } + ] + inject_files(node, ports, files) From 02bfeb52498bf85979190e490f25a1ed146d5201 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 22 Sep 2025 14:43:32 +0100 Subject: [PATCH 02/26] add understack_hwm module to IPA image --- .../install.d/60-understack-hwm | 9 +++++++ .../tmp/ironic_understack/pyproject.toml | 26 +++++++++++++++++++ .../static/tmp/ironic_understack/setup.cfg | 19 ++++++++++++++ .../understack_hwm/hardware_manager.py | 20 ++++++++++---- 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100755 ironic-images/custom_elements/undercloud-ipa/install.d/60-understack-hwm create mode 100644 ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/pyproject.toml create mode 100644 ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/setup.cfg rename python/ironic-understack/ironic_understack/understack_hardware_manager.py => ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py (81%) diff --git a/ironic-images/custom_elements/undercloud-ipa/install.d/60-understack-hwm b/ironic-images/custom_elements/undercloud-ipa/install.d/60-understack-hwm new file mode 100755 index 000000000..894cb2750 --- /dev/null +++ b/ironic-images/custom_elements/undercloud-ipa/install.d/60-understack-hwm @@ -0,0 +1,9 @@ +#!/bin/bash + +# dib-lint: disable=set setu setpipefail indent +if [ ${DIB_DEBUG_TRACE:-0} -gt 0 ]; then + set -x +fi +set -e + +/opt/ironic-python-agent/bin/python -m pip install /tmp/ironic_understack/ diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/pyproject.toml b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/pyproject.toml new file mode 100644 index 000000000..e3e840386 --- /dev/null +++ b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "understack-hardware-manager" +version = "0.0.1" +authors = [{ name = "Understack Developers" }] +description = "IPA Hardware Manager: custom steps" +license = {text = "Apache-2"} +requires-python = "~=3.11.0" +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Environment :: OpenStack", + "License :: OSI Approved :: Apache Software License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3.11", + "Topic :: System :: Hardware", +] + +[project.entry-points."ironic_python_agent.hardware_managers"] +understack_hwm = "understack_hwm.hardware_manager:UnderstackHardwareManager" + +[tool.hatch.build.targets.wheel] +packages = ["understack_hwm"] diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/setup.cfg b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/setup.cfg new file mode 100644 index 000000000..985f332f7 --- /dev/null +++ b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/setup.cfg @@ -0,0 +1,19 @@ +[metadata] +name = understack-hardware-manager +author = Marek Skrobacki +author-email = marek.skrobacki@rackspace.co.uk +summary = IPA Hardware Manager: custom steps +license = Apache-2 +classifier = + Intended Audience :: Developers + Operating System :: OS Independent + License :: OSI Approved :: Apache Software License + Programming Language :: Python :: 3 + +[files] +modules = + understack_hwm + +[entry_points] +ironic_python_agent.hardware_managers = + understack_hwm = understack_hwm.hardware_manager:UnderstackHardwareManager diff --git a/python/ironic-understack/ironic_understack/understack_hardware_manager.py b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py similarity index 81% rename from python/ironic-understack/ironic_understack/understack_hardware_manager.py rename to ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py index 3f636554b..4f5893a4f 100644 --- a/python/ironic-understack/ironic_understack/understack_hardware_manager.py +++ b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py @@ -1,5 +1,5 @@ from ironic_python_agent import hardware -from ironic_python_agent import inject_files +from ironic_python_agent.inject_files import inject_files from oslo_log import log LOG = log.getLogger() @@ -25,7 +25,17 @@ def get_deploy_steps(self, node, ports): return [ { "step": "write_storage_ips", - "priority": 10, + "priority": 50, + "interface": "deploy", + "reboot_requested": False, + } + ] + + def get_service_steps(self, node, ports): + return [ + { + "step": "write_storage_ips", + "priority": 50, "interface": "deploy", "reboot_requested": False, } @@ -40,7 +50,7 @@ def get_deploy_steps(self, node, ports): def write_storage_ips(self, node, ports): # If not specified, the agent will determine the partition based on the # first part of the path. - partition = None + # partition = None file_contents = """ [ { @@ -56,9 +66,9 @@ def write_storage_ips(self, node, ports): files = [ { "path": "/config-2/somefile.txt", - "partition": partition, + "partition": "/dev/sda4", "content": file_contents, - "mode": "644", + "mode": 644, } ] inject_files(node, ports, files) From de7caaba16b2492bb8283631ef6e1633a5341f9a Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 22 Sep 2025 18:11:04 +0100 Subject: [PATCH 03/26] ironic-images: add dev container for local IPA image builds --- ironic-images/Dockerfile | 6 ++++++ ironic-images/build.sh | 7 +++++++ 2 files changed, 13 insertions(+) create mode 100644 ironic-images/Dockerfile create mode 100755 ironic-images/build.sh diff --git a/ironic-images/Dockerfile b/ironic-images/Dockerfile new file mode 100644 index 000000000..f3cad6e84 --- /dev/null +++ b/ironic-images/Dockerfile @@ -0,0 +1,6 @@ +# NOTE: This container is used only for local development and is not +# intended to be used in CI/production +FROM ubuntu:24.04 + +RUN apt-get update && \ + apt-get -y --no-install-recommends install debootstrap qemu-utils squashfs-tools kpartx python3-pip python3-virtualenv sudo psutils procps git-core cpio diff --git a/ironic-images/build.sh b/ironic-images/build.sh new file mode 100755 index 000000000..65fc19167 --- /dev/null +++ b/ironic-images/build.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# used only for local testing +cd /code +export DIB_RELEASE=bookworm +export ELEMENTS_PATH=/code/.venv/share/ironic-python-agent-builder/dib:/code/custom_elements +export DIB_CLOUD_INIT_DATASOURCES="ConfigDrive, OpenStack, None" +diskimage-builder ./ipa-debian-bookworm.yaml From 5d13d59b786abb3bb109eb17bb648391e4d18e2b Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 23 Sep 2025 18:46:23 +0100 Subject: [PATCH 04/26] deliver IPs as NoCloud data source --- .../understack_hwm/hardware_manager.py | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py index 4f5893a4f..248de683d 100644 --- a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py +++ b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py @@ -1,3 +1,5 @@ +import base64 + from ironic_python_agent import hardware from ironic_python_agent.inject_files import inject_files from oslo_log import log @@ -52,22 +54,32 @@ def write_storage_ips(self, node, ports): # first part of the path. # partition = None file_contents = """ - [ - { - "address": "100.126.0.30/30", - "mac": "D4:04:E6:4F:87:85" - }, - { - "address": "100.126.128.30/30", - "mac": "14:23:F3:F5:3B:A1" - } - ] - """ +datasource: + NoCloud: + network-config: | + version: 2 + ethernets: + interface0: + match: + macaddress: "52:54:00:12:34:00" + set-name: interface0 + addresses: + - 100.126.0.6/255.255.255.252 + gateway4: 100.126.0.5 + interface1: + match: + macaddress: "14:23:F3:F5:3A:D1" + set-name: interface0 + addresses: + - 100.126.128.6/255.255.255.252 + gateway4: 100.126.128.5 +""" + file_encoded = base64.b64encode(file_contents.encode("utf-8")).decode("utf-8") files = [ { - "path": "/config-2/somefile.txt", - "partition": "/dev/sda4", - "content": file_contents, + "path": "/etc/cloud/cloud.cfg.d/95-undercloud-storage.cfg", + "partition": "/dev/sda3", + "content": file_encoded, "mode": 644, } ] From 45098afa4c939293d837c318abc93f99fb4d83eb Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 23 Sep 2025 19:12:26 +0100 Subject: [PATCH 05/26] add skeleton of the nautobot storage ip obtainer --- .../understack_hwm/__init__.py | 0 .../understack_hwm/nautobot_client.py | 179 ++++++++++++++++++ .../understack_hwm/test_nautobot.py | 12 ++ 3 files changed, 191 insertions(+) create mode 100644 ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/__init__.py create mode 100644 ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/nautobot_client.py create mode 100644 ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/test_nautobot.py diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/__init__.py b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/nautobot_client.py b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/nautobot_client.py new file mode 100644 index 000000000..a07b3e9a7 --- /dev/null +++ b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/nautobot_client.py @@ -0,0 +1,179 @@ +import ipaddress +from typing import Dict, Optional + +import requests +import yaml + + +class NautobotClient: + """Client for interacting with Nautobot's GraphQL API.""" + + def __init__(self, base_url: str, api_key: str): + """ + Initialize the Nautobot client. + + Args: + base_url: Base URL of the Nautobot instance (e.g., 'https://nautobot.example.com') + api_key: API key for authentication + """ + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.graphql_url = f"{self.base_url}/api/graphql/" + + def _make_graphql_request( + self, query: str, variables: Optional[Dict] = None + ) -> Dict: + """ + Make a GraphQL request to Nautobot. + + Args: + query: GraphQL query string + variables: Optional variables for the query + + Returns: + Response data from the GraphQL endpoint + + Raises: + requests.RequestException: If the request fails + ValueError: If the response contains GraphQL errors + """ + headers = { + "Authorization": f"Token {self.api_key}", + "Content-Type": "application/json", + } + + payload = {"query": query, "variables": variables or {}} + + response = requests.post( + self.graphql_url, headers=headers, json=payload, timeout=30 + ) + response.raise_for_status() + + data = response.json() + + if "errors" in data: + raise ValueError(f"GraphQL errors: {data['errors']}") + + return data + + def get_device_interfaces(self, device_id: str) -> Dict: + """ + Retrieve device interfaces and their IP assignments from Nautobot. + + Args: + device_id: UUID of the device to query + + Returns: + Dictionary containing the GraphQL response data + """ + query = """ + query ($device_id: String) { + devices(id: [$device_id]) { + id + interfaces(status: "Active") { + mac_address + ip_address_assignments { + ip_address { + address + ip_version + } + } + } + } + } + """ + + variables = {"device_id": device_id} + response = self._make_graphql_request(query, variables) + + return response + + def _calculate_gateway(self, ip_with_prefix: str) -> str: + """ + Calculate the first address of the subnet as gateway. + + Args: + ip_with_prefix: IP address with prefix (e.g., '192.168.1.10/24') + + Returns: + First address of the subnet (e.g., '192.168.1.1') + """ + network = ipaddress.ip_network(ip_with_prefix, strict=False) + # Get the first host address (network address + 1) + first_host = network.network_address + 1 + return str(first_host) + + def generate_network_config( + self, response: Dict, ignore_non_storage: bool = False + ) -> str: + """ + Generate netplan YAML configuration from Nautobot response. + + Args: + response: Response data from get_device_interfaces method + ignore_non_storage: If True, only include interfaces with IPs in 100.126.0.0/16 subnet + + Returns: + YAML string containing netplan configuration + """ + config = {"version": 2, "ethernets": {}} + + interface_count = 0 + + # Extract devices from response + devices = response.get("data", {}).get("devices", []) + + for device in devices: + interfaces = device.get("interfaces", []) + + for interface in interfaces: + mac_address = interface.get("mac_address") + ip_assignments = interface.get("ip_address_assignments", []) + + # Only process interfaces with IP assignments + if not ip_assignments or not mac_address: + continue + + # Filter for IPv4 assignments only + ipv4_assignments = [ + assignment + for assignment in ip_assignments + if assignment.get("ip_address", {}).get("ip_version") == 4 + ] + + if not ipv4_assignments: + continue + + # Take only the first IPv4 assignment + first_assignment = ipv4_assignments[0] + ip_info = first_assignment.get("ip_address", {}) + ip_address = ip_info.get("address") + + if not ip_address: + continue + + # Only process interfaces with IP addresses in 100.126.0.0/16 subnet + try: + ip_network = ipaddress.ip_network(ip_address, strict=False) + target_subnet = ipaddress.ip_network("100.126.0.0/16") + if not ip_network.subnet_of(target_subnet): + continue + except (ipaddress.AddressValueError, ValueError): + # Skip if IP address is invalid + continue + + interface_name = f"interface{interface_count}" + + # Calculate gateway (first address of the subnet) + gateway = self._calculate_gateway(ip_address) + + config["ethernets"][interface_name] = { + "match": {"macaddress": mac_address}, + "set-name": interface_name, + "addresses": [ip_address], + "gateway4": gateway, + } + + interface_count += 1 + + return yaml.dump(config, default_flow_style=False, sort_keys=False) diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/test_nautobot.py b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/test_nautobot.py new file mode 100644 index 000000000..0e822fbce --- /dev/null +++ b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/test_nautobot.py @@ -0,0 +1,12 @@ +from nautobot_client import NautobotClient + +import os + +n = NautobotClient( + api_key=os.getenv("NAUTOBOT_TOKEN"), + base_url="https://nautobot.dev.undercloud.rackspace.net", +) +devices = n.get_device_interfaces("2f75cab3-63d7-45ad-9045-b80f44e86132") + +print(devices) +print(n.generate_network_config(devices)) From 9ceaecadf209149a7e318be189a4802d3e9f03c7 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 24 Sep 2025 11:23:34 +0100 Subject: [PATCH 06/26] add Nova IronicUnderstackDriver nova driver must live under nova.virt. It cannot be external to the nova project because of this: https://github.com/openstack/nova/blob/b99a882366251f88d145e27312b94692e0b2266f/nova/virt/driver.py#L2074 --- python/nova-understack/README.md | 1 + .../ironic_understack/__init__.py | 3 + .../ironic_understack/driver.py | 96 + .../ironic_understack/nautobot_client.py | 172 ++ python/nova-understack/pyproject.toml | 50 + python/nova-understack/uv.lock | 2242 +++++++++++++++++ 6 files changed, 2564 insertions(+) create mode 100644 python/nova-understack/README.md create mode 100644 python/nova-understack/ironic_understack/__init__.py create mode 100644 python/nova-understack/ironic_understack/driver.py create mode 100644 python/nova-understack/ironic_understack/nautobot_client.py create mode 100644 python/nova-understack/pyproject.toml create mode 100644 python/nova-understack/uv.lock diff --git a/python/nova-understack/README.md b/python/nova-understack/README.md new file mode 100644 index 000000000..03b42d44d --- /dev/null +++ b/python/nova-understack/README.md @@ -0,0 +1 @@ +Nova drivers for Understack diff --git a/python/nova-understack/ironic_understack/__init__.py b/python/nova-understack/ironic_understack/__init__.py new file mode 100644 index 000000000..e1a507595 --- /dev/null +++ b/python/nova-understack/ironic_understack/__init__.py @@ -0,0 +1,3 @@ +from nova.virt.ironic_understack import driver + +IronicUnderstackDriver = driver.IronicUnderstackDriver diff --git a/python/nova-understack/ironic_understack/driver.py b/python/nova-understack/ironic_understack/driver.py new file mode 100644 index 000000000..ff328d674 --- /dev/null +++ b/python/nova-understack/ironic_understack/driver.py @@ -0,0 +1,96 @@ +from nova import exception +from nova.i18n import _ +from nova.virt.ironic.driver import IronicDriver + + +class IronicUnderstackDriver(IronicDriver): + capabilities = IronicDriver.capabilities + rebalances_nodes = IronicDriver.rebalances_nodes + + def spawn( + self, + context, + instance, + image_meta, + injected_files, + admin_password, + allocations, + network_info=None, + block_device_info=None, + power_on=True, + accel_info=None, + ): + """Deploy an instance. + + Args: + context: The security context. + instance: The instance object. + image_meta: Image dict returned by nova.image.glance + that defines the image from which to boot this instance. + injected_files: User files to inject into instance. + admin_password: Administrator password to set in instance. + allocations: Information about resources allocated to the + instance via placement, of the form returned by + SchedulerReportClient.get_allocations_for_consumer. + Ignored by this driver. + network_info: Instance network information. + block_device_info: Instance block device information. + accel_info: Accelerator requests for this instance. + power_on: True if the instance should be powered on, False otherwise. + """ + node_id = instance.get("node") + if not node_id: + raise exception.NovaException( + _("Ironic node uuid not supplied to driver for instance %s.") + % instance.uuid + ) + + storage_netinfo = self._lookup_storage_netinfo(node_id) + network_info = self._merge_storage_netinfo(network_info, storage_netinfo) + + return super().spawn( + context, + instance, + image_meta, + injected_files, + admin_password, + allocations, + network_info, + block_device_info, + power_on, + accel_info, + ) + + def _lookup_storage_netinfo(self, node_id): + return { + "links": [ + { + "id": "storage-iface-uuid", + "vif_id": "generate_or_obtain", + "type": "phy", + "mtu": 9000, + "ethernet_mac_address": "d4:04:e6:4f:90:18", + } + ], + "networks": [ + { + "id": "network0", + "type": "ipv4", + "link": "storage-iface-uuid", + "ip_address": "126.0.0.2", + "netmask": "255.255.255.252", + "routes": [ + { + "network": "127.0.0.0", + "netmask": "255.255.0.0", + "gateway": "126.0.0.1", + } + ], + "network_id": "generate_or_obtain", + } + ], + } + + def _merge_storage_netinfo(self, original, new_info): + print("original network_info: %s", original) + return original diff --git a/python/nova-understack/ironic_understack/nautobot_client.py b/python/nova-understack/ironic_understack/nautobot_client.py new file mode 100644 index 000000000..47ccce580 --- /dev/null +++ b/python/nova-understack/ironic_understack/nautobot_client.py @@ -0,0 +1,172 @@ +import ipaddress + +import requests +import yaml + + +class NautobotClient: + """Client for interacting with Nautobot's GraphQL API.""" + + def __init__(self, base_url: str, api_key: str): + """Initialize the Nautobot client. + + Args: + base_url: Base URL of the Nautobot instance (e.g., 'https://nautobot.example.com') + api_key: API key for authentication + """ + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.graphql_url = f"{self.base_url}/api/graphql/" + + def _make_graphql_request(self, query: str, variables: dict | None = None) -> dict: + """Make a GraphQL request to Nautobot. + + Args: + query: GraphQL query string + variables: Optional variables for the query + + Returns: + Response data from the GraphQL endpoint + + Raises: + requests.RequestException: If the request fails + ValueError: If the response contains GraphQL errors + """ + headers = { + "Authorization": f"Token {self.api_key}", + "Content-Type": "application/json", + } + + payload = {"query": query, "variables": variables or {}} + + response = requests.post( + self.graphql_url, headers=headers, json=payload, timeout=30 + ) + response.raise_for_status() + + data = response.json() + + if "errors" in data: + raise ValueError(f"GraphQL errors: {data['errors']}") + + return data + + def get_device_interfaces(self, device_id: str) -> dict: + """Retrieve device interfaces and their IP assignments from Nautobot. + + Args: + device_id: UUID of the device to query + + Returns: + Dictionary containing the GraphQL response data + """ + query = """ + query ($device_id: String) { + devices(id: [$device_id]) { + id + interfaces(status: "Active") { + mac_address + ip_address_assignments { + ip_address { + address + ip_version + } + } + } + } + } + """ + + variables = {"device_id": device_id} + response = self._make_graphql_request(query, variables) + + return response + + def _calculate_gateway(self, ip_with_prefix: str) -> str: + """Calculate the first address of the subnet as gateway. + + Args: + ip_with_prefix: IP address with prefix (e.g., '192.168.1.10/24') + + Returns: + First address of the subnet (e.g., '192.168.1.1') + """ + network = ipaddress.ip_network(ip_with_prefix, strict=False) + # Get the first host address (network address + 1) + first_host = network.network_address + 1 + return str(first_host) + + def generate_network_config( + self, response: dict, ignore_non_storage: bool = False + ) -> str: + """Generate netplan YAML configuration from Nautobot response. + + Args: + response: Response data from get_device_interfaces method + ignore_non_storage: If True, only include interfaces with IPs in + 100.126.0.0/16 subnet + + Returns: + YAML string containing netplan configuration + """ + config = {"version": 2, "ethernets": {}} + + interface_count = 0 + + # Extract devices from response + devices = response.get("data", {}).get("devices", []) + + for device in devices: + interfaces = device.get("interfaces", []) + + for interface in interfaces: + mac_address = interface.get("mac_address") + ip_assignments = interface.get("ip_address_assignments", []) + + # Only process interfaces with IP assignments + if not ip_assignments or not mac_address: + continue + + # Filter for IPv4 assignments only + ipv4_assignments = [ + assignment + for assignment in ip_assignments + if assignment.get("ip_address", {}).get("ip_version") == 4 + ] + + if not ipv4_assignments: + continue + + # Take only the first IPv4 assignment + first_assignment = ipv4_assignments[0] + ip_info = first_assignment.get("ip_address", {}) + ip_address = ip_info.get("address") + + if not ip_address: + continue + + # Only process interfaces with IP addresses in 100.126.0.0/16 subnet + try: + ip_network = ipaddress.ip_network(ip_address, strict=False) + target_subnet = ipaddress.ip_network("100.126.0.0/16") + if ignore_non_storage and not ip_network.subnet_of(target_subnet): # pyright: ignore + continue + except (ipaddress.AddressValueError, ValueError): + # Skip if IP address is invalid + continue + + interface_name = f"interface{interface_count}" + + # Calculate gateway (first address of the subnet) + gateway = self._calculate_gateway(ip_address) + + config["ethernets"][interface_name] = { + "match": {"macaddress": mac_address}, + "set-name": interface_name, + "addresses": [ip_address], + "gateway4": gateway, + } + + interface_count += 1 + + return yaml.dump(config, default_flow_style=False, sort_keys=False) diff --git a/python/nova-understack/pyproject.toml b/python/nova-understack/pyproject.toml new file mode 100644 index 000000000..ab585b7b2 --- /dev/null +++ b/python/nova-understack/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "nova-understack" +version = "0.0.0" +description = "Nova drivers for Understack" +authors = [{ name = "Marek Skrobacki", email = "marek.skrobacki@rackspace.co.uk" }] +requires-python = "~=3.10.0" +readme = "README.md" +license = "MIT" +dependencies = [ + "nova>=30.1", +] + +[dependency-groups] +test = [ + "pytest>=8.3.2,<9", + "pytest-github-actions-annotate-failures", + "pytest-cov>=6.2.1,<7", +] + +[tool.uv] +default-groups = ["test"] + +[tool.hatch.build.targets.sdist] +include = ["nova_understack"] + +[tool.hatch.build.targets.wheel] +include = ["nova_understack"] + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-ra --cov=nova_understack" +testpaths = [ + "tests", +] + +[tool.ruff] +# use our default and override anything we need specifically +extend = "../pyproject.toml" +target-version = "py310" + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "S101", # allow 'assert' for pytest + "S105", # allow hardcoded passwords for testing + "S106", # allow hardcoded passwords for testing +] diff --git a/python/nova-understack/uv.lock b/python/nova-understack/uv.lock new file mode 100644 index 000000000..f152637ee --- /dev/null +++ b/python/nova-understack/uv.lock @@ -0,0 +1,2242 @@ +version = 1 +revision = 2 +requires-python = "==3.10.*" + +[[package]] +name = "alembic" +version = "1.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" }, +] + +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "autopage" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/9e/559b0cfdba9f3ed6744d8cbcdbda58880d3695c43c053a31773cefcedde3/autopage-0.5.2.tar.gz", hash = "sha256:826996d74c5aa9f4b6916195547312ac6384bac3810b8517063f293248257b72", size = 33031, upload-time = "2023-10-16T09:22:19.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/63/f1c3fa431e91a52bad5e3602e9d5df6c94d8d095ac485424efa4eeddb4d2/autopage-0.5.2-py3-none-any.whl", hash = "sha256:f5eae54dd20ccc8b1ff611263fc87bc46608a9cde749bbcfc93339713a429c55", size = 30231, upload-time = "2023-10-16T09:22:17.316Z" }, +] + +[[package]] +name = "bcrypt" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" }, + { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" }, + { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" }, + { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" }, + { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, + { url = "https://files.pythonhosted.org/packages/55/2d/0c7e5ab0524bf1a443e34cdd3926ec6f5879889b2f3c32b2f5074e99ed53/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1", size = 275367, upload-time = "2025-02-28T01:23:54.578Z" }, + { url = "https://files.pythonhosted.org/packages/10/4f/f77509f08bdff8806ecc4dc472b6e187c946c730565a7470db772d25df70/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d", size = 280644, upload-time = "2025-02-28T01:23:56.547Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/7d9dc16a3a4d530d0a9b845160e9e5d8eb4f00483e05d44bb4116a1861da/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492", size = 274881, upload-time = "2025-02-28T01:23:57.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/c4/ae6921088adf1e37f2a3a6a688e72e7d9e45fdd3ae5e0bc931870c1ebbda/bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90", size = 280203, upload-time = "2025-02-28T01:23:59.331Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/e4fad8155db4a04bfb4734c7c8ff0882f078f24294d42798b3568eb63bff/cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32", size = 30988, upload-time = "2025-08-25T18:57:30.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/56/3124f61d37a7a4e7cc96afc5492c78ba0cb551151e530b54669ddd1436ef/cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6", size = 11276, upload-time = "2025-08-25T18:57:29.684Z" }, +] + +[[package]] +name = "castellan" +version = "5.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "keystoneauth1" }, + { name = "oslo-config" }, + { name = "oslo-context" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "python-barbicanclient" }, + { name = "requests" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/91/44e862ec28d3720048b0acc68230a2154756b94bad9328296fef2fe843b3/castellan-5.4.1.tar.gz", hash = "sha256:b152699fa7a956d20c17ae4f1c74408cdca73f13f5d3893f8c460613bf279974", size = 84784, upload-time = "2025-07-25T10:40:38.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0f/75fa642abc29cd0cdabce7dddae3933c5e6859638c84af9e10a3901db9b0/castellan-5.4.1-py3-none-any.whl", hash = "sha256:f2d5656ebff8558f22a493f827ea49808d78d3a6cbfc44ddb4256f8fd748e3f3", size = 99491, upload-time = "2025-07-25T10:40:37.201Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "cliff" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "autopage" }, + { name = "cmd2" }, + { name = "prettytable" }, + { name = "pyyaml" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/43/6974bae8a54e8e49aea448f2897ba1af4d261b95328a3cc112fa0e290b1a/cliff-4.11.0.tar.gz", hash = "sha256:aa33c11ac2fecdf2d1eaffea9d5d0eb4584b8e777673bb55d42a693e34ccc429", size = 86638, upload-time = "2025-08-21T09:39:32.768Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/5d/52412c2bfacd6eb513050a786d1c4eea462f3c379106578ebee9ec28b9ed/cliff-4.11.0-py3-none-any.whl", hash = "sha256:845084c452fbfbf79bf13d2a3f33248326776a2273bb854a44ea065e7be310a4", size = 84417, upload-time = "2025-08-21T09:39:31.49Z" }, +] + +[[package]] +name = "cmd2" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gnureadline", marker = "sys_platform == 'darwin'" }, + { name = "pyperclip" }, + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "rich-argparse" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/68/4bf43d284e41c01c6011146e5c2824aa6f17a3bb1ef10ba3dbbae5cf31dc/cmd2-2.7.0.tar.gz", hash = "sha256:81d8135b46210e1d03a5a810baf859069a62214788ceeec3588f44eed86fbeeb", size = 593131, upload-time = "2025-06-30T16:54:26.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/83/0f65933b7daa436912173f3d63232d158b60686318fccc7cf458ff15bfe8/cmd2-2.7.0-py3-none-any.whl", hash = "sha256:c85faf603e8cfeb4302206f49c0530a83d63386b0d90ff6a957f2c816eb767d7", size = 154309, upload-time = "2025-06-30T16:54:25.039Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli" }, +] + +[[package]] +name = "cryptography" +version = "46.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/62/e3664e6ffd7743e1694b244dde70b43a394f6f7fbcacf7014a8ff5197c73/cryptography-46.0.1.tar.gz", hash = "sha256:ed570874e88f213437f5cf758f9ef26cbfc3f336d889b1e592ee11283bb8d1c7", size = 749198, upload-time = "2025-09-17T00:10:35.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/8c/44ee01267ec01e26e43ebfdae3f120ec2312aa72fa4c0507ebe41a26739f/cryptography-46.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475", size = 7285044, upload-time = "2025-09-17T00:08:36.807Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/9ae689a25047e0601adfcb159ec4f83c0b4149fdb5c3030cc94cd218141d/cryptography-46.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ff483716be32690c14636e54a1f6e2e1b7bf8e22ca50b989f88fa1b2d287080", size = 4308182, upload-time = "2025-09-17T00:08:39.388Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/ca6cc9df7118f2fcd142c76b1da0f14340d77518c05b1ebfbbabca6b9e7d/cryptography-46.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9873bf7c1f2a6330bdfe8621e7ce64b725784f9f0c3a6a55c3047af5849f920e", size = 4572393, upload-time = "2025-09-17T00:08:41.663Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a3/0f5296f63815d8e985922b05c31f77ce44787b3127a67c0b7f70f115c45f/cryptography-46.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb7c88d4462a0cfdd0d87a3c245a7bc3feb59de101f6ff88194f740f72eda6", size = 4308400, upload-time = "2025-09-17T00:08:43.559Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8c/74fcda3e4e01be1d32775d5b4dd841acaac3c1b8fa4d0774c7ac8d52463d/cryptography-46.0.1-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e22801b61613ebdebf7deb18b507919e107547a1d39a3b57f5f855032dd7cfb8", size = 4015786, upload-time = "2025-09-17T00:08:45.758Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b8/85d23287baeef273b0834481a3dd55bbed3a53587e3b8d9f0898235b8f91/cryptography-46.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:757af4f6341ce7a1e47c326ca2a81f41d236070217e5fbbad61bbfe299d55d28", size = 4982606, upload-time = "2025-09-17T00:08:47.602Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/de61ad5b52433b389afca0bc70f02a7a1f074651221f599ce368da0fe437/cryptography-46.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f7a24ea78de345cfa7f6a8d3bde8b242c7fac27f2bd78fa23474ca38dfaeeab9", size = 4604234, upload-time = "2025-09-17T00:08:49.879Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1f/dbd4d6570d84748439237a7478d124ee0134bf166ad129267b7ed8ea6d22/cryptography-46.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e8776dac9e660c22241b6587fae51a67b4b0147daa4d176b172c3ff768ad736", size = 4307669, upload-time = "2025-09-17T00:08:52.321Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fd/ca0a14ce7f0bfe92fa727aacaf2217eb25eb7e4ed513b14d8e03b26e63ed/cryptography-46.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9f40642a140c0c8649987027867242b801486865277cbabc8c6059ddef16dc8b", size = 4947579, upload-time = "2025-09-17T00:08:54.697Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/09c30543bb93401f6f88fce556b3bdbb21e55ae14912c04b7bf355f5f96c/cryptography-46.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:449ef2b321bec7d97ef2c944173275ebdab78f3abdd005400cc409e27cd159ab", size = 4603669, upload-time = "2025-09-17T00:08:57.16Z" }, + { url = "https://files.pythonhosted.org/packages/23/9a/38cb01cb09ce0adceda9fc627c9cf98eb890fc8d50cacbe79b011df20f8a/cryptography-46.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2dd339ba3345b908fa3141ddba4025568fa6fd398eabce3ef72a29ac2d73ad75", size = 4435828, upload-time = "2025-09-17T00:08:59.606Z" }, + { url = "https://files.pythonhosted.org/packages/0f/53/435b5c36a78d06ae0bef96d666209b0ecd8f8181bfe4dda46536705df59e/cryptography-46.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7411c910fb2a412053cf33cfad0153ee20d27e256c6c3f14d7d7d1d9fec59fd5", size = 4709553, upload-time = "2025-09-17T00:09:01.832Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c4/0da6e55595d9b9cd3b6eb5dc22f3a07ded7f116a3ea72629cab595abb804/cryptography-46.0.1-cp311-abi3-win32.whl", hash = "sha256:cbb8e769d4cac884bb28e3ff620ef1001b75588a5c83c9c9f1fdc9afbe7f29b0", size = 3058327, upload-time = "2025-09-17T00:09:03.726Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/cd29a35e0d6e78a0ee61793564c8cff0929c38391cb0de27627bdc7525aa/cryptography-46.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:92e8cfe8bd7dd86eac0a677499894862cd5cc2fd74de917daa881d00871ac8e7", size = 3523893, upload-time = "2025-09-17T00:09:06.272Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dd/eea390f3e78432bc3d2f53952375f8b37cb4d37783e626faa6a51e751719/cryptography-46.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:db5597a4c7353b2e5fb05a8e6cb74b56a4658a2b7bf3cb6b1821ae7e7fd6eaa0", size = 2932145, upload-time = "2025-09-17T00:09:08.568Z" }, + { url = "https://files.pythonhosted.org/packages/98/e5/fbd632385542a3311915976f88e0dfcf09e62a3fc0aff86fb6762162a24d/cryptography-46.0.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d84c40bdb8674c29fa192373498b6cb1e84f882889d21a471b45d1f868d8d44b", size = 7255677, upload-time = "2025-09-17T00:09:42.407Z" }, + { url = "https://files.pythonhosted.org/packages/56/3e/13ce6eab9ad6eba1b15a7bd476f005a4c1b3f299f4c2f32b22408b0edccf/cryptography-46.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ed64e5083fa806709e74fc5ea067dfef9090e5b7a2320a49be3c9df3583a2d8", size = 4301110, upload-time = "2025-09-17T00:09:45.614Z" }, + { url = "https://files.pythonhosted.org/packages/a2/67/65dc233c1ddd688073cf7b136b06ff4b84bf517ba5529607c9d79720fc67/cryptography-46.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:341fb7a26bc9d6093c1b124b9f13acc283d2d51da440b98b55ab3f79f2522ead", size = 4562369, upload-time = "2025-09-17T00:09:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/17/db/d64ae4c6f4e98c3dac5bf35dd4d103f4c7c345703e43560113e5e8e31b2b/cryptography-46.0.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6ef1488967e729948d424d09c94753d0167ce59afba8d0f6c07a22b629c557b2", size = 4302126, upload-time = "2025-09-17T00:09:49.335Z" }, + { url = "https://files.pythonhosted.org/packages/3d/19/5f1eea17d4805ebdc2e685b7b02800c4f63f3dd46cfa8d4c18373fea46c8/cryptography-46.0.1-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7823bc7cdf0b747ecfb096d004cc41573c2f5c7e3a29861603a2871b43d3ef32", size = 4009431, upload-time = "2025-09-17T00:09:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/81/b5/229ba6088fe7abccbfe4c5edb96c7a5ad547fac5fdd0d40aa6ea540b2985/cryptography-46.0.1-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:f736ab8036796f5a119ff8211deda416f8c15ce03776db704a7a4e17381cb2ef", size = 4980739, upload-time = "2025-09-17T00:09:54.181Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9c/50aa38907b201e74bc43c572f9603fa82b58e831bd13c245613a23cff736/cryptography-46.0.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e46710a240a41d594953012213ea8ca398cd2448fbc5d0f1be8160b5511104a0", size = 4592289, upload-time = "2025-09-17T00:09:56.731Z" }, + { url = "https://files.pythonhosted.org/packages/5a/33/229858f8a5bb22f82468bb285e9f4c44a31978d5f5830bb4ea1cf8a4e454/cryptography-46.0.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:84ef1f145de5aee82ea2447224dc23f065ff4cc5791bb3b506615957a6ba8128", size = 4301815, upload-time = "2025-09-17T00:09:58.548Z" }, + { url = "https://files.pythonhosted.org/packages/52/cb/b76b2c87fbd6ed4a231884bea3ce073406ba8e2dae9defad910d33cbf408/cryptography-46.0.1-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9394c7d5a7565ac5f7d9ba38b2617448eba384d7b107b262d63890079fad77ca", size = 4943251, upload-time = "2025-09-17T00:10:00.475Z" }, + { url = "https://files.pythonhosted.org/packages/94/0f/f66125ecf88e4cb5b8017ff43f3a87ede2d064cb54a1c5893f9da9d65093/cryptography-46.0.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ed957044e368ed295257ae3d212b95456bd9756df490e1ac4538857f67531fcc", size = 4591247, upload-time = "2025-09-17T00:10:02.874Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/9f3134ae436b63b463cfdf0ff506a0570da6873adb4bf8c19b8a5b4bac64/cryptography-46.0.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f7de12fa0eee6234de9a9ce0ffcfa6ce97361db7a50b09b65c63ac58e5f22fc7", size = 4428534, upload-time = "2025-09-17T00:10:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/89/39/e6042bcb2638650b0005c752c38ea830cbfbcbb1830e4d64d530000aa8dc/cryptography-46.0.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7fab1187b6c6b2f11a326f33b036f7168f5b996aedd0c059f9738915e4e8f53a", size = 4699541, upload-time = "2025-09-17T00:10:06.925Z" }, + { url = "https://files.pythonhosted.org/packages/68/46/753d457492d15458c7b5a653fc9a84a1c9c7a83af6ebdc94c3fc373ca6e8/cryptography-46.0.1-cp38-abi3-win32.whl", hash = "sha256:45f790934ac1018adeba46a0f7289b2b8fe76ba774a88c7f1922213a56c98bc1", size = 3043779, upload-time = "2025-09-17T00:10:08.951Z" }, + { url = "https://files.pythonhosted.org/packages/2f/50/b6f3b540c2f6ee712feeb5fa780bb11fad76634e71334718568e7695cb55/cryptography-46.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:7176a5ab56fac98d706921f6416a05e5aff7df0e4b91516f450f8627cda22af3", size = 3517226, upload-time = "2025-09-17T00:10:10.769Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e8/77d17d00981cdd27cc493e81e1749a0b8bbfb843780dbd841e30d7f50743/cryptography-46.0.1-cp38-abi3-win_arm64.whl", hash = "sha256:efc9e51c3e595267ff84adf56e9b357db89ab2279d7e375ffcaf8f678606f3d9", size = 2923149, upload-time = "2025-09-17T00:10:13.236Z" }, + { url = "https://files.pythonhosted.org/packages/14/b9/b260180b31a66859648cfed5c980544ee22b15f8bd20ef82a23f58c0b83e/cryptography-46.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fd4b5e2ee4e60425711ec65c33add4e7a626adef79d66f62ba0acfd493af282d", size = 3714683, upload-time = "2025-09-17T00:10:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5a/1cd3ef86e5884edcbf8b27c3aa8f9544e9b9fcce5d3ed8b86959741f4f8e/cryptography-46.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:48948940d0ae00483e85e9154bb42997d0b77c21e43a77b7773c8c80de532ac5", size = 3443784, upload-time = "2025-09-17T00:10:18.014Z" }, +] + +[[package]] +name = "cursive" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "castellan" }, + { name = "cryptography" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/3d/dd3b74dab4240ca2eeb291a643e0a8780fe5a83f5d3efeb358b5bc8d2a17/cursive-0.2.3.tar.gz", hash = "sha256:f435f6cdbe6a517f054c1105c36e436d7868124f1b227d310fe809d918a8c10c", size = 43756, upload-time = "2022-11-21T20:10:20.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/c2/b82b94373fa95b3224d8f0ba644bb2d4c8413dbf39116340425ba0af63d8/cursive-0.2.3-py2.py3-none-any.whl", hash = "sha256:6ee9ec459cdc6096fe23ca0710b4705a62ad7bac7ddee1e124b1d9de6817b76e", size = 43781, upload-time = "2022-11-21T20:10:18.403Z" }, +] + +[[package]] +name = "debtcollector" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/e2/a45b5a620145937529c840df5e499c267997e85de40df27d54424a158d3c/debtcollector-3.0.0.tar.gz", hash = "sha256:2a8917d25b0e1f1d0d365d3c1c6ecfc7a522b1e9716e8a1a4a915126f7ccea6f", size = 31322, upload-time = "2024-02-22T15:39:20.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/ca/863ed8fa66d6f986de6ad7feccc5df96e37400845b1eeb29889a70feea99/debtcollector-3.0.0-py3-none-any.whl", hash = "sha256:46f9dacbe8ce49c47ebf2bf2ec878d50c9443dfae97cc7b8054be684e54c3e91", size = 23035, upload-time = "2024-02-22T15:39:18.99Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "dogpile-cache" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "stevedore" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/97/da72845c89c9aa70e3e74609b864eff5e5c2ec46366645e7bb61eaa29e9c/dogpile_cache-1.4.1.tar.gz", hash = "sha256:e25c60e677a5e28ff86124765fbf18c53257bcd7830749cd5ba350ace2a12989", size = 939952, upload-time = "2025-09-12T16:34:32.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f6/1d9579d7b86e7957dd626e52b65c72af0fcd47b277716efd9889990d92b4/dogpile_cache-1.4.1-py3-none-any.whl", hash = "sha256:99130ce990800c8d89c26a5a8d9923cbe1b78c8a9972c2aaa0abf3d2ef2984ad", size = 63593, upload-time = "2025-09-12T16:34:34.809Z" }, +] + +[[package]] +name = "eventlet" +version = "0.40.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "greenlet" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/4f/1e5227b23aa77d9ea05056b98cf0bf187cca994991060245002b640f9830/eventlet-0.40.3.tar.gz", hash = "sha256:290852db0065d78cec17a821b78c8a51cafb820a792796a354592ae4d5fceeb0", size = 565741, upload-time = "2025-08-27T09:56:16.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/b4/981362608131dc4ee8de9fdca6a38ef19e3da66ab6a13937bd158882db91/eventlet-0.40.3-py3-none-any.whl", hash = "sha256:e681cae6ee956cfb066a966b5c0541e734cc14879bda6058024104790595ac9d", size = 364333, upload-time = "2025-08-27T09:56:10.774Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "fasteners" +version = "0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/18/7881a99ba5244bfc82f06017316ffe93217dbbbcfa52b887caa1d4f2a6d3/fasteners-0.20.tar.gz", hash = "sha256:55dce8792a41b56f727ba6e123fcaee77fd87e638a6863cec00007bfea84c8d8", size = 25087, upload-time = "2025-08-11T10:19:37.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ac/e5d886f892666d2d1e5cb8c1a41146e1d79ae8896477b1153a21711d3b44/fasteners-0.20-py3-none-any.whl", hash = "sha256:9422c40d1e350e4259f509fb2e608d6bc43c0136f79a00db1b49046029d0b3b7", size = 18702, upload-time = "2025-08-11T10:19:35.716Z" }, +] + +[[package]] +name = "fixtures" +version = "4.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/ff/2d7d262a2ac563a3c976eae210057d68271f5080f0411792d8e2aa84d9a6/fixtures-4.2.6.tar.gz", hash = "sha256:95472b15b145063a672fbe33b1244ccff829fbec97d530d862d26f416d16c90b", size = 46938, upload-time = "2025-08-03T17:20:52.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/49/d92f66a79fe0da4b55b77597a2cfb6630c131d702a6b4a87ddd2ee277c3d/fixtures-4.2.6-py3-none-any.whl", hash = "sha256:58266d3646b7bda07ed463c6fd26a1b18a0273ee02de2b3f8ddec9e6e4359239", size = 66181, upload-time = "2025-08-03T17:20:51.155Z" }, +] + +[[package]] +name = "futurist" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/12/786f4aaf9d396d67b1b7b90f248ff994e916605d0751d08a0344a4a785a6/futurist-3.2.1.tar.gz", hash = "sha256:01dd4f30acdfbb2e2eb6091da565eded82d8cbaf6c48a36cc7f73c11cfa7fb3f", size = 49326, upload-time = "2025-08-29T15:06:57.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/a4418215b594fa44dea7deae61fa406139e2e8acc6442d25f93d80c52c84/futurist-3.2.1-py3-none-any.whl", hash = "sha256:c76a1e7b2c6b264666740c3dffbdcf512bd9684b4b253a3068a0135b43729745", size = 40485, upload-time = "2025-08-29T15:06:56.476Z" }, +] + +[[package]] +name = "gnureadline" +version = "8.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/92/20723aa239b9a8024e6f8358c789df8859ab1085a1ae106e5071727ad20f/gnureadline-8.2.13.tar.gz", hash = "sha256:c9b9e1e7ba99a80bb50c12027d6ce692574f77a65bf57bc97041cf81c0f49bd1", size = 3224991, upload-time = "2024-10-18T14:03:11.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/4f/81ff367156444f67d16cc8d9023b4a0a3f4bd29acaf8f8e510c7872b6927/gnureadline-8.2.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0ca03501ce0939d7ecf9d075860d6f6ceb2f49f30331b4e96e4678ce03687bab", size = 160572, upload-time = "2024-10-18T14:03:29.785Z" }, + { url = "https://files.pythonhosted.org/packages/48/06/0297bdde1e4a842ec786b9b7c9fca53116bac8fe2aed9769000f652fd1e3/gnureadline-8.2.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c28e33bfc56d4204693f213abeab927f65c505ce91f668a039720bc7c46b0353", size = 162590, upload-time = "2024-10-18T14:03:31.337Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7c/e7833dbcd8f376f3326bd728c845d31dcde4c84268d3921afcae77d90d08/greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", size = 636703, upload-time = "2025-08-07T13:53:12.622Z" }, + { url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" }, + { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, + { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, + { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "invoke" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/42/127e6d792884ab860defc3f4d80a8f9812e48ace584ffc5a346de58cdc6c/invoke-2.2.0.tar.gz", hash = "sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5", size = 299835, upload-time = "2023-07-12T18:05:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/66/7f8c48009c72d73bc6bbe6eb87ac838d6a526146f7dab14af671121eb379/invoke-2.2.0-py3-none-any.whl", hash = "sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820", size = 160274, upload-time = "2023-07-12T18:05:16.294Z" }, +] + +[[package]] +name = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jwcrypto" +version = "1.5.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/db/870e5d5fb311b0bcf049630b5ba3abca2d339fd5e13ba175b4c13b456d08/jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039", size = 87168, upload-time = "2024-03-06T19:58:31.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/58/4a1880ea64032185e9ae9f63940c9327c6952d5584ea544a8f66972f2fda/jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789", size = 92520, upload-time = "2024-03-06T19:58:29.765Z" }, +] + +[[package]] +name = "keystoneauth1" +version = "5.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iso8601" }, + { name = "os-service-types" }, + { name = "pbr" }, + { name = "requests" }, + { name = "stevedore" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/16/b96df223ca7ea4bfa78034b205e0eaf4875bfecb2f119f375fc5232d2061/keystoneauth1-5.12.0.tar.gz", hash = "sha256:dd113c2f3dcb418d9f761c73b8cd43a96ddfa8a612b51c576822381f39ca4ae8", size = 288504, upload-time = "2025-08-21T09:34:10.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/8a/803a45dc660770ac7e2d74fc1260a15ade29d2234120854747491b4a7a02/keystoneauth1-5.12.0-py3-none-any.whl", hash = "sha256:2e514b03615e2d9162f0c07c823a61a636e6d4df38ff4a34b7511a04e8a4166a", size = 343402, upload-time = "2025-08-21T09:34:09.38Z" }, +] + +[[package]] +name = "keystonemiddleware" +version = "10.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "keystoneauth1" }, + { name = "oslo-cache" }, + { name = "oslo-config" }, + { name = "oslo-context" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "pycadf" }, + { name = "pyjwt" }, + { name = "python-keystoneclient" }, + { name = "requests" }, + { name = "webob" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/20/99aa84ef91b9ed7365562a9fd9a4e31d7cc68201162094a618446c67fc39/keystonemiddleware-10.12.0.tar.gz", hash = "sha256:0da92b4af5178410e15a1b99f56d9cdeb2546eed088c69bc39e666fe09f869bf", size = 215566, upload-time = "2025-08-21T09:35:55.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/20/410d86b83bfdcb70308d40994958c329afb44587e42a8402d747cb7ced05/keystonemiddleware-10.12.0-py3-none-any.whl", hash = "sha256:459c0c0811e60d13c4fbb7c2065ba83d8bfc288c0f322a301157322ea127f075", size = 152090, upload-time = "2025-08-21T09:35:54.055Z" }, +] + +[[package]] +name = "kombu" +version = "5.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992, upload-time = "2025-06-01T10:19:22.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589, upload-time = "2025-09-22T04:00:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671, upload-time = "2025-09-22T04:00:15.411Z" }, + { url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961, upload-time = "2025-09-22T04:00:17.619Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087, upload-time = "2025-09-22T04:00:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620, upload-time = "2025-09-22T04:00:21.877Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664, upload-time = "2025-09-22T04:00:23.714Z" }, + { url = "https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397, upload-time = "2025-09-22T04:00:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178, upload-time = "2025-09-22T04:00:27.602Z" }, + { url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148, upload-time = "2025-09-22T04:00:29.323Z" }, + { url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035, upload-time = "2025-09-22T04:00:31.061Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111, upload-time = "2025-09-22T04:00:33.11Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662, upload-time = "2025-09-22T04:00:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973, upload-time = "2025-09-22T04:00:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953, upload-time = "2025-09-22T04:00:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695, upload-time = "2025-09-22T04:00:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051, upload-time = "2025-09-22T04:00:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264, upload-time = "2025-09-22T04:04:32.892Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" }, + { url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357, upload-time = "2025-09-22T04:04:39.322Z" }, + { url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295, upload-time = "2025-09-22T04:04:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913, upload-time = "2025-09-22T04:04:43.602Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "microversion-parse" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webob" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/02/7589a0b94bf5fab83417c054ad300a259775508a3cab3cb2e4d35c550cf7/microversion_parse-2.0.0.tar.gz", hash = "sha256:3a6528edf73f3bcb4507f4dad3dbc3116e9704815edfbdd6ff06dc5f899a71dc", size = 20779, upload-time = "2024-08-29T15:38:35.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/23/5859fad50fc5a985d55adc53f629ed4ff578be2d5df9d271fbfa5929decb/microversion_parse-2.0.0-py3-none-any.whl", hash = "sha256:c9bf9665ad65be8da8a7321e403fbf9ada892e4b4fbbc168395fac6f1f1a17ee", size = 19611, upload-time = "2024-08-29T15:38:34.79Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/52/f30da112c1dc92cf64f57d08a273ac771e7b29dea10b4b30369b2d7e8546/msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed", size = 81799, upload-time = "2025-06-13T06:51:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/7bfc0def2f04ab4145f7f108e3563f9b4abae4ab0ed78a61f350518cc4d2/msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8", size = 78278, upload-time = "2025-06-13T06:51:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c5/df5d6c1c39856bc55f800bf82778fd4c11370667f9b9e9d51b2f5da88f20/msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2", size = 402805, upload-time = "2025-06-13T06:51:39.538Z" }, + { url = "https://files.pythonhosted.org/packages/20/8e/0bb8c977efecfe6ea7116e2ed73a78a8d32a947f94d272586cf02a9757db/msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4", size = 408642, upload-time = "2025-06-13T06:51:41.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/731d52c1aeec52006be6d1f8027c49fdc2cfc3ab7cbe7c28335b2910d7b6/msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0", size = 395143, upload-time = "2025-06-13T06:51:42.575Z" }, + { url = "https://files.pythonhosted.org/packages/2b/92/b42911c52cda2ba67a6418ffa7d08969edf2e760b09015593c8a8a27a97d/msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26", size = 395986, upload-time = "2025-06-13T06:51:43.807Z" }, + { url = "https://files.pythonhosted.org/packages/61/dc/8ae165337e70118d4dab651b8b562dd5066dd1e6dd57b038f32ebc3e2f07/msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75", size = 402682, upload-time = "2025-06-13T06:51:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/555851cb98dcbd6ce041df1eacb25ac30646575e9cd125681aa2f4b1b6f1/msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338", size = 406368, upload-time = "2025-06-13T06:51:46.97Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/39a26add4ce16f24e99eabb9005e44c663db00e3fce17d4ae1ae9d61df99/msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd", size = 65004, upload-time = "2025-06-13T06:51:48.582Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/73dfa3e9d5d7450d39debde5b0d848139f7de23bd637a4506e36c9800fd6/msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8", size = 71548, upload-time = "2025-06-13T06:51:49.558Z" }, +] + +[[package]] +name = "netaddr" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/90/188b2a69654f27b221fba92fda7217778208532c962509e959a9cee5229d/netaddr-1.3.0.tar.gz", hash = "sha256:5c3c3d9895b551b763779ba7db7a03487dc1f8e3b385af819af341ae9ef6e48a", size = 2260504, upload-time = "2024-05-28T21:30:37.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cc/f4fe2c7ce68b92cbf5b2d379ca366e1edae38cccaad00f69f529b460c3ef/netaddr-1.3.0-py3-none-any.whl", hash = "sha256:c2c6a8ebe5554ce33b7d5b3a306b71bbb373e000bbbf2350dd5213cc56e3dbbe", size = 2262023, upload-time = "2024-05-28T21:30:34.191Z" }, +] + +[[package]] +name = "nova" +version = "31.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alembic" }, + { name = "castellan" }, + { name = "cryptography" }, + { name = "cursive" }, + { name = "decorator" }, + { name = "eventlet" }, + { name = "futurist" }, + { name = "greenlet" }, + { name = "iso8601" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "keystoneauth1" }, + { name = "keystonemiddleware" }, + { name = "lxml" }, + { name = "microversion-parse" }, + { name = "netaddr" }, + { name = "openstacksdk" }, + { name = "os-brick" }, + { name = "os-resource-classes" }, + { name = "os-service-types" }, + { name = "os-traits" }, + { name = "os-vif" }, + { name = "oslo-cache" }, + { name = "oslo-concurrency" }, + { name = "oslo-config" }, + { name = "oslo-context" }, + { name = "oslo-db" }, + { name = "oslo-i18n" }, + { name = "oslo-limit" }, + { name = "oslo-log" }, + { name = "oslo-messaging" }, + { name = "oslo-middleware" }, + { name = "oslo-policy" }, + { name = "oslo-privsep" }, + { name = "oslo-reports" }, + { name = "oslo-rootwrap" }, + { name = "oslo-serialization" }, + { name = "oslo-service" }, + { name = "oslo-upgradecheck" }, + { name = "oslo-utils" }, + { name = "oslo-versionedobjects" }, + { name = "paramiko" }, + { name = "paste" }, + { name = "pastedeploy" }, + { name = "pbr" }, + { name = "prettytable" }, + { name = "psutil" }, + { name = "python-cinderclient" }, + { name = "python-dateutil" }, + { name = "python-glanceclient" }, + { name = "python-neutronclient" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "retrying" }, + { name = "rfc3986" }, + { name = "routes" }, + { name = "sqlalchemy" }, + { name = "stevedore" }, + { name = "tooz" }, + { name = "webob" }, + { name = "websockify" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/b5/618a21e0ef8362fc5af3cb9cd14d2995c2d631c83a80655a86bf6b032051/nova-31.1.0.tar.gz", hash = "sha256:15b0ddedfa38864c326e013c515cee48811cfd38e3c10fdca44b7b45db095c47", size = 9631096, upload-time = "2025-08-25T10:16:45.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/7f/415cf8c80f8140a023254fde6e76cc7d85dc59fbdf065ae4ebf3f276e9f8/nova-31.1.0-py3-none-any.whl", hash = "sha256:ba353f8f1b0615e8943c01685ce5014365724606f7b95cd9a8ad10fdc68e4e5b", size = 5893538, upload-time = "2025-08-25T10:16:41.72Z" }, +] + +[[package]] +name = "nova-understack" +version = "0.0.0" +source = { editable = "." } +dependencies = [ + { name = "nova" }, +] + +[package.dev-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-github-actions-annotate-failures" }, +] + +[package.metadata] +requires-dist = [{ name = "nova", specifier = ">=30.1" }] + +[package.metadata.requires-dev] +test = [ + { name = "pytest", specifier = ">=8.3.2,<9" }, + { name = "pytest-cov", specifier = ">=6.2.1,<7" }, + { name = "pytest-github-actions-annotate-failures" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "openstacksdk" +version = "4.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "decorator" }, + { name = "dogpile-cache" }, + { name = "iso8601" }, + { name = "jmespath" }, + { name = "jsonpatch" }, + { name = "keystoneauth1" }, + { name = "os-service-types" }, + { name = "pbr" }, + { name = "platformdirs" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requestsexceptions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/e7/4921e513dc00e2b052b196e4a7055351b74192a680470ab287b2332b0c6a/openstacksdk-4.7.1.tar.gz", hash = "sha256:23348aa69c6cc6c1ed0e8f03fb42b156519ed8cfcd143e783ef5c1dd800ad9f1", size = 1297628, upload-time = "2025-09-11T12:49:45.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/30/4f827787b0f82bb18b45f14e775d36b3ba6e54f2d219493c1e97931700c6/openstacksdk-4.7.1-py3-none-any.whl", hash = "sha256:953d5549f4a06928e82335e5f404cedd2f1138d5ddfd68ec502f2b800d30d88f", size = 1825943, upload-time = "2025-09-11T12:49:43.833Z" }, +] + +[[package]] +name = "os-brick" +version = "6.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "os-win" }, + { name = "oslo-concurrency" }, + { name = "oslo-config" }, + { name = "oslo-context" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, + { name = "oslo-privsep" }, + { name = "oslo-serialization" }, + { name = "oslo-service" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "psutil" }, + { name = "requests" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/c2/a0cd9eba54790c87979f36cb1465234e533e6069a7acdabc9aa03c002859/os_brick-6.13.0.tar.gz", hash = "sha256:1c1417c8ae790c1b6bcc286a79b0d2a9264c6ebbf999063d875f879f8b1d9657", size = 277353, upload-time = "2025-08-25T12:48:18.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/13/316dbf2f4fe9fac1dd6e6b081fcea5440661c38a77ee7519fad46b15b211/os_brick-6.13.0-py3-none-any.whl", hash = "sha256:7414d2be5479dd41234f488d346204f6ae6c5d7bdc7d752e675f79e6cd0484cf", size = 310851, upload-time = "2025-08-25T12:48:17.13Z" }, +] + +[[package]] +name = "os-client-config" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openstacksdk" }, + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/cd/352f6f18d1fb90780b95fdc3a668a279bd41d89905d70ee06076b529077c/os_client_config-2.3.0.tar.gz", hash = "sha256:e16a260f2fd500af14f157b9b7b7d69292ce83b0f8a461ec68ce6a8a42967cbd", size = 49928, upload-time = "2025-07-08T09:00:46.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d1/0f6fe5650516fd5113ca64f3efbdd35c91be14ead96065b835207a9cd75a/os_client_config-2.3.0-py3-none-any.whl", hash = "sha256:bac8486bbafc0dd92704c29d5581d65030912804e8e675d13bc43b76ce051c82", size = 30898, upload-time = "2025-07-08T09:00:45.265Z" }, +] + +[[package]] +name = "os-resource-classes" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/81/0f05d5d149dbd20e7ca652b57a329b571c4b068a24df6edc65ca4cfc2e16/os-resource-classes-1.1.0.tar.gz", hash = "sha256:e0bcbb8961a9fe33b7213734c51c001812890e2be62101c9279c88b72f75f9eb", size = 17461, upload-time = "2021-07-15T10:19:25.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/10/efa6e42dfc059c858af4d5671daeb214671f0d3844dc415100d8913e78a8/os_resource_classes-1.1.0-py3-none-any.whl", hash = "sha256:0f51374bb26dbd1103417ac8866810052f32b65fc9cee451557c353b105d8086", size = 11014, upload-time = "2021-07-15T10:19:24.14Z" }, +] + +[[package]] +name = "os-service-types" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/e9/1725288a94496d7780cd1624d16b86b7ed596960595d5742f051c4b90df5/os_service_types-1.8.0.tar.gz", hash = "sha256:890ce74f132ca334c2b23f0025112b47c6926da6d28c2f75bcfc0a83dea3603e", size = 27279, upload-time = "2025-07-08T09:03:43.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/ef/d24a7c6772d9ec554d12b97275ee5c8461c90dd73ccd1b364cf586018bb1/os_service_types-1.8.0-py3-none-any.whl", hash = "sha256:bc0418bf826de1639c7f54b2c752827ea9aa91cbde560d0b0bf6339d97270b3b", size = 24717, upload-time = "2025-07-08T09:03:42.457Z" }, +] + +[[package]] +name = "os-traits" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/05/a483b40340af7d65f9c76424521f198ad4229032641905b7d5bdcc96a478/os_traits-3.5.0.tar.gz", hash = "sha256:65f0944bc885c1ff4454ab8e74844194863df8b05cf78c7c8c803ef295c8761d", size = 32513, upload-time = "2025-05-15T05:24:22.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/02/1cc710a536994ba27263a33dea7befb638f16f9e5fce577fd46897681b00/os_traits-3.5.0-py3-none-any.whl", hash = "sha256:238cb8de19c383a61c7682c90ddc202311f3bde4f92687325f2cc26dac174973", size = 43668, upload-time = "2025-05-15T05:24:21.133Z" }, +] + +[[package]] +name = "os-vif" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, + { name = "oslo-concurrency" }, + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, + { name = "oslo-privsep" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "oslo-versionedobjects" }, + { name = "ovsdbapp" }, + { name = "pbr" }, + { name = "pyroute2", marker = "sys_platform != 'win32'" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/8a/3f0e417a1456583a3b7e1a2974a7cfe24b2a2539c96045e6cbbd5624421a/os_vif-4.2.1.tar.gz", hash = "sha256:372ddfd471a955ecb813543829be91869fd51c51d3a1f6866a96b56954de4c52", size = 104317, upload-time = "2025-08-25T13:03:28.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/09/e48d406ecf4eb73bda077c6ed2f8e92d2b5923ecf55e3b2213ab4f3b403c/os_vif-4.2.1-py3-none-any.whl", hash = "sha256:7c9b47285b7dcb715956053a09d46b0a101bf3304fd0a59e99ecbaaafa7963ac", size = 110971, upload-time = "2025-08-25T13:03:27.545Z" }, +] + +[[package]] +name = "os-win" +version = "5.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eventlet" }, + { name = "oslo-concurrency" }, + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "pymi", marker = "sys_platform == 'win32'" }, + { name = "wmi", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/7b/49eff6bd3f40bf00ae0bdf4730d65789b14767169a651bec0ff629c05818/os-win-5.9.0.tar.gz", hash = "sha256:4d4ad5122060cdd1312d6909921c7280c7159fa6811a966fa11688fc141808c0", size = 226474, upload-time = "2023-02-10T16:31:08.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/c2/20a8a1af9c3337d2ae7274914b580fbc704fb3ca65f5e5b9298b204a4da3/os_win-5.9.0-py3-none-any.whl", hash = "sha256:42a1923fd1b187e4b75e0e5de584fe44b41e4e0828ca5b116ecd57eba39063bb", size = 274394, upload-time = "2023-02-10T16:31:04.926Z" }, +] + +[[package]] +name = "osc-lib" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cliff" }, + { name = "keystoneauth1" }, + { name = "openstacksdk" }, + { name = "oslo-i18n" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "requests" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/63/ea0eda39e1b2c0ca3e9d87798568f881ea7aa90e4f94affb78676215f72d/osc_lib-4.2.0.tar.gz", hash = "sha256:99718f06a990c1ad6fb9034bbed9655390a2ea83cef71a53781e7e9abd9f20ce", size = 101802, upload-time = "2025-08-21T09:35:30.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/9a/5943674892bda78e4671a553e68406d6e7f2ff278d253d6c991b1fe040cf/osc_lib-4.2.0-py3-none-any.whl", hash = "sha256:951d25c0975765242816c9e8f86a8e223dfae78a23ed8eb5a8e1c9c8dd688d58", size = 92898, upload-time = "2025-08-21T09:35:29.053Z" }, +] + +[[package]] +name = "oslo-cache" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, + { name = "dogpile-cache" }, + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, + { name = "oslo-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/8a/2f615998369de66c93c1eea46be7a56e8c9372a9b1810851d4be0870a3ea/oslo_cache-3.12.0.tar.gz", hash = "sha256:b994e62b1fef31913e94c62e4e5fbc289414ce45523caf62870be2bcadf4515c", size = 77217, upload-time = "2025-08-25T12:59:11.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/e4/64ac66625e785f0f140fc7b503e2e2878f99688056126a1ef2e3e4bf2bca/oslo_cache-3.12.0-py3-none-any.whl", hash = "sha256:d26fac32c65941dd1ab2f3d825759d87d1ea8f9382468d28f63cdaf922412ed0", size = 76412, upload-time = "2025-08-25T12:59:10.573Z" }, +] + +[[package]] +name = "oslo-concurrency" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, + { name = "fasteners" }, + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-utils" }, + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/bd/727bf8de6ef870b400702cd57826e8e298f2d906ab2f8dcc3890c421711f/oslo_concurrency-7.2.0.tar.gz", hash = "sha256:160e814c8549f6a464e77a04265ed996d581bdd3fd3bd4ebf311d3446bb7e1aa", size = 60285, upload-time = "2025-08-25T12:53:05.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/ab/7afe0703bc50081d6e82b00dce95285735926245b21b323018410cc84e72/oslo_concurrency-7.2.0-py3-none-any.whl", hash = "sha256:9bf8953572ba8755aaec6f0bcca6ee2a5e9128bef1827da5f49caa78caf71fae", size = 47066, upload-time = "2025-08-25T12:53:04.006Z" }, +] + +[[package]] +name = "oslo-config" +version = "10.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "netaddr" }, + { name = "oslo-i18n" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rfc3986" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/67/221128a241ab4151ecc5b101de23651e7c08491f7b2edea31744207a23dc/oslo_config-10.0.0.tar.gz", hash = "sha256:333e675db8c6be7715b3decf78c398ca1138439225aa274632e89314837f6ea3", size = 164993, upload-time = "2025-07-10T09:32:00.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/c6/9a58e64e63bb844b77cf98f7bbeb060cb6bf9697962fddbb2dd0dd8f8047/oslo_config-10.0.0-py3-none-any.whl", hash = "sha256:693a04a0408ed930a2167c9662da7d8378efca2376be424d9fbbdbc861a690bf", size = 131647, upload-time = "2025-07-10T09:31:59.494Z" }, +] + +[[package]] +name = "oslo-context" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/a3/d4804bb24e6f8cabcae4925a02ade281c2f8d90e3d0b7b367221cfb65ad8/oslo_context-6.1.0.tar.gz", hash = "sha256:c1a8d17c79f50c71024d54cc17cc0b01e89dbff258315dc11d7e04e6b1a02ce3", size = 34665, upload-time = "2025-08-25T12:54:44.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/30/3cc6032a86d0ea215b2710e59c42fbcc9233e017c88b9382f121212ca02e/oslo_context-6.1.0-py3-none-any.whl", hash = "sha256:575c37ff767f595eb986b89b080457204a06fa8774cb9a3e4c73753f88797c1f", size = 19691, upload-time = "2025-08-25T12:54:43.284Z" }, +] + +[[package]] +name = "oslo-db" +version = "17.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alembic" }, + { name = "debtcollector" }, + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-utils" }, + { name = "sqlalchemy" }, + { name = "stevedore" }, + { name = "testresources" }, + { name = "testscenarios" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/99/9bbfc917740b79bc4a96a07938cff8792c47963a63fa9a8a36f216a6ced6/oslo_db-17.4.0.tar.gz", hash = "sha256:14b62f58c416330cbb188a5329b14d95017661ef8aea1d72a0ff924eecf910a9", size = 172000, upload-time = "2025-08-07T13:58:18.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/b5/81aee8626a22b3e656ea55a2d7adaa9eafe156dc42159eda96ef246ecdd6/oslo_db-17.4.0-py3-none-any.whl", hash = "sha256:c63cf2f91e3403a29f068ea10d7ed1769db3664c349a805291fb3a069cddca4d", size = 157434, upload-time = "2025-08-07T13:58:16.581Z" }, +] + +[[package]] +name = "oslo-i18n" +version = "6.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/74/a2238cfdf6e97ee398b3fc5eda8b0e108be3913494dbef90961ebe38bf23/oslo_i18n-6.6.0.tar.gz", hash = "sha256:bb5e3becefa2e40488b259f9db12cc5ad894dd309b5b5aca56382ff190c18f5e", size = 48149, upload-time = "2025-08-25T12:50:25.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2c/463d3142ab76cdbd932714ef909a43509ce7a1929a08ab90a6a2b5f2c9d9/oslo_i18n-6.6.0-py3-none-any.whl", hash = "sha256:8e5a02fd4dba2d64cf86b839750bfa2d2b365aca2cbb3a6a7ba0fae58defe2a8", size = 46680, upload-time = "2025-08-25T12:50:24.335Z" }, +] + +[[package]] +name = "oslo-limit" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "keystoneauth1" }, + { name = "openstacksdk" }, + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/44/9c933838767b5579dbdc9a833ed5c9e581c432815e3afeaebd8688a5fc90/oslo_limit-2.8.0.tar.gz", hash = "sha256:ec5891b98ead3ac457454c0ec115f92a5f8400b47cc59cc09d75f72356b02df4", size = 33764, upload-time = "2025-08-21T09:20:44.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/42/6e267ce335b7a98f6a7f7445714143f1d3c54d1dc3998ce4e368961ffe5e/oslo_limit-2.8.0-py3-none-any.whl", hash = "sha256:f41887f001ca1f07087561fdc20041af8b163b8531b3b0151b4da78515b439ea", size = 22519, upload-time = "2025-08-21T09:20:43.767Z" }, +] + +[[package]] +name = "oslo-log" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, + { name = "oslo-config" }, + { name = "oslo-context" }, + { name = "oslo-i18n" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/6b/a7f1c1daeadd36f71633fb3ebac9817fcb1f8edfd06d6bdd71384f39010f/oslo_log-7.2.1.tar.gz", hash = "sha256:01aebabdcf06b62df00e479db99df0c23f6cd24c6500ab3110e604bd059fa8d5", size = 98147, upload-time = "2025-08-25T12:56:15.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/ee/6b92193f6deb3183caa8386e23489f6199c0d5251cbfc5360c3308d5092e/oslo_log-7.2.1-py3-none-any.whl", hash = "sha256:77a2d8dd2a06074484e9e33fa1d0e31dd4d5dd1e81b34efe2b45762e4db3c996", size = 75065, upload-time = "2025-08-25T12:56:14.379Z" }, +] + +[[package]] +name = "oslo-messaging" +version = "17.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "cachetools" }, + { name = "debtcollector" }, + { name = "futurist" }, + { name = "kombu" }, + { name = "oslo-config" }, + { name = "oslo-context" }, + { name = "oslo-log" }, + { name = "oslo-metrics" }, + { name = "oslo-middleware" }, + { name = "oslo-serialization" }, + { name = "oslo-service" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "pyyaml" }, + { name = "stevedore" }, + { name = "webob" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/42/b3ad17c818c2c83ee96055f8f3923bbc66df998b94c8582e225eebe6932e/oslo_messaging-17.1.0.tar.gz", hash = "sha256:84c7e0ebafed29a301f6a2c9ca61e9175923df6af08ee5ed36a419f7070a8bdc", size = 230478, upload-time = "2025-07-11T14:24:02.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/df/50e2dc3245b36e840d525a72243856ef358a878f8a3c5995246472cb6bb6/oslo_messaging-17.1.0-py3-none-any.whl", hash = "sha256:f6ee06859e2969aa1bdbc85bbfe9c332db932a239b6e27f150d19eafee04c500", size = 202463, upload-time = "2025-07-11T14:24:00.427Z" }, +] + +[[package]] +name = "oslo-metrics" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oslo-config" }, + { name = "oslo-log" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "prometheus-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/54/c3d174fba84511707dce6f5f0fd73b93c27e7b4aa1c1a980e0df8ed339d0/oslo_metrics-0.13.0.tar.gz", hash = "sha256:7d722fa42697a0dd8c93c741eb68003aa8c8a788a1335afacdde8195f8f703f1", size = 20935, upload-time = "2025-08-25T12:47:48.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/db/798e375d55fefe0b70ea3a0aeb1b150dfaf6d414a0668bf38558fbf01838/oslo_metrics-0.13.0-py3-none-any.whl", hash = "sha256:267b989ca5819f7c7e4bf137a444d3c83e483b341b55587ce6f09e0b9e37a8e2", size = 13888, upload-time = "2025-08-25T12:47:47.44Z" }, +] + +[[package]] +name = "oslo-middleware" +version = "6.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "debtcollector" }, + { name = "jinja2" }, + { name = "oslo-config" }, + { name = "oslo-context" }, + { name = "oslo-i18n" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "statsd" }, + { name = "stevedore" }, + { name = "typing-extensions" }, + { name = "webob" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/39/4aa1ca70229160e078d28fd6e2c8aecb34efbd2c358e0b34cf762205b390/oslo_middleware-6.6.0.tar.gz", hash = "sha256:6ccb9c80f189febc5cc50e113c0e894c992c3697dfd6fe722ff84fccdb5ba526", size = 68179, upload-time = "2025-07-10T09:45:39.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/13/596ea9360b7131cdbba15d2c0d82bc5c016f329b1e2ad3f2a3339baab179/oslo_middleware-6.6.0-py3-none-any.whl", hash = "sha256:a1cb98ae2675c489d062ea87314bab4ec263d094842679675cd742162eb595bd", size = 67066, upload-time = "2025-07-10T09:45:38.13Z" }, +] + +[[package]] +name = "oslo-policy" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oslo-config" }, + { name = "oslo-context" }, + { name = "oslo-i18n" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/fa/2dc13f3da6eca5a673469728940825739ce15713143bd474ce8fe97ac29f/oslo_policy-4.6.0.tar.gz", hash = "sha256:2132c3847e611749456b8bb6c3eaa7a64f01ada49a997962376310c33e82555e", size = 120403, upload-time = "2025-08-25T12:54:27.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/fe/52588dd74389a2c780cb19ab3503e32951639fee3e78dced4e2582e5e0ec/oslo_policy-4.6.0-py3-none-any.whl", hash = "sha256:69910c1ad33120ebe3249317c007f3041cb2323f708cb817a5ab2f38f9229474", size = 88578, upload-time = "2025-08-25T12:54:26.775Z" }, +] + +[[package]] +name = "oslo-privsep" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "debtcollector" }, + { name = "eventlet" }, + { name = "greenlet" }, + { name = "msgpack" }, + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, + { name = "oslo-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/ce/075ec43d7c7081e761e5b50429b592af8e6077397bc6f62c5f590d9ee05a/oslo_privsep-3.8.0.tar.gz", hash = "sha256:73c4e0656bddb23f620f916a21b6413ca0034483144a5968bcacedd3a34447f6", size = 49502, upload-time = "2025-08-25T12:47:19.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/0a/613759825c6ea89bba08f9a564ab20e7c27313c39e2591255651c94b0819/oslo_privsep-3.8.0-py3-none-any.whl", hash = "sha256:657554a3c5fc625cd2bd69b7a840d33cc30ce03e651faf0959c8073114747f83", size = 38747, upload-time = "2025-08-25T12:47:16.466Z" }, +] + +[[package]] +name = "oslo-reports" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/b7/af820b313ff8e77e6cf5d2d873ac2dd8081be88f6a5939ea15c4249f6d91/oslo_reports-3.6.0.tar.gz", hash = "sha256:3dc915adf2843154fceef187ae5e1813c0a38dff17faf3ab8849d4da67e94998", size = 54005, upload-time = "2025-08-25T12:48:06.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a0/87fb7b10d461a5991d0529cc71959a9be6fe89d8af132d2ef1974d3a36ff/oslo_reports-3.6.0-py3-none-any.whl", hash = "sha256:4ed0349319f9062ca50a26ab2e4fd233d75235ff57e3447e853f6f38ea4337b9", size = 53159, upload-time = "2025-08-25T12:48:05.036Z" }, +] + +[[package]] +name = "oslo-rootwrap" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/ea/d3e197f4f9b4e83d9d9f8ad99f56c4a8fb37a983c4922a3d92ba11f9cd7f/oslo_rootwrap-7.7.0.tar.gz", hash = "sha256:cb267e3cc51dbf25fc7dad0dc6ddacef3a1f0a0fda6ab57937824a69d4d2a92b", size = 50794, upload-time = "2025-08-25T12:51:27.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/c6/16b2018c5699e4b274521c9c764ff4c96c592759f9c43f2d702cc401fa29/oslo_rootwrap-7.7.0-py3-none-any.whl", hash = "sha256:4c4685c923d57d6a4cb08c94d355849f7c8c155626f23a70c0db5ba41613f90d", size = 38134, upload-time = "2025-08-25T12:51:25.974Z" }, +] + +[[package]] +name = "oslo-serialization" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "oslo-utils" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/ac/119c430df3a86dc6a664fa864f777b4fd5cc16c50caa1ba3dd3bf10f43ae/oslo_serialization-5.8.0.tar.gz", hash = "sha256:5871a62b23f98cacd5518482941ae6d2a983e2936ed52d543ad08685dc6d2343", size = 35227, upload-time = "2025-08-25T12:50:39.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/4c/269503bc1355798e33e56e6c602ed672d1e40bd65485d771da9595779606/oslo_serialization-5.8.0-py3-none-any.whl", hash = "sha256:451e19b9c72e4e7167063eea29e24ad07742d393ff2b54bb8783b9601c155b2c", size = 25766, upload-time = "2025-08-25T12:50:38.253Z" }, +] + +[[package]] +name = "oslo-service" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, + { name = "eventlet" }, + { name = "greenlet" }, + { name = "oslo-concurrency" }, + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, + { name = "oslo-utils" }, + { name = "paste" }, + { name = "pastedeploy" }, + { name = "routes" }, + { name = "webob" }, + { name = "yappi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/ea/be7735dd5e5f8e020b4559edac02c10faba1bac8388a5d6c6f3d8fb87687/oslo_service-4.3.0.tar.gz", hash = "sha256:7d856beee4c860a39e0ad5b2722882ba9f20eabf7fb29f8fdc86db2f7a5532e2", size = 105937, upload-time = "2025-08-28T09:51:42.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/04/009b035105b62524af4b9ec99b13334aec3d612f962e168eac2e4c6881f7/oslo_service-4.3.0-py3-none-any.whl", hash = "sha256:00e73b949cfcbe3c335cead78d6f0905afe46e8e482af0e3b75fa6fab43c27c5", size = 101220, upload-time = "2025-08-28T09:51:37.793Z" }, +] + +[[package]] +name = "oslo-upgradecheck" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-policy" }, + { name = "oslo-utils" }, + { name = "prettytable" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/5d/32e01be549bfd63c55efc9610973ad8eecd0a2195d881755f4739d222dce/oslo_upgradecheck-2.6.0.tar.gz", hash = "sha256:189246e7ef9aa8fa57e5a4df6b2168ff69572571b43bded63faaff676a8542d6", size = 19855, upload-time = "2025-08-25T13:03:03.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/8d/47873e44a142d625b1371fcdec9f412adaa7aa2d01306b704040ddbb3592/oslo_upgradecheck-2.6.0-py3-none-any.whl", hash = "sha256:460e7400e4963daf627f54c2809af20a122f158255c4d8be579d150ad66eb24e", size = 14425, upload-time = "2025-08-25T13:03:02.609Z" }, +] + +[[package]] +name = "oslo-utils" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, + { name = "iso8601" }, + { name = "netaddr" }, + { name = "oslo-i18n" }, + { name = "packaging" }, + { name = "pbr" }, + { name = "psutil" }, + { name = "pyparsing" }, + { name = "pyyaml" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/ec/9f12c8ded6eb7ba0774ea4a0e03bfe6cd35fea4cbc944a826c751bb49500/oslo_utils-9.1.0.tar.gz", hash = "sha256:01c3875e7cca005b59465c429f467113b5f4b04211cbd534c9ac2f152276d3b3", size = 138207, upload-time = "2025-08-25T12:49:20.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/47/1303a7050bb1dc6c5cb76a178520a215a7e7181afad637adc26482d7f257/oslo_utils-9.1.0-py3-none-any.whl", hash = "sha256:8779a2db08b84abd2cb155f8314bc6a961aedafd64ee2ff9e234ecbb80251174", size = 134210, upload-time = "2025-08-25T12:49:18.644Z" }, +] + +[[package]] +name = "oslo-versionedobjects" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "netaddr" }, + { name = "oslo-concurrency" }, + { name = "oslo-config" }, + { name = "oslo-context" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, + { name = "oslo-messaging" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "webob" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/7e/d349326371ff424ed58ae7201fe2e153557216df82dc28bbcd2038a0dc1a/oslo_versionedobjects-3.8.0.tar.gz", hash = "sha256:b66e45f62e1d7852183ce570a7f927149b8bf909f218f48ec12d43dcbdb27ffa", size = 154983, upload-time = "2025-08-25T12:48:55.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/f8/a40f5180b07c303715077af7e94479fc94e07cfb0738abc5a1b14be08b0e/oslo_versionedobjects-3.8.0-py3-none-any.whl", hash = "sha256:9b0fc25b23e1011ceefa365d0490b47048d077ab6e9d6e8955ff00186491803f", size = 86279, upload-time = "2025-08-25T12:48:54.482Z" }, +] + +[[package]] +name = "ovs" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/1f/531989438b2874f8f0dc00744cc2ceba3f69a88a96cf7dc8c19ddb1e0971/ovs-3.6.0.tar.gz", hash = "sha256:61bbf3e6e3cc3a353f94163f97126d27c84e79fc06ed6f367ef4ce1c7fe6c4bf", size = 161659, upload-time = "2025-08-18T19:46:01.34Z" } + +[[package]] +name = "ovsdbapp" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fixtures" }, + { name = "netaddr" }, + { name = "ovs" }, + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/a8/616b47043b7c0ff86e8315fd17150f9e22207f09b00c40c61462443c76ff/ovsdbapp-2.13.0.tar.gz", hash = "sha256:a6c643531a85c97a460641a4b374576b9211eaa7e41714b6be9dcb8f5804e049", size = 127786, upload-time = "2025-08-25T13:01:03.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/d4/18c69ed3c81a8298944935defdd334a300b24512757e2cd55d80574ef97e/ovsdbapp-2.13.0-py3-none-any.whl", hash = "sha256:b87795e8a0a4b733070fd83c9d46d4c2feae495c34b7b4ee51ed7aaebc0ba981", size = 145079, upload-time = "2025-08-25T13:01:02.471Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "paramiko" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "invoke" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, +] + +[[package]] +name = "paste" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/1c/6bc9040bf9b4cfc9334f66d2738f952384c106c48882adf6097fed3da966/paste-3.10.1.tar.gz", hash = "sha256:1c3d12065a5e8a7a18c0c7be1653a97cf38cc3e9a5a0c8334a9dd992d3a05e4a", size = 652629, upload-time = "2024-05-01T11:41:08.536Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/14/032895c25726a859bf48b8ed68944c3efc7a3decd920533ed929f12f08a1/Paste-3.10.1-py3-none-any.whl", hash = "sha256:995e9994b6a94a2bdd8bd9654fb70ca3946ffab75442468bacf31b4d06481c3d", size = 289253, upload-time = "2024-05-01T11:41:05.31Z" }, +] + +[[package]] +name = "pastedeploy" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/97/0c4a613ec96a54d21daa7e089178263915554320402e89b4e319436a63cb/PasteDeploy-3.1.0.tar.gz", hash = "sha256:9ddbaf152f8095438a9fe81f82c78a6714b92ae8e066bed418b6a7ff6a095a95", size = 37841, upload-time = "2023-11-21T04:54:33.203Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/30/cdddd9a88969683a59222a6d61cd6dce923977f2e9f9ffba38e1324149cd/PasteDeploy-3.1.0-py3-none-any.whl", hash = "sha256:76388ad53a661448d436df28c798063108f70e994ddc749540d733cdbd1b38cf", size = 16943, upload-time = "2023-11-21T04:54:28.226Z" }, +] + +[[package]] +name = "pbr" +version = "7.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/8d/23253ab92d4731eb34383a69b39568ca63a1685bec1e9946e91a32fc87ad/pbr-7.0.1.tar.gz", hash = "sha256:3ecbcb11d2b8551588ec816b3756b1eb4394186c3b689b17e04850dfc20f7e57", size = 130086, upload-time = "2025-08-21T09:40:56.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c1/7e588435c2394dfded9197a8307417d1ca3b7f49d9bd5b6227d1f3f03ccd/pbr-7.0.1-py2.py3-none-any.whl", hash = "sha256:32df5156fbeccb6f8a858d1ebc4e465dcf47d6cc7a4895d5df9aa951c712fc35", size = 126091, upload-time = "2025-08-21T09:40:54.614Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prettytable" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/b1/85e18ac92afd08c533603e3393977b6bc1443043115a47bb094f3b98f94f/prettytable-3.16.0.tar.gz", hash = "sha256:3c64b31719d961bf69c9a7e03d0c1e477320906a98da63952bc6698d6164ff57", size = 66276, upload-time = "2025-03-24T19:39:04.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c7/5613524e606ea1688b3bdbf48aa64bafb6d0a4ac3750274c43b6158a390f/prettytable-3.16.0-py3-none-any.whl", hash = "sha256:b5eccfabb82222f5aa46b798ff02a8452cf530a352c31bddfa29be41242863aa", size = 33863, upload-time = "2025-03-24T19:39:02.359Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" }, +] + +[[package]] +name = "psutil" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660, upload-time = "2025-09-17T20:14:52.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242, upload-time = "2025-09-17T20:14:56.126Z" }, + { url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682, upload-time = "2025-09-17T20:14:58.25Z" }, + { url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994, upload-time = "2025-09-17T20:14:59.901Z" }, + { url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163, upload-time = "2025-09-17T20:15:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625, upload-time = "2025-09-17T20:15:04.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812, upload-time = "2025-09-17T20:15:07.462Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965, upload-time = "2025-09-17T20:15:09.673Z" }, + { url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971, upload-time = "2025-09-17T20:15:12.262Z" }, +] + +[[package]] +name = "pycadf" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oslo-config" }, + { name = "oslo-serialization" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/09/c22a17ecfde3c6af5d5d2c5ebcd0ba81d1b6cdf9736a99ea7341ffa3fb33/pycadf-4.0.1.tar.gz", hash = "sha256:b015f1e4cfb6bd968bb8e23f903593328f0d82e1922902447197f4862f259d2f", size = 252343, upload-time = "2025-01-29T14:56:07.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/26/73c8a7b6be652a704de57bcc0811d1b9717b3ce693b99d8dc48c102a7cf0/pycadf-4.0.1-py3-none-any.whl", hash = "sha256:eb15577f6771392fa285ec0b2996434b1a91e91e347659878817927b92dbad0f", size = 44176, upload-time = "2025-01-29T14:56:06.2Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[[package]] +name = "pymi" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/91/791a60a4c050c31e7ffdfce69ab9b0a28e5b19e42cf5683d04fb87bba31a/pymi-1.0.8.tar.gz", hash = "sha256:c7ac8d90fe1f2399ad4c10d5c55c746c8c3b268466921d26e7f61cb3583eb1a1", size = 65523, upload-time = "2025-08-21T10:00:18.859Z" } + +[[package]] +name = "pynacl" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c6/a3124dee667a423f2c637cfd262a54d67d8ccf3e160f3c50f622a85b7723/pynacl-1.6.0.tar.gz", hash = "sha256:cb36deafe6e2bce3b286e5d1f3e1c246e0ccdb8808ddb4550bb2792f2df298f2", size = 3505641, upload-time = "2025-09-10T23:39:22.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/37/87c72df19857c5b3b47ace6f211a26eb862ada495cc96daa372d96048fca/pynacl-1.6.0-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:f4b3824920e206b4f52abd7de621ea7a44fd3cb5c8daceb7c3612345dfc54f2e", size = 382610, upload-time = "2025-09-10T23:38:49.459Z" }, + { url = "https://files.pythonhosted.org/packages/0c/64/3ce958a5817fd3cc6df4ec14441c43fd9854405668d73babccf77f9597a3/pynacl-1.6.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:16dd347cdc8ae0b0f6187a2608c0af1c8b7ecbbe6b4a06bff8253c192f696990", size = 798744, upload-time = "2025-09-10T23:38:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8a/3f0dd297a0a33fa3739c255feebd0206bb1df0b44c52fbe2caf8e8bc4425/pynacl-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16c60daceee88d04f8d41d0a4004a7ed8d9a5126b997efd2933e08e93a3bd850", size = 1397879, upload-time = "2025-09-10T23:39:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/41/94/028ff0434a69448f61348d50d2c147dda51aabdd4fbc93ec61343332174d/pynacl-1.6.0-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25720bad35dfac34a2bcdd61d9e08d6bfc6041bebc7751d9c9f2446cf1e77d64", size = 833907, upload-time = "2025-09-10T23:38:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/52/bc/a5cff7f8c30d5f4c26a07dfb0bcda1176ab8b2de86dda3106c00a02ad787/pynacl-1.6.0-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bfaa0a28a1ab718bad6239979a5a57a8d1506d0caf2fba17e524dbb409441cf", size = 1436649, upload-time = "2025-09-10T23:38:52.783Z" }, + { url = "https://files.pythonhosted.org/packages/7a/20/c397be374fd5d84295046e398de4ba5f0722dc14450f65db76a43c121471/pynacl-1.6.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ef214b90556bb46a485b7da8258e59204c244b1b5b576fb71848819b468c44a7", size = 817142, upload-time = "2025-09-10T23:38:54.4Z" }, + { url = "https://files.pythonhosted.org/packages/12/30/5efcef3406940cda75296c6d884090b8a9aad2dcc0c304daebb5ae99fb4a/pynacl-1.6.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:49c336dd80ea54780bcff6a03ee1a476be1612423010472e60af83452aa0f442", size = 1401794, upload-time = "2025-09-10T23:38:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/be/e1/a8fe1248cc17ccb03b676d80fa90763760a6d1247da434844ea388d0816c/pynacl-1.6.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f3482abf0f9815e7246d461fab597aa179b7524628a4bc36f86a7dc418d2608d", size = 772161, upload-time = "2025-09-10T23:39:01.93Z" }, + { url = "https://files.pythonhosted.org/packages/a3/76/8a62702fb657d6d9104ce13449db221a345665d05e6a3fdefb5a7cafd2ad/pynacl-1.6.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:140373378e34a1f6977e573033d1dd1de88d2a5d90ec6958c9485b2fd9f3eb90", size = 1370720, upload-time = "2025-09-10T23:39:03.531Z" }, + { url = "https://files.pythonhosted.org/packages/6d/38/9e9e9b777a1c4c8204053733e1a0269672c0bd40852908c9ad6b6eaba82c/pynacl-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6b393bc5e5a0eb86bb85b533deb2d2c815666665f840a09e0aa3362bb6088736", size = 791252, upload-time = "2025-09-10T23:39:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d972ce3d92ae05c9091363cf185e8646933f91c376e97b8be79ea6e96c22/pynacl-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a25cfede801f01e54179b8ff9514bd7b5944da560b7040939732d1804d25419", size = 1362910, upload-time = "2025-09-10T23:39:06.924Z" }, + { url = "https://files.pythonhosted.org/packages/35/2c/ee0b373a1861f66a7ca8bdb999331525615061320dd628527a50ba8e8a60/pynacl-1.6.0-cp38-abi3-win32.whl", hash = "sha256:dcdeb41c22ff3c66eef5e63049abf7639e0db4edee57ba70531fc1b6b133185d", size = 226461, upload-time = "2025-09-10T23:39:11.894Z" }, + { url = "https://files.pythonhosted.org/packages/75/f7/41b6c0b9dd9970173b6acc026bab7b4c187e4e5beef2756d419ad65482da/pynacl-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:cf831615cc16ba324240de79d925eacae8265b7691412ac6b24221db157f6bd1", size = 238802, upload-time = "2025-09-10T23:39:08.966Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0f/462326910c6172fa2c6ed07922b22ffc8e77432b3affffd9e18f444dbfbb/pynacl-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:84709cea8f888e618c21ed9a0efdb1a59cc63141c403db8bf56c469b71ad56f2", size = 183846, upload-time = "2025-09-10T23:39:10.552Z" }, +] + +[[package]] +name = "pyopenssl" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, +] + +[[package]] +name = "pyperclip" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/99/25f4898cf420efb6f45f519de018f4faea5391114a8618b16736ef3029f1/pyperclip-1.10.0.tar.gz", hash = "sha256:180c8346b1186921c75dfd14d9048a6b5d46bfc499778811952c6dd6eb1ca6be", size = 12193, upload-time = "2025-09-18T00:54:00.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/bc/22540e73c5f5ae18f02924cd3954a6c9a4aa6b713c841a94c98335d333a1/pyperclip-1.10.0-py3-none-any.whl", hash = "sha256:596fbe55dc59263bff26e61d2afbe10223e2fccb5210c9c96a28d6887cfcc7ec", size = 11062, upload-time = "2025-09-18T00:53:59.252Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, +] + +[[package]] +name = "pyroute2" +version = "0.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/5e/fc64e211cce0078555c6db98aaf14348aed527565f3c4876913a290a5b2c/pyroute2-0.9.4.tar.gz", hash = "sha256:3cbccbe1af0c2b2aeae81b327e0e91aa94c81ab19f851e74b26bef70202f3070", size = 463980, upload-time = "2025-07-29T14:35:27.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/89/c011b555ccde0e5846ad3bb5a091fd0fcac997156406a9ad107f81cf91c9/pyroute2-0.9.4-py3-none-any.whl", hash = "sha256:4e12437d18f6f42912cbd3f870edf06896183a78fd0c8126ba7a72a81f28d6cf", size = 467555, upload-time = "2025-07-29T14:35:23.88Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" }, +] + +[[package]] +name = "pytest-github-actions-annotate-failures" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/d4/c54ee6a871eee4a7468e3a8c0dead28e634c0bc2110c694309dcb7563a66/pytest_github_actions_annotate_failures-0.3.0.tar.gz", hash = "sha256:d4c3177c98046c3900a7f8ddebb22ea54b9f6822201b5d3ab8fcdea51e010db7", size = 11248, upload-time = "2025-01-17T22:39:32.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/73/7b0b15cb8605ee967b34aa1d949737ab664f94e6b0f1534e8339d9e64ab2/pytest_github_actions_annotate_failures-0.3.0-py3-none-any.whl", hash = "sha256:41ea558ba10c332c0bfc053daeee0c85187507b2034e990f21e4f7e5fef044cf", size = 6030, upload-time = "2025-01-17T22:39:31.701Z" }, +] + +[[package]] +name = "python-barbicanclient" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cliff" }, + { name = "keystoneauth1" }, + { name = "oslo-i18n" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/cca8be3d14fceea564115ffc1e7e1a219620127f5050c31edd2c12347f35/python_barbicanclient-7.2.0.tar.gz", hash = "sha256:4b085b01597442e620658017aeddc6df313a9b0a6c629d13c463b9dc39bf1579", size = 128105, upload-time = "2025-08-28T09:46:47.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/22/05e36c764a0ba11c81b164da77ce390f5ae8721bca1e398d4c4f46487f77/python_barbicanclient-7.2.0-py3-none-any.whl", hash = "sha256:ef01d393819ff149976be8ef88a662406974f68e4c71031bfa236bc4738cbab8", size = 88501, upload-time = "2025-08-28T09:46:46.154Z" }, +] + +[[package]] +name = "python-cinderclient" +version = "9.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "keystoneauth1" }, + { name = "oslo-i18n" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "prettytable" }, + { name = "requests" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/9d/a0e1283639bcd98ac8f7a991c1ba2c6efd82ed0747246e993da1eca192b9/python_cinderclient-9.8.0.tar.gz", hash = "sha256:bd3ee9f9487c5e79957f018a6b3f2dece7059dad8f6155d83dd4b6eb9447a11d", size = 237057, upload-time = "2025-09-01T13:25:42.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/3b/756fe16624dd4c0db958a834999552c3311bc865318fb873cdc98fbc0360/python_cinderclient-9.8.0-py3-none-any.whl", hash = "sha256:e82f88d1b9fd8e4a41420da5535b73bb8580084f3303ad9bcaf64f0aa5d3bae6", size = 256480, upload-time = "2025-09-01T13:25:40.869Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-glanceclient" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "keystoneauth1" }, + { name = "oslo-i18n" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "prettytable" }, + { name = "pyopenssl" }, + { name = "requests" }, + { name = "warlock" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/96/e0e2ea7258cb3825be4aa0c19372a182b5c17788660d9985090fe81f6ae5/python_glanceclient-4.10.0.tar.gz", hash = "sha256:ff6c2d42a1767c5cfa3cd1d22a3732d38ab113d471ad22c4ee64a1bd3941b103", size = 210540, upload-time = "2025-08-28T09:55:37.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/44/0d1b00f298ddd04d3467a1d84deb4890ff41a0c9038537de7930e8ceab9b/python_glanceclient-4.10.0-py3-none-any.whl", hash = "sha256:286f2fac1dcbdfcde8781b3fb41ec61608a15b7b6c154d049e75ab84395f796a", size = 208707, upload-time = "2025-08-28T09:55:36.568Z" }, +] + +[[package]] +name = "python-keystoneclient" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, + { name = "keystoneauth1" }, + { name = "oslo-config" }, + { name = "oslo-i18n" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "packaging" }, + { name = "pbr" }, + { name = "requests" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/29/3775d7a722924a72208753a8aa5ddb0a58de24f5a5dd287cc9a0f66038e4/python_keystoneclient-5.7.0.tar.gz", hash = "sha256:8ce7bf1c8cddca6d7140fc76918b44eddf1d64040a60cb8ff7059136104d4ceb", size = 322387, upload-time = "2025-08-28T09:50:01.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/d1/eb39a6e544f5f789fd44c09274a8b1b08a86e3e7ac0e016d07ceb94c0b42/python_keystoneclient-5.7.0-py3-none-any.whl", hash = "sha256:2ec19994ba8b8b16fe6cf4ac45c3f2180a22e203d1b3aa977a34a0160caae0b6", size = 397216, upload-time = "2025-08-28T09:49:59.798Z" }, +] + +[[package]] +name = "python-neutronclient" +version = "11.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cliff" }, + { name = "debtcollector" }, + { name = "keystoneauth1" }, + { name = "netaddr" }, + { name = "openstacksdk" }, + { name = "os-client-config" }, + { name = "osc-lib" }, + { name = "oslo-i18n" }, + { name = "oslo-log" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "pbr" }, + { name = "python-keystoneclient" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/15/96f2f42df4c1d6873c89a0cae2ba3b98f83273e965421eb11b7dbb257b4d/python_neutronclient-11.6.0.tar.gz", hash = "sha256:3c6958088d18c8676a10abf9d94b8dbf1a984741cbb988554f216880797e072f", size = 212450, upload-time = "2025-07-07T11:11:58.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/76/487c81f1e07049faa110dd22cfa27f68644e933154340a6c750958dd73a9/python_neutronclient-11.6.0-py3-none-any.whl", hash = "sha256:df37f80a61ed0fb068551a120bb26ead0cefefdf101bf59693c556f4a2828229", size = 296139, upload-time = "2025-07-07T11:11:56.572Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, +] + +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + +[[package]] +name = "repoze-lru" +version = "0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/bc/595a77c4b5e204847fdf19268314ef59c85193a9dc9f83630fc459c0fee5/repoze.lru-0.7.tar.gz", hash = "sha256:0429a75e19380e4ed50c0694e26ac8819b4ea7851ee1fc7583c8572db80aff77", size = 19591, upload-time = "2017-09-07T05:01:00.949Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/30/6cc0c95f0b59ad4b3b9163bff7cdcf793cc96fac64cf398ff26271f5cf5e/repoze.lru-0.7-py3-none-any.whl", hash = "sha256:f77bf0e1096ea445beadd35f3479c5cff2aa1efe604a133e67150bc8630a62ea", size = 10985, upload-time = "2017-09-07T05:01:00.007Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requestsexceptions" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/61b9652d3256503c99b0b8f145d9c8aa24c514caff6efc229989505937c1/requestsexceptions-1.4.0.tar.gz", hash = "sha256:b095cbc77618f066d459a02b137b020c37da9f46d9b057704019c9f77dba3065", size = 6880, upload-time = "2018-02-01T17:04:45.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/8c/49ca60ea8c907260da4662582c434bec98716177674e88df3fd340acf06d/requestsexceptions-1.4.0-py2.py3-none-any.whl", hash = "sha256:3083d872b6e07dc5c323563ef37671d992214ad9a32b0ca4a3d7f5500bf38ce3", size = 3802, upload-time = "2018-02-01T17:04:39.07Z" }, +] + +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "rich-argparse" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a6/34460d81e5534f6d2fc8e8d91ff99a5835fdca53578eac89e4f37b3a7c6d/rich_argparse-1.7.1.tar.gz", hash = "sha256:d7a493cde94043e41ea68fb43a74405fa178de981bf7b800f7a3bd02ac5c27be", size = 38094, upload-time = "2025-05-25T20:20:35.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/f6/5fc0574af5379606ffd57a4b68ed88f9b415eb222047fe023aefcc00a648/rich_argparse-1.7.1-py3-none-any.whl", hash = "sha256:a8650b42e4a4ff72127837632fba6b7da40784842f08d7395eb67a9cbd7b4bf9", size = 25357, upload-time = "2025-05-25T20:20:33.793Z" }, +] + +[[package]] +name = "routes" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "repoze-lru" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/01/1504b710f68840f4152d460a4ffbc6b8265485b636235ddd72a8dfe686ae/Routes-2.5.1.tar.gz", hash = "sha256:b6346459a15f0cbab01a45a90c3d25caf980d4733d628b4cc1952b865125d053", size = 190905, upload-time = "2020-10-14T02:33:58.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/d4/d3c7d029de6287ff7bd048e628920d4336b4f8d82cfc00ff078bdbb212a3/Routes-2.5.1-py2.py3-none-any.whl", hash = "sha256:fab5a042a3a87778eb271d053ca2723cadf43c95b471532a191a48539cb606ea", size = 40096, upload-time = "2020-10-14T02:33:56.551Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/ed/3aef893e2dd30e77e35d20d4ddb45ca459db59cead748cad9796ad479411/rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef", size = 371606, upload-time = "2025-08-27T12:12:25.189Z" }, + { url = "https://files.pythonhosted.org/packages/6d/82/9818b443e5d3eb4c83c3994561387f116aae9833b35c484474769c4a8faf/rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be", size = 353452, upload-time = "2025-08-27T12:12:27.433Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/d2a110ffaaa397fc6793a83c7bd3545d9ab22658b7cdff05a24a4535cc45/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9024de74731df54546fab0bfbcdb49fae19159ecaecfc8f37c18d2c7e2c0bd61", size = 381519, upload-time = "2025-08-27T12:12:28.719Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bc/e89581d1f9d1be7d0247eaef602566869fdc0d084008ba139e27e775366c/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d3ebadefcd73b73928ed0b2fd696f7fefda8629229f81929ac9c1854d0cffb", size = 394424, upload-time = "2025-08-27T12:12:30.207Z" }, + { url = "https://files.pythonhosted.org/packages/ac/2e/36a6861f797530e74bb6ed53495f8741f1ef95939eed01d761e73d559067/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2e7f8f169d775dd9092a1743768d771f1d1300453ddfe6325ae3ab5332b4657", size = 523467, upload-time = "2025-08-27T12:12:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/c1bc2be32564fa499f988f0a5c6505c2f4746ef96e58e4d7de5cf923d77e/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d905d16f77eb6ab2e324e09bfa277b4c8e5e6b8a78a3e7ff8f3cdf773b4c013", size = 402660, upload-time = "2025-08-27T12:12:33.444Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ec/ef8bf895f0628dd0a59e54d81caed6891663cb9c54a0f4bb7da918cb88cf/rpds_py-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50c946f048209e6362e22576baea09193809f87687a95a8db24e5fbdb307b93a", size = 384062, upload-time = "2025-08-27T12:12:34.857Z" }, + { url = "https://files.pythonhosted.org/packages/69/f7/f47ff154be8d9a5e691c083a920bba89cef88d5247c241c10b9898f595a1/rpds_py-0.27.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:3deab27804d65cd8289eb814c2c0e807c4b9d9916c9225e363cb0cf875eb67c1", size = 401289, upload-time = "2025-08-27T12:12:36.085Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d9/ca410363efd0615814ae579f6829cafb39225cd63e5ea5ed1404cb345293/rpds_py-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8b61097f7488de4be8244c89915da8ed212832ccf1e7c7753a25a394bf9b1f10", size = 417718, upload-time = "2025-08-27T12:12:37.401Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a0/8cb5c2ff38340f221cc067cc093d1270e10658ba4e8d263df923daa18e86/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a3f29aba6e2d7d90528d3c792555a93497fe6538aa65eb675b44505be747808", size = 558333, upload-time = "2025-08-27T12:12:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8c/1b0de79177c5d5103843774ce12b84caa7164dfc6cd66378768d37db11bf/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6cd0485b7d347304067153a6dc1d73f7d4fd995a396ef32a24d24b8ac63ac8", size = 589127, upload-time = "2025-08-27T12:12:41.48Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5e/26abb098d5e01266b0f3a2488d299d19ccc26849735d9d2b95c39397e945/rpds_py-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f4461bf931108c9fa226ffb0e257c1b18dc2d44cd72b125bec50ee0ab1248a9", size = 554899, upload-time = "2025-08-27T12:12:42.925Z" }, + { url = "https://files.pythonhosted.org/packages/de/41/905cc90ced13550db017f8f20c6d8e8470066c5738ba480d7ba63e3d136b/rpds_py-0.27.1-cp310-cp310-win32.whl", hash = "sha256:ee5422d7fb21f6a00c1901bf6559c49fee13a5159d0288320737bbf6585bd3e4", size = 217450, upload-time = "2025-08-27T12:12:44.813Z" }, + { url = "https://files.pythonhosted.org/packages/75/3d/6bef47b0e253616ccdf67c283e25f2d16e18ccddd38f92af81d5a3420206/rpds_py-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:3e039aabf6d5f83c745d5f9a0a381d031e9ed871967c0a5c38d201aca41f3ba1", size = 228447, upload-time = "2025-08-27T12:12:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/d5/63/b7cc415c345625d5e62f694ea356c58fb964861409008118f1245f8c3347/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7ba22cb9693df986033b91ae1d7a979bc399237d45fccf875b76f62bb9e52ddf", size = 371360, upload-time = "2025-08-27T12:15:29.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/12e1b24b560cf378b8ffbdb9dc73abd529e1adcfcf82727dfd29c4a7b88d/rpds_py-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b640501be9288c77738b5492b3fd3abc4ba95c50c2e41273c8a1459f08298d3", size = 353933, upload-time = "2025-08-27T12:15:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/9b/85/1bb2210c1f7a1b99e91fea486b9f0f894aa5da3a5ec7097cbad7dec6d40f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb08b65b93e0c6dd70aac7f7890a9c0938d5ec71d5cb32d45cf844fb8ae47636", size = 382962, upload-time = "2025-08-27T12:15:32.348Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/a839b9f219cf80ed65f27a7f5ddbb2809c1b85c966020ae2dff490e0b18e/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7ff07d696a7a38152ebdb8212ca9e5baab56656749f3d6004b34ab726b550b8", size = 394412, upload-time = "2025-08-27T12:15:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/02/2d/b1d7f928b0b1f4fc2e0133e8051d199b01d7384875adc63b6ddadf3de7e5/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb7c72262deae25366e3b6c0c0ba46007967aea15d1eea746e44ddba8ec58dcc", size = 523972, upload-time = "2025-08-27T12:15:35.377Z" }, + { url = "https://files.pythonhosted.org/packages/a9/af/2cbf56edd2d07716df1aec8a726b3159deb47cb5c27e1e42b71d705a7c2f/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b002cab05d6339716b03a4a3a2ce26737f6231d7b523f339fa061d53368c9d8", size = 403273, upload-time = "2025-08-27T12:15:37.051Z" }, + { url = "https://files.pythonhosted.org/packages/c0/93/425e32200158d44ff01da5d9612c3b6711fe69f606f06e3895511f17473b/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23f6b69d1c26c4704fec01311963a41d7de3ee0570a84ebde4d544e5a1859ffc", size = 385278, upload-time = "2025-08-27T12:15:38.571Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1a/1a04a915ecd0551bfa9e77b7672d1937b4b72a0fc204a17deef76001cfb2/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:530064db9146b247351f2a0250b8f00b289accea4596a033e94be2389977de71", size = 402084, upload-time = "2025-08-27T12:15:40.529Z" }, + { url = "https://files.pythonhosted.org/packages/51/f7/66585c0fe5714368b62951d2513b684e5215beaceab2c6629549ddb15036/rpds_py-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b90b0496570bd6b0321724a330d8b545827c4df2034b6ddfc5f5275f55da2ad", size = 419041, upload-time = "2025-08-27T12:15:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7e/83a508f6b8e219bba2d4af077c35ba0e0cdd35a751a3be6a7cba5a55ad71/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:879b0e14a2da6a1102a3fc8af580fc1ead37e6d6692a781bd8c83da37429b5ab", size = 560084, upload-time = "2025-08-27T12:15:43.839Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/bb945683b958a1b19eb0fe715594630d0f36396ebdef4d9b89c2fa09aa56/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:0d807710df3b5faa66c731afa162ea29717ab3be17bdc15f90f2d9f183da4059", size = 590115, upload-time = "2025-08-27T12:15:46.647Z" }, + { url = "https://files.pythonhosted.org/packages/12/00/ccfaafaf7db7e7adace915e5c2f2c2410e16402561801e9c7f96683002d3/rpds_py-0.27.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3adc388fc3afb6540aec081fa59e6e0d3908722771aa1e37ffe22b220a436f0b", size = 556561, upload-time = "2025-08-27T12:15:48.219Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b7/92b6ed9aad103bfe1c45df98453dfae40969eef2cb6c6239c58d7e96f1b3/rpds_py-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c796c0c1cc68cb08b0284db4229f5af76168172670c74908fdbd4b7d7f515819", size = 229125, upload-time = "2025-08-27T12:15:49.956Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/4e/985f7da36f09592c5ade99321c72c15101d23c0bb7eecfd1daaca5714422/sqlalchemy-2.0.43-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069", size = 2133162, upload-time = "2025-08-11T15:52:17.854Z" }, + { url = "https://files.pythonhosted.org/packages/37/34/798af8db3cae069461e3bc0898a1610dc469386a97048471d364dc8aae1c/sqlalchemy-2.0.43-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154", size = 2123082, upload-time = "2025-08-11T15:52:19.181Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0f/79cf4d9dad42f61ec5af1e022c92f66c2d110b93bb1dc9b033892971abfa/sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612", size = 3208871, upload-time = "2025-08-11T15:50:30.656Z" }, + { url = "https://files.pythonhosted.org/packages/56/b3/59befa58fb0e1a9802c87df02344548e6d007e77e87e6084e2131c29e033/sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019", size = 3209583, upload-time = "2025-08-11T15:57:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/29/d2/124b50c0eb8146e8f0fe16d01026c1a073844f0b454436d8544fe9b33bd7/sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20", size = 3148177, upload-time = "2025-08-11T15:50:32.078Z" }, + { url = "https://files.pythonhosted.org/packages/83/f5/e369cd46aa84278107624617034a5825fedfc5c958b2836310ced4d2eadf/sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18", size = 3172276, upload-time = "2025-08-11T15:57:49.477Z" }, + { url = "https://files.pythonhosted.org/packages/de/2b/4602bf4c3477fa4c837c9774e6dd22e0389fc52310c4c4dfb7e7ba05e90d/sqlalchemy-2.0.43-cp310-cp310-win32.whl", hash = "sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00", size = 2101491, upload-time = "2025-08-11T15:54:59.191Z" }, + { url = "https://files.pythonhosted.org/packages/38/2d/bfc6b6143adef553a08295490ddc52607ee435b9c751c714620c1b3dd44d/sqlalchemy-2.0.43-cp310-cp310-win_amd64.whl", hash = "sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b", size = 2125148, upload-time = "2025-08-11T15:55:00.593Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" }, +] + +[[package]] +name = "statsd" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/29/05e9f50946f4cf2ed182726c60d9c0ae523bb3f180588c574dd9746de557/statsd-4.0.1.tar.gz", hash = "sha256:99763da81bfea8daf6b3d22d11aaccb01a8d0f52ea521daab37e758a4ca7d128", size = 27814, upload-time = "2022-11-06T14:17:36.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d0/c9543b52c067a390ae6ae632d7fd1b97a35cdc8d69d40c0b7d334b326410/statsd-4.0.1-py2.py3-none-any.whl", hash = "sha256:c2676519927f7afade3723aca9ca8ea986ef5b059556a980a867721ca69df093", size = 13118, upload-time = "2022-11-06T14:17:34.258Z" }, +] + +[[package]] +name = "stevedore" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/5f/8418daad5c353300b7661dd8ce2574b0410a6316a8be650a189d5c68d938/stevedore-5.5.0.tar.gz", hash = "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73", size = 513878, upload-time = "2025-08-25T12:54:26.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/c5/0c06759b95747882bb50abda18f5fb48c3e9b0fbfc6ebc0e23550b52415d/stevedore-5.5.0-py3-none-any.whl", hash = "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf", size = 49518, upload-time = "2025-08-25T12:54:25.445Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "testresources" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/2e/905756faf6bada00adccb5dbc9e5987760675b682f08dd1312a40042a838/testresources-2.0.2.tar.gz", hash = "sha256:2cbf3d7e00ab2e9fe24b754a102644f6f334244980464c38233b18127f1deaec", size = 45057, upload-time = "2025-04-22T10:24:29.8Z" } + +[[package]] +name = "testscenarios" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, + { name = "testtools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/de/b0b5b98c0f38fd7086d082c47fcb455eedd39a044abe7c595f5f40cd6eed/testscenarios-0.5.0.tar.gz", hash = "sha256:c257cb6b90ea7e6f8fef3158121d430543412c9a87df30b5dde6ec8b9b57a2b6", size = 20951, upload-time = "2015-05-04T01:37:16.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/25/2f10da0d5427989fefa5ab51e697bc02625bbb7de2be3bc8452462efac78/testscenarios-0.5.0-py2.py3-none-any.whl", hash = "sha256:480263fa5d6e618125bdf092aab129a3aeed5996b1e668428f12cc56d6d01d28", size = 21002, upload-time = "2015-05-04T01:37:23.545Z" }, +] + +[[package]] +name = "testtools" +version = "2.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/05/a543317ac62cf72e98dc40de5ab117ef14508f36352ed715cb3cd3fe1bbb/testtools-2.7.2.tar.gz", hash = "sha256:5be5bbc1f0fa0f8b60aca6ceec07845d41d0c475cf445bfadb4d2c45ec397ea3", size = 201430, upload-time = "2024-06-10T13:11:13.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/76/17eb3cfd467e7a53f2727e7a879a77c514970a12e23e3ac12e40ad3e0ac4/testtools-2.7.2-py3-none-any.whl", hash = "sha256:11712e29cebbe92187c3ad47ace5c32f91e1bb7a9f1ac5e8684c2b01eaa6fd2d", size = 179922, upload-time = "2024-06-10T13:10:40.452Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "tooz" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "debtcollector" }, + { name = "fasteners" }, + { name = "futurist" }, + { name = "msgpack" }, + { name = "oslo-serialization" }, + { name = "oslo-utils" }, + { name = "stevedore" }, + { name = "tenacity" }, + { name = "voluptuous" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/80/79441bdc556221f1491c2d4855d0170de170730a21d067d4edb2cbbd3678/tooz-7.0.0.tar.gz", hash = "sha256:af0aa21cb8b7bd561df3aea85b127e54858975314ecb69d1eac56a03e6e5b8d5", size = 102569, upload-time = "2025-06-05T13:24:30.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/2b/3117ab1da7742429ad4548e9abbb8e1d2819fde6a9c63c6d53863ec38c81/tooz-7.0.0-py3-none-any.whl", hash = "sha256:023b8692dd151984558a409151b3b97d3969fe4346e905b6ce58f408789fc62d", size = 93305, upload-time = "2025-06-05T13:24:28.434Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "voluptuous" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/af/a54ce0fb6f1d867e0b9f0efe5f082a691f51ccf705188fca67a3ecefd7f4/voluptuous-0.15.2.tar.gz", hash = "sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa", size = 51651, upload-time = "2024-07-02T19:10:00.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/a8/8f9cc6749331186e6a513bfe3745454f81d25f6e34c6024f88f80c71ed28/voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566", size = 31349, upload-time = "2024-07-02T19:09:58.125Z" }, +] + +[[package]] +name = "warlock" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "jsonschema" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/c2/3ba4daeddd47f1cfdbc703048cbee27bcbc50535261a2bbe36412565f3c9/warlock-2.1.0.tar.gz", hash = "sha256:82319ba017341e7fcdc81efc2be9dd2f8237a0da07c71476b5425651b317b1c9", size = 8041, upload-time = "2025-05-30T17:16:34.993Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/4b/8b3a3ae43afd4f7e4eebd86b53924d5271f84857ca38161ca502d0891caa/warlock-2.1.0-py3-none-any.whl", hash = "sha256:55cb5ad3399083724f1bfbfef4234a11fbacfc78bf2b9c4d0c18b6e203cf78fe", size = 10280, upload-time = "2025-05-30T17:16:33.601Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +] + +[[package]] +name = "webob" +version = "1.8.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/0b/1732085540b01f65e4e7999e15864fe14cd18b12a95731a43fd6fd11b26a/webob-1.8.9.tar.gz", hash = "sha256:ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589", size = 279775, upload-time = "2024-10-24T03:19:20.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/bd/c336448be43d40be28e71f2e0f3caf7ccb28e2755c58f4c02c065bfe3e8e/WebOb-1.8.9-py2.py3-none-any.whl", hash = "sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9", size = 115364, upload-time = "2024-10-24T03:19:18.642Z" }, +] + +[[package]] +name = "websockify" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jwcrypto" }, + { name = "numpy" }, + { name = "redis" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/9e/fa7b6322fe22a02cf34256cbe300a9468533038b1262c7960119a787b595/websockify-0.13.0.tar.gz", hash = "sha256:9969731116653226c4c499a8a50712c8f3f7c105c615ead7ebc6ae781f0ba954", size = 50653, upload-time = "2025-05-26T14:40:14.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/e8/6476cd85a71f7cfb04ca1e199809b75bc8580d075d4338ffcde4c761d620/websockify-0.13.0-py3-none-any.whl", hash = "sha256:ac497a8dafc7f51d28ca989f01cdf5f8add663a497d425a56b159dcb8d6a314c", size = 41537, upload-time = "2025-05-26T14:40:12.915Z" }, +] + +[[package]] +name = "wmi" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/66/6364deb0a03415f96c66803d8c4379f808f2401da3bdb183348487b10510/WMI-1.5.1.tar.gz", hash = "sha256:b6a6be5711b1b6c8d55bda7a8befd75c48c12b770b9d227d31c1737dbf0d40a6", size = 26254, upload-time = "2020-04-28T08:22:58.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/b9/a80d1ed4d115dac8e2ac08d16af046a77ab58e3d186e22395bf2add24090/WMI-1.5.1-py2.py3-none-any.whl", hash = "sha256:1d6b085e5c445141c475476000b661f60fff1aaa19f76bf82b7abb92e0ff4942", size = 28912, upload-time = "2020-04-28T08:22:56.055Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "yappi" +version = "1.6.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/5b/cfde09baf28f7046194b98f1c4907e172c48e7c1b2db35a918fc8a57727a/yappi-1.6.10.tar.gz", hash = "sha256:463b822727658937bd95a7d80ca9758605b8cd0014e004e9e520ec9cb4db0c92", size = 59379, upload-time = "2024-11-12T11:24:38.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5b/17dc1e58919cc14e8fb5027ccdb1134e324c235d527ccaac53d2e64778f7/yappi-1.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1f03127742746ec4cf7e422b08212daf094505ab7f5d725d7b273ed3c475c3d9", size = 32771, upload-time = "2024-11-12T11:23:25.892Z" }, + { url = "https://files.pythonhosted.org/packages/11/52/eaba290ab9bac96791cf2f101e1949408ef3c347d633435467c70f8a78ce/yappi-1.6.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7bbafb779c3f90edd09fd34733859226785618adee3179d5949dbba2e90f550a", size = 75700, upload-time = "2024-11-12T11:23:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/cb/93/fd7248f51eb3f80885ad0bd0ea4e16966e39023f82788d1cd1265d244c87/yappi-1.6.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f326045442f7d63aa54dc4a18eda358b186af3316ae52619dd606058fb3b4182", size = 79067, upload-time = "2024-11-12T11:23:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1d/505a9f9f1fc865879c3b6273755e739e1c626413a5d5356738b0641ebc79/yappi-1.6.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:737e3cb6bb05f326eb63000663a4dc08dc08cc9827f7634445250c9610e5e717", size = 74247, upload-time = "2024-11-12T11:23:30.62Z" }, + { url = "https://files.pythonhosted.org/packages/7c/89/43c245ba4cf4c35d2e1879c6c9b9b255152b5dbea815b32d34bf1741d3ef/yappi-1.6.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c01a2bd8abc3b6d33ae60dea26f97e2372e0087a747289bbab0fe67c8ac8925", size = 76560, upload-time = "2024-11-12T11:23:32.338Z" }, + { url = "https://files.pythonhosted.org/packages/3d/06/4b3be344d3f47ca79235f71d8309b6a7fe7db2a71d40b7e83266925b4d05/yappi-1.6.10-cp310-cp310-win32.whl", hash = "sha256:cf117a9f733e0d8386bc8c454c11b275999c4bf559d742cbb8b60ace1d813f23", size = 31818, upload-time = "2024-11-12T11:23:34.034Z" }, + { url = "https://files.pythonhosted.org/packages/16/34/67e485b3ce68584641a612bc29bcc09bac049a28a985ed435b6da03bda32/yappi-1.6.10-cp310-cp310-win_amd64.whl", hash = "sha256:402252d543e47464707ea5d7e4a63c7e77ce81cb58b8559c8883e67ae483911c", size = 34283, upload-time = "2024-11-12T11:23:35.62Z" }, +] From 8fe190f7916d7e353ce0e9e0089cbd33d8ad0a30 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 24 Sep 2025 11:59:44 +0100 Subject: [PATCH 07/26] install IronicUnderstackDriver into the nova container --- containers/nova/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/containers/nova/Dockerfile b/containers/nova/Dockerfile index d7009fcff..abc8dba14 100644 --- a/containers/nova/Dockerfile +++ b/containers/nova/Dockerfile @@ -12,3 +12,4 @@ RUN apt-get update && \ COPY containers/nova/patches /tmp/patches/ RUN cd /var/lib/openstack/lib/python3.10/site-packages && \ QUILT_PATCHES=/tmp/patches quilt push -a +COPY python/nova-understack/ironic_understack /var/lib/openstack/lib/python3.10/site-packages/nova/virt/ironic_understack From 53658de37491a2d72f00a4438315f6db1ecda32c Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 2 Oct 2025 10:51:47 +0100 Subject: [PATCH 08/26] nova: add logging to IronicUnderstackDriver --- python/nova-understack/ironic_understack/driver.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/nova-understack/ironic_understack/driver.py b/python/nova-understack/ironic_understack/driver.py index ff328d674..b0a2e4767 100644 --- a/python/nova-understack/ironic_understack/driver.py +++ b/python/nova-understack/ironic_understack/driver.py @@ -1,7 +1,11 @@ +import logging + from nova import exception from nova.i18n import _ from nova.virt.ironic.driver import IronicDriver +logger = logging.getLogger(__name__) + class IronicUnderstackDriver(IronicDriver): capabilities = IronicDriver.capabilities From e6ffca47147a4afd9c8efd79ba92dc89fa2490e1 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 25 Sep 2025 14:21:16 +0100 Subject: [PATCH 09/26] nova: inject IPs through _get_network_metadata override Rather than trying to intercept and rewrite whole spawn() method, we should be able to override only the _get_network_metadata(). It's better this way because it's much smaller and it's also used inside the rebuild() which we'd have to override too. --- .../ironic_understack/driver.py | 68 ++++--------------- 1 file changed, 12 insertions(+), 56 deletions(-) diff --git a/python/nova-understack/ironic_understack/driver.py b/python/nova-understack/ironic_understack/driver.py index b0a2e4767..134ad6f9f 100644 --- a/python/nova-understack/ironic_understack/driver.py +++ b/python/nova-understack/ironic_understack/driver.py @@ -1,7 +1,5 @@ import logging -from nova import exception -from nova.i18n import _ from nova.virt.ironic.driver import IronicDriver logger = logging.getLogger(__name__) @@ -11,60 +9,6 @@ class IronicUnderstackDriver(IronicDriver): capabilities = IronicDriver.capabilities rebalances_nodes = IronicDriver.rebalances_nodes - def spawn( - self, - context, - instance, - image_meta, - injected_files, - admin_password, - allocations, - network_info=None, - block_device_info=None, - power_on=True, - accel_info=None, - ): - """Deploy an instance. - - Args: - context: The security context. - instance: The instance object. - image_meta: Image dict returned by nova.image.glance - that defines the image from which to boot this instance. - injected_files: User files to inject into instance. - admin_password: Administrator password to set in instance. - allocations: Information about resources allocated to the - instance via placement, of the form returned by - SchedulerReportClient.get_allocations_for_consumer. - Ignored by this driver. - network_info: Instance network information. - block_device_info: Instance block device information. - accel_info: Accelerator requests for this instance. - power_on: True if the instance should be powered on, False otherwise. - """ - node_id = instance.get("node") - if not node_id: - raise exception.NovaException( - _("Ironic node uuid not supplied to driver for instance %s.") - % instance.uuid - ) - - storage_netinfo = self._lookup_storage_netinfo(node_id) - network_info = self._merge_storage_netinfo(network_info, storage_netinfo) - - return super().spawn( - context, - instance, - image_meta, - injected_files, - admin_password, - allocations, - network_info, - block_device_info, - power_on, - accel_info, - ) - def _lookup_storage_netinfo(self, node_id): return { "links": [ @@ -95,6 +39,18 @@ def _lookup_storage_netinfo(self, node_id): ], } + def _get_network_metadata(self, node, network_info): + base_metadata = super()._get_network_metadata(node, network_info) + if not base_metadata: + return base_metadata + additions = self._lookup_storage_netinfo(node["uuid"]) + for link in additions["links"]: + base_metadata["links"].append(link) + for network in additions["networks"]: + base_metadata["networks"].append(network) + return base_metadata + def _merge_storage_netinfo(self, original, new_info): print("original network_info: %s", original) + return original From 52326a923e3ed6bc59f9749d324f450a6268d9f7 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 29 Sep 2025 12:15:25 +0100 Subject: [PATCH 10/26] nova: use Nautobot client to obtain storage IPs --- .../nova-understack/ironic_understack/conf.py | 20 ++ .../ironic_understack/driver.py | 60 ++-- .../ironic_understack/nautobot_client.py | 311 ++++++++++++++---- 3 files changed, 281 insertions(+), 110 deletions(-) create mode 100644 python/nova-understack/ironic_understack/conf.py diff --git a/python/nova-understack/ironic_understack/conf.py b/python/nova-understack/ironic_understack/conf.py new file mode 100644 index 000000000..6c5e607b7 --- /dev/null +++ b/python/nova-understack/ironic_understack/conf.py @@ -0,0 +1,20 @@ +from oslo_config import cfg + +CONF = cfg.CONF + + +def setup_conf(): + grp = cfg.OptGroup("nova_understack") + opts = [ + cfg.StrOpt( + "nautobot_base_url", + help="Nautobot's base URL", + default="https://nautobot.nautobot.svc", + ), + cfg.StrOpt("nautobot_api_key", help="Nautotbot's API key", default=""), + ] + cfg.CONF.register_group(grp) + cfg.CONF.register_opts(opts, group=grp) + + +setup_conf() diff --git a/python/nova-understack/ironic_understack/driver.py b/python/nova-understack/ironic_understack/driver.py index 134ad6f9f..d1322a0a1 100644 --- a/python/nova-understack/ironic_understack/driver.py +++ b/python/nova-understack/ironic_understack/driver.py @@ -1,7 +1,11 @@ import logging +from uuid import UUID from nova.virt.ironic.driver import IronicDriver +from .conf import CONF +from .nautobot_client import NautobotClient + logger = logging.getLogger(__name__) @@ -9,48 +13,30 @@ class IronicUnderstackDriver(IronicDriver): capabilities = IronicDriver.capabilities rebalances_nodes = IronicDriver.rebalances_nodes - def _lookup_storage_netinfo(self, node_id): - return { - "links": [ - { - "id": "storage-iface-uuid", - "vif_id": "generate_or_obtain", - "type": "phy", - "mtu": 9000, - "ethernet_mac_address": "d4:04:e6:4f:90:18", - } - ], - "networks": [ - { - "id": "network0", - "type": "ipv4", - "link": "storage-iface-uuid", - "ip_address": "126.0.0.2", - "netmask": "255.255.255.252", - "routes": [ - { - "network": "127.0.0.0", - "netmask": "255.255.0.0", - "gateway": "126.0.0.1", - } - ], - "network_id": "generate_or_obtain", - } - ], - } + def __init__(self, virtapi, read_only=False): + self._nautobot_connection = NautobotClient( + CONF.nova_understack.nautobot_base_url, + CONF.nova_understack.nautobot_api_key, + ) + + super().__init__(virtapi, read_only) def _get_network_metadata(self, node, network_info): + """Obtain network_metadata to be used in config drive. + + This pulls storage IP information and adds it to the base + information obtained by original IronicDriver. + """ base_metadata = super()._get_network_metadata(node, network_info) if not base_metadata: return base_metadata - additions = self._lookup_storage_netinfo(node["uuid"]) - for link in additions["links"]: + + extra_interfaces = self._nautobot_connection.storage_network_config_for_node( + UUID(node["uuid"]) + ) + + for link in extra_interfaces["links"]: base_metadata["links"].append(link) - for network in additions["networks"]: + for network in extra_interfaces["networks"]: base_metadata["networks"].append(network) return base_metadata - - def _merge_storage_netinfo(self, original, new_info): - print("original network_info: %s", original) - - return original diff --git a/python/nova-understack/ironic_understack/nautobot_client.py b/python/nova-understack/ironic_understack/nautobot_client.py index 47ccce580..1f6052c08 100644 --- a/python/nova-understack/ironic_understack/nautobot_client.py +++ b/python/nova-understack/ironic_understack/nautobot_client.py @@ -1,7 +1,214 @@ import ipaddress +import uuid +from dataclasses import dataclass +from uuid import UUID import requests -import yaml + + +@dataclass +class IPAddress: + """Represents an IP address from Nautobot.""" + + interface: ipaddress.IPv4Interface | ipaddress.IPv6Interface + + @classmethod + def from_address_string(cls, address: str) -> "IPAddress": + """Create IPAddress from address string with CIDR notation.""" + try: + # Try to parse as interface (with prefix) + interface = ipaddress.ip_interface(address) + return cls(interface=interface) + except ipaddress.AddressValueError as e: + raise ValueError(f"Invalid IP address format: {address}") from e + + @property + def address(self) -> str: + """Get the IP address as string.""" + return str(self.interface.ip) + + @property + def address_with_prefix(self) -> str: + """Get the IP address with prefix as string.""" + return str(self.interface) + + @property + def network(self) -> ipaddress.IPv4Network | ipaddress.IPv6Network: + """Get the network this IP belongs to.""" + return self.interface.network + + @property + def netmask(self): + return str(self.interface.netmask) + + @property + def ip_version(self) -> int: + """Get the IP version (4 or 6).""" + return self.interface.version + + def is_ipv4(self) -> bool: + """Check if this is an IPv4 address.""" + return self.ip_version == 4 + + def is_in_subnet(self, subnet: str) -> bool: + """Check if this IP address is within the specified subnet.""" + try: + target_subnet = ipaddress.ip_network(subnet) + return self.network.subnet_of(target_subnet) # pyright: ignore + except (ipaddress.AddressValueError, ValueError): + return False + + @property + def calculated_gateway(self) -> str: + """Calculate the first address of the subnet as gateway.""" + first_host = self.network.network_address + 1 + return str(first_host) + + +@dataclass +class IPAddressAssignment: + """Represents an IP address assignment to an interface.""" + + ip_address: IPAddress + + +@dataclass +class Interface: + """Represents a network interface from Nautobot.""" + + id: str | None + mac_address: str | None + ip_address_assignments: list[IPAddressAssignment] + + def get_ipv4_assignments(self) -> list[IPAddressAssignment]: + """Get only IPv4 address assignments.""" + return [ + assignment + for assignment in self.ip_address_assignments + if assignment.ip_address.is_ipv4() + ] + + def get_first_ipv4_assignment(self) -> IPAddressAssignment | None: + """Get the first IPv4 address assignment.""" + ipv4_assignments = self.get_ipv4_assignments() + return ipv4_assignments[0] if ipv4_assignments else None + + def has_ip_in_subnet(self, subnet: str) -> bool: + """Check if any IP assignment is in the specified subnet.""" + return any( + assignment.ip_address.is_in_subnet(subnet) + for assignment in self.ip_address_assignments + ) + + def is_valid_for_config(self) -> bool: + """Check if interface is valid for network configuration.""" + return ( + self.mac_address is not None + and len(self.ip_address_assignments) > 0 + and self.get_first_ipv4_assignment() is not None + ) + + def as_openstack_link(self, if_index=0) -> dict[str, str | int | None]: + return { + "id": f"tap-stor-{if_index}", + "vif_id": self.id, + "type": "phy", + "mtu": 9000, + "ethernet_mac_address": self.mac_address, + } + + def as_openstack_network(self, if_index=0) -> dict[str, str | int | list | None]: + ip_assignment = self.get_first_ipv4_assignment() + if not ip_assignment: + return {} + ip = ip_assignment.ip_address + return { + "id": f"network-for-if{if_index}", + "type": "ipv4", + "link": f"tap-stor-{if_index}", + "ip_address": ip.address, + "netmask": str(ip.netmask), + "routes": [ + { + "network": "127.0.0.0", + "netmask": "255.255.0.0", + "gateway": ip.calculated_gateway, + } + ], + "network_id": uuid.uuid4().hex, + } + + +@dataclass +class Device: + """Represents a device from Nautobot.""" + + id: str + interfaces: list[Interface] + + def get_active_interfaces(self) -> list[Interface]: + """Get interfaces that are valid for network configuration.""" + return [ + interface + for interface in self.interfaces + if interface.is_valid_for_config() + ] + + def get_storage_interfaces( + self, storage_subnet: str = "100.126.0.0/16" + ) -> list[Interface]: + """Get interfaces with IPs in the storage subnet.""" + return [ + interface + for interface in self.get_active_interfaces() + if interface.has_ip_in_subnet(storage_subnet) + ] + + +@dataclass +class DeviceInterfacesResponse: + """Represents the complete response from get_device_interfaces.""" + + devices: list[Device] + + @classmethod + def from_graphql_response(cls, response: dict) -> "DeviceInterfacesResponse": + """Create instance from GraphQL response data.""" + devices_data = response.get("data", {}).get("devices", []) + devices = [] + + for device_data in devices_data: + interfaces_data = device_data.get("interfaces", []) + interfaces = [] + + for interface_data in interfaces_data: + assignments_data = interface_data.get("ip_address_assignments", []) + assignments = [] + + for assignment_data in assignments_data: + ip_data = assignment_data.get("ip_address", {}) + address_str = ip_data.get("address", "") + if address_str: + try: + ip_address = IPAddress.from_address_string(address_str) + assignments.append( + IPAddressAssignment(ip_address=ip_address) + ) + except ValueError: + # Skip invalid IP addresses + continue + + interface = Interface( + id=interface_data.get("id"), + mac_address=interface_data.get("mac_address"), + ip_address_assignments=assignments, + ) + interfaces.append(interface) + + device = Device(id=device_data.get("id", ""), interfaces=interfaces) + devices.append(device) + + return cls(devices=devices) class NautobotClient: @@ -51,20 +258,21 @@ def _make_graphql_request(self, query: str, variables: dict | None = None) -> di return data - def get_device_interfaces(self, device_id: str) -> dict: + def get_device_interfaces(self, device_id: str) -> DeviceInterfacesResponse: """Retrieve device interfaces and their IP assignments from Nautobot. Args: device_id: UUID of the device to query Returns: - Dictionary containing the GraphQL response data + DeviceInterfacesResponse containing structured interface data """ query = """ query ($device_id: String) { devices(id: [$device_id]) { id interfaces(status: "Active") { + id mac_address ip_address_assignments { ip_address { @@ -80,25 +288,11 @@ def get_device_interfaces(self, device_id: str) -> dict: variables = {"device_id": device_id} response = self._make_graphql_request(query, variables) - return response - - def _calculate_gateway(self, ip_with_prefix: str) -> str: - """Calculate the first address of the subnet as gateway. - - Args: - ip_with_prefix: IP address with prefix (e.g., '192.168.1.10/24') - - Returns: - First address of the subnet (e.g., '192.168.1.1') - """ - network = ipaddress.ip_network(ip_with_prefix, strict=False) - # Get the first host address (network address + 1) - first_host = network.network_address + 1 - return str(first_host) + return DeviceInterfacesResponse.from_graphql_response(response) def generate_network_config( - self, response: dict, ignore_non_storage: bool = False - ) -> str: + self, response: DeviceInterfacesResponse, ignore_non_storage: bool = False + ) -> dict[str, list[dict]]: """Generate netplan YAML configuration from Nautobot response. Args: @@ -107,66 +301,37 @@ def generate_network_config( 100.126.0.0/16 subnet Returns: - YAML string containing netplan configuration + OpenStack compatible network_data dictionary """ - config = {"version": 2, "ethernets": {}} - - interface_count = 0 + config = {"links": [], "networks": []} - # Extract devices from response - devices = response.get("data", {}).get("devices", []) + # To avoid conflict we start indexing from 100 + interface_count = 100 - for device in devices: - interfaces = device.get("interfaces", []) + for device in response.devices: + # Get appropriate interfaces based on filtering + if ignore_non_storage: + interfaces = device.get_storage_interfaces() + else: + interfaces = device.get_active_interfaces() for interface in interfaces: - mac_address = interface.get("mac_address") - ip_assignments = interface.get("ip_address_assignments", []) - - # Only process interfaces with IP assignments - if not ip_assignments or not mac_address: + # Get the first IPv4 assignment + first_assignment = interface.get_first_ipv4_assignment() + if not first_assignment: continue - # Filter for IPv4 assignments only - ipv4_assignments = [ - assignment - for assignment in ip_assignments - if assignment.get("ip_address", {}).get("ip_version") == 4 - ] - - if not ipv4_assignments: - continue - - # Take only the first IPv4 assignment - first_assignment = ipv4_assignments[0] - ip_info = first_assignment.get("ip_address", {}) - ip_address = ip_info.get("address") - - if not ip_address: - continue - - # Only process interfaces with IP addresses in 100.126.0.0/16 subnet - try: - ip_network = ipaddress.ip_network(ip_address, strict=False) - target_subnet = ipaddress.ip_network("100.126.0.0/16") - if ignore_non_storage and not ip_network.subnet_of(target_subnet): # pyright: ignore - continue - except (ipaddress.AddressValueError, ValueError): - # Skip if IP address is invalid - continue - - interface_name = f"interface{interface_count}" - - # Calculate gateway (first address of the subnet) - gateway = self._calculate_gateway(ip_address) - - config["ethernets"][interface_name] = { - "match": {"macaddress": mac_address}, - "set-name": interface_name, - "addresses": [ip_address], - "gateway4": gateway, - } + config["links"].append( + interface.as_openstack_link(if_index=interface_count) + ) + config["networks"].append( + interface.as_openstack_network(if_index=interface_count) + ) interface_count += 1 - return yaml.dump(config, default_flow_style=False, sort_keys=False) + return config + + def storage_network_config_for_node(self, node_id: UUID): + response = self.get_device_interfaces(str(node_id)) + return self.generate_network_config(response, ignore_non_storage=True) From 1cb9eac262ae82d3ee4e250e660347d469a4a3ac Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 29 Sep 2025 12:36:40 +0100 Subject: [PATCH 11/26] nova: create and mount nautobot token secret --- components/nova/kustomization.yaml | 1 + components/nova/nova-nautobot-token.yaml | 27 ++++++++++++++++++++++++ components/nova/values.yaml | 2 ++ 3 files changed, 30 insertions(+) create mode 100644 components/nova/nova-nautobot-token.yaml diff --git a/components/nova/kustomization.yaml b/components/nova/kustomization.yaml index a4e99f681..fac3f352a 100644 --- a/components/nova/kustomization.yaml +++ b/components/nova/kustomization.yaml @@ -11,3 +11,4 @@ resources: # working due to the way the chart hardcodes the config-file parameter which then # takes precedence over the directory - configmap-nova-bin.yaml + - nova-nautobot-token.yaml diff --git a/components/nova/nova-nautobot-token.yaml b/components/nova/nova-nautobot-token.yaml new file mode 100644 index 000000000..3991ef0a4 --- /dev/null +++ b/components/nova/nova-nautobot-token.yaml @@ -0,0 +1,27 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: nova-nautobot + namespace: openstack +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: nautobot + target: + name: nova-nautobot + creationPolicy: Owner + deletionPolicy: Delete + template: + engineVersion: v2 + data: + nova-nautobot.conf: | + [nova_understack] + nautobot_base_url = http://nautobot-default.nautobot.svc.cluster.local + nautobot_api_key = {{ .token }} + data: + - secretKey: token + remoteRef: + key: nautobot-superuser + property: apitoken diff --git a/components/nova/values.yaml b/components/nova/values.yaml index 989e201b4..69968e158 100644 --- a/components/nova/values.yaml +++ b/components/nova/values.yaml @@ -232,6 +232,8 @@ pod: sources: - secret: name: nova-ks-etc + - secret: + name: nova-nautobot lifecycle: disruption_budget: osapi: From 7d27fc065d7163b7fd9c73361522f4eb1128dd89 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 30 Sep 2025 10:03:50 +0100 Subject: [PATCH 12/26] nova: add secret and role bindings for Argo access --- components/nova/kustomization.yaml | 2 ++ components/nova/roles-nova-argo-token.yaml | 34 +++++++++++++++++++++ components/nova/secret-nova-argo-token.yaml | 8 +++++ 3 files changed, 44 insertions(+) create mode 100644 components/nova/roles-nova-argo-token.yaml create mode 100644 components/nova/secret-nova-argo-token.yaml diff --git a/components/nova/kustomization.yaml b/components/nova/kustomization.yaml index fac3f352a..fe42ff102 100644 --- a/components/nova/kustomization.yaml +++ b/components/nova/kustomization.yaml @@ -12,3 +12,5 @@ resources: # takes precedence over the directory - configmap-nova-bin.yaml - nova-nautobot-token.yaml + - secret-nova-argo-token.yaml + - roles-nova-argo-token.yaml diff --git a/components/nova/roles-nova-argo-token.yaml b/components/nova/roles-nova-argo-token.yaml new file mode 100644 index 000000000..f639b9b07 --- /dev/null +++ b/components/nova/roles-nova-argo-token.yaml @@ -0,0 +1,34 @@ +# These roles allow nova-compute-ironic to create and list Argo workflows +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + creationTimestamp: null + name: nova-argo-workflows + namespace: argo-events +rules: +- apiGroups: + - argoproj.io + resources: + - workflows + verbs: + - get + - list + - create + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + creationTimestamp: null + name: nova-argo-workflows + namespace: argo-events +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: nova-argo-workflows +subjects: +- kind: ServiceAccount + name: nova-compute-ironic + namespace: openstack +--- diff --git a/components/nova/secret-nova-argo-token.yaml b/components/nova/secret-nova-argo-token.yaml new file mode 100644 index 000000000..2c5a016d0 --- /dev/null +++ b/components/nova/secret-nova-argo-token.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: nova-argo.token + annotations: + kubernetes.io/service-account.name: nova-conductor +type: kubernetes.io/service-account-token From d51a5e538fc6dc9cd7d6c31392f528ad268cb095 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 30 Sep 2025 11:33:49 +0100 Subject: [PATCH 13/26] nova: add ArgoClient --- .../ironic_understack/argo_client.py | 120 ++++++++++++++++++ python/nova-understack/pyproject.toml | 1 + python/nova-understack/uv.lock | 6 +- 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 python/nova-understack/ironic_understack/argo_client.py diff --git a/python/nova-understack/ironic_understack/argo_client.py b/python/nova-understack/ironic_understack/argo_client.py new file mode 100644 index 000000000..e80182ccb --- /dev/null +++ b/python/nova-understack/ironic_understack/argo_client.py @@ -0,0 +1,120 @@ +import time + +import requests + + +class ArgoClient: + """Client for interacting with Argo Workflows REST API.""" + + def __init__(self, url: str, token: str, namespace="argo-events"): + """Initialize the Argo client. + + Args: + url: Base URL of the Argo Workflows server + token: Authentication token for API access + """ + self.url = url.rstrip("/") + self.token = token + self.session = requests.Session() + self.session.headers.update( + {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + ) + self.namespace = namespace + + def run_playbook(self, playbook_name: str, project_id: str, device_id: str) -> dict: + """Run an Ansible playbook via Argo Workflows. + + This method creates a workflow from the ansible-workflow-template and waits + for it to complete synchronously. + + Args: + playbook_name: Name of the Ansible playbook to run + project_id: Project ID parameter for the playbook + device_id: Device ID parameter for the playbook + env: Environment parameter (dev, staging, prod) + + Returns: + dict: The final workflow status + + Raises: + requests.RequestException: If API requests fail + RuntimeError: If workflow fails or times out + """ + # Create workflow from template + workflow_request = { + "workflow": { + "metadata": {"generateName": "ansible-on-server-create-"}, + "spec": { + "workflowTemplateRef": {"name": "ansible-workflow-template"}, + "entrypoint": "ansible-run", + "arguments": { + "parameters": [ + {"name": "playbook", "value": playbook_name}, + { + "name": "extra_vars", + "value": ( + f"project_id={project_id}" + f" device_id={device_id}", + ), + }, + ] + }, + }, + } + } + + # Submit workflow + response = self.session.post( + f"{self.url}/api/v1/workflows/{self.namespace}", json=workflow_request + ) + response.raise_for_status() + + workflow = response.json() + workflow_name = workflow["metadata"]["name"] + + # Wait for workflow completion + return self._wait_for_completion(workflow_name) + + def _wait_for_completion( + self, workflow_name: str, timeout: int = 600, poll_interval: int = 5 + ) -> dict: + """Wait for workflow to complete. + + Args: + workflow_name: Name of the workflow to monitor + timeout: Maximum time to wait in seconds (default: 10 minutes) + poll_interval: Time between status checks in seconds + + Returns: + dict: Final workflow status + + Raises: + RuntimeError: If workflow fails or times out + """ + start_time = time.time() + + while time.time() - start_time < timeout: + response = self.session.get( + f"{self.url}/api/v1/workflows/{self.namespace}/{workflow_name}" + ) + response.raise_for_status() + + workflow = response.json() + phase = workflow.get("status", {}).get("phase") + + if phase == "Succeeded": + return workflow + elif phase == "Failed": + status = workflow.get("status", {}).get("message", "Unknown error") + raise RuntimeError(f"Workflow {workflow_name} failed: {status}") + elif phase == "Error": + status = workflow.get("status", {}).get("message", "Unknown error") + raise RuntimeError( + f"Workflow {workflow_name} encountered an error: {status}" + ) + + time.sleep(poll_interval) + + raise RuntimeError( + f"Workflow {workflow_name} timed out after {timeout} seconds" + ) diff --git a/python/nova-understack/pyproject.toml b/python/nova-understack/pyproject.toml index ab585b7b2..9966251ad 100644 --- a/python/nova-understack/pyproject.toml +++ b/python/nova-understack/pyproject.toml @@ -12,6 +12,7 @@ readme = "README.md" license = "MIT" dependencies = [ "nova>=30.1", + "requests>=2.25.0", ] [dependency-groups] diff --git a/python/nova-understack/uv.lock b/python/nova-understack/uv.lock index f152637ee..6fa92f41b 100644 --- a/python/nova-understack/uv.lock +++ b/python/nova-understack/uv.lock @@ -807,6 +807,7 @@ version = "0.0.0" source = { editable = "." } dependencies = [ { name = "nova" }, + { name = "requests" }, ] [package.dev-dependencies] @@ -817,7 +818,10 @@ test = [ ] [package.metadata] -requires-dist = [{ name = "nova", specifier = ">=30.1" }] +requires-dist = [ + { name = "nova", specifier = ">=30.1" }, + { name = "requests", specifier = ">=2.25.0" }, +] [package.metadata.requires-dev] test = [ From ce294d3eb5c2347ee82623b3f0bda4e68c465b0e Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 30 Sep 2025 11:41:42 +0100 Subject: [PATCH 14/26] nova: make ArgoClient more generic In case we need to run other playbooks... --- .../ironic_understack/argo_client.py | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/python/nova-understack/ironic_understack/argo_client.py b/python/nova-understack/ironic_understack/argo_client.py index e80182ccb..97a992b3c 100644 --- a/python/nova-understack/ironic_understack/argo_client.py +++ b/python/nova-understack/ironic_understack/argo_client.py @@ -21,7 +21,28 @@ def __init__(self, url: str, token: str, namespace="argo-events"): ) self.namespace = namespace - def run_playbook(self, playbook_name: str, project_id: str, device_id: str) -> dict: + def _generate_workflow_name(self, playbook_name: str) -> str: + """Generate workflow name based on playbook name. + + Strips .yaml/.yml suffix and creates ansible-- format. + + Args: + playbook_name: Name of the Ansible playbook + + Returns: + str: Generated workflow name in format ansible-- + + Examples: + storage_on_server_create.yml -> ansible-storage_on_server_create- + network_setup.yaml -> ansible-network_setup- + deploy_app -> ansible-deploy_app- + """ + base_name = playbook_name.replace("_", "-") + if base_name.endswith((".yaml", ".yml")): + base_name = base_name.rsplit(".", 1)[0] + return f"ansible-{base_name}-" + + def run_playbook(self, playbook_name: str, **extra_vars) -> dict: """Run an Ansible playbook via Argo Workflows. This method creates a workflow from the ansible-workflow-template and waits @@ -29,9 +50,7 @@ def run_playbook(self, playbook_name: str, project_id: str, device_id: str) -> d Args: playbook_name: Name of the Ansible playbook to run - project_id: Project ID parameter for the playbook - device_id: Device ID parameter for the playbook - env: Environment parameter (dev, staging, prod) + **extra_vars: Arbitrary key/value pairs to pass as extra_vars to Ansible Returns: dict: The final workflow status @@ -40,10 +59,16 @@ def run_playbook(self, playbook_name: str, project_id: str, device_id: str) -> d requests.RequestException: If API requests fail RuntimeError: If workflow fails or times out """ + # Convert extra_vars dict to space-separated key=value string + extra_vars_str = " ".join(f"{key}={value}" for key, value in extra_vars.items()) + + # Generate workflow name based on playbook name + generate_name = self._generate_workflow_name(playbook_name) + # Create workflow from template workflow_request = { "workflow": { - "metadata": {"generateName": "ansible-on-server-create-"}, + "metadata": {"generateName": generate_name}, "spec": { "workflowTemplateRef": {"name": "ansible-workflow-template"}, "entrypoint": "ansible-run", @@ -52,10 +77,7 @@ def run_playbook(self, playbook_name: str, project_id: str, device_id: str) -> d {"name": "playbook", "value": playbook_name}, { "name": "extra_vars", - "value": ( - f"project_id={project_id}" - f" device_id={device_id}", - ), + "value": extra_vars_str, }, ] }, From 9c0e0c0e1f96162bc914ecc5ffbfb99b9187e8bf Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 30 Sep 2025 13:39:32 +0100 Subject: [PATCH 15/26] nova: use dynamic routes for A/B side Rather than hardcoding 100.127.0.0/16, we should use side-specific routes so both sides can be reached independently. --- .../ironic_understack/nautobot_client.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/python/nova-understack/ironic_understack/nautobot_client.py b/python/nova-understack/ironic_understack/nautobot_client.py index 1f6052c08..2a0a1c2f0 100644 --- a/python/nova-understack/ironic_understack/nautobot_client.py +++ b/python/nova-understack/ironic_understack/nautobot_client.py @@ -27,6 +27,16 @@ def address(self) -> str: """Get the IP address as string.""" return str(self.interface.ip) + @property + def target_network(self) -> ipaddress.IPv4Network: + """Returns the respective target-side network.""" + third_octet = self.address.split(".")[2] + if third_octet not in ["0", "128"]: + raise ValueError( + f"Cannot determine the target-side network from {self.address}" + ) + return ipaddress.IPv4Network(f"100.127.{third_octet}.0/24") + @property def address_with_prefix(self) -> str: """Get the IP address with prefix as string.""" @@ -130,8 +140,8 @@ def as_openstack_network(self, if_index=0) -> dict[str, str | int | list | None] "netmask": str(ip.netmask), "routes": [ { - "network": "127.0.0.0", - "netmask": "255.255.0.0", + "network": str(ip.target_network.network_address), + "netmask": str(ip.target_network.netmask), "gateway": ip.calculated_gateway, } ], From 94e0a8c0d6d8d879da4c68462936ef5e442a9681 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 30 Sep 2025 15:48:39 +0100 Subject: [PATCH 16/26] nova: inject storage info only when requested --- .../ironic_understack/argo_client.py | 6 +- .../ironic_understack/driver.py | 64 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/python/nova-understack/ironic_understack/argo_client.py b/python/nova-understack/ironic_understack/argo_client.py index 97a992b3c..55a064193 100644 --- a/python/nova-understack/ironic_understack/argo_client.py +++ b/python/nova-understack/ironic_understack/argo_client.py @@ -17,9 +17,13 @@ def __init__(self, url: str, token: str, namespace="argo-events"): self.token = token self.session = requests.Session() self.session.headers.update( - {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } ) self.namespace = namespace + print(f"Token: {self.token}") def _generate_workflow_name(self, playbook_name: str) -> str: """Generate workflow name based on playbook name. diff --git a/python/nova-understack/ironic_understack/driver.py b/python/nova-understack/ironic_understack/driver.py index d1322a0a1..d7b469416 100644 --- a/python/nova-understack/ironic_understack/driver.py +++ b/python/nova-understack/ironic_understack/driver.py @@ -1,6 +1,12 @@ +import base64 +import gzip import logging +import shutil +import tempfile from uuid import UUID +from nova.api.metadata import base as instance_metadata +from nova.virt import configdrive from nova.virt.ironic.driver import IronicDriver from .conf import CONF @@ -21,7 +27,7 @@ def __init__(self, virtapi, read_only=False): super().__init__(virtapi, read_only) - def _get_network_metadata(self, node, network_info): + def _get_network_metadata_with_storage(self, node, network_info): """Obtain network_metadata to be used in config drive. This pulls storage IP information and adds it to the base @@ -34,9 +40,65 @@ def _get_network_metadata(self, node, network_info): extra_interfaces = self._nautobot_connection.storage_network_config_for_node( UUID(node["uuid"]) ) + logger.debug("Injecting extra network_info: %s", extra_interfaces) for link in extra_interfaces["links"]: base_metadata["links"].append(link) for network in extra_interfaces["networks"]: base_metadata["networks"].append(network) return base_metadata + + # This is almost exact copy of the IronicDriver's _generate_configdrive, + # but we make a determination if injecting storage IPs information is needed + # based on the instance 'storage' property. + def _generate_configdrive( + self, context, instance, node, network_info, extra_md=None, files=None + ): + """Generate a config drive with optional storage info. + + :param instance: The instance object. + :param node: The node object. + :param network_info: Instance network information. + :param extra_md: Optional, extra metadata to be added to the + configdrive. + :param files: Optional, a list of paths to files to be added to + the configdrive. + + """ + if not extra_md: + extra_md = {} + + if instance.metadata["storage"] == "wanted": + logger.info("Instance %s requires storage network setup.", instance.uuid) + network_metadata = self._get_network_metadata_with_storage( + node, network_info + ) + else: + logger.info( + "Instance %s does not require storage network setup.", + instance.uuid, + ) + network_metadata = self._get_network_metadata(node, network_info) + + i_meta = instance_metadata.InstanceMetadata( + instance, + content=files, + extra_md=extra_md, + network_info=network_info, + network_metadata=network_metadata, + ) + + with tempfile.NamedTemporaryFile() as uncompressed: + with configdrive.ConfigDriveBuilder(instance_md=i_meta) as cdb: + cdb.make_drive(uncompressed.name) + + with tempfile.NamedTemporaryFile() as compressed: + # compress config drive + with gzip.GzipFile(fileobj=compressed, mode="wb") as gzipped: + uncompressed.seek(0) + shutil.copyfileobj(uncompressed, gzipped) + + # base64 encode config drive and then decode to utf-8 for JSON + # serialization + compressed.seek(0) + return base64.b64encode(compressed.read()).decode() From 9802865f47a42937fcce6a7c40abc1c6a688f04d Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 1 Oct 2025 09:14:33 +0100 Subject: [PATCH 17/26] nova: run ansible playbook before configdrive build --- .../ironic_understack/argo_client.py | 24 +++++++++++++++---- .../nova-understack/ironic_understack/conf.py | 5 ++++ .../ironic_understack/driver.py | 18 ++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/python/nova-understack/ironic_understack/argo_client.py b/python/nova-understack/ironic_understack/argo_client.py index 55a064193..cd7d85584 100644 --- a/python/nova-understack/ironic_understack/argo_client.py +++ b/python/nova-understack/ironic_understack/argo_client.py @@ -6,16 +6,21 @@ class ArgoClient: """Client for interacting with Argo Workflows REST API.""" - def __init__(self, url: str, token: str, namespace="argo-events"): + def __init__( + self, url: str, token: str | None, namespace="argo-events", ssl_verify=False + ): """Initialize the Argo client. Args: url: Base URL of the Argo Workflows server - token: Authentication token for API access + (Optional) token: Authentication token for API access. If not provided + the default token from + /var/run/secrets/kubernetes.io/serviceaccount/token is used. """ self.url = url.rstrip("/") - self.token = token + self.token = token or self._kubernetes_token self.session = requests.Session() + self.session.verify = ssl_verify self.session.headers.update( { "Authorization": f"Bearer {self.token}", @@ -23,7 +28,6 @@ def __init__(self, url: str, token: str, namespace="argo-events"): } ) self.namespace = namespace - print(f"Token: {self.token}") def _generate_workflow_name(self, playbook_name: str) -> str: """Generate workflow name based on playbook name. @@ -46,6 +50,18 @@ def _generate_workflow_name(self, playbook_name: str) -> str: base_name = base_name.rsplit(".", 1)[0] return f"ansible-{base_name}-" + @property + def _kubernetes_token(self) -> str: + """Reads pod's Kubernetes token. + + Args: + None + Returns: + str: value of the token + """ + with open("/var/run/secrets/kubernetes.io/serviceaccount/token") as f: + return f.read() + def run_playbook(self, playbook_name: str, **extra_vars) -> dict: """Run an Ansible playbook via Argo Workflows. diff --git a/python/nova-understack/ironic_understack/conf.py b/python/nova-understack/ironic_understack/conf.py index 6c5e607b7..1019fa373 100644 --- a/python/nova-understack/ironic_understack/conf.py +++ b/python/nova-understack/ironic_understack/conf.py @@ -12,6 +12,11 @@ def setup_conf(): default="https://nautobot.nautobot.svc", ), cfg.StrOpt("nautobot_api_key", help="Nautotbot's API key", default=""), + cfg.StrOpt( + "argo_api_url", + help="Argo Workflows API url", + default="https://argo-server.argo.svc:2746", + ), ] cfg.CONF.register_group(grp) cfg.CONF.register_opts(opts, group=grp) diff --git a/python/nova-understack/ironic_understack/driver.py b/python/nova-understack/ironic_understack/driver.py index d7b469416..a91a0f080 100644 --- a/python/nova-understack/ironic_understack/driver.py +++ b/python/nova-understack/ironic_understack/driver.py @@ -9,6 +9,7 @@ from nova.virt import configdrive from nova.virt.ironic.driver import IronicDriver +from .argo_client import ArgoClient from .conf import CONF from .nautobot_client import NautobotClient @@ -25,6 +26,8 @@ def __init__(self, virtapi, read_only=False): CONF.nova_understack.nautobot_api_key, ) + self._argo_connection = ArgoClient(CONF.nova_understack.argo_api_url, None) + super().__init__(virtapi, read_only) def _get_network_metadata_with_storage(self, node, network_info): @@ -68,8 +71,22 @@ def _generate_configdrive( if not extra_md: extra_md = {} + ### Understack modified code START if instance.metadata["storage"] == "wanted": logger.info("Instance %s requires storage network setup.", instance.uuid) + project_id = instance.project_id + device_id = node["uuid"] + playbook_args = {"device_id": device_id, "project_id": project_id} + logger.info( + "Scheduling ansible run of storage_on_server_create.yml for " + "device_id=%(device_id)s project_id=%(project_id)s", + playbook_args, + ) + result = self._argo_connection.run_playbook( + "storage_on_server_create.yml", **playbook_args + ) + logger.debug("Ansible result: %s", result) + logger.info("Playbook run completed, collecting rest of metadata.") network_metadata = self._get_network_metadata_with_storage( node, network_info ) @@ -79,6 +96,7 @@ def _generate_configdrive( instance.uuid, ) network_metadata = self._get_network_metadata(node, network_info) + ### Understack modified code END i_meta = instance_metadata.InstanceMetadata( instance, From 94176af4315da9343f618b5928bade6304630fb8 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 1 Oct 2025 09:15:44 +0100 Subject: [PATCH 18/26] sensor-ironic: don't run storage_on_server_create Running this again would be redundant since this is already triggered in Nova (see previous commit) --- .../openstack/sensors/sensor-ironic-oslo-event.yaml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/workflows/openstack/sensors/sensor-ironic-oslo-event.yaml b/workflows/openstack/sensors/sensor-ironic-oslo-event.yaml index 4976170be..b1c3ed203 100644 --- a/workflows/openstack/sensors/sensor-ironic-oslo-event.yaml +++ b/workflows/openstack/sensors/sensor-ironic-oslo-event.yaml @@ -101,17 +101,6 @@ spec: import uuid project_id_without_dashes = "{{workflow.parameters.project_id}}" print(str(uuid.UUID(project_id_without_dashes))) - - - name: ansible-on-server-create - when: "\"{{steps.oslo-events.outputs.parameters.storage}}\" == wanted" - templateRef: - name: ansible-workflow-template - template: ansible-run - arguments: - parameters: - - name: playbook - value: storage_on_server_create.yml - - name: extra_vars - value: project_id={{steps.convert-project-id.outputs.result}} device_id={{workflow.parameters.device_id}} - - name: ansible-storage-update when: "\"{{steps.oslo-events.outputs.parameters.storage}}\" == wanted" templateRef: From e6949a9ee3af47623a42b63750762db026d21b23 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 1 Oct 2025 17:39:41 +0100 Subject: [PATCH 19/26] nova: normalize project_id before calling ansible Openstack uses format without dashes, Nautobot expects one with dashes. --- python/nova-understack/ironic_understack/argo_client.py | 3 +++ python/nova-understack/ironic_understack/driver.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/python/nova-understack/ironic_understack/argo_client.py b/python/nova-understack/ironic_understack/argo_client.py index cd7d85584..e2817ee73 100644 --- a/python/nova-understack/ironic_understack/argo_client.py +++ b/python/nova-understack/ironic_understack/argo_client.py @@ -1,6 +1,7 @@ import time import requests +import urllib3 class ArgoClient: @@ -21,6 +22,8 @@ def __init__( self.token = token or self._kubernetes_token self.session = requests.Session() self.session.verify = ssl_verify + if not ssl_verify: + urllib3.disable_warnings() self.session.headers.update( { "Authorization": f"Bearer {self.token}", diff --git a/python/nova-understack/ironic_understack/driver.py b/python/nova-understack/ironic_understack/driver.py index a91a0f080..867224ae9 100644 --- a/python/nova-understack/ironic_understack/driver.py +++ b/python/nova-understack/ironic_understack/driver.py @@ -74,7 +74,7 @@ def _generate_configdrive( ### Understack modified code START if instance.metadata["storage"] == "wanted": logger.info("Instance %s requires storage network setup.", instance.uuid) - project_id = instance.project_id + project_id = str(UUID(instance.project_id)) device_id = node["uuid"] playbook_args = {"device_id": device_id, "project_id": project_id} logger.info( From 47dc284c4fc7931918b1eec4537caf287d24ff2c Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 2 Oct 2025 10:31:53 +0100 Subject: [PATCH 20/26] scripts: normalise uuids in cleanup_storage_stuff_in_nautobot --- scripts/cleanup_storage_stuff_in_nautobot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/cleanup_storage_stuff_in_nautobot.py b/scripts/cleanup_storage_stuff_in_nautobot.py index 43079ac6c..db08df1ba 100755 --- a/scripts/cleanup_storage_stuff_in_nautobot.py +++ b/scripts/cleanup_storage_stuff_in_nautobot.py @@ -17,8 +17,8 @@ def validate_uuid(uuid_string: str) -> str: """Validate that the provided string is a valid UUID.""" try: - uuid.UUID(uuid_string) - return uuid_string + result = uuid.UUID(uuid_string) + return str(result) except ValueError: raise argparse.ArgumentTypeError(f"'{uuid_string}' is not a valid UUID") From 0d301e2b5c63424ef582a2c4698e58f6466f40b4 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Thu, 2 Oct 2025 19:30:30 +0100 Subject: [PATCH 21/26] Revert Ironic hardware manager version This reverts commit c37e3fd2e618a200990d9d18f7ef48656950b55b. This reverts commit c57b2a8b204e3466eff064b837f38966492b43dd. --- ironic-images/Dockerfile | 6 - ironic-images/build.sh | 7 - .../install.d/60-understack-hwm | 9 - .../tmp/ironic_understack/pyproject.toml | 26 --- .../static/tmp/ironic_understack/setup.cfg | 19 -- .../understack_hwm/__init__.py | 0 .../understack_hwm/hardware_manager.py | 86 --------- .../understack_hwm/nautobot_client.py | 179 ------------------ .../understack_hwm/test_nautobot.py | 12 -- 9 files changed, 344 deletions(-) delete mode 100644 ironic-images/Dockerfile delete mode 100755 ironic-images/build.sh delete mode 100755 ironic-images/custom_elements/undercloud-ipa/install.d/60-understack-hwm delete mode 100644 ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/pyproject.toml delete mode 100644 ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/setup.cfg delete mode 100644 ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/__init__.py delete mode 100644 ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py delete mode 100644 ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/nautobot_client.py delete mode 100644 ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/test_nautobot.py diff --git a/ironic-images/Dockerfile b/ironic-images/Dockerfile deleted file mode 100644 index f3cad6e84..000000000 --- a/ironic-images/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -# NOTE: This container is used only for local development and is not -# intended to be used in CI/production -FROM ubuntu:24.04 - -RUN apt-get update && \ - apt-get -y --no-install-recommends install debootstrap qemu-utils squashfs-tools kpartx python3-pip python3-virtualenv sudo psutils procps git-core cpio diff --git a/ironic-images/build.sh b/ironic-images/build.sh deleted file mode 100755 index 65fc19167..000000000 --- a/ironic-images/build.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -# used only for local testing -cd /code -export DIB_RELEASE=bookworm -export ELEMENTS_PATH=/code/.venv/share/ironic-python-agent-builder/dib:/code/custom_elements -export DIB_CLOUD_INIT_DATASOURCES="ConfigDrive, OpenStack, None" -diskimage-builder ./ipa-debian-bookworm.yaml diff --git a/ironic-images/custom_elements/undercloud-ipa/install.d/60-understack-hwm b/ironic-images/custom_elements/undercloud-ipa/install.d/60-understack-hwm deleted file mode 100755 index 894cb2750..000000000 --- a/ironic-images/custom_elements/undercloud-ipa/install.d/60-understack-hwm +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -# dib-lint: disable=set setu setpipefail indent -if [ ${DIB_DEBUG_TRACE:-0} -gt 0 ]; then - set -x -fi -set -e - -/opt/ironic-python-agent/bin/python -m pip install /tmp/ironic_understack/ diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/pyproject.toml b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/pyproject.toml deleted file mode 100644 index e3e840386..000000000 --- a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/pyproject.toml +++ /dev/null @@ -1,26 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "understack-hardware-manager" -version = "0.0.1" -authors = [{ name = "Understack Developers" }] -description = "IPA Hardware Manager: custom steps" -license = {text = "Apache-2"} -requires-python = "~=3.11.0" -classifiers = [ - "Development Status :: 3 - Alpha", - "Environment :: Console", - "Environment :: OpenStack", - "License :: OSI Approved :: Apache Software License", - "Operating System :: POSIX :: Linux", - "Programming Language :: Python :: 3.11", - "Topic :: System :: Hardware", -] - -[project.entry-points."ironic_python_agent.hardware_managers"] -understack_hwm = "understack_hwm.hardware_manager:UnderstackHardwareManager" - -[tool.hatch.build.targets.wheel] -packages = ["understack_hwm"] diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/setup.cfg b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/setup.cfg deleted file mode 100644 index 985f332f7..000000000 --- a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/setup.cfg +++ /dev/null @@ -1,19 +0,0 @@ -[metadata] -name = understack-hardware-manager -author = Marek Skrobacki -author-email = marek.skrobacki@rackspace.co.uk -summary = IPA Hardware Manager: custom steps -license = Apache-2 -classifier = - Intended Audience :: Developers - Operating System :: OS Independent - License :: OSI Approved :: Apache Software License - Programming Language :: Python :: 3 - -[files] -modules = - understack_hwm - -[entry_points] -ironic_python_agent.hardware_managers = - understack_hwm = understack_hwm.hardware_manager:UnderstackHardwareManager diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/__init__.py b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py deleted file mode 100644 index 248de683d..000000000 --- a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/hardware_manager.py +++ /dev/null @@ -1,86 +0,0 @@ -import base64 - -from ironic_python_agent import hardware -from ironic_python_agent.inject_files import inject_files -from oslo_log import log - -LOG = log.getLogger() - - -class UnderstackHardwareManager(hardware.HardwareManager): - """Hardware Manager that injects Undercloud specific metadata.""" - - HARDWARE_MANAGER_NAME = "UnderstackHardwareManager" - HARDWARE_MANAGER_VERSION = "1" - - def evaluate_hardware_support(self): - """Declare level of hardware support provided. - - Since this example is explicitly about enforcing business logic during - cleaning, we want to return a static value. - - :returns: HardwareSupport level for this manager. - """ - return hardware.HardwareSupport.SERVICE_PROVIDER - - def get_deploy_steps(self, node, ports): - return [ - { - "step": "write_storage_ips", - "priority": 50, - "interface": "deploy", - "reboot_requested": False, - } - ] - - def get_service_steps(self, node, ports): - return [ - { - "step": "write_storage_ips", - "priority": 50, - "interface": "deploy", - "reboot_requested": False, - } - ] - - # "Files to inject, a list of file structures with keys: 'path' " - # "(path to the file), 'partition' (partition specifier), " - # "'content' (base64 encoded string), 'mode' (new file mode) and " - # "'dirmode' (mode for the leaf directory, if created). " - # "Merged with the values from node.properties[inject_files]." - - def write_storage_ips(self, node, ports): - # If not specified, the agent will determine the partition based on the - # first part of the path. - # partition = None - file_contents = """ -datasource: - NoCloud: - network-config: | - version: 2 - ethernets: - interface0: - match: - macaddress: "52:54:00:12:34:00" - set-name: interface0 - addresses: - - 100.126.0.6/255.255.255.252 - gateway4: 100.126.0.5 - interface1: - match: - macaddress: "14:23:F3:F5:3A:D1" - set-name: interface0 - addresses: - - 100.126.128.6/255.255.255.252 - gateway4: 100.126.128.5 -""" - file_encoded = base64.b64encode(file_contents.encode("utf-8")).decode("utf-8") - files = [ - { - "path": "/etc/cloud/cloud.cfg.d/95-undercloud-storage.cfg", - "partition": "/dev/sda3", - "content": file_encoded, - "mode": 644, - } - ] - inject_files(node, ports, files) diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/nautobot_client.py b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/nautobot_client.py deleted file mode 100644 index a07b3e9a7..000000000 --- a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/nautobot_client.py +++ /dev/null @@ -1,179 +0,0 @@ -import ipaddress -from typing import Dict, Optional - -import requests -import yaml - - -class NautobotClient: - """Client for interacting with Nautobot's GraphQL API.""" - - def __init__(self, base_url: str, api_key: str): - """ - Initialize the Nautobot client. - - Args: - base_url: Base URL of the Nautobot instance (e.g., 'https://nautobot.example.com') - api_key: API key for authentication - """ - self.base_url = base_url.rstrip("/") - self.api_key = api_key - self.graphql_url = f"{self.base_url}/api/graphql/" - - def _make_graphql_request( - self, query: str, variables: Optional[Dict] = None - ) -> Dict: - """ - Make a GraphQL request to Nautobot. - - Args: - query: GraphQL query string - variables: Optional variables for the query - - Returns: - Response data from the GraphQL endpoint - - Raises: - requests.RequestException: If the request fails - ValueError: If the response contains GraphQL errors - """ - headers = { - "Authorization": f"Token {self.api_key}", - "Content-Type": "application/json", - } - - payload = {"query": query, "variables": variables or {}} - - response = requests.post( - self.graphql_url, headers=headers, json=payload, timeout=30 - ) - response.raise_for_status() - - data = response.json() - - if "errors" in data: - raise ValueError(f"GraphQL errors: {data['errors']}") - - return data - - def get_device_interfaces(self, device_id: str) -> Dict: - """ - Retrieve device interfaces and their IP assignments from Nautobot. - - Args: - device_id: UUID of the device to query - - Returns: - Dictionary containing the GraphQL response data - """ - query = """ - query ($device_id: String) { - devices(id: [$device_id]) { - id - interfaces(status: "Active") { - mac_address - ip_address_assignments { - ip_address { - address - ip_version - } - } - } - } - } - """ - - variables = {"device_id": device_id} - response = self._make_graphql_request(query, variables) - - return response - - def _calculate_gateway(self, ip_with_prefix: str) -> str: - """ - Calculate the first address of the subnet as gateway. - - Args: - ip_with_prefix: IP address with prefix (e.g., '192.168.1.10/24') - - Returns: - First address of the subnet (e.g., '192.168.1.1') - """ - network = ipaddress.ip_network(ip_with_prefix, strict=False) - # Get the first host address (network address + 1) - first_host = network.network_address + 1 - return str(first_host) - - def generate_network_config( - self, response: Dict, ignore_non_storage: bool = False - ) -> str: - """ - Generate netplan YAML configuration from Nautobot response. - - Args: - response: Response data from get_device_interfaces method - ignore_non_storage: If True, only include interfaces with IPs in 100.126.0.0/16 subnet - - Returns: - YAML string containing netplan configuration - """ - config = {"version": 2, "ethernets": {}} - - interface_count = 0 - - # Extract devices from response - devices = response.get("data", {}).get("devices", []) - - for device in devices: - interfaces = device.get("interfaces", []) - - for interface in interfaces: - mac_address = interface.get("mac_address") - ip_assignments = interface.get("ip_address_assignments", []) - - # Only process interfaces with IP assignments - if not ip_assignments or not mac_address: - continue - - # Filter for IPv4 assignments only - ipv4_assignments = [ - assignment - for assignment in ip_assignments - if assignment.get("ip_address", {}).get("ip_version") == 4 - ] - - if not ipv4_assignments: - continue - - # Take only the first IPv4 assignment - first_assignment = ipv4_assignments[0] - ip_info = first_assignment.get("ip_address", {}) - ip_address = ip_info.get("address") - - if not ip_address: - continue - - # Only process interfaces with IP addresses in 100.126.0.0/16 subnet - try: - ip_network = ipaddress.ip_network(ip_address, strict=False) - target_subnet = ipaddress.ip_network("100.126.0.0/16") - if not ip_network.subnet_of(target_subnet): - continue - except (ipaddress.AddressValueError, ValueError): - # Skip if IP address is invalid - continue - - interface_name = f"interface{interface_count}" - - # Calculate gateway (first address of the subnet) - gateway = self._calculate_gateway(ip_address) - - config["ethernets"][interface_name] = { - "match": {"macaddress": mac_address}, - "set-name": interface_name, - "addresses": [ip_address], - "gateway4": gateway, - } - - interface_count += 1 - - return yaml.dump(config, default_flow_style=False, sort_keys=False) diff --git a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/test_nautobot.py b/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/test_nautobot.py deleted file mode 100644 index 0e822fbce..000000000 --- a/ironic-images/custom_elements/undercloud-ipa/static/tmp/ironic_understack/understack_hwm/test_nautobot.py +++ /dev/null @@ -1,12 +0,0 @@ -from nautobot_client import NautobotClient - -import os - -n = NautobotClient( - api_key=os.getenv("NAUTOBOT_TOKEN"), - base_url="https://nautobot.dev.undercloud.rackspace.net", -) -devices = n.get_device_interfaces("2f75cab3-63d7-45ad-9045-b80f44e86132") - -print(devices) -print(n.generate_network_config(devices)) From 2f75728fd0b4ff95ef98a61cf2bad4e8802b3ebb Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 6 Oct 2025 10:29:45 +0100 Subject: [PATCH 22/26] nova: make ansible playbook name configurable --- python/nova-understack/ironic_understack/conf.py | 5 +++++ python/nova-understack/ironic_understack/driver.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/python/nova-understack/ironic_understack/conf.py b/python/nova-understack/ironic_understack/conf.py index 1019fa373..cb9cd3068 100644 --- a/python/nova-understack/ironic_understack/conf.py +++ b/python/nova-understack/ironic_understack/conf.py @@ -17,6 +17,11 @@ def setup_conf(): help="Argo Workflows API url", default="https://argo-server.argo.svc:2746", ), + cfg.StrOpt( + "ansible_playbook_filename", + help="Name of the Ansible playbook to execute when server is created.", + default="storage_on_server_create.yml", + ), ] cfg.CONF.register_group(grp) cfg.CONF.register_opts(opts, group=grp) diff --git a/python/nova-understack/ironic_understack/driver.py b/python/nova-understack/ironic_understack/driver.py index 867224ae9..b44b003c0 100644 --- a/python/nova-understack/ironic_understack/driver.py +++ b/python/nova-understack/ironic_understack/driver.py @@ -83,7 +83,7 @@ def _generate_configdrive( playbook_args, ) result = self._argo_connection.run_playbook( - "storage_on_server_create.yml", **playbook_args + CONF.nova_understack.ansible_playbook_filename, **playbook_args ) logger.debug("Ansible result: %s", result) logger.info("Playbook run completed, collecting rest of metadata.") From a9c4e7b2955f633da8af830c27f3ef51d0b490e9 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 6 Oct 2025 10:38:20 +0100 Subject: [PATCH 23/26] nova: add conf option to disable IP injection feature --- python/nova-understack/ironic_understack/conf.py | 5 +++++ python/nova-understack/ironic_understack/driver.py | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/python/nova-understack/ironic_understack/conf.py b/python/nova-understack/ironic_understack/conf.py index cb9cd3068..62f6e82ee 100644 --- a/python/nova-understack/ironic_understack/conf.py +++ b/python/nova-understack/ironic_understack/conf.py @@ -22,6 +22,11 @@ def setup_conf(): help="Name of the Ansible playbook to execute when server is created.", default="storage_on_server_create.yml", ), + cfg.BoolOpt( + "ip_injection_enabled", + help="Controls if Nova should inject storage IPs to config drive.", + default=True, + ), ] cfg.CONF.register_group(grp) cfg.CONF.register_opts(opts, group=grp) diff --git a/python/nova-understack/ironic_understack/driver.py b/python/nova-understack/ironic_understack/driver.py index b44b003c0..7fe38c3d0 100644 --- a/python/nova-understack/ironic_understack/driver.py +++ b/python/nova-understack/ironic_understack/driver.py @@ -72,7 +72,10 @@ def _generate_configdrive( extra_md = {} ### Understack modified code START - if instance.metadata["storage"] == "wanted": + if ( + instance.metadata["storage"] == "wanted" + and CONF.nova_understack.ip_injection_enabled + ): logger.info("Instance %s requires storage network setup.", instance.uuid) project_id = str(UUID(instance.project_id)) device_id = node["uuid"] From 843c57e333fddf804ab0d279092eb3e40f861377 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 6 Oct 2025 11:19:06 +0100 Subject: [PATCH 24/26] nova: refactor the IronicUnderstackDriver to make testing easier This way our modified code is almost all in separate method and it's clear what has been changed. --- .../ironic_understack/__init__.py | 4 +- .../ironic_understack/driver.py | 50 ++++++++++++------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/python/nova-understack/ironic_understack/__init__.py b/python/nova-understack/ironic_understack/__init__.py index e1a507595..dd212c6c1 100644 --- a/python/nova-understack/ironic_understack/__init__.py +++ b/python/nova-understack/ironic_understack/__init__.py @@ -1,3 +1,3 @@ -from nova.virt.ironic_understack import driver +from .driver import IronicUnderstackDriver -IronicUnderstackDriver = driver.IronicUnderstackDriver +__all__ = ["IronicUnderstackDriver"] diff --git a/python/nova-understack/ironic_understack/driver.py b/python/nova-understack/ironic_understack/driver.py index 7fe38c3d0..e2c66631b 100644 --- a/python/nova-understack/ironic_understack/driver.py +++ b/python/nova-understack/ironic_understack/driver.py @@ -51,29 +51,20 @@ def _get_network_metadata_with_storage(self, node, network_info): base_metadata["networks"].append(network) return base_metadata - # This is almost exact copy of the IronicDriver's _generate_configdrive, - # but we make a determination if injecting storage IPs information is needed - # based on the instance 'storage' property. - def _generate_configdrive( - self, context, instance, node, network_info, extra_md=None, files=None - ): - """Generate a config drive with optional storage info. + def _understack_get_network_metadata(self, instance, node, network_info): + """Get network metadata with optional storage network configuration. + + This method implements the Understack-specific logic for determining + whether to include storage network configuration based on instance + metadata and configuration settings. :param instance: The instance object. :param node: The node object. :param network_info: Instance network information. - :param extra_md: Optional, extra metadata to be added to the - configdrive. - :param files: Optional, a list of paths to files to be added to - the configdrive. - + :returns: Network metadata dictionary """ - if not extra_md: - extra_md = {} - - ### Understack modified code START if ( - instance.metadata["storage"] == "wanted" + instance.metadata.get("storage") == "wanted" and CONF.nova_understack.ip_injection_enabled ): logger.info("Instance %s requires storage network setup.", instance.uuid) @@ -99,6 +90,31 @@ def _generate_configdrive( instance.uuid, ) network_metadata = self._get_network_metadata(node, network_info) + + return network_metadata + + # This is almost exact copy of the IronicDriver's _generate_configdrive, + # but we make a determination if injecting storage IPs information is needed + # based on the instance 'storage' property. + def _generate_configdrive( + self, context, instance, node, network_info, extra_md=None, files=None + ): + """Generate a config drive with optional storage info. + + :param instance: The instance object. + :param node: The node object. + :param network_info: Instance network information. + :param extra_md: Optional, extra metadata to be added to the + configdrive. + :param files: Optional, a list of paths to files to be added to + the configdrive. + + """ + if not extra_md: + extra_md = {} + + ### Understack modified code START + network_metadata = self._understack_get_network_metadata(instance, node, network_info) ### Understack modified code END i_meta = instance_metadata.InstanceMetadata( From bba90379220cf95f8c6746c7da00d849dcb56380 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 7 Oct 2025 17:08:12 +0100 Subject: [PATCH 25/26] nova: refactor the IronicUnderstackDriver to make testing easier This way our modified code is almost all in separate method and it's clear what has been changed. --- python/nova-understack/ironic_understack/driver.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/nova-understack/ironic_understack/driver.py b/python/nova-understack/ironic_understack/driver.py index e2c66631b..e69bfc9bb 100644 --- a/python/nova-understack/ironic_understack/driver.py +++ b/python/nova-understack/ironic_understack/driver.py @@ -114,7 +114,9 @@ def _generate_configdrive( extra_md = {} ### Understack modified code START - network_metadata = self._understack_get_network_metadata(instance, node, network_info) + network_metadata = self._understack_get_network_metadata( + instance, node, network_info + ) ### Understack modified code END i_meta = instance_metadata.InstanceMetadata( From ab35ff16504accfb9210fdf2788fe476d5cd2981 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Mon, 6 Oct 2025 11:23:00 +0100 Subject: [PATCH 26/26] nova: add tests for IronicUnderstackDriver and related classes --- python/nova-understack/tests/__init__.py | 1 + .../nova-understack/tests/test_argo_client.py | 277 ++++++++ python/nova-understack/tests/test_driver.py | 368 ++++++++++ .../tests/test_nautobot_client.py | 641 ++++++++++++++++++ 4 files changed, 1287 insertions(+) create mode 100644 python/nova-understack/tests/__init__.py create mode 100644 python/nova-understack/tests/test_argo_client.py create mode 100644 python/nova-understack/tests/test_driver.py create mode 100644 python/nova-understack/tests/test_nautobot_client.py diff --git a/python/nova-understack/tests/__init__.py b/python/nova-understack/tests/__init__.py new file mode 100644 index 000000000..07519de70 --- /dev/null +++ b/python/nova-understack/tests/__init__.py @@ -0,0 +1 @@ +# Test package for nova-understack diff --git a/python/nova-understack/tests/test_argo_client.py b/python/nova-understack/tests/test_argo_client.py new file mode 100644 index 000000000..520b92a6a --- /dev/null +++ b/python/nova-understack/tests/test_argo_client.py @@ -0,0 +1,277 @@ +"""Unit tests for ArgoClient.""" + +from unittest.mock import Mock +from unittest.mock import mock_open +from unittest.mock import patch + +import pytest +import requests + +from ironic_understack.argo_client import ArgoClient + + +class TestArgoClient: + """Test cases for ArgoClient class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.base_url = "https://argo.example.com" + self.token = "test-token" + self.namespace = "test-namespace" + + @patch("ironic_understack.argo_client.urllib3.disable_warnings") + def test_init_with_token(self, mock_disable_warnings): + """Test ArgoClient initialization with provided token.""" + client = ArgoClient( + url=self.base_url, + token=self.token, + namespace=self.namespace, + ssl_verify=False, + ) + + assert client.url == self.base_url + assert client.token == self.token + assert client.namespace == self.namespace + assert not client.session.verify + assert client.session.headers["Authorization"] == f"Bearer {self.token}" + assert client.session.headers["Content-Type"] == "application/json" + mock_disable_warnings.assert_called_once() + + @patch("builtins.open", mock_open(read_data="k8s-token")) + @patch("ironic_understack.argo_client.urllib3.disable_warnings") + def test_init_without_token(self, mock_disable_warnings): + """Test ArgoClient initialization without token (uses k8s token).""" + client = ArgoClient( + url=self.base_url, token=None, namespace=self.namespace, ssl_verify=False + ) + + assert client.token == "k8s-token" + assert client.session.headers["Authorization"] == "Bearer k8s-token" + + def test_init_with_ssl_verify(self): + """Test ArgoClient initialization with SSL verification enabled.""" + client = ArgoClient(url=self.base_url, token=self.token, ssl_verify=True) + + assert client.session.verify is True + + def test_url_stripping(self): + """Test that trailing slashes are stripped from URL.""" + client = ArgoClient(url="https://argo.example.com/", token=self.token) + + assert client.url == "https://argo.example.com" + + def test_generate_workflow_name(self): + """Test workflow name generation from playbook names.""" + client = ArgoClient(self.base_url, self.token) + + # Test with .yml extension + result = client._generate_workflow_name("storage_on_server_create.yml") + assert result == "ansible-storage-on-server-create-" + + # Test with .yaml extension + result = client._generate_workflow_name("network_setup.yaml") + assert result == "ansible-network-setup-" + + # Test without extension + result = client._generate_workflow_name("deploy_app") + assert result == "ansible-deploy-app-" + + # Test underscore replacement + result = client._generate_workflow_name("test_playbook_name") + assert result == "ansible-test-playbook-name-" + + @patch("builtins.open", mock_open(read_data="k8s-service-token")) + def test_kubernetes_token_property(self): + """Test reading Kubernetes service account token.""" + client = ArgoClient(self.base_url, None) + + token = client._kubernetes_token + assert token == "k8s-service-token" + + @patch("requests.Session.post") + @patch("requests.Session.get") + def test_run_playbook_success(self, mock_get, mock_post): + """Test successful playbook execution.""" + client = ArgoClient(self.base_url, self.token) + + # Mock workflow creation response + workflow_response = {"metadata": {"name": "ansible-test-playbook-abc123"}} + mock_post.return_value.json.return_value = workflow_response + mock_post.return_value.raise_for_status = Mock() + + # Mock workflow completion response + completed_workflow = { + "status": {"phase": "Succeeded"}, + "metadata": {"name": "ansible-test-playbook-abc123"}, + } + mock_get.return_value.json.return_value = completed_workflow + mock_get.return_value.raise_for_status = Mock() + + result = client.run_playbook( + "test_playbook.yml", device_id="device-123", project_id="project-456" + ) + + # Verify workflow creation request + expected_workflow_request = { + "workflow": { + "metadata": {"generateName": "ansible-test-playbook-"}, + "spec": { + "workflowTemplateRef": {"name": "ansible-workflow-template"}, + "entrypoint": "ansible-run", + "arguments": { + "parameters": [ + {"name": "playbook", "value": "test_playbook.yml"}, + { + "name": "extra_vars", + "value": "device_id=device-123 project_id=project-456", + }, + ] + }, + }, + } + } + + mock_post.assert_called_once_with( + f"{self.base_url}/api/v1/workflows/{client.namespace}", + json=expected_workflow_request, + ) + + # Verify workflow monitoring + mock_get.assert_called_with( + f"{self.base_url}/api/v1/workflows/{client.namespace}/ansible-test-playbook-abc123" + ) + + assert result == completed_workflow + + @patch("requests.Session.post") + def test_run_playbook_creation_failure(self, mock_post): + """Test playbook execution when workflow creation fails.""" + client = ArgoClient(self.base_url, self.token) + + mock_post.return_value.raise_for_status.side_effect = requests.RequestException( + "API Error" + ) + + with pytest.raises(requests.RequestException): + client.run_playbook("test_playbook.yml") + + @patch("requests.Session.post") + @patch("requests.Session.get") + def test_run_playbook_with_empty_extra_vars(self, mock_get, mock_post): + """Test playbook execution with no extra variables.""" + client = ArgoClient(self.base_url, self.token) + + workflow_response = {"metadata": {"name": "ansible-test-abc123"}} + mock_post.return_value.json.return_value = workflow_response + mock_post.return_value.raise_for_status = Mock() + + completed_workflow = {"status": {"phase": "Succeeded"}} + mock_get.return_value.json.return_value = completed_workflow + mock_get.return_value.raise_for_status = Mock() + + client.run_playbook("test_playbook.yml") + + # Verify empty extra_vars string + call_args = mock_post.call_args[1]["json"] + extra_vars_param = call_args["workflow"]["spec"]["arguments"]["parameters"][1] + assert extra_vars_param["value"] == "" + + @patch("time.sleep") + @patch("requests.Session.get") + def test_wait_for_completion_success(self, mock_get, mock_sleep): + """Test successful workflow completion monitoring.""" + client = ArgoClient(self.base_url, self.token) + + # Mock workflow status progression + responses = [ + {"status": {"phase": "Running"}}, + {"status": {"phase": "Running"}}, + {"status": {"phase": "Succeeded"}}, + ] + mock_get.return_value.json.side_effect = responses + mock_get.return_value.raise_for_status = Mock() + + result = client._wait_for_completion("test-workflow") + + assert result == responses[-1] + assert mock_get.call_count == 3 + assert mock_sleep.call_count == 2 + + @patch("time.sleep") + @patch("requests.Session.get") + def test_wait_for_completion_failure(self, mock_get, mock_sleep): + """Test workflow failure during monitoring.""" + client = ArgoClient(self.base_url, self.token) + + failed_workflow = { + "status": {"phase": "Failed", "message": "Workflow execution failed"} + } + mock_get.return_value.json.return_value = failed_workflow + mock_get.return_value.raise_for_status = Mock() + + with pytest.raises(RuntimeError, match="Workflow test-workflow failed"): + client._wait_for_completion("test-workflow") + + @patch("time.sleep") + @patch("requests.Session.get") + def test_wait_for_completion_error(self, mock_get, mock_sleep): + """Test workflow error during monitoring.""" + client = ArgoClient(self.base_url, self.token) + + error_workflow = { + "status": {"phase": "Error", "message": "Workflow encountered an error"} + } + mock_get.return_value.json.return_value = error_workflow + mock_get.return_value.raise_for_status = Mock() + + with pytest.raises( + RuntimeError, match="Workflow test-workflow encountered an error" + ): + client._wait_for_completion("test-workflow") + + @patch("time.time") + @patch("time.sleep") + @patch("requests.Session.get") + def test_wait_for_completion_timeout(self, mock_get, mock_sleep, mock_time): + """Test workflow timeout during monitoring.""" + client = ArgoClient(self.base_url, self.token) + + # Mock time progression to simulate timeout + mock_time.side_effect = [0, 300, 700] # Start, middle, timeout + + running_workflow = {"status": {"phase": "Running"}} + mock_get.return_value.json.return_value = running_workflow + mock_get.return_value.raise_for_status = Mock() + + with pytest.raises(RuntimeError, match="Workflow test-workflow timed out"): + client._wait_for_completion("test-workflow", timeout=600) + + @patch("requests.Session.get") + def test_wait_for_completion_api_error(self, mock_get): + """Test API error during workflow monitoring.""" + client = ArgoClient(self.base_url, self.token) + + mock_get.return_value.raise_for_status.side_effect = requests.RequestException( + "API Error" + ) + + with pytest.raises(requests.RequestException): + client._wait_for_completion("test-workflow") + + @patch("time.sleep") + @patch("requests.Session.get") + def test_wait_for_completion_missing_status(self, mock_get, mock_sleep): + """Test workflow monitoring with missing status information.""" + client = ArgoClient(self.base_url, self.token) + + # Mock workflow without status + workflow_no_status = {"metadata": {"name": "test-workflow"}} + mock_get.return_value.json.return_value = workflow_no_status + mock_get.return_value.raise_for_status = Mock() + + # Should continue polling when status is missing + with patch("time.time", side_effect=[0, 700]): # Simulate timeout + with pytest.raises(RuntimeError, match="timed out"): + client._wait_for_completion( + "test-workflow", timeout=600, poll_interval=1 + ) diff --git a/python/nova-understack/tests/test_driver.py b/python/nova-understack/tests/test_driver.py new file mode 100644 index 000000000..e2ac85995 --- /dev/null +++ b/python/nova-understack/tests/test_driver.py @@ -0,0 +1,368 @@ +"""Unit tests for IronicUnderstackDriver.""" + +from unittest.mock import Mock +from unittest.mock import patch +from uuid import UUID +from uuid import uuid4 + +import pytest + + +class TestIronicUnderstackDriver: + """Test cases for IronicUnderstackDriver class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.virtapi = Mock() + + # Mock the configuration + self.mock_conf = Mock() + self.mock_conf.nova_understack.nautobot_base_url = ( + "https://nautobot.example.com" + ) + self.mock_conf.nova_understack.nautobot_api_key = "test-api-key" + self.mock_conf.nova_understack.argo_api_url = "https://argo.example.com" + self.mock_conf.nova_understack.ip_injection_enabled = True + self.mock_conf.nova_understack.ansible_playbook_filename = ( + "storage_on_server_create.yml" + ) + + @patch("ironic_understack.driver.CONF") + @patch("ironic_understack.driver.ArgoClient") + @patch("ironic_understack.driver.NautobotClient") + @patch("nova.virt.ironic.driver.IronicDriver.__init__") + def test_init( + self, mock_super_init, mock_nautobot_client, mock_argo_client, mock_conf + ): + """Test driver initialization.""" + mock_conf.nova_understack = self.mock_conf.nova_understack + mock_super_init.return_value = None + + # Import here to avoid Nova import issues during module loading + from ironic_understack.driver import IronicUnderstackDriver + + driver = IronicUnderstackDriver(self.virtapi, read_only=False) + + # Verify clients are initialized with correct parameters + mock_nautobot_client.assert_called_once_with( + "https://nautobot.example.com", "test-api-key" + ) + mock_argo_client.assert_called_once_with("https://argo.example.com", None) + + assert driver._nautobot_connection == mock_nautobot_client.return_value + assert driver._argo_connection == mock_argo_client.return_value + mock_super_init.assert_called_once_with(self.virtapi, False) + + @patch("ironic_understack.driver.CONF") + @patch("ironic_understack.driver.ArgoClient") + @patch("ironic_understack.driver.NautobotClient") + @patch("nova.virt.ironic.driver.IronicDriver.__init__") + def test_understack_get_network_metadata_with_storage_wanted( + self, mock_super_init, mock_nautobot_client, mock_argo_client, mock_conf + ): + """Test _understack_get_network_metadata with storage=wanted.""" + mock_conf.nova_understack = self.mock_conf.nova_understack + mock_super_init.return_value = None + + from ironic_understack.driver import IronicUnderstackDriver + + driver = IronicUnderstackDriver(self.virtapi) + + # Mock instance with storage metadata + instance = Mock() + instance.metadata = {"storage": "wanted"} + instance.uuid = str(uuid4()) + instance.project_id = str(uuid4()) + + # Mock node + node = {"uuid": str(uuid4())} + + # Mock network_info + network_info = Mock() + + # Mock argo client response + argo_result = {"status": {"phase": "Succeeded"}} + driver._argo_connection.run_playbook.return_value = argo_result + + # Mock network metadata + expected_metadata = {"test": "storage_metadata"} + + # Mock the _get_network_metadata_with_storage method + with patch.object( + driver, "_get_network_metadata_with_storage", return_value=expected_metadata + ): + result = driver._understack_get_network_metadata( + instance, node, network_info + ) + + # Verify argo client was called with correct parameters + expected_playbook_args = { + "device_id": node["uuid"], + "project_id": str(UUID(instance.project_id)), + } + driver._argo_connection.run_playbook.assert_called_once_with( + "storage_on_server_create.yml", **expected_playbook_args + ) + + # Verify network metadata was retrieved with storage + driver._get_network_metadata_with_storage.assert_called_once_with( + node, network_info + ) + + assert result == expected_metadata + + @patch("ironic_understack.driver.CONF") + @patch("ironic_understack.driver.ArgoClient") + @patch("ironic_understack.driver.NautobotClient") + @patch("nova.virt.ironic.driver.IronicDriver.__init__") + @patch("nova.virt.ironic.driver.IronicDriver._get_network_metadata") + def test_understack_get_network_metadata_without_storage( + self, + mock_parent_get_metadata, + mock_super_init, + mock_nautobot_client, + mock_argo_client, + mock_conf, + ): + """Test _understack_get_network_metadata without storage requirement.""" + mock_conf.nova_understack = self.mock_conf.nova_understack + mock_super_init.return_value = None + + from ironic_understack.driver import IronicUnderstackDriver + + driver = IronicUnderstackDriver(self.virtapi) + + # Mock instance without storage metadata + instance = Mock() + instance.metadata = {"storage": "not-wanted"} + instance.uuid = str(uuid4()) + + # Mock node + node = {"uuid": str(uuid4())} + + # Mock network_info + network_info = Mock() + + # Mock network metadata + expected_metadata = {"test": "standard_metadata"} + mock_parent_get_metadata.return_value = expected_metadata + + result = driver._understack_get_network_metadata(instance, node, network_info) + + # Verify argo client was NOT called + driver._argo_connection.run_playbook.assert_not_called() + + # Verify standard network metadata was retrieved + mock_parent_get_metadata.assert_called_once_with(node, network_info) + + assert result == expected_metadata + + @patch("ironic_understack.driver.CONF") + @patch("ironic_understack.driver.ArgoClient") + @patch("ironic_understack.driver.NautobotClient") + @patch("nova.virt.ironic.driver.IronicDriver.__init__") + @patch("nova.virt.ironic.driver.IronicDriver._get_network_metadata") + def test_understack_get_network_metadata_ip_injection_disabled( + self, + mock_parent_get_metadata, + mock_super_init, + mock_nautobot_client, + mock_argo_client, + mock_conf, + ): + """Test _understack_get_network_metadata with IP injection disabled.""" + # Disable IP injection + self.mock_conf.nova_understack.ip_injection_enabled = False + mock_conf.nova_understack = self.mock_conf.nova_understack + mock_super_init.return_value = None + + from ironic_understack.driver import IronicUnderstackDriver + + driver = IronicUnderstackDriver(self.virtapi) + + # Mock instance with storage metadata + instance = Mock() + instance.metadata = {"storage": "wanted"} + instance.uuid = str(uuid4()) + + # Mock node + node = {"uuid": str(uuid4())} + + # Mock network_info + network_info = Mock() + + # Mock network metadata + expected_metadata = {"test": "standard_metadata"} + mock_parent_get_metadata.return_value = expected_metadata + + result = driver._understack_get_network_metadata(instance, node, network_info) + + # Verify argo client was NOT called even though storage is wanted + driver._argo_connection.run_playbook.assert_not_called() + + # Verify standard network metadata was retrieved + mock_parent_get_metadata.assert_called_once_with(node, network_info) + + assert result == expected_metadata + + @patch("ironic_understack.driver.CONF") + @patch("ironic_understack.driver.ArgoClient") + @patch("ironic_understack.driver.NautobotClient") + @patch("nova.virt.ironic.driver.IronicDriver.__init__") + @patch("nova.virt.ironic.driver.IronicDriver._get_network_metadata") + def test_understack_get_network_metadata_missing_storage_key( + self, + mock_parent_get_metadata, + mock_super_init, + mock_nautobot_client, + mock_argo_client, + mock_conf, + ): + """Test when storage key is missing from metadata.""" + mock_conf.nova_understack = self.mock_conf.nova_understack + mock_super_init.return_value = None + + from ironic_understack.driver import IronicUnderstackDriver + + driver = IronicUnderstackDriver(self.virtapi) + + # Mock instance without storage key in metadata + instance = Mock() + instance.metadata = {"other_key": "value"} + instance.uuid = str(uuid4()) + + # Mock node + node = {"uuid": str(uuid4())} + + # Mock network_info + network_info = Mock() + + # Mock network metadata + expected_metadata = {"test": "standard_metadata"} + mock_parent_get_metadata.return_value = expected_metadata + + result = driver._understack_get_network_metadata(instance, node, network_info) + + # Verify argo client was NOT called + driver._argo_connection.run_playbook.assert_not_called() + + # Verify standard network metadata was retrieved + mock_parent_get_metadata.assert_called_once_with(node, network_info) + + assert result == expected_metadata + + @patch("ironic_understack.driver.CONF") + @patch("ironic_understack.driver.ArgoClient") + @patch("ironic_understack.driver.NautobotClient") + @patch("nova.virt.ironic.driver.IronicDriver.__init__") + def test_understack_get_network_metadata_argo_playbook_failure( + self, mock_super_init, mock_nautobot_client, mock_argo_client, mock_conf + ): + """Test _understack_get_network_metadata when Argo playbook fails.""" + mock_conf.nova_understack = self.mock_conf.nova_understack + mock_super_init.return_value = None + + from ironic_understack.driver import IronicUnderstackDriver + + driver = IronicUnderstackDriver(self.virtapi) + + # Mock instance with storage metadata + instance = Mock() + instance.metadata = {"storage": "wanted"} + instance.uuid = str(uuid4()) + instance.project_id = str(uuid4()) + + # Mock node + node = {"uuid": str(uuid4())} + + # Mock network_info + network_info = Mock() + + # Mock argo client to raise exception + driver._argo_connection.run_playbook.side_effect = RuntimeError( + "Playbook failed" + ) + + # Should propagate the exception + with pytest.raises(RuntimeError, match="Playbook failed"): + driver._understack_get_network_metadata(instance, node, network_info) + + @patch("ironic_understack.driver.CONF") + @patch("ironic_understack.driver.ArgoClient") + @patch("ironic_understack.driver.NautobotClient") + @patch("nova.virt.ironic.driver.IronicDriver.__init__") + def test_get_network_metadata_with_storage( + self, mock_super_init, mock_nautobot_client, mock_argo_client, mock_conf + ): + """Test _get_network_metadata_with_storage method.""" + mock_conf.nova_understack = self.mock_conf.nova_understack + mock_super_init.return_value = None + + from ironic_understack.driver import IronicUnderstackDriver + + driver = IronicUnderstackDriver(self.virtapi) + + # Mock the parent class method + base_metadata = { + "links": [{"id": "existing-link"}], + "networks": [{"id": "existing-network"}], + } + + with patch.object( + driver.__class__.__bases__[0], + "_get_network_metadata", + return_value=base_metadata, + ): + # Mock nautobot client response + extra_interfaces = { + "links": [{"id": "storage-link"}], + "networks": [{"id": "storage-network"}], + } + driver._nautobot_connection.storage_network_config_for_node.return_value = ( + extra_interfaces + ) + + node = {"uuid": str(uuid4())} + network_info = Mock() + + result = driver._get_network_metadata_with_storage(node, network_info) + + # Verify nautobot client was called with correct UUID + driver._nautobot_connection.storage_network_config_for_node.assert_called_once_with( + UUID(node["uuid"]) + ) + + # Verify metadata was merged correctly + expected_result = { + "links": [{"id": "existing-link"}, {"id": "storage-link"}], + "networks": [{"id": "existing-network"}, {"id": "storage-network"}], + } + assert result == expected_result + + @patch("ironic_understack.driver.CONF") + @patch("ironic_understack.driver.ArgoClient") + @patch("ironic_understack.driver.NautobotClient") + @patch("nova.virt.ironic.driver.IronicDriver.__init__") + def test_get_network_metadata_with_storage_no_base_metadata( + self, mock_super_init, mock_nautobot_client, mock_argo_client, mock_conf + ): + """Test _get_network_metadata_with_storage when base metadata is None.""" + mock_conf.nova_understack = self.mock_conf.nova_understack + mock_super_init.return_value = None + + from ironic_understack.driver import IronicUnderstackDriver + + driver = IronicUnderstackDriver(self.virtapi) + + # Mock the parent class method to return None + with patch.object( + driver.__class__.__bases__[0], "_get_network_metadata", return_value=None + ): + node = {"uuid": str(uuid4())} + network_info = Mock() + + result = driver._get_network_metadata_with_storage(node, network_info) + + # Should return None without calling nautobot + assert result is None + driver._nautobot_connection.storage_network_config_for_node.assert_not_called() diff --git a/python/nova-understack/tests/test_nautobot_client.py b/python/nova-understack/tests/test_nautobot_client.py new file mode 100644 index 000000000..1cce76083 --- /dev/null +++ b/python/nova-understack/tests/test_nautobot_client.py @@ -0,0 +1,641 @@ +"""Unit tests for NautobotClient and related classes.""" + +import ipaddress +import uuid +from unittest.mock import Mock +from unittest.mock import patch + +import pytest +import requests + +from ironic_understack.nautobot_client import Device +from ironic_understack.nautobot_client import DeviceInterfacesResponse +from ironic_understack.nautobot_client import Interface +from ironic_understack.nautobot_client import IPAddress +from ironic_understack.nautobot_client import IPAddressAssignment +from ironic_understack.nautobot_client import NautobotClient + + +class TestIPAddress: + """Test cases for IPAddress class.""" + + def test_from_address_string_ipv4(self): + """Test creating IPAddress from IPv4 string.""" + ip = IPAddress.from_address_string("192.168.1.10/24") + + assert ip.address == "192.168.1.10" + assert ip.address_with_prefix == "192.168.1.10/24" + assert ip.netmask == "255.255.255.0" + assert ip.ip_version == 4 + assert ip.is_ipv4() is True + + def test_from_address_string_ipv6(self): + """Test creating IPAddress from IPv6 string.""" + ip = IPAddress.from_address_string("2001:db8::1/64") + + assert ip.address == "2001:db8::1" + assert ip.address_with_prefix == "2001:db8::1/64" + assert ip.ip_version == 6 + assert ip.is_ipv4() is False + + def test_from_address_string_invalid(self): + """Test creating IPAddress from invalid string.""" + with pytest.raises( + ValueError, match="does not appear to be an IPv4 or IPv6 interface" + ): + IPAddress.from_address_string("invalid-ip") + + def test_network_property(self): + """Test network property.""" + ip = IPAddress.from_address_string("192.168.1.10/24") + + expected_network = ipaddress.IPv4Network("192.168.1.0/24") + assert ip.network == expected_network + + def test_target_network_octet_0(self): + """Test target_network calculation for third octet 0.""" + ip = IPAddress.from_address_string("100.127.0.10/24") + + expected_network = ipaddress.IPv4Network("100.127.0.0/24") + assert ip.target_network == expected_network + + def test_target_network_octet_128(self): + """Test target_network calculation for third octet 128.""" + ip = IPAddress.from_address_string("100.127.128.10/24") + + expected_network = ipaddress.IPv4Network("100.127.128.0/24") + assert ip.target_network == expected_network + + def test_target_network_invalid_octet(self): + """Test target_network with invalid third octet.""" + ip = IPAddress.from_address_string("100.127.64.10/24") + + with pytest.raises( + ValueError, match="Cannot determine the target-side network" + ): + _ = ip.target_network + + def test_is_in_subnet_true(self): + """Test is_in_subnet returns True for matching subnet.""" + ip = IPAddress.from_address_string("192.168.1.10/24") + + assert ip.is_in_subnet("192.168.0.0/16") is True + + def test_is_in_subnet_false(self): + """Test is_in_subnet returns False for non-matching subnet.""" + ip = IPAddress.from_address_string("192.168.1.10/24") + + assert ip.is_in_subnet("10.0.0.0/8") is False + + def test_is_in_subnet_invalid_subnet(self): + """Test is_in_subnet with invalid subnet.""" + ip = IPAddress.from_address_string("192.168.1.10/24") + + assert ip.is_in_subnet("invalid-subnet") is False + + def test_calculated_gateway(self): + """Test calculated gateway (first host in network).""" + ip = IPAddress.from_address_string("192.168.1.10/24") + + assert ip.calculated_gateway == "192.168.1.1" + + +class TestInterface: + """Test cases for Interface class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.ip_assignment = IPAddressAssignment( + ip_address=IPAddress.from_address_string("192.168.1.10/24") + ) + self.ipv6_assignment = IPAddressAssignment( + ip_address=IPAddress.from_address_string("2001:db8::1/64") + ) + + def test_get_ipv4_assignments(self): + """Test filtering IPv4 assignments.""" + interface = Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[self.ip_assignment, self.ipv6_assignment], + ) + + ipv4_assignments = interface.get_ipv4_assignments() + + assert len(ipv4_assignments) == 1 + assert ipv4_assignments[0] == self.ip_assignment + + def test_get_first_ipv4_assignment(self): + """Test getting first IPv4 assignment.""" + interface = Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[self.ipv6_assignment, self.ip_assignment], + ) + + first_ipv4 = interface.get_first_ipv4_assignment() + + assert first_ipv4 == self.ip_assignment + + def test_get_first_ipv4_assignment_none(self): + """Test getting first IPv4 assignment when none exist.""" + interface = Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[self.ipv6_assignment], + ) + + first_ipv4 = interface.get_first_ipv4_assignment() + + assert first_ipv4 is None + + def test_has_ip_in_subnet_true(self): + """Test has_ip_in_subnet returns True.""" + interface = Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[self.ip_assignment], + ) + + assert interface.has_ip_in_subnet("192.168.0.0/16") is True + + def test_has_ip_in_subnet_false(self): + """Test has_ip_in_subnet returns False.""" + interface = Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[self.ip_assignment], + ) + + assert interface.has_ip_in_subnet("10.0.0.0/8") is False + + def test_is_valid_for_config_true(self): + """Test interface is valid for configuration.""" + interface = Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[self.ip_assignment], + ) + + assert interface.is_valid_for_config() is True + + def test_is_valid_for_config_no_mac(self): + """Test interface is invalid without MAC address.""" + interface = Interface( + id="interface-1", + mac_address=None, + ip_address_assignments=[self.ip_assignment], + ) + + assert interface.is_valid_for_config() is False + + def test_is_valid_for_config_no_ip(self): + """Test interface is invalid without IP assignments.""" + interface = Interface( + id="interface-1", mac_address="aa:bb:cc:dd:ee:ff", ip_address_assignments=[] + ) + + assert interface.is_valid_for_config() is False + + def test_is_valid_for_config_no_ipv4(self): + """Test interface is invalid without IPv4 assignments.""" + interface = Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[self.ipv6_assignment], + ) + + assert interface.is_valid_for_config() is False + + def test_as_openstack_link(self): + """Test OpenStack link generation.""" + interface = Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[self.ip_assignment], + ) + + link = interface.as_openstack_link(if_index=5) + + expected = { + "id": "tap-stor-5", + "vif_id": "interface-1", + "type": "phy", + "mtu": 9000, + "ethernet_mac_address": "aa:bb:cc:dd:ee:ff", + } + assert link == expected + + def test_as_openstack_network(self): + """Test OpenStack network generation.""" + # Create IP with specific values for predictable gateway calculation + ip_assignment = IPAddressAssignment( + ip_address=IPAddress.from_address_string("100.127.0.10/24") + ) + interface = Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[ip_assignment], + ) + + with patch("uuid.uuid4") as mock_uuid: + mock_uuid.return_value.hex = "test-network-id" + + network = interface.as_openstack_network(if_index=5) + + expected = { + "id": "network-for-if5", + "type": "ipv4", + "link": "tap-stor-5", + "ip_address": "100.127.0.10", + "netmask": "255.255.255.0", + "routes": [ + { + "network": "100.127.0.0", + "netmask": "255.255.255.0", + "gateway": "100.127.0.1", + } + ], + "network_id": "test-network-id", + } + assert network == expected + + def test_as_openstack_network_no_ipv4(self): + """Test OpenStack network generation without IPv4.""" + interface = Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[self.ipv6_assignment], + ) + + network = interface.as_openstack_network() + + assert network == {} + + +class TestDevice: + """Test cases for Device class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.valid_interface = Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[ + IPAddressAssignment( + ip_address=IPAddress.from_address_string("192.168.1.10/24") + ) + ], + ) + self.invalid_interface = Interface( + id="interface-2", mac_address=None, ip_address_assignments=[] + ) + self.storage_interface = Interface( + id="interface-3", + mac_address="11:22:33:44:55:66", + ip_address_assignments=[ + IPAddressAssignment( + ip_address=IPAddress.from_address_string("100.126.1.10/24") + ) + ], + ) + + def test_get_active_interfaces(self): + """Test filtering active interfaces.""" + device = Device( + id="device-1", + interfaces=[ + self.valid_interface, + self.invalid_interface, + self.storage_interface, + ], + ) + + active_interfaces = device.get_active_interfaces() + + assert len(active_interfaces) == 2 + assert self.valid_interface in active_interfaces + assert self.storage_interface in active_interfaces + assert self.invalid_interface not in active_interfaces + + def test_get_storage_interfaces_default_subnet(self): + """Test filtering storage interfaces with default subnet.""" + device = Device( + id="device-1", interfaces=[self.valid_interface, self.storage_interface] + ) + + storage_interfaces = device.get_storage_interfaces() + + assert len(storage_interfaces) == 1 + assert storage_interfaces[0] == self.storage_interface + + def test_get_storage_interfaces_custom_subnet(self): + """Test filtering storage interfaces with custom subnet.""" + device = Device( + id="device-1", interfaces=[self.valid_interface, self.storage_interface] + ) + + storage_interfaces = device.get_storage_interfaces("192.168.0.0/16") + + assert len(storage_interfaces) == 1 + assert storage_interfaces[0] == self.valid_interface + + +class TestDeviceInterfacesResponse: + """Test cases for DeviceInterfacesResponse class.""" + + def test_from_graphql_response_complete(self): + """Test parsing complete GraphQL response.""" + graphql_response = { + "data": { + "devices": [ + { + "id": "device-1", + "interfaces": [ + { + "id": "interface-1", + "mac_address": "aa:bb:cc:dd:ee:ff", + "ip_address_assignments": [ + {"ip_address": {"address": "192.168.1.10/24"}} + ], + } + ], + } + ] + } + } + + response = DeviceInterfacesResponse.from_graphql_response(graphql_response) + + assert len(response.devices) == 1 + device = response.devices[0] + assert device.id == "device-1" + assert len(device.interfaces) == 1 + + interface = device.interfaces[0] + assert interface.id == "interface-1" + assert interface.mac_address == "aa:bb:cc:dd:ee:ff" + assert len(interface.ip_address_assignments) == 1 + + assignment = interface.ip_address_assignments[0] + assert assignment.ip_address.address == "192.168.1.10" + + def test_from_graphql_response_invalid_ip(self): + """Test parsing GraphQL response with invalid IP address.""" + graphql_response = { + "data": { + "devices": [ + { + "id": "device-1", + "interfaces": [ + { + "id": "interface-1", + "mac_address": "aa:bb:cc:dd:ee:ff", + "ip_address_assignments": [ + {"ip_address": {"address": "invalid-ip"}}, + {"ip_address": {"address": "192.168.1.10/24"}}, + ], + } + ], + } + ] + } + } + + response = DeviceInterfacesResponse.from_graphql_response(graphql_response) + + # Should skip invalid IP and include valid one + interface = response.devices[0].interfaces[0] + assert len(interface.ip_address_assignments) == 1 + assert interface.ip_address_assignments[0].ip_address.address == "192.168.1.10" + + def test_from_graphql_response_empty(self): + """Test parsing empty GraphQL response.""" + graphql_response = {"data": {"devices": []}} + + response = DeviceInterfacesResponse.from_graphql_response(graphql_response) + + assert len(response.devices) == 0 + + def test_from_graphql_response_missing_data(self): + """Test parsing GraphQL response with missing data.""" + graphql_response = {} + + response = DeviceInterfacesResponse.from_graphql_response(graphql_response) + + assert len(response.devices) == 0 + + +class TestNautobotClient: + """Test cases for NautobotClient class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.base_url = "https://nautobot.example.com" + self.api_key = "test-api-key" + self.client = NautobotClient(self.base_url, self.api_key) + + def test_init(self): + """Test NautobotClient initialization.""" + assert self.client.base_url == self.base_url + assert self.client.api_key == self.api_key + assert self.client.graphql_url == f"{self.base_url}/api/graphql/" + + def test_init_url_stripping(self): + """Test URL stripping during initialization.""" + client = NautobotClient("https://nautobot.example.com/", self.api_key) + + assert client.base_url == "https://nautobot.example.com" + + @patch("requests.post") + def test_make_graphql_request_success(self, mock_post): + """Test successful GraphQL request.""" + expected_response = {"data": {"test": "value"}} + mock_post.return_value.json.return_value = expected_response + mock_post.return_value.raise_for_status = Mock() + + query = "query { test }" + variables = {"var1": "value1"} + + result = self.client._make_graphql_request(query, variables) + + assert result == expected_response + + # Verify request parameters + mock_post.assert_called_once_with( + self.client.graphql_url, + headers={ + "Authorization": f"Token {self.api_key}", + "Content-Type": "application/json", + }, + json={"query": query, "variables": variables}, + timeout=30, + ) + + @patch("requests.post") + def test_make_graphql_request_no_variables(self, mock_post): + """Test GraphQL request without variables.""" + expected_response = {"data": {"test": "value"}} + mock_post.return_value.json.return_value = expected_response + mock_post.return_value.raise_for_status = Mock() + + query = "query { test }" + + self.client._make_graphql_request(query) + + # Verify empty variables dict was used + call_args = mock_post.call_args[1]["json"] + assert call_args["variables"] == {} + + @patch("requests.post") + def test_make_graphql_request_http_error(self, mock_post): + """Test GraphQL request with HTTP error.""" + mock_post.return_value.raise_for_status.side_effect = requests.RequestException( + "HTTP Error" + ) + + with pytest.raises(requests.RequestException): + self.client._make_graphql_request("query { test }") + + @patch("requests.post") + def test_make_graphql_request_graphql_errors(self, mock_post): + """Test GraphQL request with GraphQL errors.""" + error_response = {"errors": [{"message": "GraphQL error"}], "data": None} + mock_post.return_value.json.return_value = error_response + mock_post.return_value.raise_for_status = Mock() + + with pytest.raises(ValueError, match="GraphQL errors"): + self.client._make_graphql_request("query { test }") + + @patch.object(NautobotClient, "_make_graphql_request") + def test_get_device_interfaces(self, mock_graphql): + """Test getting device interfaces.""" + mock_response = { + "data": { + "devices": [ + { + "id": "device-1", + "interfaces": [ + { + "id": "interface-1", + "mac_address": "aa:bb:cc:dd:ee:ff", + "ip_address_assignments": [ + {"ip_address": {"address": "192.168.1.10/24"}} + ], + } + ], + } + ] + } + } + mock_graphql.return_value = mock_response + + device_id = "test-device-id" + result = self.client.get_device_interfaces(device_id) + + expected_variables = {"device_id": device_id} + + mock_graphql.assert_called_once() + call_args = mock_graphql.call_args + assert call_args[0][1] == expected_variables # variables parameter + + # Verify response parsing + assert isinstance(result, DeviceInterfacesResponse) + assert len(result.devices) == 1 + + def test_generate_network_config_all_interfaces(self): + """Test generating network config for all interfaces.""" + # Create mock response with valid target network IPs + device = Device( + id="device-1", + interfaces=[ + Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[ + IPAddressAssignment( + ip_address=IPAddress.from_address_string("100.127.0.10/24") + ) + ], + ), + Interface( + id="interface-2", + mac_address="11:22:33:44:55:66", + ip_address_assignments=[ + IPAddressAssignment( + ip_address=IPAddress.from_address_string( + "100.127.128.10/24" + ) + ) + ], + ), + ], + ) + response = DeviceInterfacesResponse(devices=[device]) + + with patch("uuid.uuid4") as mock_uuid: + mock_uuid.return_value.hex = "test-network-id" + + config = self.client.generate_network_config( + response, ignore_non_storage=False + ) + + assert len(config["links"]) == 2 + assert len(config["networks"]) == 2 + + # Verify interface indexing starts from 100 + assert config["links"][0]["id"] == "tap-stor-100" + assert config["links"][1]["id"] == "tap-stor-101" + + def test_generate_network_config_storage_only(self): + """Test generating network config for storage interfaces only.""" + device = Device( + id="device-1", + interfaces=[ + Interface( + id="interface-1", + mac_address="aa:bb:cc:dd:ee:ff", + ip_address_assignments=[ + IPAddressAssignment( + ip_address=IPAddress.from_address_string("192.168.1.10/24") + ) + ], + ), + Interface( + id="interface-2", + mac_address="11:22:33:44:55:66", + ip_address_assignments=[ + IPAddressAssignment( + ip_address=IPAddress.from_address_string("100.126.0.10/24") + ) + ], + ), + ], + ) + response = DeviceInterfacesResponse(devices=[device]) + + with patch("uuid.uuid4") as mock_uuid: + mock_uuid.return_value.hex = "test-network-id" + + config = self.client.generate_network_config( + response, ignore_non_storage=True + ) + + # Should only include storage interface (100.126.x.x) + assert len(config["links"]) == 1 + assert len(config["networks"]) == 1 + + @patch.object(NautobotClient, "get_device_interfaces") + @patch.object(NautobotClient, "generate_network_config") + def test_storage_network_config_for_node(self, mock_generate, mock_get_interfaces): + """Test getting storage network config for a node.""" + node_id = uuid.uuid4() + mock_response = Mock() + mock_get_interfaces.return_value = mock_response + mock_generate.return_value = {"test": "config"} + + result = self.client.storage_network_config_for_node(node_id) + + mock_get_interfaces.assert_called_once_with(str(node_id)) + mock_generate.assert_called_once_with(mock_response, ignore_non_storage=True) + assert result == {"test": "config"}