From 0d74babb71ea245f309079a419f0100a65325238 Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Wed, 15 Jan 2025 21:48:55 +0000 Subject: [PATCH] Replace @sync decorator with APIObjectSyncMixin for all sync objects (#551) --- conftest.py | 3 +- kr8s/__init__.py | 89 ++++++++++-- kr8s/_api.py | 37 ++--- kr8s/_objects.py | 248 +++++++++++++++++++++++++++---- kr8s/_portforward.py | 9 ++ kr8s/objects.py | 290 +++++++++++++++++++++---------------- kr8s/portforward.py | 15 +- kr8s/tests/test_api.py | 2 - kr8s/tests/test_objects.py | 18 ++- pyproject.toml | 4 +- 10 files changed, 523 insertions(+), 192 deletions(-) diff --git a/conftest.py b/conftest.py index ba28e89..63956b1 100644 --- a/conftest.py +++ b/conftest.py @@ -3,6 +3,7 @@ import gc import os import time +from collections.abc import Generator import pytest from pytest_kind.cluster import KindCluster @@ -17,7 +18,7 @@ def ensure_gc(): @pytest.fixture(scope="session", autouse=True) -def k8s_cluster(request) -> KindCluster: +def k8s_cluster(request) -> Generator[KindCluster, None, None]: image = None if version := os.environ.get("KUBERNETES_VERSION"): image = f"kindest/node:v{version}" diff --git a/kr8s/__init__.py b/kr8s/__init__.py index 874d758..8c08df9 100644 --- a/kr8s/__init__.py +++ b/kr8s/__init__.py @@ -6,14 +6,17 @@ At the top level, `kr8s` provides a synchronous API that wraps the asynchronous API provided by `kr8s.asyncio`. Both APIs are functionally identical with the same objects, method signatures and return values. """ +# Disable missing docstrings, these are inherited from the async version of the objects +# ruff: noqa: D102 +from __future__ import annotations + from functools import partial, update_wrapper -from typing import Dict, Optional, Type, Union +from typing import Generator from . import asyncio, objects, portforward from ._api import ALL from ._api import Api as _AsyncApi from ._async_utils import run_sync as _run_sync -from ._async_utils import sync as _sync from ._exceptions import ( APITimeoutError, ConnectionClosedError, @@ -21,6 +24,7 @@ NotFoundError, ServerError, ) +from ._objects import APIObject from .asyncio import ( api as _api, ) @@ -48,18 +52,73 @@ __version_tuple__ = (0, 0, 0) -@_sync class Api(_AsyncApi): - __doc__ = _AsyncApi.__doc__ + _asyncio = False + + def version(self) -> dict: # type: ignore + return _run_sync(self.async_version)() # type: ignore + + def reauthenticate(self): # type: ignore + return _run_sync(self.async_reauthenticate)() # type: ignore + + def whoami(self): # type: ignore + return _run_sync(self.async_whoami)() # type: ignore + + def lookup_kind(self, kind) -> tuple[str, str, bool]: # type: ignore + return _run_sync(self.async_lookup_kind)(kind) # type: ignore + + def get( # type: ignore + self, + kind: str | type, + *names: str, + namespace: str | None = None, + label_selector: str | dict | None = None, + field_selector: str | dict | None = None, + as_object: type[APIObject] | None = None, + allow_unknown_type: bool = True, + **kwargs, + ) -> Generator[APIObject]: + yield from _run_sync(self.async_get)( + kind, + *names, + namespace=namespace, + label_selector=label_selector, + field_selector=field_selector, + as_object=as_object, + allow_unknown_type=allow_unknown_type, + **kwargs, + ) + + def watch( # type: ignore + self, + kind: str, + namespace: str | None = None, + label_selector: str | dict | None = None, + field_selector: str | dict | None = None, + since: str | None = None, + ) -> Generator[tuple[str, APIObject]]: + yield from _run_sync(self.async_watch)( + kind, + namespace=namespace, + label_selector=label_selector, + field_selector=field_selector, + since=since, + ) + + def api_resources(self) -> list[dict]: # type: ignore + return _run_sync(self.async_api_resources)() # type: ignore + + def api_versions(self) -> Generator[str]: # type: ignore + yield from _run_sync(self.async_api_versions)() def get( kind: str, *names: str, - namespace: Optional[str] = None, - label_selector: Optional[Union[str, Dict]] = None, - field_selector: Optional[Union[str, Dict]] = None, - as_object: Optional[Type] = None, + namespace: str | None = None, + label_selector: str | dict | None = None, + field_selector: str | dict | None = None, + as_object: type | None = None, allow_unknown_type: bool = True, api=None, **kwargs, @@ -109,12 +168,12 @@ def get( def api( - url: Optional[str] = None, - kubeconfig: Optional[str] = None, - serviceaccount: Optional[str] = None, - namespace: Optional[str] = None, - context: Optional[str] = None, -) -> Union[Api, _AsyncApi]: + url: str | None = None, + kubeconfig: str | None = None, + serviceaccount: str | None = None, + namespace: str | None = None, + context: str | None = None, +) -> Api: """Create a :class:`kr8s.Api` object for interacting with the Kubernetes API. If a kr8s object already exists with the same arguments in this thread, it will be returned. @@ -142,7 +201,7 @@ def api( context=context, _asyncio=False, ) - assert isinstance(ret, (Api, _AsyncApi)) + assert isinstance(ret, Api) return ret diff --git a/kr8s/_api.py b/kr8s/_api.py index c4fbee6..e193a83 100644 --- a/kr8s/_api.py +++ b/kr8s/_api.py @@ -260,6 +260,9 @@ async def async_version(self) -> dict: async def reauthenticate(self) -> None: """Reauthenticate the API.""" + return await self.async_reauthenticate() + + async def async_reauthenticate(self) -> None: await self.auth.reauthenticate() async def whoami(self): @@ -293,6 +296,22 @@ async def async_whoami(self): [name] = cert.subject.get_attributes_for_oid(x509.OID_COMMON_NAME) return name.value + async def lookup_kind(self, kind) -> tuple[str, str, bool]: + """Lookup a Kubernetes resource kind. + + Check whether a resource kind exists on the remote server. + + Args: + kind: The kind of resource to lookup. + + Returns: + The kind of resource, the plural form and whether the resource is namespaced + + Raises: + ValueError: If the kind is not found. + """ + return await self.async_lookup_kind(kind) + async def async_lookup_kind(self, kind) -> tuple[str, str, bool]: """Lookup a Kubernetes resource kind.""" from ._objects import parse_kind @@ -321,22 +340,6 @@ async def async_lookup_kind(self, kind) -> tuple[str, str, bool]: ) raise ValueError(f"Kind {kind} not found.") - async def lookup_kind(self, kind) -> tuple[str, str, bool]: - """Lookup a Kubernetes resource kind. - - Check whether a resource kind exists on the remote server. - - Args: - kind: The kind of resource to lookup. - - Returns: - The kind of resource, the plural form and whether the resource is namespaced - - Raises: - ValueError: If the kind is not found. - """ - return await self.async_lookup_kind(kind) - @contextlib.asynccontextmanager async def async_get_kind( self, @@ -542,7 +545,7 @@ async def watch( label_selector: str | dict | None = None, field_selector: str | dict | None = None, since: str | None = None, - ): + ) -> AsyncGenerator[tuple[str, APIObject]]: """Watch a Kubernetes resource.""" async for t, object in self.async_watch( kind, diff --git a/kr8s/_objects.py b/kr8s/_objects.py index f75e93e..db85f5e 100644 --- a/kr8s/_objects.py +++ b/kr8s/_objects.py @@ -26,7 +26,7 @@ import kr8s import kr8s.asyncio from kr8s._api import Api -from kr8s._async_utils import sync +from kr8s._async_utils import run_sync from kr8s._data_utils import ( dict_to_selector, dot_to_nested_dict, @@ -241,6 +241,28 @@ async def get( field_selector: str | dict[str, str] | None = None, timeout: int = 2, **kwargs, + ) -> Self: + """Get a Kubernetes resource by name or via selectors.""" + return await cls.async_get( + name=name, + namespace=namespace, + api=api, + label_selector=label_selector, + field_selector=field_selector, + timeout=timeout, + **kwargs, + ) + + @classmethod + async def async_get( + cls, + name: str | None = None, + namespace: str | None = None, + api: Api | None = None, + label_selector: str | dict[str, str] | None = None, + field_selector: str | dict[str, str] | None = None, + timeout: int = 2, + **kwargs, ) -> Self: """Get a Kubernetes resource by name or via selectors.""" if api is None: @@ -323,7 +345,7 @@ async def async_exists(self, ensure=False) -> bool: ) return False - async def create(self) -> None: + async def async_create(self) -> None: """Create this object in Kubernetes.""" assert self.api async with self.api.call_api( @@ -335,7 +357,15 @@ async def create(self) -> None: ) as resp: self.raw = resp.json() + async def create(self) -> None: + """Create this object in Kubernetes.""" + return await self.async_create() + async def delete(self, propagation_policy: str | None = None) -> None: + """Delete this object from Kubernetes.""" + return await self.async_delete(propagation_policy=propagation_policy) + + async def async_delete(self, propagation_policy: str | None = None) -> None: """Delete this object from Kubernetes.""" data = {} if propagation_policy: @@ -413,6 +443,10 @@ async def async_patch( raise e async def scale(self, replicas: int | None = None) -> None: + """Scale this object in Kubernetes.""" + return await self.async_scale(replicas=replicas) + + async def async_scale(self, replicas: int | None = None) -> None: """Scale this object in Kubernetes.""" if not self.scalable: raise NotImplementedError(f"{self.kind} is not scalable") @@ -531,6 +565,14 @@ async def wait( Given that ``for`` is a reserved word anyway we can't exactly match the kubectl API so we use ``condition`` in combination with a ``mode`` instead. """ + return await self.async_wait(conditions, mode=mode, timeout=timeout) + + async def async_wait( + self, + conditions: list[str] | str, + mode: Literal["any", "all"] = "any", + timeout: int | None = None, + ): if isinstance(conditions, str): conditions = [conditions] @@ -547,6 +589,10 @@ async def wait( return async def annotate(self, annotations: dict | None = None, **kwargs) -> None: + """Annotate this object in Kubernetes.""" + return await self.async_annotate(annotations=annotations, **kwargs) + + async def async_annotate(self, annotations: dict | None = None, **kwargs) -> None: """Annotate this object in Kubernetes.""" if annotations is None: annotations = kwargs @@ -572,6 +618,9 @@ async def label(self, labels: dict | None = None, **kwargs) -> None: >>> deployment.label({"app": "my-app"}) >>> deployment.label(app="my-app") """ + return await self.async_label(labels=labels, **kwargs) + + async def async_label(self, labels: dict | None = None, **kwargs) -> None: if labels is None: labels = kwargs if not labels: @@ -642,6 +691,9 @@ async def adopt(self, child: APIObject) -> None: >>> deployment.adopt(pod) """ + return await self.async_adopt(child) + + async def async_adopt(self, child: APIObject) -> None: await child.async_set_owner(self) def to_dict(self) -> dict: @@ -694,6 +746,13 @@ def to_pykube(self, api) -> Any: def gen(cls, *args, **kwargs): raise NotImplementedError("gen is not implemented for this object") + @classmethod + async def async_list(cls, **kwargs) -> AsyncGenerator[Self]: + api = await kr8s.asyncio.api() + async for resource in api.async_get(kind=cls, **kwargs): + if isinstance(resource, cls): + yield resource + # Must be the last method defined due to https://github.com/python/mypy/issues/17517 @classmethod async def list(cls, **kwargs) -> AsyncGenerator[Self]: @@ -705,10 +764,78 @@ async def list(cls, **kwargs) -> AsyncGenerator[Self]: Returns: A list of objects. """ - api = await kr8s.asyncio.api() - async for resource in api.async_get(kind=cls, **kwargs): - if isinstance(resource, cls): - yield resource + async for resource in cls.async_list(**kwargs): + yield resource + + +class APIObjectSyncMixin(APIObject): + _asyncio = False + + @classmethod + def get( # type: ignore + cls, + name: str | None = None, + namespace: str | None = None, + api: Api | None = None, + label_selector: str | dict[str, str] | None = None, + field_selector: str | dict[str, str] | None = None, + timeout: int = 2, + **kwargs, + ) -> Self: + return run_sync(cls.async_get)( + name=name, + namespace=namespace, + api=api, + label_selector=label_selector, + field_selector=field_selector, + timeout=timeout, + **kwargs, + ) # type: ignore + + def exists(self, ensure=False) -> bool: # type: ignore + return run_sync(self.async_exists)(ensure=ensure) # type: ignore + + def create(self) -> None: # type: ignore + return run_sync(self.async_create)() # type: ignore + + def delete(self, propagation_policy: str | None = None) -> None: # type: ignore + return run_sync(self.async_delete)(propagation_policy=propagation_policy) # type: ignore + + def refresh(self): + return run_sync(self.async_refresh)() # type: ignore + + def patch(self, patch, *, subresource=None, type=None): + return run_sync(self.async_patch)(patch, subresource=subresource, type=type) # type: ignore + + def scale(self, replicas=None): + return run_sync(self.async_scale)(replicas=replicas) # type: ignore + + def watch(self): + yield from run_sync(self.async_watch)() + + def wait( + self, + conditions: list[str] | str, + mode: Literal["any", "all"] = "any", + timeout: int | float | None = None, + ): + return run_sync(self.async_wait)(conditions, mode=mode, timeout=timeout) # type: ignore + + def annotate(self, annotations=None, **kwargs): + return run_sync(self.async_annotate)(annotations, **kwargs) # type: ignore + + def label(self, labels=None, **kwargs): + return run_sync(self.async_label)(labels, **kwargs) # type: ignore + + def set_owner(self, owner): + return run_sync(self.async_set_owner)(owner) # type: ignore + + def adopt(self, child): + return run_sync(self.async_adopt)(child) # type: ignore + + @classmethod + def list(cls): + yield from run_sync(cls.async_list)() ## v1 objects @@ -821,6 +948,9 @@ async def cordon(self) -> None: This will mark the node as unschedulable. """ + return await self.async_cordon() + + async def async_cordon(self) -> None: await self.async_patch({"spec": {"unschedulable": True}}) async def uncordon(self) -> None: @@ -828,10 +958,16 @@ async def uncordon(self) -> None: This will mark the node as schedulable. """ + return await self.async_uncordon() + + async def async_uncordon(self) -> None: await self.async_patch({"spec": {"unschedulable": False}}) async def taint(self, key: str, value: str, *, effect: str) -> None: """Taint a node.""" + return await self.async_taint(key, value, effect=effect) + + async def async_taint(self, key: str, value: str, *, effect: str) -> None: await self.async_refresh() if effect.endswith("-"): # Remove taint with key @@ -919,6 +1055,33 @@ async def logs( limit_bytes=None, follow=False, timeout=3600, + ) -> AsyncGenerator[str]: + async for line in self.async_logs( + container=container, + pretty=pretty, + previous=previous, + since_seconds=since_seconds, + since_time=since_time, + timestamps=timestamps, + tail_lines=tail_lines, + limit_bytes=limit_bytes, + follow=follow, + timeout=timeout, + ): + yield line + + async def async_logs( + self, + container=None, + pretty=None, + previous=False, + since_seconds=None, + since_time=None, + timestamps=False, + tail_lines=None, + limit_bytes=None, + follow=False, + timeout=3600, ) -> AsyncGenerator[str]: """Streams logs from a Pod. @@ -1005,7 +1168,7 @@ def portforward( remote_port: int, local_port: LocalPortType = "match", address: list[str] | str = "127.0.0.1", - ) -> SyncPortForward | AsyncPortForward: + ) -> AsyncPortForward: """Port forward a pod. Returns an instance of :class:`kr8s.portforward.PortForward` for this Pod. @@ -1246,6 +1409,23 @@ async def tolerate( value (str): Value of taint to tolerate. toleration_seconds (str): Toleration seconds. """ + return await self.async_tolerate( + key, + operator=operator, + effect=effect, + value=value, + toleration_seconds=toleration_seconds, + ) + + async def async_tolerate( + self, + key: str, + *, + operator: str, + effect: str, + value: str | None = None, + toleration_seconds: int | None = None, + ): new_toleration: dict = {"key": key, "operator": operator, "effect": effect} if value is not None: new_toleration["value"] = value @@ -1290,6 +1470,9 @@ class ReplicationController(APIObject): async def ready(self): """Check if the deployment is ready.""" + return await self.async_ready() + + async def async_ready(self): await self.async_refresh() return ( self.raw["status"].get("observedGeneration", 0) @@ -1426,6 +1609,9 @@ async def async_ready_pods(self) -> list[Pod]: async def ready(self) -> bool: """Check if the service is ready.""" + return await self.async_ready() + + async def async_ready(self) -> bool: await self.async_refresh() # If the service is of type LoadBalancer, check if it has endpoints @@ -1444,7 +1630,7 @@ def portforward( remote_port: int, local_port: LocalPortType = "match", address: str | list[str] = "127.0.0.1", - ) -> SyncPortForward | AsyncPortForward: + ) -> AsyncPortForward: """Port forward a service. Returns an instance of :class:`kr8s.portforward.PortForward` for this Service. @@ -1523,6 +1709,9 @@ class Deployment(APIObject): async def pods(self) -> list[Pod]: """Return a list of Pods for this Deployment.""" + return await self.async_pods() + + async def async_pods(self) -> list[Pod]: assert self.api pods = [ pod @@ -1542,6 +1731,9 @@ async def pods(self) -> list[Pod]: async def ready(self): """Check if the deployment is ready.""" + return await self.async_ready() + + async def async_ready(self): await self.async_refresh() return ( self.raw["status"].get("observedGeneration", 0) @@ -1846,24 +2038,28 @@ def new_class( if version is None: version = "v1" plural = plural or kind.lower() + "s" - newcls = type( - kind, - (APIObject,), - { - "kind": kind, - "version": version, - "_asyncio": asyncio, - "endpoint": plural.lower(), - "plural": plural.lower(), - "singular": kind.lower(), - "namespaced": namespaced, - "scalable": scalable or False, - "scalable_spec": scalable_spec or "replicas", - }, - ) - if not asyncio: - newcls = sync(newcls) - return newcls + construct_args = { + "kind": kind, + "version": version, + "_asyncio": asyncio, + "endpoint": plural.lower(), + "plural": plural.lower(), + "singular": kind.lower(), + "namespaced": namespaced, + "scalable": scalable or False, + "scalable_spec": scalable_spec or "replicas", + } + if asyncio: + return type(kind, (APIObject,), construct_args) + else: + return type( + kind, + ( + APIObjectSyncMixin, + APIObject, + ), + construct_args, + ) def object_from_spec( diff --git a/kr8s/_portforward.py b/kr8s/_portforward.py index 7cea4dc..a986a3c 100644 --- a/kr8s/_portforward.py +++ b/kr8s/_portforward.py @@ -129,6 +129,9 @@ async def __aexit__(self, *args, **kwargs): async def start(self) -> int: """Start a background task with the port forward running.""" + return await self.async_start() + + async def async_start(self) -> int: if self._loop is None: self._loop = asyncio.get_event_loop() if self._bg_task is not None: @@ -147,6 +150,9 @@ async def f(): async def stop(self) -> None: """Stop the background task.""" + return await self.async_stop() + + async def async_stop(self) -> None: if self._bg_future: self._bg_future.set_result(None) self._bg_task = None @@ -160,6 +166,9 @@ async def run_forever(self) -> None: >>> pf = PortForward(pod, remote_port=8888, local_port=8889) >>> await pf.run_forever() """ + return await self.async_run_forever() + + async def async_run_forever(self) -> None: async with self: with contextlib.suppress(asyncio.CancelledError): for server in self.servers: diff --git a/kr8s/objects.py b/kr8s/objects.py index fe4f89a..eefb70b 100644 --- a/kr8s/objects.py +++ b/kr8s/objects.py @@ -5,12 +5,18 @@ This module provides classes that represent Kubernetes resources. These classes are used to interact with resources in the Kubernetes API server. """ + +# Disable missing docstrings, these are inherited from the async version of the objects +# ruff: noqa: D102 +from __future__ import annotations + from functools import partial +from typing import Any -from ._async_utils import run_sync, sync -from ._objects import ( - APIObject as _APIObject, -) +import httpx + +from ._async_utils import run_sync +from ._objects import APIObjectSyncMixin from ._objects import ( Binding as _Binding, ) @@ -129,222 +135,260 @@ object_from_spec as _object_from_spec, ) from ._objects import objects_from_files as _objects_from_files +from .portforward import PortForward -@sync -class APIObject(_APIObject): - __doc__ = _APIObject.__doc__ - _asyncio = False - - -@sync -class Binding(_Binding): +class Binding(APIObjectSyncMixin, _Binding): __doc__ = _Binding.__doc__ - _asyncio = False -@sync -class ComponentStatus(_ComponentStatus): +class ComponentStatus(APIObjectSyncMixin, _ComponentStatus): __doc__ = _ComponentStatus.__doc__ - _asyncio = False -@sync -class ConfigMap(_ConfigMap): +class ConfigMap(APIObjectSyncMixin, _ConfigMap): __doc__ = _ConfigMap.__doc__ - _asyncio = False -@sync -class Endpoints(_Endpoints): +class Endpoints(APIObjectSyncMixin, _Endpoints): __doc__ = _Endpoints.__doc__ - _asyncio = False -@sync -class Event(_Event): +class Event(APIObjectSyncMixin, _Event): __doc__ = _Event.__doc__ - _asyncio = False -@sync -class LimitRange(_LimitRange): +class LimitRange(APIObjectSyncMixin, _LimitRange): __doc__ = _LimitRange.__doc__ - _asyncio = False -@sync -class Namespace(_Namespace): +class Namespace(APIObjectSyncMixin, _Namespace): __doc__ = _Namespace.__doc__ - _asyncio = False -@sync -class Node(_Node): - __doc__ = _Node.__doc__ - _asyncio = False +class Node(APIObjectSyncMixin, _Node): + def cordon(self): + return run_sync(self.async_cordon)() # type: ignore -@sync -class PersistentVolume(_PersistentVolume): - __doc__ = _PersistentVolume.__doc__ - _asyncio = False + def uncordon(self): + return run_sync(self.async_uncordon)() # type: ignore + def taint(self, key, value, *, effect): + return run_sync(self.async_taint)(key, value, effect=effect) # type: ignore -@sync -class PersistentVolumeClaim(_PersistentVolumeClaim): - __doc__ = _PersistentVolumeClaim.__doc__ - _asyncio = False + +class PersistentVolume(APIObjectSyncMixin, _PersistentVolume): + __doc__ = _PersistentVolume.__doc__ -@sync -class Pod(_Pod): - __doc__ = _Pod.__doc__ - _asyncio = False +class PersistentVolumeClaim(APIObjectSyncMixin, _PersistentVolumeClaim): + __doc__ = _PersistentVolumeClaim.__doc__ -@sync -class PodTemplate(_PodTemplate): +class Pod(APIObjectSyncMixin, _Pod): + def ready(self): + return run_sync(self.async_ready)() # type: ignore + + def logs( + self, + container=None, + pretty=None, + previous=False, + since_seconds=None, + since_time=None, + timestamps=False, + tail_lines=None, + limit_bytes=None, + follow=False, + timeout=3600, + ): + return run_sync(self.async_logs)( + container, + pretty, + previous, + since_seconds, + since_time, + timestamps, + tail_lines, + limit_bytes, + follow, + timeout, + ) # type: ignore + + def exec( + self, + command, + *, + container=None, + stdin=None, + stdout=None, + stderr=None, + check=True, + capture_output=True, + ): + return run_sync(self.async_exec)( + command, + container=container, + stdin=stdin, + stdout=stdout, + stderr=stderr, + check=check, + capture_output=capture_output, + ) # type: ignore + + def tolerate(self, key, *, operator, effect, value=None, toleration_seconds=None): + return run_sync(self.async_tolerate)( + key, + operator=operator, + effect=effect, + value=value, + toleration_seconds=toleration_seconds, + ) + + def portforward( + self, remote_port, local_port="match", address="127.0.0.1" + ) -> PortForward: + pf = super().portforward(remote_port, local_port, address) + assert isinstance(pf, PortForward) + return pf + + +class PodTemplate(APIObjectSyncMixin, _PodTemplate): __doc__ = _PodTemplate.__doc__ - _asyncio = False -@sync -class ReplicationController(_ReplicationController): - __doc__ = _ReplicationController.__doc__ - _asyncio = False +class ReplicationController(APIObjectSyncMixin, _ReplicationController): + def ready(self): + return run_sync(self.async_ready)() # type: ignore -@sync -class ResourceQuota(_ResourceQuota): + +class ResourceQuota(APIObjectSyncMixin, _ResourceQuota): __doc__ = _ResourceQuota.__doc__ - _asyncio = False -@sync -class Secret(_Secret): +class Secret(APIObjectSyncMixin, _Secret): __doc__ = _Secret.__doc__ - _asyncio = False -@sync -class Service(_Service): - __doc__ = _Service.__doc__ - _asyncio = False +class ServiceAccount(APIObjectSyncMixin, _ServiceAccount): + __doc__ = _ServiceAccount.__doc__ -@sync -class ServiceAccount(_ServiceAccount): - __doc__ = _ServiceAccount.__doc__ - _asyncio = False +class Service(APIObjectSyncMixin, _Service): + def proxy_http_request( + self, method: str, path: str, port: int | None = None, **kwargs: Any + ) -> httpx.Response: + return run_sync(self.async_proxy_http_request)(method, path, port=port, **kwargs) # type: ignore + + def proxy_http_get( + self, path: str, port: int | None = None, **kwargs + ) -> httpx.Response: + return run_sync(self.async_proxy_http_request)("GET", path, port, **kwargs) # type: ignore + + def proxy_http_post(self, path: str, port: int | None = None, **kwargs) -> None: # type: ignore + return run_sync(self.async_proxy_http_request)("POST", path, port, **kwargs) # type: ignore + + def proxy_http_put( + self, path: str, port: int | None = None, **kwargs + ) -> httpx.Response: + return run_sync(self.async_proxy_http_request)("PUT", path, port, **kwargs) # type: ignore + def proxy_http_delete( + self, path: str, port: int | None = None, **kwargs + ) -> httpx.Response: + return run_sync(self.async_proxy_http_request)("DELETE", path, port, **kwargs) # type: ignore -@sync -class ControllerRevision(_ControllerRevision): + def ready_pods(self) -> list[Pod]: # type: ignore + return run_sync(self.async_ready_pods)() # type: ignore + + def ready(self): + return run_sync(self.async_ready)() # type: ignore + + def portforward( + self, remote_port, local_port="match", address="127.0.0.1" + ) -> PortForward: + pf = super().portforward(remote_port, local_port, address) + assert isinstance(pf, PortForward) + return pf + + +class ControllerRevision(APIObjectSyncMixin, _ControllerRevision): __doc__ = _ControllerRevision.__doc__ - _asyncio = False -@sync -class DaemonSet(_DaemonSet): +class DaemonSet(APIObjectSyncMixin, _DaemonSet): __doc__ = _DaemonSet.__doc__ - _asyncio = False -@sync -class Deployment(_Deployment): - __doc__ = _Deployment.__doc__ - _asyncio = False +class Deployment(APIObjectSyncMixin, _Deployment): + + def pods(self) -> list[Pod]: # type: ignore + return run_sync(self.async_pods)() # type: ignore + + def ready(self): + return run_sync(self.async_ready)() # type: ignore -@sync -class ReplicaSet(_ReplicaSet): +class ReplicaSet(APIObjectSyncMixin, _ReplicaSet): __doc__ = _ReplicaSet.__doc__ - _asyncio = False -@sync -class StatefulSet(_StatefulSet): +class StatefulSet(APIObjectSyncMixin, _StatefulSet): __doc__ = _StatefulSet.__doc__ - _asyncio = False -@sync -class HorizontalPodAutoscaler(_HorizontalPodAutoscaler): +class HorizontalPodAutoscaler(APIObjectSyncMixin, _HorizontalPodAutoscaler): __doc__ = _HorizontalPodAutoscaler.__doc__ - _asyncio = False -@sync -class CronJob(_CronJob): +class CronJob(APIObjectSyncMixin, _CronJob): __doc__ = _CronJob.__doc__ - _asyncio = False -@sync -class Job(_Job): +class Job(APIObjectSyncMixin, _Job): __doc__ = _Job.__doc__ - _asyncio = False -@sync -class Ingress(_Ingress): +class Ingress(APIObjectSyncMixin, _Ingress): __doc__ = _Ingress.__doc__ - _asyncio = False -@sync -class IngressClass(_IngressClass): +class IngressClass(APIObjectSyncMixin, _IngressClass): __doc__ = _IngressClass.__doc__ - _asyncio = False -@sync -class NetworkPolicy(_NetworkPolicy): +class NetworkPolicy(APIObjectSyncMixin, _NetworkPolicy): __doc__ = _NetworkPolicy.__doc__ - _asyncio = False -@sync -class PodDisruptionBudget(_PodDisruptionBudget): +class PodDisruptionBudget(APIObjectSyncMixin, _PodDisruptionBudget): __doc__ = _PodDisruptionBudget.__doc__ - _asyncio = False -@sync -class ClusterRoleBinding(_ClusterRoleBinding): +class ClusterRoleBinding(APIObjectSyncMixin, _ClusterRoleBinding): __doc__ = _ClusterRoleBinding.__doc__ - _asyncio = False -@sync -class ClusterRole(_ClusterRole): +class ClusterRole(APIObjectSyncMixin, _ClusterRole): __doc__ = _ClusterRole.__doc__ - _asyncio = False -@sync -class RoleBinding(_RoleBinding): +class RoleBinding(APIObjectSyncMixin, _RoleBinding): __doc__ = _RoleBinding.__doc__ - _asyncio = False -@sync -class Role(_Role): +class Role(APIObjectSyncMixin, _Role): __doc__ = _Role.__doc__ - _asyncio = False -@sync -class CustomResourceDefinition(_CustomResourceDefinition): +class CustomResourceDefinition(APIObjectSyncMixin, _CustomResourceDefinition): __doc__ = _CustomResourceDefinition.__doc__ - _asyncio = False -@sync -class Table(_Table): +class Table(APIObjectSyncMixin, _Table): __doc__ = _Table.__doc__ - _asyncio = False object_from_name_type = run_sync(partial(_object_from_name_type, _asyncio=False)) diff --git a/kr8s/portforward.py b/kr8s/portforward.py index 7e4c899..d7e9049 100644 --- a/kr8s/portforward.py +++ b/kr8s/portforward.py @@ -4,23 +4,32 @@ This module provides a class for managing a port forward connection to a Kubernetes Pod or Service. """ +# Disable missing docstrings, these are inherited from the async version of the objects +# ruff: noqa: D102, D105 from __future__ import annotations import threading import time -from ._async_utils import sync +from ._async_utils import run_sync from ._portforward import LocalPortType from ._portforward import PortForward as _PortForward __all__ = ["PortForward", "LocalPortType"] -@sync class PortForward(_PortForward): - __doc__ = _PortForward.__doc__ _bg_thread = None + def __enter__(self, *args, **kwargs): + return run_sync(self.__aenter__)(*args, **kwargs) + + def __exit__(self, *args, **kwargs): + return run_sync(self.__aexit__)(*args, **kwargs) + + def run_forever(self): + return run_sync(self.run_forever)() # type: ignore + def start(self): """Start a background thread with the port forward running.""" self._bg_thread = threading.Thread(target=self.run_forever, daemon=True) diff --git a/kr8s/tests/test_api.py b/kr8s/tests/test_api.py index af9081d..ac8d57e 100644 --- a/kr8s/tests/test_api.py +++ b/kr8s/tests/test_api.py @@ -28,9 +28,7 @@ async def example_crd(example_crd_spec): async def test_factory_bypass() -> None: with pytest.raises(ValueError, match="kr8s.api()"): _ = kr8s.Api() - assert not kr8s.Api._instances _ = kr8s.api() - assert kr8s.Api._instances async def test_api_factory(serviceaccount) -> None: diff --git a/kr8s/tests/test_objects.py b/kr8s/tests/test_objects.py index 51ff8da..294e71a 100644 --- a/kr8s/tests/test_objects.py +++ b/kr8s/tests/test_objects.py @@ -19,10 +19,13 @@ from kr8s._exec import CompletedExec, ExecError from kr8s.asyncio.objects import ( APIObject, + ConfigMap, Deployment, Ingress, + Node, PersistentVolume, Pod, + Secret, Service, get_class, new_class, @@ -689,6 +692,7 @@ async def test_node(): nodes = [node async for node in api.get("nodes")] assert len(nodes) > 0 for node in nodes: + assert isinstance(node, Node) assert node.unschedulable is False await node.cordon() assert node.unschedulable is True @@ -700,6 +704,7 @@ async def test_node_taint(): nodes = [node async for node in api.get("nodes")] assert len(nodes) > 0 node = nodes[0] + assert isinstance(node, Node) # Remove existing taints just in case they still exist for taint in node.taints: @@ -721,6 +726,7 @@ async def test_node_taint(): async def test_service_proxy(): api = await kr8s.asyncio.api() service = await anext(api.get("services", "kubernetes")) + assert isinstance(service, Service) assert service.name == "kubernetes" data = await service.proxy_http_get("/version", raise_for_status=False) assert isinstance(data, httpx.Response) @@ -822,7 +828,7 @@ async def test_service_port_forward_start_stop(nginx_service): async def test_unsupported_port_forward(): pv = await PersistentVolume({"metadata": {"name": "foo"}}) with pytest.raises(AttributeError): - await pv.portforward(80, local_port=None) + await pv.portforward(80, local_port=None) # type: ignore with pytest.raises(ValueError): await PortForward(pv, 80, local_port=None).start() @@ -888,13 +894,16 @@ async def test_objects_from_file(): def test_objects_from_file_sync(): - objects = sync_objects_from_files( - CURRENT_DIR / "resources" / "simple" / "nginx_pod_service.yaml" + objects = list( + sync_objects_from_files( + CURRENT_DIR / "resources" / "simple" / "nginx_pod_service.yaml" + ) ) assert len(objects) == 2 assert isinstance(objects[0], Pod) assert isinstance(objects[1], Service) assert not objects[0]._asyncio + assert objects[0].api assert not objects[0].api._asyncio assert len(objects[1].spec.ports) == 1 @@ -953,6 +962,7 @@ async def test_cast_to_from_lightkube(example_pod_spec): lightkube_pod = kr8s_pod.to_lightkube() assert isinstance(lightkube_pod, LightkubePod) + assert lightkube_pod.metadata assert lightkube_pod.metadata.name == example_pod_spec["metadata"]["name"] assert lightkube_pod.metadata.namespace == example_pod_spec["metadata"]["namespace"] @@ -1073,6 +1083,7 @@ async def test_pod_exec_not_ready(ns): async def test_configmap_data(ns): [cm] = await objects_from_files(CURRENT_DIR / "resources" / "configmap.yaml") + assert isinstance(cm, ConfigMap) cm.namespace = ns await cm.create() assert "game.properties" in cm.data @@ -1083,6 +1094,7 @@ async def test_configmap_data(ns): async def test_secret_data(ns): [secret] = await objects_from_files(CURRENT_DIR / "resources" / "secret.yaml") + assert isinstance(secret, Secret) secret.namespace = ns await secret.create() assert "tls.crt" in secret.data diff --git a/pyproject.toml b/pyproject.toml index da46258..4b71158 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,10 +145,10 @@ convention = "google" "ci/*" = ["D", "N", "B"] [tool.mypy] -exclude = ["examples", "venv", "ci", "docs", "conftest.py"] +exclude = ["examples", "venv", "ci", "docs"] [tool.pyright] -exclude = ["examples", "**/tests", "venv", "ci", "docs", "conftest.py"] +exclude = ["examples", "venv", "ci", "docs"] # We often override corotuines with sync methods so this is not useful reportIncompatibleMethodOverride = "none"