diff --git a/.github/workflows/black.yaml b/.github/workflows/black.yaml new file mode 100644 index 0000000..df53d5a --- /dev/null +++ b/.github/workflows/black.yaml @@ -0,0 +1,20 @@ +name: Black + +on: + - push + - pull_request + +jobs: + black: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.13' + - name: Black check + run: uv tool run black --check . diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..0f35ad7 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,22 @@ +name: Check image building + +on: + - pull_request + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + - name: Install podman + run: | + sudo apt-get update + sudo apt-get -y install podman + - name: Verify podman + run: podman --version + - name: Build image + run: podman build -t assisted-service-mcp:latest . + diff --git a/.github/workflows/mypy.yaml b/.github/workflows/mypy.yaml new file mode 100644 index 0000000..9d93815 --- /dev/null +++ b/.github/workflows/mypy.yaml @@ -0,0 +1,22 @@ +name: Type checks + +on: + - push + - pull_request + +jobs: + mypy: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.13' + - name: Install dependencies + run: uv sync + - name: Python linter + run: uv run mypy --explicit-package-bases --disallow-untyped-calls --disallow-untyped-defs --disallow-incomplete-defs --ignore-missing-imports --disable-error-code attr-defined . diff --git a/.github/workflows/outdated_dependencies.yaml b/.github/workflows/outdated_dependencies.yaml new file mode 100644 index 0000000..a93fa89 --- /dev/null +++ b/.github/workflows/outdated_dependencies.yaml @@ -0,0 +1,24 @@ +name: List outdated dependencies + +on: + - push + - pull_request + +jobs: + list_outdated_dependencies: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.13' + - name: Install dependencies + run: uv sync + - name: List dependencies + run: uv pip list + - name: List outdated dependencies + run: uv pip list --outdated diff --git a/.github/workflows/pydocstyle.yaml b/.github/workflows/pydocstyle.yaml new file mode 100644 index 0000000..0b6e971 --- /dev/null +++ b/.github/workflows/pydocstyle.yaml @@ -0,0 +1,20 @@ +name: Pydocstyle + +on: + - push + - pull_request + +jobs: + pydocstyle: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.13' + - name: Python linter + run: uv tool run pydocstyle -v . diff --git a/.github/workflows/pylint.yaml b/.github/workflows/pylint.yaml new file mode 100644 index 0000000..9ea8b8e --- /dev/null +++ b/.github/workflows/pylint.yaml @@ -0,0 +1,23 @@ +name: Python linter + +on: + - push + - pull_request + +jobs: + pylint: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + name: "Pylinter" + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.13' + - name: Install dependencies + run: uv sync + - name: Python linter + run: uv run pylint . diff --git a/.github/workflows/pyright.yaml b/.github/workflows/pyright.yaml new file mode 100644 index 0000000..869f1aa --- /dev/null +++ b/.github/workflows/pyright.yaml @@ -0,0 +1,23 @@ +name: Pyright + +on: + - push + - pull_request + +jobs: + pyright: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + name: "Pyright" + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.13' + - name: Install dependencies + run: uv sync + - name: Run Pyright tests + run: uv run pyright . diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml new file mode 100644 index 0000000..187c0bf --- /dev/null +++ b/.github/workflows/ruff.yaml @@ -0,0 +1,20 @@ +name: Ruff + +on: + - push + - pull_request + +jobs: + ruff: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.13' + - name: Python linter + run: uv tool run ruff check . diff --git a/Makefile b/Makefile index f45b9e3..7f2759b 100644 --- a/Makefile +++ b/Makefile @@ -16,3 +16,28 @@ run: .PHONY: run-local run-local: uv run server.py + +.PHONY: black pylint pyright docstyle ruff check-types verify format +black: + uv run black --check . + +pylint: + uv run pylint . + +pyright: + uv run pyright . + +docstyle: + uv run pydocstyle -v . + +ruff: + uv run ruff check . + +check-types: + uv run mypy --explicit-package-bases --disallow-untyped-calls --disallow-untyped-defs --disallow-incomplete-defs --ignore-missing-imports --disable-error-code attr-defined . + +verify: black pylint pyright docstyle ruff check-types + +format: + uv run black . + uv run ruff check . --fix diff --git a/pyproject.toml b/pyproject.toml index 4c9e0c4..2bf8ba1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,4 +10,36 @@ dependencies = [ "netaddr>=1.3.0", "requests>=2.32.3", "retry>=0.9.2", + "types-requests>=2.32.4.20250611", +] + +[dependency-groups] +dev = [ + "black>=25.1.0", + "mypy>=1.16.1", + "pydocstyle>=6.3.0", + "pylint>=3.3.7", + "pyright>=1.1.402", + "ruff>=0.12.1", +] + +[tool.pylint.main] +ignore-paths = [ + ".venv", + "venv", + ".git", + "__pycache__", + ".pytest_cache", + "build", + "dist", +] + +[tool.pylint.messages_control] +disable = [ + "missing-module-docstring", # We'll add these selectively + "missing-class-docstring", # We'll add these selectively + "missing-function-docstring", # We'll add these selectively + "too-few-public-methods", # Common in utility classes + "line-too-long", # Handled by black + "broad-exception-caught", # Sometimes necessary ] diff --git a/server.py b/server.py index b4915ea..6ace890 100644 --- a/server.py +++ b/server.py @@ -1,14 +1,24 @@ -from mcp.server.fastmcp import FastMCP +""" +MCP server for Red Hat Assisted Service API. + +This module provides Model Context Protocol (MCP) tools for interacting with +Red Hat's Assisted Service API to manage OpenShift cluster installations. +""" + import json import os + import requests +from mcp.server.fastmcp import FastMCP from service_client import InventoryClient mcp = FastMCP("AssistedService", host="0.0.0.0") + def get_offline_token() -> str: - """Retrieve the offline token from environment variables or request headers. + """ + Retrieve the offline token from environment variables or request headers. This function attempts to get the Red Hat OpenShift Cluster Manager (OCM) offline token first from the OFFLINE_TOKEN environment variable, then from the OCM-Offline-Token @@ -26,14 +36,18 @@ def get_offline_token() -> str: if token: return token - token = mcp.get_context().request_context.request.headers.get("OCM-Offline-Token") - if token: - return token + request = mcp.get_context().request_context.request + if request is not None: + token = request.headers.get("OCM-Offline-Token") + if token: + return token raise RuntimeError("No offline token found in environment or request headers") + def get_access_token() -> str: - """Retrieve the access token. + """ + Retrieve the access token. This function tries to get the Red Hat OpenShift Cluster Manager (OCM) access token. First it tries to extract it from the authorization header, and if it isn't there then it tries @@ -60,14 +74,19 @@ def get_access_token() -> str: "grant_type": "refresh_token", "refresh_token": get_offline_token(), } - sso_url = os.environ.get("SSO_URL", "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token") - response = requests.post(sso_url, data=params) + sso_url = os.environ.get( + "SSO_URL", + "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token", + ) + response = requests.post(sso_url, data=params, timeout=30) response.raise_for_status() return response.json()["access_token"] + @mcp.tool() async def cluster_info(cluster_id: str) -> str: - """Get comprehensive information about a specific assisted installer cluster. + """ + Get comprehensive information about a specific assisted installer cluster. Retrieves detailed cluster information including configuration, status, hosts, network settings, and installation progress for the specified cluster ID. @@ -87,9 +106,11 @@ async def cluster_info(cluster_id: str) -> str: result = await client.get_cluster(cluster_id=cluster_id) return result.to_str() + @mcp.tool() async def list_clusters() -> str: - """List all assisted installer clusters for the current user. + """ + List all assisted installer clusters for the current user. Retrieves a summary of all clusters associated with the current user's account. This provides basic information about each cluster without detailed configuration. @@ -105,12 +126,22 @@ async def list_clusters() -> str: """ client = InventoryClient(get_access_token()) clusters = await client.list_clusters() - resp = [{"name": cluster["name"], "id": cluster["id"], "openshift_version": cluster["openshift_version"], "status": cluster["status"]} for cluster in clusters] + resp = [ + { + "name": cluster["name"], + "id": cluster["id"], + "openshift_version": cluster["openshift_version"], + "status": cluster["status"], + } + for cluster in clusters + ] return json.dumps(resp) + @mcp.tool() async def cluster_events(cluster_id: str) -> str: - """Get the events related to a cluster with the given cluster id. + """ + Get the events related to a cluster with the given cluster id. Retrieves chronological events related to cluster installation, configuration changes, and status updates. These events help track installation progress @@ -126,9 +157,11 @@ async def cluster_events(cluster_id: str) -> str: client = InventoryClient(get_access_token()) return await client.get_events(cluster_id=cluster_id) + @mcp.tool() async def host_events(cluster_id: str, host_id: str) -> str: - """Get events specific to a particular host within a cluster. + """ + Get events specific to a particular host within a cluster. Retrieves events related to a specific host's installation progress, hardware validation, role assignment, and any host-specific issues or status changes. @@ -144,9 +177,11 @@ async def host_events(cluster_id: str, host_id: str) -> str: client = InventoryClient(get_access_token()) return await client.get_events(cluster_id=cluster_id, host_id=host_id) + @mcp.tool() async def infraenv_info(infraenv_id: str) -> str: - """Get detailed information about an infrastructure environment (InfraEnv). + """ + Get detailed information about an infrastructure environment (InfraEnv). An InfraEnv contains the configuration and resources needed to boot and discover hosts for cluster installation, including the discovery ISO image and network @@ -167,9 +202,13 @@ async def infraenv_info(infraenv_id: str) -> str: result = await client.get_infra_env(infraenv_id) return result.to_str() + @mcp.tool() -async def create_cluster(name: str, version: str, base_domain: str, single_node: bool) -> str: - """Create a new OpenShift cluster and associated infrastructure environment. +async def create_cluster( + name: str, version: str, base_domain: str, single_node: bool +) -> str: + """ + Create a new OpenShift cluster and associated infrastructure environment. Creates both a cluster definition and an InfraEnv for host discovery. The cluster can be configured for high availability (multi-node) or single-node deployment. @@ -191,13 +230,19 @@ async def create_cluster(name: str, version: str, base_domain: str, single_node: """ client = InventoryClient(get_access_token()) mcp_tags = {"created-by": "mcp"} - cluster = await client.create_cluster(name, version, single_node, base_dns_domain=base_domain, tags=mcp_tags) - infraenv = await client.create_infra_env(name, cluster_id=cluster.id, openshift_version=cluster.openshift_version) - return json.dumps({'cluster_id': cluster.id, 'infraenv_id': infraenv.id}) + cluster = await client.create_cluster( + name, version, single_node, base_dns_domain=base_domain, tags=mcp_tags + ) + infraenv = await client.create_infra_env( + name, cluster_id=cluster.id, openshift_version=cluster.openshift_version + ) + return json.dumps({"cluster_id": cluster.id, "infraenv_id": infraenv.id}) + @mcp.tool() async def set_cluster_vips(cluster_id: str, api_vip: str, ingress_vip: str) -> str: - """Configure the virtual IP addresses (VIPs) for cluster API and ingress traffic. + """ + Configure the virtual IP addresses (VIPs) for cluster API and ingress traffic. Sets the API VIP (for cluster management) and Ingress VIP (for application traffic) for the specified cluster. These VIPs must be available IP addresses within the @@ -215,12 +260,16 @@ async def set_cluster_vips(cluster_id: str, api_vip: str, ingress_vip: str) -> s showing the newly set VIP addresses. """ client = InventoryClient(get_access_token()) - result = await client.update_cluster(cluster_id, api_vip=api_vip, ingress_vip=ingress_vip) + result = await client.update_cluster( + cluster_id, api_vip=api_vip, ingress_vip=ingress_vip + ) return result.to_str() + @mcp.tool() async def install_cluster(cluster_id: str) -> str: - """Trigger the installation process for a prepared cluster. + """ + Trigger the installation process for a prepared cluster. Initiates the OpenShift installation on all discovered and validated hosts. The cluster must have all prerequisites met including sufficient hosts, @@ -243,9 +292,11 @@ async def install_cluster(cluster_id: str) -> str: result = await client.install_cluster(cluster_id) return result.to_str() + @mcp.tool() async def list_versions() -> str: - """List all available OpenShift versions for installation. + """ + List all available OpenShift versions for installation. Retrieves the complete list of OpenShift versions that can be installed using the assisted installer service, including release versions and @@ -259,9 +310,11 @@ async def list_versions() -> str: result = await client.get_openshift_versions(True) return json.dumps(result) + @mcp.tool() async def list_operator_bundles() -> str: - """List available operator bundles for cluster installation. + """ + List available operator bundles for cluster installation. Retrieves operator bundles that can be optionally installed during cluster deployment. These include Red Hat and certified partner operators for @@ -275,9 +328,11 @@ async def list_operator_bundles() -> str: result = await client.get_operator_bundles() return json.dumps(result) + @mcp.tool() async def add_operator_bundle_to_cluster(cluster_id: str, bundle_name: str) -> str: - """Add an operator bundle to be installed with the cluster. + """ + Add an operator bundle to be installed with the cluster. Configures the specified operator bundle to be automatically installed during cluster deployment. The bundle must be from the list of available @@ -296,9 +351,11 @@ async def add_operator_bundle_to_cluster(cluster_id: str, bundle_name: str) -> s result = await client.add_operator_bundle_to_cluster(cluster_id, bundle_name) return result.to_str() + @mcp.tool() async def set_host_role(host_id: str, infraenv_id: str, role: str) -> str: - """Assign a specific role to a discovered host in the cluster. + """ + Assign a specific role to a discovered host in the cluster. Sets the role for a host that has been discovered through the InfraEnv boot process. The role determines the host's function in the OpenShift cluster. @@ -319,5 +376,6 @@ async def set_host_role(host_id: str, infraenv_id: str, role: str) -> str: result = await client.update_host(host_id, infraenv_id, host_role=role) return result.to_str() + if __name__ == "__main__": mcp.run(transport="sse") diff --git a/service_client/__init__.py b/service_client/__init__.py index 371eb3e..a48c4f1 100644 --- a/service_client/__init__.py +++ b/service_client/__init__.py @@ -1,4 +1,11 @@ +""" +Service client package for Red Hat Assisted Service API. + +This package provides client classes and utilities for interacting with +Red Hat's Assisted Service API to manage OpenShift cluster installations. +""" + from .assisted_service_api import InventoryClient -from .logger import SuppressAndLog, add_log_record, log +from .logger import log -__all__ = ["InventoryClient", "log", "add_log_record", "SuppressAndLog"] +__all__ = ["InventoryClient", "log"] diff --git a/service_client/assisted_service_api.py b/service_client/assisted_service_api.py index dfed6ec..21d7cf2 100644 --- a/service_client/assisted_service_api.py +++ b/service_client/assisted_service_api.py @@ -1,79 +1,147 @@ +""" +Client for Red Hat Assisted Service API. + +This module provides the InventoryClient class for interacting with Red Hat's +Assisted Service API to manage OpenShift cluster installations, infrastructure +environments, and host management. +""" + import os import asyncio -from typing import Optional +from typing import Any, Optional, cast from urllib.parse import urlparse import requests from assisted_service_client import ApiClient, Configuration, api, models -from retry import retry from service_client.logger import log -class InventoryClient(object): + +class InventoryClient: + """ + Client for interacting with Red Hat Assisted Service API. + + This class provides methods to manage OpenShift clusters, infrastructure + environments, hosts, and installation workflows through the Red Hat + Assisted Service API. + + Args: + access_token (str): The access token for authenticating with the API. + """ + def __init__(self, access_token: str): + """Initialize the InventoryClient with an access token.""" self.access_token = access_token self.pull_secret = self._get_pull_secret() - self.inventory_url = os.environ.get("INVENTORY_URL", "https://api.openshift.com/api/assisted-install/v2") + self.inventory_url = os.environ.get( + "INVENTORY_URL", "https://api.openshift.com/api/assisted-install/v2" + ) self.client_debug = os.environ.get("CLIENT_DEBUG", "False").lower() == "true" def _get_pull_secret(self) -> str: - url = os.environ.get("PULL_SECRET_URL", "https://api.openshift.com/api/accounts_mgmt/v1/access_token") + url = os.environ.get( + "PULL_SECRET_URL", + "https://api.openshift.com/api/accounts_mgmt/v1/access_token", + ) headers = {"Authorization": f"Bearer {self.access_token}"} - response = requests.post(url, headers=headers) + response = requests.post(url, headers=headers, timeout=30) response.raise_for_status() return response.text - def _get_client(self): + def _get_client(self) -> ApiClient: configs = Configuration() - configs.host = self.get_host(configs) + configs.host = self._get_host(configs) configs.debug = self.client_debug configs.api_key_prefix["Authorization"] = "Bearer" configs.api_key["Authorization"] = self.access_token return ApiClient(configuration=configs) - def _installer_api(self): + def _installer_api(self) -> api.InstallerApi: api_client = self._get_client() return api.InstallerApi(api_client=api_client) - def _events_api(self): + def _events_api(self) -> api.EventsApi: api_client = self._get_client() return api.EventsApi(api_client=api_client) - def _operators_api(self): + def _operators_api(self) -> api.OperatorsApi: api_client = self._get_client() return api.OperatorsApi(api_client=api_client) - def _versions_api(self): + def _versions_api(self) -> api.VersionsApi: api_client = self._get_client() return api.VersionsApi(api_client=api_client) - def get_host(self, configs: Configuration) -> str: + def _get_host(self, configs: Configuration) -> str: parsed_host = urlparse(configs.host) parsed_inventory_url = urlparse(self.inventory_url) - return parsed_host._replace(netloc=parsed_inventory_url.netloc, scheme=parsed_inventory_url.scheme).geturl() + return parsed_host._replace( + netloc=parsed_inventory_url.netloc, scheme=parsed_inventory_url.scheme + ).geturl() - async def get_cluster(self, cluster_id: str, get_unregistered_clusters: bool = False) -> models.Cluster: - return await asyncio.to_thread( - self._installer_api().v2_get_cluster, - cluster_id=cluster_id, - get_unregistered_clusters=get_unregistered_clusters + async def get_cluster( + self, cluster_id: str, get_unregistered_clusters: bool = False + ) -> models.Cluster: + """ + Get cluster information by ID. + + Args: + cluster_id: The unique identifier of the cluster. + get_unregistered_clusters: Whether to include unregistered clusters. + + Returns: + models.Cluster: The cluster object containing cluster information. + """ + return cast( + models.Cluster, + await asyncio.to_thread( + self._installer_api().v2_get_cluster, + cluster_id=cluster_id, + get_unregistered_clusters=get_unregistered_clusters, + ), ) async def list_clusters(self) -> list: - return await asyncio.to_thread(self._installer_api().v2_list_clusters) + """ + List all clusters accessible to the authenticated user. + + Returns: + list: A list of cluster objects. + """ + return cast( + list, await asyncio.to_thread(self._installer_api().v2_list_clusters) + ) async def get_events( self, cluster_id: Optional[str] = "", host_id: Optional[str] = "", infra_env_id: Optional[str] = "", - categories=None, - **kwargs, + categories: Optional[list[str]] = None, + **kwargs: Any, ) -> str: + """ + Get events for clusters, hosts, or infrastructure environments. + + Args: + cluster_id: Optional cluster ID to filter events. + host_id: Optional host ID to filter events. + infra_env_id: Optional infrastructure environment ID to filter events. + categories: List of event categories to filter. Defaults to ["user"]. + **kwargs: Additional parameters for the API call. + + Returns: + str: Raw event data as a json string. + """ if categories is None: categories = ["user"] - log.info("Downloading events for cluster %s, host %s, infraenv %s, categories %s", cluster_id, host_id, infra_env_id, categories) - + log.info( + "Downloading events for cluster %s, host %s, infraenv %s, categories %s", + cluster_id, + host_id, + infra_env_id, + categories, + ) response = await asyncio.to_thread( self._events_api().v2_list_events, cluster_id=cluster_id, @@ -83,76 +151,208 @@ async def get_events( _preload_content=False, **kwargs, ) - return response.data + return cast(Any, response).data async def get_infra_env(self, infra_env_id: str) -> models.InfraEnv: - return await asyncio.to_thread( - self._installer_api().get_infra_env, - infra_env_id=infra_env_id + """ + Get infrastructure environment information by ID. + + Args: + infra_env_id: The unique identifier of the infrastructure environment. + + Returns: + models.InfraEnv: The infrastructure environment object. + """ + return cast( + models.InfraEnv, + await asyncio.to_thread( + self._installer_api().get_infra_env, infra_env_id=infra_env_id + ), ) - async def create_cluster(self, name: str, version: str, single_node: bool, **cluster_params) -> models.Cluster: + async def create_cluster( + self, name: str, version: str, single_node: bool, **cluster_params: Any + ) -> models.Cluster: + """ + Create a new OpenShift cluster. + + Args: + name: The name of the cluster. + version: The OpenShift version to install. + single_node: Whether to create a single-node cluster. + **cluster_params: Additional cluster configuration parameters. + + Returns: + models.Cluster: The created cluster object. + """ if single_node: cluster_params["control_plane_count"] = 1 cluster_params["high_availability_mode"] = "None" cluster_params["user_managed_networking"] = True - params = models.ClusterCreateParams(name=name, openshift_version=version, pull_secret=self.pull_secret, **cluster_params) + params = models.ClusterCreateParams( + name=name, + openshift_version=version, + pull_secret=self.pull_secret, + **cluster_params, + ) log.info("Creating cluster with params %s", params.__dict__) result = await asyncio.to_thread( - self._installer_api().v2_register_cluster, - new_cluster_params=params + self._installer_api().v2_register_cluster, new_cluster_params=params ) - return result + return cast(models.Cluster, result) + + async def create_infra_env( + self, name: str, **infra_env_params: Any + ) -> models.InfraEnv: + """ + Create a new infrastructure environment. + + Args: + name: The name of the infrastructure environment. + **infra_env_params: Additional infrastructure environment parameters. - async def create_infra_env(self, name: str, **infra_env_params) -> models.InfraEnv: - infra_env = models.InfraEnvCreateParams(name=name, pull_secret=self.pull_secret, **infra_env_params) + Returns: + models.InfraEnv: The created infrastructure environment object. + """ + infra_env = models.InfraEnvCreateParams( + name=name, pull_secret=self.pull_secret, **infra_env_params + ) log.info("Creating infra-env with params %s", infra_env.__dict__) result = await asyncio.to_thread( - self._installer_api().register_infra_env, - infraenv_create_params=infra_env + self._installer_api().register_infra_env, infraenv_create_params=infra_env ) - return result + return cast(models.InfraEnv, result) + + async def update_cluster( + self, + cluster_id: str, + api_vip: Optional[str] = "", + ingress_vip: Optional[str] = "", + **update_params: Any, + ) -> models.Cluster: + """ + Update cluster configuration. + + Args: + cluster_id: The unique identifier of the cluster to update. + api_vip: Optional API virtual IP address. + ingress_vip: Optional ingress virtual IP address. + **update_params: Additional cluster update parameters. - async def update_cluster(self, cluster_id: str, api_vip: Optional[str] = "", ingress_vip: Optional[str] = "", **update_params) -> models.Cluster: + Returns: + models.Cluster: The updated cluster object. + """ params = models.V2ClusterUpdateParams(**update_params) if api_vip != "": params.api_vips = [models.ApiVip(cluster_id=cluster_id, ip=api_vip)] if ingress_vip != "": - params.ingress_vips = [models.IngressVip(cluster_id=cluster_id, ip=ingress_vip)] + params.ingress_vips = [ + models.IngressVip(cluster_id=cluster_id, ip=ingress_vip) + ] log.info("Updating cluster %s with params %s", cluster_id, params) - return await asyncio.to_thread( - self._installer_api().v2_update_cluster, - cluster_id=cluster_id, - cluster_update_params=params + return cast( + models.Cluster, + await asyncio.to_thread( + self._installer_api().v2_update_cluster, + cluster_id=cluster_id, + cluster_update_params=params, + ), ) async def install_cluster(self, cluster_id: str) -> models.Cluster: + """ + Start the installation process for a cluster. + + Args: + cluster_id: The unique identifier of the cluster to install. + + Returns: + models.Cluster: The cluster object with updated installation status. + """ log.info("Installing cluster %s", cluster_id) - return await asyncio.to_thread( - self._installer_api().v2_install_cluster, - cluster_id=cluster_id + return cast( + models.Cluster, + await asyncio.to_thread( + self._installer_api().v2_install_cluster, cluster_id=cluster_id + ), ) - async def get_openshift_versions(self, only_latest: bool) -> models.OpenshiftVersions: - return await asyncio.to_thread( - self._versions_api().v2_list_supported_openshift_versions, - only_latest=only_latest + async def get_openshift_versions( + self, only_latest: bool + ) -> models.OpenshiftVersions: + """ + Get supported OpenShift versions. + + Args: + only_latest: Whether to return only the latest versions. + + Returns: + models.OpenshiftVersions: Object containing available OpenShift versions. + """ + return cast( + models.OpenshiftVersions, + await asyncio.to_thread( + self._versions_api().v2_list_supported_openshift_versions, + only_latest=only_latest, + ), ) - async def get_operator_bundles(self): - bundles = await asyncio.to_thread(self._operators_api().v2_list_bundles) + async def get_operator_bundles(self) -> list[dict[str, Any]]: + """ + Get available operator bundles. + + Returns: + list: A list of operator bundle dictionaries. + """ + bundles = cast( + list, await asyncio.to_thread(self._operators_api().v2_list_bundles) + ) return [bundle.to_dict() for bundle in bundles] - async def add_operator_bundle_to_cluster(self, cluster_id: str, bundle_name: str) -> models.Cluster: - bundle = await asyncio.to_thread(self._operators_api().v2_get_bundle, bundle_name) - olm_operators = [models.OperatorCreateParams(name=op_name) for op_name in bundle.operators] - return await self.update_cluster(cluster_id=cluster_id, olm_operators=olm_operators) + async def add_operator_bundle_to_cluster( + self, cluster_id: str, bundle_name: str + ) -> models.Cluster: + """ + Add an operator bundle to a cluster. + + Args: + cluster_id: The unique identifier of the cluster. + bundle_name: The name of the operator bundle to add. + + Returns: + models.Cluster: The updated cluster object with the new operator. + """ + bundle = cast( + Any, + await asyncio.to_thread(self._operators_api().v2_get_bundle, bundle_name), + ) + olm_operators = [ + models.OperatorCreateParams(name=op_name) for op_name in bundle.operators + ] + return await self.update_cluster( + cluster_id=cluster_id, olm_operators=olm_operators + ) + + async def update_host( + self, host_id: str, infra_env_id: str, **update_params: Any + ) -> models.Host: + """ + Update host configuration within an infrastructure environment. + + Args: + host_id: The unique identifier of the host to update. + infra_env_id: The infrastructure environment ID containing the host. + **update_params: Host update parameters. - async def update_host(self, host_id: str, infra_env_id: str, **update_params) -> models.Host: + Returns: + models.Host: The updated host object. + """ params = models.HostUpdateParams(**update_params) - return await asyncio.to_thread( - self._installer_api().v2_update_host, - infra_env_id, host_id, params + return cast( + models.Host, + await asyncio.to_thread( + self._installer_api().v2_update_host, infra_env_id, host_id, params + ), ) diff --git a/service_client/logger.py b/service_client/logger.py index a64f99d..d7da957 100644 --- a/service_client/logger.py +++ b/service_client/logger.py @@ -1,115 +1,76 @@ +""" +Logging utilities with sensitive information filtering. + +This module provides logging configuration and formatting utilities that +automatically filter sensitive information like pull secrets, SSH keys, +and vSphere credentials from log messages. +""" + # -*- coding: utf-8 -*- import logging import os import re import sys -import traceback -from contextlib import suppress -from enum import Enum -from types import TracebackType -from typing import Type class SensitiveFormatter(logging.Formatter): """Formatter that removes sensitive info.""" @staticmethod - def _filter(s): + def _filter(s: str) -> str: # Dict filter s = re.sub(r"('_pull_secret':\s+)'(.*?)'", r"\g<1>'*** PULL_SECRET ***'", s) s = re.sub(r"('_ssh_public_key':\s+)'(.*?)'", r"\g<1>'*** SSH_KEY ***'", s) - s = re.sub(r"('_vsphere_username':\s+)'(.*?)'", r"\g<1>'*** VSPHERE_USER ***'", s) - s = re.sub(r"('_vsphere_password':\s+)'(.*?)'", r"\g<1>'*** VSPHERE_PASSWORD ***'", s) + s = re.sub( + r"('_vsphere_username':\s+)'(.*?)'", r"\g<1>'*** VSPHERE_USER ***'", s + ) + s = re.sub( + r"('_vsphere_password':\s+)'(.*?)'", r"\g<1>'*** VSPHERE_PASSWORD ***'", s + ) # Object filter - s = re.sub(r"(pull_secret='[^']*(?=')')", "pull_secret = *** PULL_SECRET ***", s) - s = re.sub(r"(ssh_public_key='[^']*(?=')')", "ssh_public_key = *** SSH_KEY ***", s) - s = re.sub(r"(vsphere_username='[^']*(?=')')", "vsphere_username = *** VSPHERE_USER ***", s) - s = re.sub(r"(vsphere_password='[^']*(?=')')", "vsphere_password = *** VSPHERE_PASSWORD ***", s) + s = re.sub( + r"(pull_secret='[^']*(?=')')", "pull_secret = *** PULL_SECRET ***", s + ) + s = re.sub( + r"(ssh_public_key='[^']*(?=')')", "ssh_public_key = *** SSH_KEY ***", s + ) + s = re.sub( + r"(vsphere_username='[^']*(?=')')", + "vsphere_username = *** VSPHERE_USER ***", + s, + ) + s = re.sub( + r"(vsphere_password='[^']*(?=')')", + "vsphere_password = *** VSPHERE_PASSWORD ***", + s, + ) return s - def format(self, record): - original = logging.Formatter.format(self, record) - return self._filter(original) - - -class Color(Enum): - BLUE = "\033[0;34m" - LIGHT_RED = "\033[1;31m" - LIGHT_YELLOW = "\033[1;33m" - LIGHT_BLUE = "\033[1;34m" - LIGHT_PURPLE = "\033[1;35m" - LIGHT_CYAN = "\033[1;36m" - WHITE = "\033[1;37m" - RESET = "\033[0m" - - -ColorLevel = { - logging.DEBUG: Color.BLUE.value, - logging.INFO: Color.RESET.value, - logging.WARNING: Color.LIGHT_YELLOW.value, - logging.ERROR: Color.LIGHT_RED.value, - logging.CRITICAL: Color.LIGHT_PURPLE.value, -} - - -class ColorizingFileHandler(logging.FileHandler): - def emit(self, record): - if self.stream is None: - if self.mode != "w" or not self._closed: - self.stream = self._open() - if self.stream: - ColorizingStreamHandler.emit(self, record) - - @property - def is_tty(self): - return True - - -class ColorizingStreamHandler(logging.StreamHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - @property - def is_tty(self): - isatty = getattr(self.stream, "isatty", None) - return isatty and isatty() + def format(self, record: logging.LogRecord) -> str: + """ + Format log record while filtering sensitive information. - def emit(self, record): - try: - message = self.format(record) - stream = self.stream - if not self.is_tty: - stream.write(message) - else: - message = ColorLevel[record.levelno] + message + Color.RESET.value - stream.write(message) - stream.write(getattr(self, "terminator", "\n")) - self.flush() - except Exception: - self.handleError(record) - - -def add_log_record(test_id): - # Adding log record for testcase id - _former_log_record_factory = logging.getLogRecordFactory() - - def log_record_uuid_injector(*args, **kwargs): - record = _former_log_record_factory(*args, **kwargs) - record.test_id = test_id - return record - - logging.setLogRecordFactory(log_record_uuid_injector) + Args: + record: The LogRecord instance to be formatted. + Returns: + str: The formatted log message with sensitive info redacted. + """ + original = logging.Formatter.format(self, record) + return self._filter(original) -# set test_id record to "" empty by default -add_log_record("") +def get_logging_level() -> int: + """ + Get the logging level from environment variable. -def get_logging_level(): + Returns: + int: The logging level (defaults to INFO if not set or invalid). + """ level = os.environ.get("LOGGING_LEVEL", "") - return logging.getLevelName(level.upper()) if level else logging.INFO + return getattr(logging, level.upper(), logging.INFO) if level else logging.INFO logging.getLogger("requests").setLevel(logging.ERROR) @@ -118,20 +79,37 @@ def get_logging_level(): def add_log_file_handler(logger: logging.Logger, filename: str) -> logging.FileHandler: + """ + Add a file handler to the logger with sensitive information filtering. + + Args: + logger: The logger instance to add the handler to. + filename: The path to the log file. + + Returns: + logging.FileHandler: The created file handler. + """ fmt = SensitiveFormatter( - "%(asctime)s - %(name)s - %(levelname)s - %(test_id)s:%(thread)d:%(process)d - %(message)s" + "%(asctime)s - %(name)s - %(levelname)s - %(thread)d:%(process)d - %(message)s" ) - fh = ColorizingFileHandler(filename) + fh = logging.FileHandler(filename) fh.setFormatter(fmt) logger.addHandler(fh) return fh -def add_stream_handler(logger: logging.Logger): +def add_stream_handler(logger: logging.Logger) -> None: + """ + Add a stream handler to the logger with sensitive information filtering. + + Args: + logger: The logger instance to add the handler to. + """ fmt = SensitiveFormatter( - "%(asctime)s %(name)s %(levelname)-10s - %(thread)d - %(message)s \t" "(%(pathname)s:%(lineno)d)->%(funcName)s" + "%(asctime)s %(name)s %(levelname)-10s - %(thread)d - %(message)s \t" + "(%(pathname)s:%(lineno)d)->%(funcName)s" ) - ch = ColorizingStreamHandler(sys.stderr) + ch = logging.StreamHandler(sys.stderr) ch.setFormatter(fmt) logger.addHandler(ch) @@ -150,15 +128,3 @@ def add_stream_handler(logger: logging.Logger): add_log_file_handler(urllib3_logger, "assisted-service-mcp.log") add_stream_handler(log) add_stream_handler(urllib3_logger) - - -class SuppressAndLog(suppress): - def __exit__(self, exctype: Type[Exception], excinst: Exception, exctb: TracebackType): - res = super().__exit__(exctype, excinst, exctb) - - if res: - with suppress(BaseException): - tb_data = traceback.extract_tb(exctb, 1)[0] - log.warning(f"Suppressed {exctype.__name__} from {tb_data.name}:{tb_data.lineno} : {excinst}") - - return res diff --git a/uv.lock b/uv.lock index d0b7bbc..b73229e 100644 --- a/uv.lock +++ b/uv.lock @@ -49,6 +49,17 @@ dependencies = [ { name = "netaddr" }, { name = "requests" }, { name = "retry" }, + { name = "types-requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "mypy" }, + { name = "pydocstyle" }, + { name = "pylint" }, + { name = "pyright" }, + { name = "ruff" }, ] [package.metadata] @@ -58,6 +69,26 @@ requires-dist = [ { name = "netaddr", specifier = ">=1.3.0" }, { name = "requests", specifier = ">=2.32.3" }, { name = "retry", specifier = ">=0.9.2" }, + { name = "types-requests", specifier = ">=2.32.4.20250611" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=25.1.0" }, + { name = "mypy", specifier = ">=1.16.1" }, + { name = "pydocstyle", specifier = ">=6.3.0" }, + { name = "pylint", specifier = ">=3.3.7" }, + { name = "pyright", specifier = ">=1.1.402" }, + { name = "ruff", specifier = ">=0.12.1" }, +] + +[[package]] +name = "astroid" +version = "3.3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/c2/9b2de9ed027f9fe5734a6c0c0a601289d796b3caaf1e372e23fa88a73047/astroid-3.3.10.tar.gz", hash = "sha256:c332157953060c6deb9caa57303ae0d20b0fbdb2e59b4a4f2a6ba49d0a7961ce", size = 398941, upload-time = "2025-05-10T13:33:10.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/58/5260205b9968c20b6457ed82f48f9e3d6edf2f1f95103161798b73aeccf0/astroid-3.3.10-py3-none-any.whl", hash = "sha256:104fb9cb9b27ea95e847a94c003be03a9e039334a8ebca5ee27dafaf5c5711eb", size = 275388, upload-time = "2025-05-10T13:33:08.391Z" }, ] [[package]] @@ -72,6 +103,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981, upload-time = "2025-05-23T00:21:43.075Z" }, ] +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, +] + [[package]] name = "certifi" version = "2025.4.26" @@ -190,6 +241,15 @@ 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 = "dill" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -273,6 +333,15 @@ 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 = "isort" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload-time = "2025-02-26T21:13:16.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -285,6 +354,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + [[package]] name = "mcp" version = "1.9.4" @@ -314,6 +392,35 @@ 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 = "mypy" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "netaddr" version = "1.3.0" @@ -323,6 +430,15 @@ 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 = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + [[package]] name = "openapi-pydantic" version = "0.5.1" @@ -335,6 +451,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, ] +[[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 = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + [[package]] name = "py" version = "1.11.0" @@ -410,6 +553,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" }, ] +[[package]] +name = "pydocstyle" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "snowballstemmer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/d5385ca59fd065e3c6a5fe19f9bc9d5ea7f2509fa8c9c22fb6b2031dd953/pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1", size = 36796, upload-time = "2023-01-17T20:29:19.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/ea/99ddefac41971acad68f14114f38261c1f27dac0b3ec529824ebc739bdaa/pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019", size = 38038, upload-time = "2023-01-17T20:29:18.094Z" }, +] + [[package]] name = "pygments" version = "2.19.1" @@ -419,6 +574,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pylint" +version = "3.3.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/e4/83e487d3ddd64ab27749b66137b26dc0c5b5c161be680e6beffdc99070b3/pylint-3.3.7.tar.gz", hash = "sha256:2b11de8bde49f9c5059452e0c310c079c746a0a8eeaa789e5aa966ecc23e4559", size = 1520709, upload-time = "2025-05-04T17:07:51.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/83/bff755d09e31b5d25cc7fdc4bf3915d1a404e181f1abf0359af376845c24/pylint-3.3.7-py3-none-any.whl", hash = "sha256:43860aafefce92fca4cf6b61fe199cdc5ae54ea28f9bf4cd49de267b5195803d", size = 522565, upload-time = "2025-05-04T17:07:48.714Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.402" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/04/ce0c132d00e20f2d2fb3b3e7c125264ca8b909e693841210534b1ea1752f/pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683", size = 3888207, upload-time = "2025-06-11T08:48:35.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/37/1a1c62d955e82adae588be8e374c7f77b165b6cb4203f7d581269959abbc/pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982", size = 5624004, upload-time = "2025-06-11T08:48:33.998Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -490,6 +676,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, ] +[[package]] +name = "ruff" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/97/38/796a101608a90494440856ccfb52b1edae90de0b817e76bfade66b12d320/ruff-0.12.1.tar.gz", hash = "sha256:806bbc17f1104fd57451a98a58df35388ee3ab422e029e8f5cf30aa4af2c138c", size = 4413426, upload-time = "2025-06-26T20:34:14.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/bf/3dba52c1d12ab5e78d75bd78ad52fb85a6a1f29cc447c2423037b82bed0d/ruff-0.12.1-py3-none-linux_armv6l.whl", hash = "sha256:6013a46d865111e2edb71ad692fbb8262e6c172587a57c0669332a449384a36b", size = 10305649, upload-time = "2025-06-26T20:33:39.242Z" }, + { url = "https://files.pythonhosted.org/packages/8c/65/dab1ba90269bc8c81ce1d499a6517e28fe6f87b2119ec449257d0983cceb/ruff-0.12.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b3f75a19e03a4b0757d1412edb7f27cffb0c700365e9d6b60bc1b68d35bc89e0", size = 11120201, upload-time = "2025-06-26T20:33:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3e/2d819ffda01defe857fa2dd4cba4d19109713df4034cc36f06bbf582d62a/ruff-0.12.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a256522893cb7e92bb1e1153283927f842dea2e48619c803243dccc8437b8be", size = 10466769, upload-time = "2025-06-26T20:33:44.102Z" }, + { url = "https://files.pythonhosted.org/packages/63/37/bde4cf84dbd7821c8de56ec4ccc2816bce8125684f7b9e22fe4ad92364de/ruff-0.12.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:069052605fe74c765a5b4272eb89880e0ff7a31e6c0dbf8767203c1fbd31c7ff", size = 10660902, upload-time = "2025-06-26T20:33:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3a/390782a9ed1358c95e78ccc745eed1a9d657a537e5c4c4812fce06c8d1a0/ruff-0.12.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a684f125a4fec2d5a6501a466be3841113ba6847827be4573fddf8308b83477d", size = 10167002, upload-time = "2025-06-26T20:33:47.81Z" }, + { url = "https://files.pythonhosted.org/packages/6d/05/f2d4c965009634830e97ffe733201ec59e4addc5b1c0efa035645baa9e5f/ruff-0.12.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdecdef753bf1e95797593007569d8e1697a54fca843d78f6862f7dc279e23bd", size = 11751522, upload-time = "2025-06-26T20:33:49.857Z" }, + { url = "https://files.pythonhosted.org/packages/35/4e/4bfc519b5fcd462233f82fc20ef8b1e5ecce476c283b355af92c0935d5d9/ruff-0.12.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:70d52a058c0e7b88b602f575d23596e89bd7d8196437a4148381a3f73fcd5010", size = 12520264, upload-time = "2025-06-26T20:33:52.199Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/7756a6925da236b3a31f234b4167397c3e5f91edb861028a631546bad719/ruff-0.12.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84d0a69d1e8d716dfeab22d8d5e7c786b73f2106429a933cee51d7b09f861d4e", size = 12133882, upload-time = "2025-06-26T20:33:54.231Z" }, + { url = "https://files.pythonhosted.org/packages/dd/00/40da9c66d4a4d51291e619be6757fa65c91b92456ff4f01101593f3a1170/ruff-0.12.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cc32e863adcf9e71690248607ccdf25252eeeab5193768e6873b901fd441fed", size = 11608941, upload-time = "2025-06-26T20:33:56.202Z" }, + { url = "https://files.pythonhosted.org/packages/91/e7/f898391cc026a77fbe68dfea5940f8213622474cb848eb30215538a2dadf/ruff-0.12.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fd49a4619f90d5afc65cf42e07b6ae98bb454fd5029d03b306bd9e2273d44cc", size = 11602887, upload-time = "2025-06-26T20:33:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/f6/02/0891872fc6aab8678084f4cf8826f85c5d2d24aa9114092139a38123f94b/ruff-0.12.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ed5af6aaaea20710e77698e2055b9ff9b3494891e1b24d26c07055459bb717e9", size = 10521742, upload-time = "2025-06-26T20:34:00.465Z" }, + { url = "https://files.pythonhosted.org/packages/2a/98/d6534322c74a7d47b0f33b036b2498ccac99d8d8c40edadb552c038cecf1/ruff-0.12.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:801d626de15e6bf988fbe7ce59b303a914ff9c616d5866f8c79eb5012720ae13", size = 10149909, upload-time = "2025-06-26T20:34:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/34/5c/9b7ba8c19a31e2b6bd5e31aa1e65b533208a30512f118805371dbbbdf6a9/ruff-0.12.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2be9d32a147f98a1972c1e4df9a6956d612ca5f5578536814372113d09a27a6c", size = 11136005, upload-time = "2025-06-26T20:34:04.723Z" }, + { url = "https://files.pythonhosted.org/packages/dc/34/9bbefa4d0ff2c000e4e533f591499f6b834346025e11da97f4ded21cb23e/ruff-0.12.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49b7ce354eed2a322fbaea80168c902de9504e6e174fd501e9447cad0232f9e6", size = 11648579, upload-time = "2025-06-26T20:34:06.766Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/20cdb593783f8f411839ce749ec9ae9e4298c2b2079b40295c3e6e2089e1/ruff-0.12.1-py3-none-win32.whl", hash = "sha256:d973fa626d4c8267848755bd0414211a456e99e125dcab147f24daa9e991a245", size = 10519495, upload-time = "2025-06-26T20:34:08.718Z" }, + { url = "https://files.pythonhosted.org/packages/cf/56/7158bd8d3cf16394928f47c637d39a7d532268cd45220bdb6cd622985760/ruff-0.12.1-py3-none-win_amd64.whl", hash = "sha256:9e1123b1c033f77bd2590e4c1fe7e8ea72ef990a85d2484351d408224d603013", size = 11547485, upload-time = "2025-06-26T20:34:11.008Z" }, + { url = "https://files.pythonhosted.org/packages/91/d0/6902c0d017259439d6fd2fd9393cea1cfe30169940118b007d5e0ea7e954/ruff-0.12.1-py3-none-win_arm64.whl", hash = "sha256:78ad09a022c64c13cc6077707f036bab0fac8cd7088772dcd1e5be21c5002efc", size = 10691209, upload-time = "2025-06-26T20:34:12.928Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -517,6 +728,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + [[package]] name = "sse-starlette" version = "2.3.5" @@ -542,6 +762,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, ] +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + [[package]] name = "typer" version = "0.15.4" @@ -557,6 +786,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/62/d4ba7afe2096d5659ec3db8b15d8665bdcb92a3c6ff0b95e99895b335a9c/typer-0.15.4-py3-none-any.whl", hash = "sha256:eb0651654dcdea706780c466cf06d8f174405a659ffff8f163cfbfee98c0e173", size = 45258, upload-time = "2025-05-14T16:34:55.583Z" }, ] +[[package]] +name = "types-requests" +version = "2.32.4.20250611" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/7f/73b3a04a53b0fd2a911d4ec517940ecd6600630b559e4505cc7b68beb5a0/types_requests-2.32.4.20250611.tar.gz", hash = "sha256:741c8777ed6425830bf51e54d6abe245f79b4dcb9019f1622b773463946bf826", size = 23118, upload-time = "2025-06-11T03:11:41.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ea/0be9258c5a4fa1ba2300111aa5a0767ee6d18eb3fd20e91616c12082284d/types_requests-2.32.4.20250611-py3-none-any.whl", hash = "sha256:ad2fe5d3b0cb3c2c902c8815a70e7fb2302c4b8c1f77bdcd738192cdb3878072", size = 20643, upload-time = "2025-06-11T03:11:40.186Z" }, +] + [[package]] name = "typing-extensions" version = "4.13.2"