From 24a50884d6587cd7856b64a630f7a84e8c615434 Mon Sep 17 00:00:00 2001 From: Giuseppe Tribulato Date: Sat, 8 Aug 2026 11:59:10 +0200 Subject: [PATCH 1/3] Typing fixes --- src/lightkube/core/generic_client.py | 45 +++++++++++++++++----------- src/lightkube/core/resource.py | 4 ++- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/lightkube/core/generic_client.py b/src/lightkube/core/generic_client.py index 685644e..1587eb6 100644 --- a/src/lightkube/core/generic_client.py +++ b/src/lightkube/core/generic_client.py @@ -2,7 +2,7 @@ import dataclasses import time from dataclasses import dataclass -from typing import Any, AsyncIterable, AsyncIterator, Dict, Iterable, Iterator, Optional, Tuple, Type, TypeVar, Union +from typing import Any, AsyncIterable, AsyncIterator, Iterable, Iterator, Literal, Optional, Type, TypeVar, Union, overload import httpx2 as httpx import msgspec @@ -49,9 +49,9 @@ class BasicRequest: method: str url: str response_type: Optional[Type[DictMixin]] = None - params: Dict[str, str] = dataclasses.field(default_factory=dict) + params: dict[str, str] = dataclasses.field(default_factory=dict) data: Any = None - headers: Optional[Dict[str, str]] = None + headers: Optional[dict[str, str]] = None timeout: Optional[httpx.Timeout] = None @@ -97,7 +97,7 @@ def resourceVersion(self) -> str: return self._resourceVersion raise NotReadyError("resourceVersion", "only available after the iteration started") - def __init__(self, inner_iter: Iterator[Tuple[str, Iterator[T]]]) -> None: + def __init__(self, inner_iter: Iterator[tuple[Optional[str], list[T]]]) -> None: self._inner_iter = inner_iter def __iter__(self) -> Iterator[T]: @@ -118,7 +118,7 @@ def resourceVersion(self) -> str: return self._resourceVersion raise NotReadyError("resourceVersion", "only available after the iteration started") - def __init__(self, inner_iter: AsyncIterator[Tuple[str, Iterator[T]]]) -> None: + def __init__(self, inner_iter: AsyncIterator[tuple[Optional[str], list[T]]]) -> None: self._inner_iter = inner_iter async def __aiter__(self) -> AsyncIterator[T]: @@ -236,7 +236,7 @@ def prepare_request( data = obj else: data = obj - if isinstance(obj, r.Resource): + if isinstance(data, r.Resource): if not data.apiVersion: data.apiVersion = api_info.resource.api_version if not data.kind: @@ -245,7 +245,7 @@ def prepare_request( path.append(api_info.plural) if method in ("delete", "get", "patch", "put", "exec") or api_info.action: if name is None and method == "put": - name = obj.metadata.name + name = obj.metadata.name # type: ignore if name is None: raise ValueError("resource name not defined") path.append(name) @@ -294,7 +294,15 @@ def build_adapter_request(self, br: BasicRequest) -> httpx.Request: timeout=timeout, ) - def handle_response(self, method, resp, br): + @overload + def handle_response( + self, method: Literal["list"], resp: httpx.Response, br: BasicRequest + ) -> tuple[bool, Optional[str], list]: ... + + @overload + def handle_response(self, method: str, resp: httpx.Response, br: BasicRequest) -> Any: ... + + def handle_response(self, method: str, resp: httpx.Response, br: BasicRequest) -> Any: self.raise_for_status(resp) res = br.response_type if res is None: @@ -357,10 +365,10 @@ def request( def ws_request( self, - method, - name=None, - namespace=None, - params: Optional[dict] = None, + method: str, + name: str, + namespace: str, + params: dict, raise_on_error: bool = False, decode: Optional[str] = None, timeout: Optional[float] = None, @@ -380,7 +388,7 @@ def ws_request( stdin=stdin, stdout=stdout, stderr=stderr, raise_on_error=raise_on_error, decode=decode ) - def list_chunks(self, br: BasicRequest) -> Iterator[Tuple[str, Iterator]]: + def list_chunks(self, br: BasicRequest) -> Iterator[tuple[Optional[str], list[Any]]]: cont = True while cont: req = self.build_adapter_request(br) @@ -396,6 +404,7 @@ def close(self): class GenericAsyncClient(GenericClient): + _client: httpx.AsyncClient AdapterClient = staticmethod(client_adapter.AsyncClient) async def send(self, req, stream=False): @@ -446,10 +455,10 @@ async def request( async def ws_request( self, - method, - name=None, - namespace=None, - params: Optional[dict] = None, + method: str, + name: str, + namespace: str, + params: dict, raise_on_error: bool = False, decode: Optional[str] = None, timeout: Optional[float] = None, @@ -469,7 +478,7 @@ async def ws_request( stdin=stdin, stdout=stdout, stderr=stderr, raise_on_error=raise_on_error, decode=decode ) - async def list_chunks(self, br: BasicRequest) -> AsyncIterator[Tuple[str, Iterator]]: + async def list_chunks(self, br: BasicRequest) -> AsyncIterator[tuple[Optional[str], list[Any]]]: cont = True while cont: req = self.build_adapter_request(br) diff --git a/src/lightkube/core/resource.py b/src/lightkube/core/resource.py index 7c56d01..b89d922 100644 --- a/src/lightkube/core/resource.py +++ b/src/lightkube/core/resource.py @@ -18,10 +18,12 @@ class ApiInfo: plural: str verbs: List[str] parent: Optional[ResourceDef] = None - action: str = None + action: Optional[str] = None class Resource: + apiVersion: Optional[str] + kind: Optional[str] _api_info: ApiInfo From 72220571752dfaa5e5ce5a3729b9a0511373c3fd Mon Sep 17 00:00:00 2001 From: Giuseppe Tribulato Date: Sat, 8 Aug 2026 12:01:08 +0200 Subject: [PATCH 2/3] Bump version to 1.0.0 --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 386b992..ddc918f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lightkube" -version = "0.22.0" +version = "1.0.0" description = "Lightweight kubernetes client library" readme = "README.md" authors = [ diff --git a/uv.lock b/uv.lock index 84b0add..6ba25e4 100644 --- a/uv.lock +++ b/uv.lock @@ -382,7 +382,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -632,7 +632,7 @@ wheels = [ [[package]] name = "lightkube" -version = "0.22.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "httpx2", extra = ["http2", "ws"] }, From e94451721cfb1f6b2eddd694d9da29f6bc18eb06 Mon Sep 17 00:00:00 2001 From: Giuseppe Tribulato Date: Sat, 8 Aug 2026 15:25:08 +0200 Subject: [PATCH 3/3] Add migration guide --- docs/migration-to-v1.md | 177 ++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 178 insertions(+) create mode 100644 docs/migration-to-v1.md diff --git a/docs/migration-to-v1.md b/docs/migration-to-v1.md new file mode 100644 index 0000000..2540bbf --- /dev/null +++ b/docs/migration-to-v1.md @@ -0,0 +1,177 @@ +# Migrating from v0.x to v1.x + +Lightkube v1 keeps the public client and resource APIs familiar, but changes a +few implementation dependencies and fixes two streaming issues. +Use this guide to check code that uses Lightkube internals, custom resources, +`httpx` types, or retry handlers. + +Lightkube v1 requires Python 3.10 or newer. This is also the minimum Python +version required by `httpx2`, which is used by Lightkube v1. + +## Migrate from httpx to httpx2 + +Lightkube v1 is now using [httpx2](https://github.com/pydantic/httpx2) instead of +`httpx`. The change matters if your code passes `httpx` objects into Lightkube +or catches `httpx` exceptions raised by it. + +For example, update the HTTP types passed when creating a `Client`: + +```python +# v0 +import httpx +from lightkube import Client + +transport: httpx.BaseTransport = ... +client = Client(timeout=httpx.Timeout(30.0), transport=transport) +``` + +```python +# v1 +import httpx2 +from lightkube import Client + +transport: httpx2.BaseTransport = ... +client = Client(timeout=httpx2.Timeout(30.0), transport=transport) +``` + +For `AsyncClient`, use `httpx2.AsyncBaseTransport` for the transport instead. + +Update exception handlers separately: + +```python +# v0 +import httpx + +try: + ... +except httpx.HTTPError: + ... +``` + +```python +# v1 +import httpx2 + +try: + ... +except httpx2.HTTPError: + ... +``` + +Alternatively, you can alias `httpx2` as `httpx` to reduce the number of code +changes: + +```python +import httpx2 as httpx +``` + +If your application uses `httpx` for another dependency, keep that dependency +as needed, but use `httpx2` for objects and exceptions exchanged with +Lightkube. + +## Models use msgspec.Struct + +Lightkube's generated models now use +[msgspec.Struct](https://msgspec.dev/structs) instead of +standard-library dataclasses. Normal use of generated models is unchanged: +construct them, access their attributes, and use `from_dict` and `to_dict` as +before. + +The benefit is that msgspec provides schema-aware, optimized encoding and +decoding with low allocation overhead. It can convert directly between bytes +and typed Struct instances, avoiding the intermediate standard Python object +representation. This is faster and more memory efficient while retaining +typed model fields. + +In the benchmark of a 700-pod response, v1 decodes a Pod list into fully +materialized typed objects about 12x faster than v0 with `lazy=False`. It is +also about 2.2x faster than the v0 `lazy=True` measurement, although lazy +decoding deferred part of the conversion work until fields were accessed. + +### Custom resources + +In preparation for the msgspec migration, the `lightkube.core.schema` +compatibility layer has been available since Lightkube v0.15.1. Its use is +required for custom resource models from v1 onward, so make this source change +when upgrading. You can also apply it before upgrading. + +Change: + +```python +# v0.* +from dataclasses import dataclass, field +from lightkube.core.dataclasses_dict import DataclassDictMixIn +``` + +to: + +```python +# v1.* +from lightkube.core.schema import dataclass, field, DictMixin +``` + +Models need to subclass `DictMixin` instead of `DataclassDictMixIn`, as shown in +the [custom resources guide](custom-resources.md). The `dataclass` decorator is +provided for source compatibility, but it is now a no-op. The `field` helper +translates dataclass-style field metadata for msgspec. + +If your code imports `DataclassDictMixIn` or other helpers directly from +`lightkube.core.dataclasses_dict`, migrate those imports to the corresponding +exports from `lightkube.core.schema`. The `dataclasses_dict` implementation is +replaced by the msgspec-backed compatibility layer in v1. + +## Remove lazy decoding and custom dict factories + +`msgspec` decodes Structs eagerly, so lazy model decoding is no longer +available. The `lazy` argument is retained for compatibility in some APIs, +but it has no effect. In particular, passing `lazy=True` to +`DictMixin.from_dict` emits a `DeprecationWarning`; use the default eager +decoding instead and remove `lazy` arguments from your code. + +The non-default `dict_factory` argument to `DictMixin.to_dict` is also +deprecated. Call `to_dict()` to receive a normal `dict`, then transform that +dictionary separately if a custom mapping type is required. + +## Pod logs with follow + +`Client.log` and `AsyncClient.log` now disable the HTTP read timeout when +`follow=True`. This allows a long-lived log stream to remain open while the +container is temporarily quiet. The configured timeout still applies to the +other timeout phases and to non-following log requests. + +No call-site change is required, but code that expected an idle followed log +stream to fail with a read-timeout exception should now close or cancel the +stream explicitly. + +## Recovering watches after a 410 response + +When `Client.watch` or `AsyncClient.watch` is configured to retry, a Kubernetes +`410 Gone` response means that the saved `resourceVersion` is too old. v1 +clears that version before retrying the request. Kubernetes can then return a +fresh state and the watch can continue instead of retrying forever with the +same invalid version. + +The default error handler still raises errors. Opt into retry as before: + +```python +from lightkube import Client +from lightkube.resources.core_v1 import Pod +from lightkube.types import on_error_retry + +with Client() as client: + for event, pod in client.watch(Pod, on_error=on_error_retry): + print(event, pod.metadata.name) +``` + +Custom handlers that return `OnErrorAction.RETRY` receive the same 410 +recovery behavior. + +## Upgrade checklist + +1. Change Lightkube-related `httpx` imports and exception handlers to + `httpx2`, or use `import httpx2 as httpx`. +2. Change custom resource models to import `dataclass`, `field` and `DictMixin` from + `lightkube.core.schema`. +3. Remove reliance on lazy decoding and non-default `dict_factory` values. +4. Review followed log streams and watch retry handlers if their previous + failure behavior was part of your application logic. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index ffe1f8c..9b5be2d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,7 @@ edit_uri: "" nav: - QuickStart: index.md + - "Migration Guide: v0.x to v1.x": migration-to-v1.md - Configuration: configuration.md - Reference: - Client: reference/client.md