Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions docs/migration-to-v1.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "lightkube"
version = "0.22.0"
version = "1.0.0"
description = "Lightweight kubernetes client library"
readme = "README.md"
authors = [
Expand Down
45 changes: 27 additions & 18 deletions src/lightkube/core/generic_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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]:
Expand All @@ -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]:
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion src/lightkube/core/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading