diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index 205ce96..0000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: reviewdog -on: [pull_request] -jobs: - linter_name: - name: runner / black formatter - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: reviewdog/action-black@v3 - with: - github_token: ${{ secrets.github_token }} - # Change reviewdog reporter if you need [github-pr-check, github-check]. - reporter: github-pr-check diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index ac2c611..1e5a80d 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -13,7 +13,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.x" + python-version: "3.14" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 37b9820..1e7d3f9 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -19,8 +19,8 @@ jobs: fail-fast: false matrix: python-version: - - '3.9' - - '3.10' + - '3.13' + - '3.14' steps: - uses: actions/checkout@v5 @@ -36,17 +36,17 @@ jobs: python -m pip install -r requirements.txt python -m pip install -e .[dev] - - name: Lint with flake8 + - name: Lint with Ruff run: | - flake8 . --count --show-source --statistics + ruff check . - - name: Check typing with mypy + - name: Check formatting with Ruff run: | - mypy --install-types --non-interactive . + ruff format --check . - - name: Check syntax with pyupgrade + - name: Check typing with mypy run: | - find . -type f -regex '.*\.py$' -exec pyupgrade --py39-plus {} \; + mypy --install-types --non-interactive . - name: Create k8s Kind Cluster uses: helm/kind-action@v1.12.0 diff --git a/pyproject.toml b/pyproject.toml index e0f4985..bf6fc28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,26 +1,33 @@ -[tool.black] +[tool.ruff] line-length = 120 -target-version = ['py39'] -exclude = ''' +target-version = "py311" +exclude = [ + ".eggs", + ".git", + ".hg", + ".mypy_cache", + ".tox", + ".venv", + "_build", + "buck-out", + "build", + "dist", + "documentation", + "es_utils", + "alembic", +] -( - /( - \.eggs # exclude a few common directories in the - | \.git # root of the project - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | _build - | buck-out - | build - | dist - )/ - | es_utils - | alembic - | documentation -) -''' +[tool.ruff.lint] +select = [ + "E", + "F", + "I", + "UP", +] +ignore = [ + "E203", + "E501", +] [tool.bandit] exclude_dirs = ["./.git", "./kubernetes", "./documentation", "./src/wiremind_kubernetes/tests", "./.venv", "./build"] diff --git a/requirements.txt b/requirements.txt index 15e5a4a..6e4719c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,54 +1,36 @@ -# -# This file is autogenerated by pip-compile with Python 3.9 -# by the following command: -# -# pip-compile --no-emit-index-url setup.py -# -cachetools==5.2.0 - # via google-auth -certifi==2024.7.4 +# This file was autogenerated by uv via the following command: +# uv pip compile pyproject.toml --no-emit-index-url --python-platform=linux --output-file=requirements.txt +certifi==2026.2.25 # via # kubernetes # requests -charset-normalizer==2.1.1 +charset-normalizer==3.4.5 # via requests -google-auth==2.11.1 +durationpy==0.10 # via kubernetes -idna==3.7 +idna==3.11 # via requests -kubernetes==24.2.0 - # via wiremind-kubernetes (setup.py) -oauthlib==3.2.2 +kubernetes==35.0.0 + # via wiremind-kubernetes (pyproject.toml) +oauthlib==3.3.1 # via requests-oauthlib -pyasn1==0.4.8 - # via - # pyasn1-modules - # rsa -pyasn1-modules==0.2.8 - # via google-auth -python-dateutil==2.8.2 +python-dateutil==2.9.0.post0 # via kubernetes -pyyaml==6.0 +pyyaml==6.0.3 # via kubernetes -requests==2.32.4 +requests==2.32.5 # via # kubernetes # requests-oauthlib -requests-oauthlib==1.3.1 +requests-oauthlib==2.0.0 # via kubernetes -rsa==4.9 - # via google-auth -six==1.16.0 +six==1.17.0 # via - # google-auth # kubernetes # python-dateutil -urllib3==1.26.19 +urllib3==2.6.3 # via # kubernetes # requests -websocket-client==1.4.1 +websocket-client==1.9.0 # via kubernetes - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/setup.cfg b/setup.cfg index 3779b62..926d474 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,20 +1,8 @@ [options] -python_requires = >= 3.9 - -[flake8] -max-line-length = 120 -# Enable flake8-mutable -enable-extensions = M511 -# W503 line break before binary operator. Black causes this error -# E203 whitespace before ':' -# E231 missing whitespace after ','. Black causes this error but seems to be right (see pep8) -# E501 line too long -# Q000 Double quotes found but single quotes preferred -ignore = W503, E203, E231, E501, Q000 -jobs = 4 +python_requires = >= 3.13 [mypy] -python_version = 3.9 +python_version = 3.13 ignore_missing_imports = True check_untyped_defs = True disallow_untyped_defs = True @@ -26,17 +14,6 @@ no_implicit_optional = True show_error_codes = True files = src -[isort] -src_paths = src,tests -profile = black -line_length = 120 -sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -no_lines_before = LOCALFOLDER -multi_line_output = 3 -include_trailing_comma = True -use_parentheses = True -force_grid_wrap = 0 - [tool:pytest] log_level=INFO # Deterministic ordering for tests; useful for pytest-xdist. diff --git a/setup.py b/setup.py index 6ea5d42..de0c56a 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ """ wiremind-kubernetes """ + from setuptools import find_packages, setup with open("VERSION") as version_file: @@ -16,13 +17,7 @@ ] extra_require_dev = ( [ - "flake8", - "black", - "isort", - "bandit", - "flake8-mutable", - "pip-tools>=3.7.0", - "pyupgrade", + "ruff", "safety", ] + extra_require_mypy @@ -55,11 +50,14 @@ "mypy": extra_require_mypy, "test": extra_require_test, }, - python_requires=">=3.9.0", + python_requires=">=3.13.0", keywords=["kubernetes"], classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ], ) diff --git a/src/wiremind_kubernetes/__init__.py b/src/wiremind_kubernetes/__init__.py index c91ca86..eb89bd2 100644 --- a/src/wiremind_kubernetes/__init__.py +++ b/src/wiremind_kubernetes/__init__.py @@ -1,3 +1,7 @@ # noqa: F401 -from .kubernetes_helper import KubernetesDeploymentManager, KubernetesHelper, NamespacedKubernetesHelper # noqa: F401 +from .kubernetes_helper import ( + KubernetesDeploymentManager, # noqa: F401 + KubernetesHelper, # noqa: F401 + NamespacedKubernetesHelper, # noqa: F401 +) from .utils import run_command # noqa: F401 diff --git a/src/wiremind_kubernetes/exceptions.py b/src/wiremind_kubernetes/exceptions.py index a6b797d..ba1db52 100644 --- a/src/wiremind_kubernetes/exceptions.py +++ b/src/wiremind_kubernetes/exceptions.py @@ -1,6 +1,3 @@ -from typing import Optional - - class WiremindKubernetesException(Exception): """ Base wiremind-kubernetes Exception. @@ -8,7 +5,7 @@ class WiremindKubernetesException(Exception): message: str - def __init__(self, message: Optional[str] = None): + def __init__(self, message: str | None = None): super().__init__() if message: self.message = message diff --git a/src/wiremind_kubernetes/kube_config.py b/src/wiremind_kubernetes/kube_config.py index 306b7fc..8227f11 100644 --- a/src/wiremind_kubernetes/kube_config.py +++ b/src/wiremind_kubernetes/kube_config.py @@ -1,13 +1,12 @@ import logging import os -from typing import Optional import kubernetes logger = logging.getLogger(__name__) -def _load_kubeconfig(config_file: Optional[str] = None, context: Optional[str] = None) -> None: +def _load_kubeconfig(config_file: str | None = None, context: str | None = None) -> None: kubernetes.config.load_kube_config(config_file=config_file, context=context) logger.debug("Kubernetes configuration successfully set.") @@ -18,7 +17,9 @@ def _load_incluster_config() -> None: def load_kubernetes_config( - use_kubeconfig: Optional[bool] = None, config_file: Optional[str] = None, context: Optional[str] = None + use_kubeconfig: bool | None = None, + config_file: str | None = None, + context: str | None = None, ) -> None: """ Load kubernetes configuration in memory, either from incluster method or from kubeconfig. diff --git a/src/wiremind_kubernetes/kubernetes_client_additional_arguments.py b/src/wiremind_kubernetes/kubernetes_client_additional_arguments.py index 94d55f4..8b2faa2 100644 --- a/src/wiremind_kubernetes/kubernetes_client_additional_arguments.py +++ b/src/wiremind_kubernetes/kubernetes_client_additional_arguments.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any import kubernetes.client @@ -11,14 +11,14 @@ class ClientWithArguments: """ client: Any - read_additional_arguments: Dict[str, Any] - additional_arguments: Dict[str, Any] + read_additional_arguments: dict[str, Any] + additional_arguments: dict[str, Any] + read_argument_exclusions: dict[str, set[str]] def __init__(self, client: Any, dry_run: bool = False, pretty: bool = True): self.client = client() # like kubernetes.client.CoreV1Api self.read_additional_arguments = {} - # Only add it when its true because we set pretty client wide, - # read_cluster_custom_object which accepts it will not have it set, but it's ok for now. + self.read_argument_exclusions = {} if pretty: self.read_additional_arguments["pretty"] = pretty # Every request, either read or write, will have those arguments added @@ -27,6 +27,13 @@ def __init__(self, client: Any, dry_run: bool = False, pretty: bool = True): # Dry run, in kube API, is not true or false, but either dry_run: All or not defined. self.additional_arguments["dry_run"] = "All" + def get_read_additional_arguments(self, attr: str) -> dict[str, Any]: + read_additional_arguments = self.read_additional_arguments.copy() + # Some generated client read methods do not accept every shared read kwarg. + for argument_name in self.read_argument_exclusions.get(attr, set()): + read_additional_arguments.pop(argument_name, None) + return read_additional_arguments + def __getattr__(self, attr: str) -> Any: original_attr = getattr(self.client, attr) @@ -43,7 +50,7 @@ def fn(*args: Any, **kwargs: Any) -> Any: if is_write_function: kwargs.update(self.additional_arguments) else: # A read function - kwargs.update(self.read_additional_arguments) + kwargs.update(self.get_read_additional_arguments(attr)) return original_attr(*args, **kwargs) return fn @@ -68,6 +75,7 @@ class AutoscalingV1ApiWithArguments(ClientWithArguments): def __init__(self, *args: Any, dry_run: bool = False, pretty: bool = False, **kwargs: Any) -> None: super().__init__(client=kubernetes.client.AutoscalingV1Api, dry_run=dry_run, pretty=pretty) + class AutoscalingV2ApiWithArguments(ClientWithArguments): def __init__(self, *args: Any, dry_run: bool = False, pretty: bool = False, **kwargs: Any) -> None: super().__init__(client=kubernetes.client.AutoscalingV2Api, dry_run=dry_run, pretty=pretty) @@ -76,11 +84,25 @@ def __init__(self, *args: Any, dry_run: bool = False, pretty: bool = False, **kw class CustomObjectsApiWithArguments(ClientWithArguments): def __init__(self, *args: Any, dry_run: bool = False, pretty: bool = False, **kwargs: Any) -> None: super().__init__(client=kubernetes.client.CustomObjectsApi, dry_run=dry_run, pretty=pretty) + # Kubernetes custom-object GET-like methods reject `pretty`, while list methods still accept it. + self.read_argument_exclusions = { + "get_api_resources": {"pretty"}, + "get_cluster_custom_object": {"pretty"}, + "get_cluster_custom_object_scale": {"pretty"}, + "get_cluster_custom_object_status": {"pretty"}, + "get_namespaced_custom_object": {"pretty"}, + "get_namespaced_custom_object_scale": {"pretty"}, + "get_namespaced_custom_object_status": {"pretty"}, + } class RbacAuthorizationV1ApiWithArguments(ClientWithArguments): def __init__(self, *args: Any, dry_run: bool = False, pretty: bool = False, **kwargs: Any) -> None: - super().__init__(client=kubernetes.client.RbacAuthorizationV1Api, dry_run=dry_run, pretty=pretty) + super().__init__( + client=kubernetes.client.RbacAuthorizationV1Api, + dry_run=dry_run, + pretty=pretty, + ) class NetworkingV1ApiWithArguments(ClientWithArguments): diff --git a/src/wiremind_kubernetes/kubernetes_helper.py b/src/wiremind_kubernetes/kubernetes_helper.py index d11b4a7..e81de08 100644 --- a/src/wiremind_kubernetes/kubernetes_helper.py +++ b/src/wiremind_kubernetes/kubernetes_helper.py @@ -1,13 +1,16 @@ import logging import pprint import time -from typing import Any, Dict, Generator, List, Optional, Union +from collections.abc import Generator +from typing import Any import kubernetes from wiremind_kubernetes.exceptions import PodNotFound + from .kube_config import load_kubernetes_config from .kubernetes_client_additional_arguments import ( + AdmissionregistrationV1ApiWithArguments, AppV1ApiWithArguments, AutoscalingV2ApiWithArguments, BatchV1ApiWithArguments, @@ -16,7 +19,6 @@ NetworkingV1ApiWithArguments, RbacAuthorizationV1ApiWithArguments, StorageV1ApiWithArguments, - AdmissionregistrationV1ApiWithArguments, ) from .utils import retry_kubernetes_request, retry_kubernetes_request_no_ignore @@ -46,11 +48,11 @@ class KubernetesHelper: def __init__( self, - use_kubeconfig: Optional[bool] = False, + use_kubeconfig: bool | None = False, dry_run: bool = False, pretty: bool = True, should_load_kubernetes_config: bool = True, - context: Optional[str] = None, + context: str | None = None, ): """ :param use_kubeconfig: @@ -94,11 +96,11 @@ class NamespacedKubernetesHelper(KubernetesHelper): def __init__( self, - use_kubeconfig: Optional[bool] = False, - namespace: Union[None, str] = None, + use_kubeconfig: bool | None = False, + namespace: None | str = None, dry_run: bool = False, should_load_kubernetes_config: bool = True, - context: Optional[str] = None, + context: str | None = None, ): """ :param use_kubeconfig: @@ -164,7 +166,7 @@ def is_statefulset_stopped(self, deployment_name: str) -> bool: return self.is_deployment_stopped(deployment_name, statefulset=True) @retry_kubernetes_request_no_ignore - def _get_pods_from_deployment(self, deployment_name: str, statefulset: bool = False) -> List: + def _get_pods_from_deployment(self, deployment_name: str, statefulset: bool = False) -> list: if statefulset: logger.debug("Asking if StatefulSet %s is stopped", deployment_name) labels = self.client_appsv1_api.read_namespaced_stateful_set( @@ -178,7 +180,8 @@ def _get_pods_from_deployment(self, deployment_name: str, statefulset: bool = Fa try: return self.client_corev1_api.list_namespaced_pod( - namespace=self.namespace, label_selector=",".join(["%s=%s" % kv for kv in labels.items()]) + namespace=self.namespace, + label_selector=",".join(["{}={}".format(*kv) for kv in labels.items()]), ).items except kubernetes.client.rest.ApiException as e: if e.status == 404: @@ -188,7 +191,7 @@ def _get_pods_from_deployment(self, deployment_name: str, statefulset: bool = Fa def is_deployment_stopped(self, deployment_name: str, statefulset: bool = False) -> bool: try: - pod_list: List = self._get_pods_from_deployment(deployment_name, statefulset) + pod_list: list = self._get_pods_from_deployment(deployment_name, statefulset) except kubernetes.client.rest.ApiException as e: if e.status == 404: logger.warning("Not found, ignoring.") @@ -245,7 +248,7 @@ def getPodNameFromDeployment(self, deployment_name: str, namespace_name: str) -> selector = ",".join(f"{key}={value}" for key, value in deployment.spec.selector.match_labels.items()) pod_list = self.client_corev1_api.list_namespaced_pod(namespace_name, label_selector=selector).items if not pod_list: - raise PodNotFound("No matching pod was found in the namespace %s" % (namespace_name)) + raise PodNotFound(f"No matching pod was found in the namespace {namespace_name}") return pod_list[0].metadata.name def get_deployment_hpa(self, *, deployment_name: str) -> Generator: @@ -280,7 +283,7 @@ def __init__(self, release_name: str, **kwargs: Any): super().__init__(**kwargs) @retry_kubernetes_request_no_ignore - def _get_expected_deployment_scale_dict(self) -> Dict[int, Dict[str, int]]: + def _get_expected_deployment_scale_dict(self) -> dict[int, dict[str, int]]: """ Return a dict of expected deployment scale: { @@ -296,11 +299,11 @@ def _get_expected_deployment_scale_dict(self) -> Dict[int, Dict[str, int]]: } """ logger.debug("Getting Expected Deployment Scale list") - eds_list: List[Dict[str, Any]] = [] + eds_list: list[dict[str, Any]] = [] release_label_keys = ["app.kubernetes.io/instance", "release"] for release_label_key in release_label_keys: - logger.debug(f"Getting Expected Deployment Scale list with the" f" release label key {release_label_key}") + logger.debug(f"Getting Expected Deployment Scale list with the release label key {release_label_key}") try: eds_list.extend( self.client_custom_objects_api.list_namespaced_custom_object( @@ -315,7 +318,7 @@ def _get_expected_deployment_scale_dict(self) -> Dict[int, Dict[str, int]]: if e.status != 404: raise - eds_dict: Dict[int, Dict[str, int]] = {} + eds_dict: dict[int, dict[str, int]] = {} for eds in eds_list: deployment_name: str = eds["spec"]["deploymentName"] expected_scale: int = eds["spec"]["expectedScale"] @@ -334,14 +337,14 @@ def start_pods(self) -> None: """ Start all Pods that should be started """ - expected_deployment_scale_dict: Dict[int, Dict[str, int]] = self._get_expected_deployment_scale_dict() + expected_deployment_scale_dict: dict[int, dict[str, int]] = self._get_expected_deployment_scale_dict() logger.info("Scaling up application Deployments...") if not expected_deployment_scale_dict: logger.info("No Deployments to scale up") return - priority_dict: Dict[str, int] + priority_dict: dict[str, int] # Don't assume anything about having a priority dict within the main dict # So we manually test for existence scaled: bool = False @@ -358,7 +361,7 @@ def start_pods(self) -> None: else: logger.info("No Deployments to scale up") - def _are_deployments_stopped(self, deployment_dict: Dict[str, int]) -> bool: + def _are_deployments_stopped(self, deployment_dict: dict[str, int]) -> bool: for deployment_name in deployment_dict: if not self.is_deployment_stopped(deployment_name): return False @@ -377,7 +380,7 @@ def re_enable_hpa(self, *, deployment_name: str) -> None: hpa.spec.scale_target_ref.name = deployment_name self.patch_deployment_hpa(hpa_name=hpa.metadata.name, body=hpa) - def _stop_deployments(self, deployment_dict: Dict[str, int]) -> None: + def _stop_deployments(self, deployment_dict: dict[str, int]) -> None: """ Scale down a dict (deployment_name, expected_scale) of Deployments. """ @@ -397,7 +400,7 @@ def stop_pods(self) -> None: stop all deployments, then wait for actual stop, by priority (descending order): Example: stop all deployments with priority 1, then all deployments with priority 0 """ - expected_deployment_scale_dict: Dict[int, Dict[str, int]] = self._get_expected_deployment_scale_dict() + expected_deployment_scale_dict: dict[int, dict[str, int]] = self._get_expected_deployment_scale_dict() logger.info("Scaling down application Deployments...") if not expected_deployment_scale_dict: @@ -405,9 +408,9 @@ def stop_pods(self) -> None: return priority: int - priorities: List[int] = sorted(expected_deployment_scale_dict, reverse=True) + priorities: list[int] = sorted(expected_deployment_scale_dict, reverse=True) for priority in priorities: - priority_dict: Dict[str, int] = expected_deployment_scale_dict[priority] + priority_dict: dict[str, int] = expected_deployment_scale_dict[priority] if len(priority_dict): self._stop_deployments(priority_dict) logger.info("Done scaling down application Deployments.") @@ -416,12 +419,12 @@ def generate_job( self, job_name: str, container_image: str, - labels: Dict[str, str], - command: Union[str, None] = None, - args: Union[List[str], None] = None, - environment_variables: Union[Dict["str", "str"], None] = None, + labels: dict[str, str], + command: str | None = None, + args: list[str] | None = None, + environment_variables: dict["str", "str"] | None = None, ttl_seconds_after_finished: int = 1800, - image_pull_secrets: Union[List[kubernetes.client.V1LocalObjectReference], None] = None, + image_pull_secrets: list[kubernetes.client.V1LocalObjectReference] | None = None, image_pull_policy: str = "IfNotPresent", priority_class_name: str = "", ) -> kubernetes.client.V1Job: @@ -474,7 +477,7 @@ def create_job(self, job_body: kubernetes.client.V1Job) -> kubernetes.client.V1J try: return self.client_batchv1_api.create_namespaced_job(self.namespace, job_body) except kubernetes.client.rest.ApiException as e: - print("Exception when calling BatchV1Api->create_namespaced_job: %s\n" % e) + print(f"Exception when calling BatchV1Api->create_namespaced_job: {e}\n") def get_job(self, job_name: str) -> kubernetes.client.V1Job: """ diff --git a/src/wiremind_kubernetes/tests/e2e_tests/conftest.py b/src/wiremind_kubernetes/tests/e2e_tests/conftest.py index 0e0e8f0..37f010f 100644 --- a/src/wiremind_kubernetes/tests/e2e_tests/conftest.py +++ b/src/wiremind_kubernetes/tests/e2e_tests/conftest.py @@ -1,7 +1,7 @@ import logging import os import time -from typing import Generator +from collections.abc import Generator import kubernetes import pytest diff --git a/src/wiremind_kubernetes/tests/e2e_tests/create_job_test.py b/src/wiremind_kubernetes/tests/e2e_tests/create_job_test.py index e7d2f42..b0bbcb3 100644 --- a/src/wiremind_kubernetes/tests/e2e_tests/create_job_test.py +++ b/src/wiremind_kubernetes/tests/e2e_tests/create_job_test.py @@ -6,6 +6,7 @@ from pytest_mock import MockerFixture from wiremind_kubernetes import KubernetesDeploymentManager + from .conftest import TEST_NAMESPACE logger = logging.getLogger(__name__) @@ -18,7 +19,9 @@ def test_create_job(concerned_dm: KubernetesDeploymentManager, create_namespace: job_name = "my-test-job" concerned_dm.create_job( concerned_dm.generate_job( - job_name=job_name, container_image="gcr.io/google_containers/pause-amd64:3.1", labels={"foo": "bar"} + job_name=job_name, + container_image="gcr.io/google_containers/pause-amd64:3.1", + labels={"foo": "bar"}, ) ) for _ in range(1, 20): @@ -50,7 +53,8 @@ def test_create_job(concerned_dm: KubernetesDeploymentManager, create_namespace: for _ in range(1, 30): pod_list = concerned_dm.client_corev1_api.list_namespaced_pod( - TEST_NAMESPACE, label_selector=f"job-name={concerned_dm.release_name}-{job_name}" + TEST_NAMESPACE, + label_selector=f"job-name={concerned_dm.release_name}-{job_name}", ).items if not pod_list: break diff --git a/src/wiremind_kubernetes/tests/e2e_tests/helpers.py b/src/wiremind_kubernetes/tests/e2e_tests/helpers.py index d71b140..2a87645 100644 --- a/src/wiremind_kubernetes/tests/e2e_tests/helpers.py +++ b/src/wiremind_kubernetes/tests/e2e_tests/helpers.py @@ -3,7 +3,7 @@ import subprocess import sys import urllib.parse -from typing import Any, Dict, List +from typing import Any import kubernetes @@ -17,7 +17,12 @@ "kubernetes.docker.internal", # Docker for Mac ] -DEFAULT_TEST_NODES_WHITELISTED = ["minikube", "kind-control-plane", "kind", "kind-worker"] +DEFAULT_TEST_NODES_WHITELISTED = [ + "minikube", + "kind-control-plane", + "kind", + "kind-worker", +] def get_default_kube_context() -> str: @@ -31,7 +36,7 @@ def get_default_kube_context() -> str: return "" -def is_ip_whitelisted(*, ips_whitelisted: List[str]) -> bool: +def is_ip_whitelisted(*, ips_whitelisted: list[str]) -> bool: api_server = subprocess.check_output( "kubectl config view --minify | grep server | cut -f 2- -d ':' | tr -d ' '", shell=True, @@ -42,7 +47,7 @@ def is_ip_whitelisted(*, ips_whitelisted: List[str]) -> bool: return hostname in ips_whitelisted -def is_node_whitelisted(*, nodes_whitelisted: List[str]) -> bool: +def is_node_whitelisted(*, nodes_whitelisted: list[str]) -> bool: output, *_ = run_command("kubectl get nodes -o name", return_result=True) cluster_nodes = [x for x in output.replace("node/", "").split("\n") if x != ""] for node in cluster_nodes: @@ -53,8 +58,8 @@ def is_node_whitelisted(*, nodes_whitelisted: List[str]) -> bool: def check_using_test_cluster( *, - ips_whitelisted: List[str] = DEFAULT_TEST_IPS_WHITELISTED, - nodes_whitelisted: List[str] = DEFAULT_TEST_NODES_WHITELISTED, + ips_whitelisted: list[str] = DEFAULT_TEST_IPS_WHITELISTED, + nodes_whitelisted: list[str] = DEFAULT_TEST_NODES_WHITELISTED, ) -> bool: """ Will sys.exit(1) if kubectl current context api server is not a test cluster (like kind, minikube, etc) @@ -87,8 +92,9 @@ def get_k8s_username() -> str: return username -def kubectl_get_json(*, resource: str, namespace: str, name: str) -> Dict[str, Any]: +def kubectl_get_json(*, resource: str, namespace: str, name: str) -> dict[str, Any]: output, *_ = run_command( - f"kubectl get {resource} {name} -n {namespace} --ignore-not-found -o json", return_result=True + f"kubectl get {resource} {name} -n {namespace} --ignore-not-found -o json", + return_result=True, ) return json.loads(output or "{}") diff --git a/src/wiremind_kubernetes/tests/e2e_tests/start_stop_test.py b/src/wiremind_kubernetes/tests/e2e_tests/start_stop_test.py index c5d5583..e591de6 100644 --- a/src/wiremind_kubernetes/tests/e2e_tests/start_stop_test.py +++ b/src/wiremind_kubernetes/tests/e2e_tests/start_stop_test.py @@ -24,7 +24,8 @@ def assert_hpa_scale_target_ref_name(*, hpa_name: str, scale_target_ref_name: st def are_deployments_ready( - concerned_dm: KubernetesDeploymentManager, unconcerned_dm: KubernetesDeploymentManager + concerned_dm: KubernetesDeploymentManager, + unconcerned_dm: KubernetesDeploymentManager, ) -> bool: return ( concerned_dm.is_deployment_ready("concerned") @@ -35,7 +36,8 @@ def are_deployments_ready( def wait_for_deployments_ready( - concerned_dm: KubernetesDeploymentManager, unconcerned_dm: KubernetesDeploymentManager + concerned_dm: KubernetesDeploymentManager, + unconcerned_dm: KubernetesDeploymentManager, ) -> None: for _ in range(1, 10): logger.info("Waiting for deployments to be started...") diff --git a/src/wiremind_kubernetes/tests/unit_tests/kube_config_test.py b/src/wiremind_kubernetes/tests/unit_tests/kube_config_test.py index 9f37bdd..6205cab 100644 --- a/src/wiremind_kubernetes/tests/unit_tests/kube_config_test.py +++ b/src/wiremind_kubernetes/tests/unit_tests/kube_config_test.py @@ -1,5 +1,5 @@ import os -from typing import Dict, Generator, Optional +from collections.abc import Generator import kubernetes import pytest @@ -107,12 +107,12 @@ def clean_os_environ() -> Generator: ], ) def test_load_kubernetes_config_1( - use_kubeconfig: Optional[bool], - config_file: Optional[str], - context: Optional[str], - extra_env_vars: Dict[str, str], + use_kubeconfig: bool | None, + config_file: str | None, + context: str | None, + extra_env_vars: dict[str, str], service_token_present: bool, - should_call: Optional[str], + should_call: str | None, mocker: MockerFixture, ) -> None: """ @@ -127,7 +127,10 @@ def test_load_kubernetes_config_1( # merge extra_env_vars with os.environ mocker.patch.dict(os.environ, extra_env_vars) # os.path.exists is used ONLY to check for token file in wiremind_kubernetes.kube_config for now - mocker.patch("wiremind_kubernetes.kube_config.os.path.exists", kawrgs={"side_effect": service_token_present}) + mocker.patch( + "wiremind_kubernetes.kube_config.os.path.exists", + kawrgs={"side_effect": service_token_present}, + ) load_kubernetes_config(use_kubeconfig=use_kubeconfig, config_file=config_file, context=context) diff --git a/src/wiremind_kubernetes/tests/unit_tests/kubernetes_client_additional_arguments_test.py b/src/wiremind_kubernetes/tests/unit_tests/kubernetes_client_additional_arguments_test.py index 7586add..498cd21 100644 --- a/src/wiremind_kubernetes/tests/unit_tests/kubernetes_client_additional_arguments_test.py +++ b/src/wiremind_kubernetes/tests/unit_tests/kubernetes_client_additional_arguments_test.py @@ -1,9 +1,12 @@ +import pytest from pytest_mock import MockerFixture import wiremind_kubernetes.kubernetes_helper -def test_kubernetes_client_additional_arguments_core_v1_api(mocker: MockerFixture) -> None: +def test_kubernetes_client_additional_arguments_core_v1_api( + mocker: MockerFixture, +) -> None: """ Test that we add mandatory args to each function call of kubernetes client """ @@ -21,7 +24,9 @@ def test_kubernetes_client_additional_arguments_core_v1_api(mocker: MockerFixtur mocked_create_namespaced_pod.assert_called_once_with("foo", "bar", pretty=True, dry_run="All") -def test_kubernetes_client_additional_arguments_disabled_core_v1_api(mocker: MockerFixture) -> None: +def test_kubernetes_client_additional_arguments_disabled_core_v1_api( + mocker: MockerFixture, +) -> None: """ Test that we do not add args to each function call of kubernetes client """ @@ -37,3 +42,52 @@ def test_kubernetes_client_additional_arguments_disabled_core_v1_api(mocker: Moc kubernetes_helper.client_corev1_api.create_namespaced_pod("foo", "bar") mocked_create_namespaced_pod.assert_called_once_with("foo", "bar", dry_run="All") + + +@pytest.mark.parametrize( + "method_name,args", + [ + ("get_api_resources", ("group", "version")), + ("get_cluster_custom_object", ("group", "version", "plural", "name")), + ("get_cluster_custom_object_scale", ("group", "version", "plural", "name")), + ("get_cluster_custom_object_status", ("group", "version", "plural", "name")), + ( + "get_namespaced_custom_object", + ("group", "version", "namespace", "plural", "name"), + ), + ( + "get_namespaced_custom_object_scale", + ("group", "version", "namespace", "plural", "name"), + ), + ( + "get_namespaced_custom_object_status", + ("group", "version", "namespace", "plural", "name"), + ), + ], +) +def test_custom_objects_read_methods_skip_pretty( + mocker: MockerFixture, method_name: str, args: tuple[str, ...] +) -> None: + # These generated read methods raise ApiTypeError if `pretty` is forwarded. + mocked_method = mocker.patch(f"kubernetes.client.CustomObjectsApi.{method_name}") + + kubernetes_helper = wiremind_kubernetes.kubernetes_helper.KubernetesHelper( + dry_run=True, should_load_kubernetes_config=False + ) + + getattr(kubernetes_helper.client_custom_objects_api, method_name)(*args) + + mocked_method.assert_called_once_with(*args) + + +def test_custom_objects_list_methods_keep_pretty(mocker: MockerFixture) -> None: + # Keep the shared pretty behavior on list methods that still accept it. + mocked_list_cluster_custom_object = mocker.patch("kubernetes.client.CustomObjectsApi.list_cluster_custom_object") + + kubernetes_helper = wiremind_kubernetes.kubernetes_helper.KubernetesHelper( + dry_run=True, should_load_kubernetes_config=False + ) + + kubernetes_helper.client_custom_objects_api.list_cluster_custom_object("group", "version", "plural") + + mocked_list_cluster_custom_object.assert_called_once_with("group", "version", "plural", pretty=True) diff --git a/src/wiremind_kubernetes/tests/unit_tests/kubernetes_deployment_manager_test.py b/src/wiremind_kubernetes/tests/unit_tests/kubernetes_deployment_manager_test.py index 696c3e7..f019dfe 100644 --- a/src/wiremind_kubernetes/tests/unit_tests/kubernetes_deployment_manager_test.py +++ b/src/wiremind_kubernetes/tests/unit_tests/kubernetes_deployment_manager_test.py @@ -33,7 +33,10 @@ def test_stop_pods_priority(mocker: MockerFixture) -> None: mocked_stop_deployments = mocker.patch("wiremind_kubernetes.KubernetesDeploymentManager._stop_deployments") kdm = wiremind_kubernetes.KubernetesDeploymentManager( - should_load_kubernetes_config=False, namespace="foo", release_name="concerned", dry_run=True + should_load_kubernetes_config=False, + namespace="foo", + release_name="concerned", + dry_run=True, ) kdm.stop_pods() @@ -58,11 +61,15 @@ def test_stop_deployments_correctly_wait(mocker: MockerFixture) -> None: deployment_dict = {"my-pod": 42, "my-other-pod": 113} mocked_are_deployments_stopped = mocker.patch( - "wiremind_kubernetes.KubernetesDeploymentManager._are_deployments_stopped", side_effect=[False, False, True] + "wiremind_kubernetes.KubernetesDeploymentManager._are_deployments_stopped", + side_effect=[False, False, True], ) kdm = wiremind_kubernetes.KubernetesDeploymentManager( - should_load_kubernetes_config=False, namespace="foo", release_name="concerned", dry_run=True + should_load_kubernetes_config=False, + namespace="foo", + release_name="concerned", + dry_run=True, ) kdm._stop_deployments(deployment_dict) diff --git a/src/wiremind_kubernetes/tests/unit_tests/namespaced_kubernetes_helper_test.py b/src/wiremind_kubernetes/tests/unit_tests/namespaced_kubernetes_helper_test.py index 02a24fd..4dd613e 100644 --- a/src/wiremind_kubernetes/tests/unit_tests/namespaced_kubernetes_helper_test.py +++ b/src/wiremind_kubernetes/tests/unit_tests/namespaced_kubernetes_helper_test.py @@ -23,7 +23,8 @@ class DummyPodObject: ) mocker.patch( - "wiremind_kubernetes.NamespacedKubernetesHelper._get_pods_from_deployment", return_value=[DummyPodObject()] + "wiremind_kubernetes.NamespacedKubernetesHelper._get_pods_from_deployment", + return_value=[DummyPodObject()], ) assert namespaced_kubernetes_helper.is_deployment_stopped("bar") diff --git a/src/wiremind_kubernetes/tests/unit_tests/utils_test/retry_kubernetes_request_test.py b/src/wiremind_kubernetes/tests/unit_tests/utils_test/retry_kubernetes_request_test.py index 35ed835..e26456c 100644 --- a/src/wiremind_kubernetes/tests/unit_tests/utils_test/retry_kubernetes_request_test.py +++ b/src/wiremind_kubernetes/tests/unit_tests/utils_test/retry_kubernetes_request_test.py @@ -1,7 +1,10 @@ import kubernetes.client.rest import pytest -from wiremind_kubernetes.utils import retry_kubernetes_request, retry_kubernetes_request_no_ignore +from wiremind_kubernetes.utils import ( + retry_kubernetes_request, + retry_kubernetes_request_no_ignore, +) def test_no_retry_required() -> None: diff --git a/src/wiremind_kubernetes/utils.py b/src/wiremind_kubernetes/utils.py index 63eff57..e430596 100644 --- a/src/wiremind_kubernetes/utils.py +++ b/src/wiremind_kubernetes/utils.py @@ -3,7 +3,8 @@ import shlex import subprocess import time -from typing import Any, Callable, List, Optional, Tuple, Union +from collections.abc import Callable +from typing import Any import kubernetes @@ -13,8 +14,11 @@ def run_command( - command: Union[List, str], return_result: bool = False, line_callback: Union[Callable, None] = None, **kw_args: Any -) -> Tuple[str, str, int]: + command: list | str, + return_result: bool = False, + line_callback: Callable | None = None, + **kw_args: Any, +) -> tuple[str, str, int]: """ Run command, print stdout/stderr, check that command exited correctly, return stdout/err """ @@ -25,14 +29,18 @@ def run_command( if not line_callback: line_callback = logger.info - interpreted_command: List[str] + interpreted_command: list[str] if isinstance(command, str): interpreted_command = shlex.split(command) else: interpreted_command = command process = subprocess.Popen( - interpreted_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, **kw_args + interpreted_command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + **kw_args, ) if return_result: @@ -96,7 +104,11 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: def kubernetes_exec( - commands: List[str], api: Any, pod_name: str, namespace_name: str, container_name: Optional[str] = None + commands: list[str], + api: Any, + pod_name: str, + namespace_name: str, + container_name: str | None = None, ) -> None: logger.info('Connecting to "%s" pod from "%s" namespace', pod_name, namespace_name) resp = kubernetes.stream.stream(