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
7 changes: 6 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
# A "t" suffix is the free-threaded (no-GIL) build.
python-version: ["3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- name: Install dependencies
run: uv lock --python ${{ matrix.python-version }} && uv sync --python ${{ matrix.python-version }} --all-extras
# Otherwise the free-threading tests would silently skip.
- name: Check the interpreter is free-threaded
if: endsWith(matrix.python-version, 't')
run: uv run python -c "import sys, sysconfig; sys.exit(0 if sysconfig.get_config_var('Py_GIL_DISABLED') else 'not a free-threaded build')"
- name: Run pytest
run: uv run pytest .
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Free-threaded Python 3.14t and 3.15t support (beta).

### Fixed

- Loading a kubeconfig from several threads at once no longer leaves temp files with decoded
`*-data` credentials behind.
- A resource class first used from several threads at once builds its list model only once.

## [0.1.0-beta.4] - 2026-10-28

### Added
Expand Down
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ test/ # Test suite
├── test_metadata_accessor.py # MetadataAccessor (get, list, patch) tests
├── test_invalid_objects.py # list()/watch(invalid=) parsing rules, is_initial_events_end, overload types (assert_type)
├── test_api_request_validation.py # Api-level name/namespace validation (nothing reaches the client)
├── test_free_threading.py # Free-threaded builds only (skipped elsewhere): importing kubex + httpx_ws/trio keeps the GIL off
├── test_models/ # Unit tests for kubex-core models, field types and generated-model behaviour
│ └── test_eviction.py # Eviction model tests
├── test_patch/ # Unit tests for patch models
Expand All @@ -217,7 +218,8 @@ test/ # Test suite
├── test_stream/ # Unit tests for _BaseChannelSession lifecycle + StreamSession channel multiplexer (shared by exec and attach)
├── test_client/ # Unit tests for BaseClient ABC, AioHttpClient, HttpxClient, and trust_env env-proxy + netrc resolver (test_trust_env.py);
│ # conftest.py parametrizes backends (aiohttp/asyncio, httpx/asyncio, httpx/trio), _servers.py has local
│ # aiohttp/raw-TCP/TLS test servers (streaming, transport errors, tls_server_name, headers, WS keepalive)
│ # aiohttp/raw-TCP/TLS test servers (streaming, transport errors, tls_server_name, headers, WS keepalive);
│ # test_threads.py runs clients on several threads (one event loop each) sharing one ClientConfiguration
├── test_subresource_descriptors/ # Unit tests for descriptor-based subresource APIs
└── test_timeout/ # Unit tests for HTTP timeout settings

Expand Down Expand Up @@ -254,7 +256,7 @@ lychee.toml # Link checker configuration

.github/workflows/
├── lint.yaml # Pre-commit, ruff check, ruff format --check, mypy, codegen verify
├── test.yaml # pytest with all extras on every supported Python (3.11–3.15 matrix)
├── test.yaml # pytest with all extras on every supported Python (3.11–3.15 matrix, plus free-threaded 3.14t and 3.15t)
├── publish-test.yaml # Build + publish to Test PyPI on PRs (OIDC trusted publishing)
└── publish.yaml # Build + publish to production PyPI on v* tag push (OIDC trusted publishing)
```
Expand Down Expand Up @@ -372,7 +374,9 @@ Five GitHub Actions workflows:
6. Verify generated packages — runs `python -m scripts.codegen verify` for each `packages/kubex-k8s-*`

**Test** (`test.yaml`) — runs on push and pull_request:
1. `pytest .` with all extras, as a matrix over Python 3.11, 3.12, 3.13, 3.14, 3.15 (`fail-fast: false`)
1. `pytest .` with all extras, as a matrix over Python 3.11, 3.12, 3.13, 3.14, 3.14t, 3.15, 3.15t (`fail-fast: false`).
`t` marks a free-threaded build; a step first fails those jobs if the interpreter is not free-threaded, so
`test/test_free_threading.py` (GIL stays off after importing kubex) cannot skip silently

**Publish Test** (`publish-test.yaml`) — runs on pull requests to `main`:
1. Appends `.devN` version suffix to all 9 packages
Expand Down
3 changes: 3 additions & 0 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ For most users, pick one HTTP client and one K8s version:

Kubex is tested and supported on Python 3.11 through 3.15.

Free-threaded (no-GIL) builds of Python 3.14 and 3.15 (`3.14t`, `3.15t`) are tested too; support
for them is in beta.

## Next steps

Once installed, head to the [Quickstart](quickstart.md) to connect to your cluster and make your first request.
49 changes: 35 additions & 14 deletions kubex/configuration/file_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import atexit
import os
import sys
import threading
import warnings
from base64 import b64decode, b64encode
from pathlib import Path
Expand Down Expand Up @@ -40,11 +42,29 @@ class _TempFile(NamedTuple):
_temp_files: dict[str, _TempFile] = {}
"""Decoded `*-data` payload -> temp file holding it."""
_cleanup_registered = False
_temp_files_lock = threading.Lock()
"""Guards `_temp_files` and `_cleanup_registered`.

Without it, threads loading the same kubeconfig at once each write their own
temp file and only the last one is tracked, so the others (decoded keys and
certificates) are never removed.
"""

if sys.platform != "win32":
# Hold the lock across fork() so a child never inherits it locked by a
# thread that does not exist there.
os.register_at_fork(
before=_temp_files_lock.acquire,
after_in_parent=_temp_files_lock.release,
after_in_child=_temp_files_lock.release,
)


def _cleanup_temp_files() -> None:
pid = os.getpid()
for temp_file in list(_temp_files.values()):
with _temp_files_lock:
temp_files = list(_temp_files.values())
for temp_file in temp_files:
if temp_file.owner_pid != pid:
# Inherited across fork(): the creating process still uses it.
continue
Expand Down Expand Up @@ -473,16 +493,17 @@ def _decode_and_put_to_file(data: str) -> Path:
only by the process that created it.
"""
global _cleanup_registered
cached = _temp_files.get(data)
if cached is not None and cached.path.exists():
return cached.path
# Line breaks are tolerated (as by Go's decoder); other junk is an error.
decoded = b64decode("".join(data.split()), validate=True)
if not _cleanup_registered:
atexit.register(_cleanup_temp_files)
_cleanup_registered = True
with NamedTemporaryFile(delete=False) as f:
f.write(decoded)
path = Path(f.name)
_temp_files[data] = _TempFile(path, os.getpid())
return path
with _temp_files_lock:
cached = _temp_files.get(data)
if cached is not None and cached.path.exists():
return cached.path
# Line breaks are tolerated (as by Go's decoder); other junk is an error.
decoded = b64decode("".join(data.split()), validate=True)
if not _cleanup_registered:
atexit.register(_cleanup_temp_files)
_cleanup_registered = True
with NamedTemporaryFile(delete=False) as f:
f.write(decoded)
path = Path(f.name)
_temp_files[data] = _TempFile(path, os.getpid())
return path
52 changes: 39 additions & 13 deletions packages/kubex-core/kubex_core/models/resource_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import os
import sys
import threading
from enum import Enum
from functools import cached_property
from typing import TYPE_CHECKING, Any, Generic, Literal, Self, Type, cast
Expand All @@ -17,6 +20,24 @@ class Scope(Enum):
NAMESPACE = "namespace"


_setup_lock = threading.RLock()
"""Makes the one-time setup in `ResourceConfig.__get__` happen once per class.

Without it, threads touching a resource class for the first time at once each
build their own config copy and list model, and the last one stored wins.
Reentrant in case building a list model touches another resource's config.
"""

if sys.platform != "win32":
# Hold the lock across fork() so a child never inherits it locked by a
# thread that does not exist there.
os.register_at_fork(
before=_setup_lock.acquire,
after_in_parent=_setup_lock.release,
after_in_child=_setup_lock.release,
)


class ResourceConfig(Generic[ResourceType]):
"""ResourceConfig is the configuration for a resource."""

Expand Down Expand Up @@ -50,18 +71,21 @@ def __get__(self, instance: Any, owner: Type[ResourceType]) -> Self:
"""
config = owner.__dict__.get("__RESOURCE_CONFIG__")
if config is None:
# The list model is not copied: an inherited one is typed for the
# parent (e.g. `PodList` for `class MyPod(Pod)`), so `list()`
# would return parent instances. `_fill_from` builds one for
# `owner` instead.
config = ResourceConfig(
version=self._version,
kind=self._kind,
plural=self._plural,
scope=self._scope,
group=self._group,
)
setattr(owner, "__RESOURCE_CONFIG__", config)
with _setup_lock:
config = owner.__dict__.get("__RESOURCE_CONFIG__")
if config is None:
# The list model is not copied: an inherited one is typed
# for the parent (e.g. `PodList` for `class MyPod(Pod)`),
# so `list()` would return parent instances. `_fill_from`
# builds one for `owner` instead.
config = ResourceConfig(
version=self._version,
kind=self._kind,
plural=self._plural,
scope=self._scope,
group=self._group,
)
setattr(owner, "__RESOURCE_CONFIG__", config)
return cast(Self, config._fill_from(owner))

def _fill_from(self, owner: Type[ResourceType]) -> Self:
Expand All @@ -80,7 +104,9 @@ def _fill_from(self, owner: Type[ResourceType]) -> Self:
else:
return self
if self._list_model is None:
self._list_model = create_list_model(owner, self)
with _setup_lock:
if self._list_model is None:
self._list_model = create_list_model(owner, self)
if self._scope is None:
self._scope = Scope.NAMESPACE
if self._plural is None:
Expand Down
1 change: 1 addition & 0 deletions packages/kubex-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions packages/kubex-k8s-1-32/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions packages/kubex-k8s-1-33/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions packages/kubex-k8s-1-34/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions packages/kubex-k8s-1-35/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions packages/kubex-k8s-1-36/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions packages/kubex-k8s-1-37/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions packages/kubex-k8s-1-38/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]

[project.urls]
Expand Down
1 change: 1 addition & 0 deletions scripts/codegen/templates/pyproject.toml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]

[build-system]
Expand Down
1 change: 1 addition & 0 deletions scripts/codegen/tests/golden/kubex-k8s-1-30/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Free Threading :: 2 - Beta",
]

[build-system]
Expand Down
41 changes: 24 additions & 17 deletions test/test_client/_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,32 @@ def __enter__(self) -> ThreadedAioServer:
errors: list[BaseException] = []

def _run() -> None:
loop: asyncio.AbstractEventLoop | None = None
try:
loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
runner = web.AppRunner(self.app, handle_signals=False)
loop.run_until_complete(runner.setup())
site = web.TCPSite(
runner, "127.0.0.1", 0, ssl_context=self._ssl_context
)
loop.run_until_complete(site.start())
self._runner = runner
server: Any = site._server
self.port = server.sockets[0].getsockname()[1]
except BaseException as exc: # pragma: no cover - startup failure
errors.append(exc)
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
runner = web.AppRunner(self.app, handle_signals=False)
loop.run_until_complete(runner.setup())
site = web.TCPSite(
runner, "127.0.0.1", 0, ssl_context=self._ssl_context
)
loop.run_until_complete(site.start())
server: Any = site._server
self.port = server.sockets[0].getsockname()[1]
self._loop = loop
self._runner = runner
except BaseException as exc: # pragma: no cover - startup failure
errors.append(exc)
ready.set()
return
ready.set()
return
ready.set()
loop.run_forever()
loop.run_forever()
finally:
# Left open, the loop is closed by its finalizer later, which
# raises an unraisable-exception warning on free-threaded builds.
if loop is not None:
loop.close()

self._thread = threading.Thread(target=_run, daemon=True)
self._thread.start()
Expand Down
Loading
Loading