diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 00000000..c72a09c1 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,56 @@ +name: Docs + +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + inputs: + version: + description: "Version label (defaults to 'dev')" + required: false + default: "dev" + +permissions: + contents: write + +concurrency: + group: docs-deploy + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v3 + with: + python-version: "3.13" + - name: Install docs dependencies + run: uv lock --python 3.13 && uv sync --group docs + - name: Build docs (strict) + run: uv run --group docs mkdocs build --strict + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Deploy (main → dev) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: uv run --group docs mike deploy --push --update-aliases dev + - name: Deploy (tag → versioned) + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + uv run --group docs mike deploy --push "$VERSION" + # NOTE: promotion to the `latest` alias and `mike set-default` is + # deliberately manual. Auto-promoting on every `v*` tag would demote + # a parallel-line release (e.g., a v1.x patch tag pushed after v2.x + # would silently make v1 the default). Promote via workflow_dispatch + # only after confirming the new tag is the project's newest line. + - name: Deploy (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + env: + VERSION_LABEL: ${{ github.event.inputs.version }} + run: uv run --group docs mike deploy --push --update-aliases "$VERSION_LABEL" diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index d74c4c05..2cd7e4ca 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -45,3 +45,20 @@ jobs: uv run python -m scripts.codegen verify "$pkg" echo "::endgroup::" done + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + python-version: "3.13" + - name: Install docs dependencies + run: uv lock --python 3.13 && uv sync --group docs + - name: Build docs (strict) + run: uv run --group docs mkdocs build --strict + - name: Check links + uses: lycheeverse/lychee-action@v2 + with: + args: --config lychee.toml docs/ + fail: true + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-test.yaml b/.github/workflows/publish-test.yaml index 58dff9fe..e6f54386 100644 --- a/.github/workflows/publish-test.yaml +++ b/.github/workflows/publish-test.yaml @@ -33,6 +33,7 @@ jobs: outputs: dev-version: ${{ steps.version.outputs.dev-version }} core-dev-version: ${{ steps.version.outputs.core-dev-version }} + k8s-versions: ${{ steps.version.outputs.k8s-versions }} steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v3 @@ -65,12 +66,23 @@ jobs: CORE_DEV_VERSION=$(python3 -c "from packaging.version import Version; print(Version('${CORE_DEV_VERSION_RAW}'))") echo "core-dev-version=${CORE_DEV_VERSION}" >> "$GITHUB_OUTPUT" sed -i "s/\"kubex-core\"/\"kubex-core==${CORE_DEV_VERSION}\"/" pyproject.toml + K8S_VERSIONS_JSON='{' + FIRST=true for pkg in packages/kubex-k8s-*/; do K8S_DEV_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "${pkg}pyproject.toml")" + K8S_NORMALIZED=$(python3 -c "from packaging.version import Version; print(Version('${K8S_DEV_VERSION}'))") pkg_name=$(basename "$pkg") - sed -i "s/\"${pkg_name}\"/\"${pkg_name}==${K8S_DEV_VERSION}\"/" pyproject.toml + sed -i "s/\"${pkg_name}\"/\"${pkg_name}==${K8S_NORMALIZED}\"/" pyproject.toml sed -i "s/\"kubex-core\"/\"kubex-core==${CORE_DEV_VERSION}\"/" "${pkg}pyproject.toml" + if [ "$FIRST" = true ]; then + FIRST=false + else + K8S_VERSIONS_JSON="${K8S_VERSIONS_JSON}," + fi + K8S_VERSIONS_JSON="${K8S_VERSIONS_JSON}\"${pkg_name}\":\"${K8S_NORMALIZED}\"" done + K8S_VERSIONS_JSON="${K8S_VERSIONS_JSON}}" + echo "k8s-versions=${K8S_VERSIONS_JSON}" >> "$GITHUB_OUTPUT" - name: Build packages run: | @@ -102,7 +114,7 @@ jobs: with: script: | const sha = context.payload.pull_request.head.sha; - const required = ['pre-commit', 'lint', 'format', 'mypy', 'test']; + const required = ['pre-commit', 'lint', 'format', 'mypy', 'test', 'docs']; const sleep = ms => new Promise(r => setTimeout(r, ms)); while (true) { @@ -168,19 +180,16 @@ jobs: env: DEV_VERSION: ${{ needs.build.outputs.dev-version }} CORE_DEV_VERSION: ${{ needs.build.outputs.core-dev-version }} + K8S_VERSIONS: ${{ needs.build.outputs.k8s-versions }} with: script: | const devVersion = process.env.DEV_VERSION; const coreDevVersion = process.env.CORE_DEV_VERSION; + const k8sVersions = JSON.parse(process.env.K8S_VERSIONS); const packages = [ { name: 'kubex', version: devVersion }, { name: 'kubex-core', version: coreDevVersion }, - { name: 'kubex-k8s-1-32', version: coreDevVersion }, - { name: 'kubex-k8s-1-33', version: coreDevVersion }, - { name: 'kubex-k8s-1-34', version: coreDevVersion }, - { name: 'kubex-k8s-1-35', version: coreDevVersion }, - { name: 'kubex-k8s-1-36', version: coreDevVersion }, - { name: 'kubex-k8s-1-37', version: coreDevVersion }, + ...Object.entries(k8sVersions).map(([name, version]) => ({ name, version })), ]; const lines = packages.map(({ name, version }) => `- [${name} ${version}](https://test.pypi.org/project/${name}/${version}/)` diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index cc224c29..a6c11893 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -33,35 +33,34 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Verify all package versions match the tag + - name: Verify kubex and kubex-core versions match the tag run: | TAG_VERSION="${GITHUB_REF_NAME#v}" echo "Tag version: ${TAG_VERSION}" ERRORS=0 - # Check kubex root package (dynamic version from __version__.py) + # Only kubex (the umbrella) and kubex-core (the shared base models) + # are required to match the tag. The generated kubex-k8s-* packages + # are versioned independently — they track Kubernetes minor releases + # and do not need to be re-published on every kubex release. KUBEX_VERSION=$(sed -n 's/^VERSION = "\(.*\)"/\1/p' kubex/__version__.py) if [ "$KUBEX_VERSION" != "$TAG_VERSION" ]; then echo "::error::kubex version mismatch: got '${KUBEX_VERSION}', expected '${TAG_VERSION}'" ERRORS=$((ERRORS + 1)) fi - # Check workspace packages (version in pyproject.toml) - for pkg in packages/*/; do - PKG_NAME=$(basename "$pkg") - PKG_VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' "${pkg}pyproject.toml") - if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then - echo "::error::${PKG_NAME} version mismatch: got '${PKG_VERSION}', expected '${TAG_VERSION}'" - ERRORS=$((ERRORS + 1)) - fi - done + KUBEX_CORE_VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' packages/kubex-core/pyproject.toml) + if [ "$KUBEX_CORE_VERSION" != "$TAG_VERSION" ]; then + echo "::error::kubex-core version mismatch: got '${KUBEX_CORE_VERSION}', expected '${TAG_VERSION}'" + ERRORS=$((ERRORS + 1)) + fi if [ "$ERRORS" -gt 0 ]; then echo "::error::${ERRORS} package(s) have version mismatches with tag ${GITHUB_REF_NAME}" exit 1 fi - echo "All package versions match tag ${GITHUB_REF_NAME}" + echo "kubex and kubex-core versions match tag ${GITHUB_REF_NAME}" build: needs: verify-versions @@ -103,3 +102,4 @@ jobs: - uses: pypa/gh-action-pypi-publish@release/v1.14 with: attestations: true + skip-existing: true diff --git a/.gitignore b/.gitignore index 9064fda6..cfece36d 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,6 @@ cython_debug/ # Ruff .ruff_cache/ scratches/ + + +.ralphex/ diff --git a/CLAUDE.md b/CLAUDE.md index f3567540..cd8261e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,11 @@ ## Project Overview -Kubex is an async-first Kubernetes client library for Python, inspired by [kube.rs](https://kube.rs/). It is built on Pydantic v2 and is async-runtime agnostic (supports asyncio and trio). The project is in **alpha** (v0.1.0-alpha.1) — backward compatibility may break between releases. +Kubex is an async-first Kubernetes client library for Python, inspired by [kube.rs](https://kube.rs/). It is built on Pydantic v2 and is async-runtime agnostic (supports asyncio and trio). The project is in **beta** (v0.1.0-beta.1) — backward compatibility may still break between releases. + +**Documentation site:** https://kubex.codemageddon.me/ + +**Implementation plans** live at `.ralphex/plans/` (not `docs/`). ## Quick Reference @@ -28,6 +32,12 @@ uv run mypy . # Run pre-commit hooks pre-commit run --all-files +# Serve docs locally with live reload +mise run docs:serve + +# Build docs in strict mode (--strict turns warnings into errors) +mise run docs:build + # Regenerate all K8s model packages (downloads specs + runs codegen + verifies) mise run regenerate-models ``` @@ -39,9 +49,9 @@ kubex/ # Main package — PEP 420 namespace package (no # workspace `kubex-k8s-*` packages can contribute `kubex.k8s.*` submodules. # Public API is imported from explicit submodules: # `from kubex.api import Api, create_api`, - # `from kubex.client import BaseClient, create_client`, + # `from kubex.client import BaseClient, create_client, ClientOptions`, # `from kubex.configuration import ClientConfiguration` -├── __version__.py # Version string (0.1.0-alpha.1) +├── __version__.py # Version string (0.1.0-beta.1) ├── py.typed # PEP 561 type hint marker ├── api/ # High-level API layer │ ├── api.py # Api[ResourceType] generic class + create_api() factory @@ -60,6 +70,7 @@ kubex/ # Main package — PEP 420 namespace package (no │ └── _protocol.py # ApiProtocol[ResourceType], type aliases, SubresourceNotAvailable, namespace helpers ├── client/ # HTTP client implementations │ ├── client.py # BaseClient ABC, create_client() factory, ClientChoise enum +│ ├── options.py # ClientOptions pydantic model — timeout, log_api_warnings, proxy, keep_alive, keep_alive_timeout, buffer_size, ws_max_message_size, pool_size, pool_size_per_host │ ├── websocket.py # WebSocketConnection ABC — abstraction used by exec and attach subresources │ ├── httpx.py # HttpxClient implementation (exec via httpx-ws) │ └── aiohttp.py # AioHttpClient implementation (exec via aiohttp ws_connect) @@ -187,8 +198,23 @@ examples/ # Usage examples ├── exec_pod.py # Pod exec subresource — api.exec.run() + api.exec.stream() interactive shell ├── attach_pod.py # Pod attach subresource — api.attach.stream() with stdin/stdout ├── portforward_pod.py # Pod portforward subresource — api.portforward.forward() (low-level ByteStream) + api.portforward.listen() (local TCP listener) +├── client_options.py # ClientOptions knobs — proxy, keep_alive, buffer_size, pool_size, ws_max_message_size +├── custom_resource.py # Define CRD models (Widget, ClusterWidget) + create/get/list/patch/status/delete └── delete_collection.py # Bulk delete with label_selector +docs/ # MkDocs documentation site source +├── index.md # Landing page +├── CNAME # Custom domain for GitHub Pages (kubex.codemageddon.me) +├── stylesheets/extra.css # Site-specific CSS overrides +├── getting-started/ # Installation + quickstart guides +├── concepts/ # Core concept explanations (Api, clients, config, exceptions, subresources) +├── operations/ # CRUD, watch, patch, timeouts +├── subresources/ # Per-subresource pages (logs, metadata, scale, status, eviction, ephemeral-containers, resize, exec, attach, portforward) +├── advanced/ # Advanced topics (multi-version K8s, clients/runtimes, auth, benchmarks) +└── reference/ # Auto-generated API reference via mkdocstrings +mkdocs.yml # MkDocs configuration (theme, nav, plugins) +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 Python 3.13 @@ -237,7 +263,7 @@ Each resource model declares a `__RESOURCE_CONFIG__` class variable (a `Resource All models inherit from `BaseK8sModel` which uses `alias_generator=to_camel` and `populate_by_name=True`. This means Python code uses `snake_case` while JSON serialization uses `camelCase` to match the Kubernetes API. ### Pluggable HTTP clients -`BaseClient` is an ABC. Implementations (`HttpxClient`, `AioHttpClient`) are lazily imported. The `create_client()` factory auto-detects which library is installed (prefers httpx). `BaseClient` also exposes `connect_websocket(request, subprotocols)` returning a `WebSocketConnection` (defined in `kubex/client/websocket.py`); the default raises `NotImplementedError`. `HttpxClient` implements it via `httpx-ws` (lazy import — raises `ConfgiurationError` if missing); `AioHttpClient` uses aiohttp's built-in `ws_connect`. Both adapters prefer `Request.query_param_pairs` over `Request.query_params` when building the upgrade URL so exec's repeated `command=` entries are preserved. +`BaseClient` is an ABC. Implementations (`HttpxClient`, `AioHttpClient`) are lazily imported. The `create_client()` factory auto-detects which library is installed (prefers aiohttp, falls back to httpx). `BaseClient` also exposes `connect_websocket(request, subprotocols)` returning a `WebSocketConnection` (defined in `kubex/client/websocket.py`); the default raises `NotImplementedError`. `HttpxClient` implements it via `httpx-ws` (lazy import — raises `ConfgiurationError` if missing); `AioHttpClient` uses aiohttp's built-in `ws_connect`. Both adapters prefer `Request.query_param_pairs` over `Request.query_params` when building the upgrade URL so exec's repeated `command=` entries are preserved. `create_client()` also accepts `options: ClientOptions | None` — a `ClientOptions` instance carrying the client-level `timeout` default, `log_api_warnings` flag, and the following connection-pool / proxy / WebSocket knobs: `proxy` (str or per-scheme dict), `keep_alive` (bool), `keep_alive_timeout` (float|None|...), `buffer_size` (int|None|...), `ws_max_message_size` (int|None|...), `pool_size` (int|None|...), `pool_size_per_host` (int|None|...). When `None`, a `ClientOptions()` with library defaults is used. Both `HttpxClient` and `AioHttpClient` expose an `options` property. `HttpxClient._create_inner_client()` maps these into `httpx.Limits(...)`, `proxy=`, and `mounts=`; `AioHttpClient._create_inner_client()` maps them into `TCPConnector(limit=, limit_per_host=, keepalive_timeout=, force_close=)` and `ClientSession(read_bufsize=, proxy=)`. Backend asymmetries (httpx ignores `buffer_size` and `pool_size_per_host`; aiohttp warns on `keep_alive_timeout=None` and `proxy=dict` with non-matching schemes) emit a `UserWarning` at client construction time. ### Configuration auto-loading `create_client()` → tries kubeconfig file first → falls back to in-cluster pod environment. @@ -245,7 +271,7 @@ All models inherit from `BaseK8sModel` which uses `alias_generator=to_camel` and ### Ellipsis sentinel for optional overrides `Ellipsis` (`...`) is used as a sentinel to distinguish "not provided" (use the default) from `None` (explicitly disabled). This pattern is used in two places: - **Namespace**: `...` = use the `Api` instance default; `None` = explicitly no namespace. -- **Request timeout**: `...` = use the client-level default (or the HTTP library default if none was configured); `None` = explicitly disable timeouts for this call. +- **Request timeout**: `...` = use the `ClientOptions.timeout` default (itself `...` unless overridden, meaning use the HTTP library's own default); `None` = explicitly disable timeouts for this call. ### Exception hierarchy ``` @@ -281,7 +307,7 @@ Resources declare capabilities via multiple inheritance from marker classes: `Na - **Framework**: pytest with `pytest-cov` and `anyio` for async support - **E2E tests** use `testcontainers` with a K3S container (requires Docker); located in `test/e2e/` -- **Unit tests** for request builder methods in `test/test_request_builder/`, error handling in `test/test_error_handling.py`, metadata accessor in `test/test_metadata_accessor.py`, configuration/auth in `test/test_configuration/`, timeout settings in `test/test_timeout/`, patch models in `test/test_patch/`, subresource descriptors in `test/test_subresource_descriptors/` +- **Unit tests** for request builder methods in `test/test_request_builder/`, error handling in `test/test_error_handling.py`, metadata accessor in `test/test_metadata_accessor.py`, configuration/auth in `test/test_configuration/`, timeout settings in `test/test_timeout/`, patch models in `test/test_patch/`, subresource descriptors in `test/test_subresource_descriptors/`, `ClientOptions` and `create_client()` in `test/test_client/` - **Codegen tests** with golden snapshots in `scripts/codegen/tests/` - E2E tests are parameterized over both HTTP clients (`httpx`, `aiohttp`) and async backends (`asyncio`, `trio` — trio only with httpx) - Mark async tests with `@pytest.mark.anyio` @@ -289,7 +315,7 @@ Resources declare capabilities via multiple inheritance from marker classes: `Na ## CI/CD -Four GitHub Actions workflows: +Five GitHub Actions workflows: **Lint** (`lint.yaml`) — runs on push and pull_request: 1. Pre-commit hooks (all files) @@ -309,19 +335,24 @@ Four GitHub Actions workflows: - Uses GitHub environment `test-pypi`; skips publish for fork PRs **Publish** (`publish.yaml`) — runs on `v*` tag push: -1. Verifies all 8 package versions match the tag +1. Verifies `kubex` and `kubex-core` versions match the tag (kubex-k8s-* packages are versioned independently) 2. Builds all packages in dependency order 3. Publishes to production PyPI using OIDC trusted publishing - Uses GitHub environment `pypi` +**Docs** (`docs.yaml`) — runs on push to `main`, `v*` tag, and `workflow_dispatch`: +1. Builds docs in strict mode (`mkdocs build --strict`) +2. Deploys to GitHub Pages via `mike` (`dev` alias on main push, versioned label on tag) +- Promotion of a versioned label to `latest` is intentionally manual + ## Releasing To publish a new version to PyPI: -1. Bump the version in `kubex/__version__.py` and in every `packages/*/pyproject.toml` — all 8 packages must have the same version string +1. Bump the version in `kubex/__version__.py` and in `packages/kubex-core/pyproject.toml` — these two must match the git tag. The `packages/kubex-k8s-*/pyproject.toml` versions are independent (they track Kubernetes minor releases) and only need to be bumped when the corresponding generated package actually changes. 2. Commit and push to `main` -3. Create and push a git tag matching the version: `git tag v && git push origin v` -4. The `publish.yaml` workflow will verify version consistency, build all packages, and publish to production PyPI +3. Create and push a git tag matching the `kubex` / `kubex-core` version: `git tag v && git push origin v` +4. The `publish.yaml` workflow will verify that `kubex` and `kubex-core` versions match the tag, build all packages, and publish to production PyPI Both publish workflows use PyPI OIDC trusted publishing — no API tokens are stored in the repository. Each of the 8 packages must have a trusted publisher configured in its PyPI (and Test PyPI) project settings. See the comment block at the top of each workflow file for the exact configuration values. diff --git a/README.md b/README.md index b8790b30..c919cd56 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,12 @@ -# Kubex +

+ Kubex logo +

+ +

Kubex

+ +[![Docs](https://github.com/codemageddon/kubex/actions/workflows/docs.yaml/badge.svg)](https://github.com/codemageddon/kubex/actions/workflows/docs.yaml) + +**Documentation:** https://kubex.codemageddon.me/ Kubex is a Kubernetes client library for Python inspired by kube.rs. It is built on top of [Pydantic](https://github.com/pydantic/pydantic) and is async-runtime agnostic. @@ -6,16 +14,16 @@ Kubex is a Kubernetes client library for Python inspired by kube.rs. It is built ### Performance -Kubex is dramatically faster than [kubernetes-asyncio](https://github.com/tomplus/kubernetes_asyncio), the most popular async Kubernetes client for Python. Benchmarks against a K3s 1.35 cluster (see `benchmarks/`): +Kubex is dramatically faster than [kubernetes-asyncio](https://github.com/tomplus/kubernetes_asyncio), the most popular async Kubernetes client for Python. Benchmarks against a K3s 1.35.4 cluster (see `benchmarks/`): | Scenario | kubernetes-asyncio | kubex (aiohttp) | kubex (httpx) | Speedup | |---|---|---|---|---| -| Single GET | 60 ms | 6 ms | 28 ms | **10x** | -| List 100 pods | 2,783 ms | 74 ms | 102 ms | **37x** | -| List 500 pods | 14,167 ms | 351 ms | 409 ms | **40x** | -| Watch 50 events | 3,856 ms | 635 ms | 1,914 ms | **6x** | +| Single GET | 61 ms | 6 ms | 26 ms | **10×** | +| List 100 pods | 2,813 ms | 73 ms | 102 ms | **38×** | +| List 500 pods | 14,441 ms | 351 ms | 410 ms | **41×** | +| Watch 50 events | 3,957 ms | 562 ms | 1,764 ms | **7×** | -Kubex also uses **49% less heap memory** and makes **up to 5x fewer allocations**, reducing GC pressure in long-running controllers and operators. +Kubex also uses **~47% less heap memory** and makes **up to ~5x fewer allocations**, reducing GC pressure in long-running controllers and operators. ### Fully typed @@ -55,6 +63,14 @@ Kubex works with both **asyncio** and **trio** (via httpx), with no framework lo * `httpx` and `aiohttp` as an underlying http-client support. * `asyncio` and `trio` async runtime support (only `httpx` client is supported for `trio`). * Comprehensive, fully-typed Kubernetes resource models (1.32–1.37) generated from the OpenAPI spec via a built-in code generator. +* Custom Resource Definitions (CRDs) — define a Pydantic model inheriting from `NamespaceScopedEntity` or `ClusterScopedEntity` with `__RESOURCE_CONFIG__` and use `Api[T]` for full CRUD, watch, and subresource access. No code generation required. See `examples/custom_resource.py` and the [Custom Resources docs](https://kubex.codemageddon.me/advanced/custom-resources/). +* `ClientOptions` — per-client HTTP configuration: `proxy` (single URL string or per-scheme `{"http": …, "https": …}` dict), `keep_alive` / `keep_alive_timeout`, `buffer_size` (aiohttp read buffer), `ws_max_message_size` (WebSocket frame cap for exec/attach/portforward), `pool_size` (total connection pool), and `pool_size_per_host`. Pass `options=ClientOptions(…)` to `create_client()`. See `examples/client_options.py`. + +> **Experimental — WebSocket subresources.** The `exec`, `attach`, and +> `portforward` APIs described below are still under active development. +> Their public surface (method signatures, accessor shape, session helpers) +> may change in future releases without notice. + * Pod `exec` subresource over WebSocket — one-shot `run()` for collecting output, and `stream()` for interactive sessions with stdin/resize. Both accept `command`, `container`, `stdout`, `stderr`, and `request_timeout`; `run()` takes `stdin` as bytes (or `None` to skip), while `stream()` takes `stdin` and `tty` as bools and exposes `session.stdin.write()`/`close()`, `session.stdout`/`session.stderr` as async iterators, `session.resize(width=, height=)`, and `await session.wait_for_status()` (resolves to a `Status` model when the server emits one on the error channel, or `None` if the connection closes first; correspondingly, `result.exit_code` is `0` on success, the parsed integer for a non-zero exit, or `None` when no recognisable exit information is present — `None` does not imply success). Trio is supported only via the `httpx` client; the `aiohttp` backend is asyncio-only and raises on `connect_websocket()` if used with trio. Requires Kubernetes ≥1.30 (uses the v5 channel protocol; install via `kubex[httpx-ws]` to pull in `httpx-ws` (the plain `kubex[httpx]` extra omits it so non-WebSocket installs stay slim); on Python 3.10 the `exceptiongroup` backport is also installed). See `examples/exec_pod.py`. * Pod `attach` subresource over WebSocket — `stream()` attaches to an existing container process (stdin/stdout/stderr) without issuing a new command. Exposes the same `StreamSession` interface as `exec` (`session.stdin.write()`/`close()`, `session.stdout`/`session.stderr` as async iterators, `session.resize(width=, height=)`, `await session.wait_for_status()`). Only `stream()` is provided — there is no `run()`. The container must be created with `stdin=True` in its spec for stdin writes to reach the process. Requires `kubex[httpx-ws]` (or `aiohttp`). See `examples/attach_pod.py`. * Pod `portforward` subresource over WebSocket — two-level API: `api.portforward.forward(name, ports=[…])` yields a `PortForwarder` with one `anyio.abc.ByteStream` per port (`pf.streams[port]`) and a per-port error iterator (`pf.errors[port]`); `api.portforward.listen(name, port_map={remote_port: local_port})` opens local TCP listener sockets and copies bytes bidirectionally between each accepted local connection and a fresh portforward WebSocket session (one session per connection, matching `kubectl port-forward` semantics). Requires `kubex[httpx-ws]` (or `aiohttp`). See `examples/portforward_pod.py`. diff --git a/benchmarks/README.md b/benchmarks/README.md index 7ab24c10..5e48e0f4 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -15,7 +15,7 @@ No benchmark numbers are committed — run the suite yourself and read | `kubex-httpx-asyncio` | kubex | httpx | asyncio | | `kubex-httpx-trio` | kubex | httpx | trio | | `kubex-aiohttp-asyncio` | kubex | aiohttp | asyncio | -| `kubex-metadata-httpx-asyncio` | kubex (`PartialObjectMetadata`) | httpx | asyncio | +| `kubex-metadata-aiohttp-asyncio` | kubex (`PartialObjectMetadata`) | aiohttp | asyncio | | `k8s-asyncio` | kubernetes-asyncio | aiohttp | asyncio | kubernetes-asyncio does not support trio (its aiohttp backend is @@ -23,7 +23,7 @@ asyncio-only), so trio rows exist only for the kubex-httpx combination. K8s server is pinned to **v1.35** via the K3s testcontainer image. kubex uses models from the `kubex-k8s-1-35` workspace package; kubernetes-asyncio -is pinned to `>=35.0.1,<36` — both sides target the same 1.35 wire schema. +is pinned to `>=35.0.0,<36` — both sides target the same 1.35 wire schema. ## Scenarios @@ -48,10 +48,10 @@ uv sync --group benchmark --python 3.14 The `benchmark` dependency group adds: -- `kubernetes-asyncio>=35.0.1,<36` +- `kubernetes-asyncio>=35.0.0,<36` - `memray`, `pytest-memray`, `pyinstrument` - `pytest-benchmark` -- `kubex-k8s-1-35` (same as dev-default) +- `kubex-k8s-1-35` - Both HTTP backends (`httpx`, `aiohttp`) and `trio` ## Run diff --git a/benchmarks/_cluster.py b/benchmarks/_cluster.py index 39ee2042..8face171 100644 --- a/benchmarks/_cluster.py +++ b/benchmarks/_cluster.py @@ -29,7 +29,14 @@ # K3s image pinned to 1.35 so the wire server matches the kubernetes-asyncio # 35.x client schema and the kubex-k8s-1-35 model package. -DEFAULT_K3S_IMAGE = "rancher/k3s:v1.35.3-k3s1" +DEFAULT_K3S_IMAGE = "rancher/k3s:v1.35.4-k3s1" + + +def k8s_version_from_image(image: str = DEFAULT_K3S_IMAGE) -> str: + """Extract the Kubernetes version (e.g. '1.35.4') from a K3s image tag.""" + tag = image.split(":")[-1].lstrip("v") # 'v1.35.4-k3s1' -> '1.35.4-k3s1' + return tag.split("-k3s")[0] # '1.35.4-k3s1' -> '1.35.4' + # Pause image — minimal container used for seeded pods. We do not wait for # Running state; list/get endpoints serve the created object regardless of diff --git a/benchmarks/adapters/__init__.py b/benchmarks/adapters/__init__.py index f00e30a3..70a0373b 100644 --- a/benchmarks/adapters/__init__.py +++ b/benchmarks/adapters/__init__.py @@ -39,7 +39,7 @@ def _load_k8s_asyncio() -> type[ClientAdapter]: "kubex-httpx-asyncio": _load_kubex_httpx_asyncio, "kubex-httpx-trio": _load_kubex_httpx_trio, "kubex-aiohttp-asyncio": _load_kubex_aiohttp, - "kubex-metadata-httpx-asyncio": _load_kubex_metadata, + "kubex-metadata-aiohttp-asyncio": _load_kubex_metadata, "k8s-asyncio": _load_k8s_asyncio, } diff --git a/benchmarks/adapters/kubex_metadata.py b/benchmarks/adapters/kubex_metadata.py index 5d1c9be7..edca737a 100644 --- a/benchmarks/adapters/kubex_metadata.py +++ b/benchmarks/adapters/kubex_metadata.py @@ -2,8 +2,8 @@ from typing import ClassVar +from kubex.client.aiohttp import AioHttpClient from kubex.client.client import BaseClient -from kubex.client.httpx import HttpxClient from kubex.configuration.configuration import ClientConfiguration from ._kubex_base import KubexAdapterBase @@ -24,7 +24,7 @@ class KubexMetadataAdapter(KubexAdapterBase): typed Pod API because those endpoints have no metadata-only equivalent. """ - name: ClassVar[str] = "kubex-metadata-httpx-asyncio" + name: ClassVar[str] = "kubex-metadata-aiohttp-asyncio" capabilities: ClassVar[frozenset[str]] = frozenset( {CAP_POD_CRUD, CAP_NAMESPACE_LIST, CAP_METADATA} ) @@ -32,7 +32,7 @@ class KubexMetadataAdapter(KubexAdapterBase): async def _build_client(self, config: object) -> BaseClient: assert isinstance(config, ClientConfiguration) - return HttpxClient(config) + return AioHttpClient(config) async def list_pods(self, namespace: str, *, limit: int | None = None) -> int: result = await self._pods().metadata.list(namespace=namespace, limit=limit) diff --git a/benchmarks/run.py b/benchmarks/run.py index 01d3bbb4..9025a26a 100644 --- a/benchmarks/run.py +++ b/benchmarks/run.py @@ -17,7 +17,12 @@ import sys from pathlib import Path -from ._cluster import k3s_cluster, seed_namespace, teardown_namespace +from ._cluster import ( + k3s_cluster, + k8s_version_from_image, + seed_namespace, + teardown_namespace, +) from .runner.driver import main as driver_main @@ -31,7 +36,6 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: p.add_argument("--scenarios", nargs="*", default=None) p.add_argument("--no-memory", action="store_true") p.add_argument("--cpu-profile", action="store_true") - p.add_argument("--k8s-version", default="1.35") p.add_argument("--warmup-iters", type=int, default=-1) p.add_argument("--measure-iters", type=int, default=-1) return p.parse_args(argv) @@ -40,6 +44,7 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: async def _setup_and_run(args: argparse.Namespace) -> int: async with k3s_cluster() as kubeconfig_path: ns = await seed_namespace(kubeconfig_path, args.seed_pods) + k8s_version = k8s_version_from_image() try: driver_argv = [ "--kubeconfig", @@ -53,7 +58,7 @@ async def _setup_and_run(args: argparse.Namespace) -> int: "--csv", args.csv, "--k8s-version", - args.k8s_version, + k8s_version, ] if args.adapters: driver_argv += ["--adapters", *args.adapters] diff --git a/benchmarks/runner/driver.py b/benchmarks/runner/driver.py index b6247c0c..c5cb27a9 100644 --- a/benchmarks/runner/driver.py +++ b/benchmarks/runner/driver.py @@ -59,7 +59,7 @@ "watch_n_events", "stream_logs_n_lines", ], - "kubex-metadata-httpx-asyncio": [ + "kubex-metadata-aiohttp-asyncio": [ "single_get_metadata", "list_metadata_only", ], @@ -198,7 +198,7 @@ def main(argv: list[str] | None = None) -> int: for adapter, scenario in pairs: _run_pair(args, adapter, scenario, artifacts_dir) - build_report(artifacts_dir, Path(args.report), Path(args.csv)) + build_report(artifacts_dir, Path(args.report), Path(args.csv), args.k8s_version) print(f"[driver] report written to {args.report}") return 0 diff --git a/benchmarks/runner/report.py b/benchmarks/runner/report.py index 5bde5c0f..b10fa37a 100644 --- a/benchmarks/runner/report.py +++ b/benchmarks/runner/report.py @@ -6,11 +6,11 @@ from .metrics import Metrics, loads -_HEADER_NOTE = """\ +_HEADER_TEMPLATE = """\ # Kubex vs kubernetes-asyncio — Benchmark Report -Both libraries run against the same K3s testcontainer (K8s 1.35). kubex uses -the `kubex-k8s-1-35` model package; `kubernetes-asyncio 35.x` targets the same +Both libraries run against the same K3s testcontainer (K8s {k8s_version}). kubex uses +the `kubex-k8s-{version_dashed}` model package; `kubernetes-asyncio {k8s_minor}.x` targets the same server schema — any schema-size differences on the wire are minimised. Columns: @@ -115,12 +115,36 @@ def _load_artifacts(artifacts_dir: Path) -> list[Metrics]: return out -def _render_markdown(artifacts: list[Metrics]) -> str: +def _header_note(artifacts: list[Metrics], k8s_version: str | None = None) -> str: + if k8s_version is None: + # Fall back to the most common version across artifacts when the caller + # didn't provide an explicit version (e.g. stand-alone report builds). + if artifacts: + from collections import Counter + + k8s_version = Counter(m.k8s_version for m in artifacts).most_common(1)[0][0] + else: + k8s_version = "1.35" + parts = k8s_version.split(".") + # version_dashed needs only major.minor (e.g. "1-35"), not the patch component. + version_dashed = ( + "-".join(parts[:2]) if len(parts) >= 2 else k8s_version.replace(".", "-") + ) + # k8s_minor is the minor portion (e.g. "35" from "1.35.4"). + k8s_minor = parts[1] if len(parts) >= 2 else k8s_version + return _HEADER_TEMPLATE.format( + k8s_version=k8s_version, + version_dashed=version_dashed, + k8s_minor=k8s_minor, + ) + + +def _render_markdown(artifacts: list[Metrics], k8s_version: str | None = None) -> str: by_scenario: dict[str, list[Metrics]] = {} for m in artifacts: by_scenario.setdefault(m.scenario, []).append(m) - lines: list[str] = [_HEADER_NOTE] + lines: list[str] = [_header_note(artifacts, k8s_version)] for scenario in sorted(by_scenario): rows = sorted(by_scenario[scenario], key=lambda m: m.adapter) caveat = " *(asymmetric)*" if any(m.asymmetric for m in rows) else "" @@ -187,11 +211,14 @@ def _render_csv(artifacts: list[Metrics]) -> str: def build_report( - artifacts_dir: Path, out_md: Path, out_csv: Path | None = None + artifacts_dir: Path, + out_md: Path, + out_csv: Path | None = None, + k8s_version: str | None = None, ) -> None: artifacts = _load_artifacts(artifacts_dir) out_md.parent.mkdir(parents=True, exist_ok=True) - out_md.write_text(_render_markdown(artifacts)) + out_md.write_text(_render_markdown(artifacts, k8s_version)) if out_csv is not None: out_csv.parent.mkdir(parents=True, exist_ok=True) out_csv.write_text(_render_csv(artifacts)) diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 00000000..99311829 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +kubex.codemageddon.me diff --git a/docs/advanced/authentication.md b/docs/advanced/authentication.md new file mode 100644 index 00000000..729198bb --- /dev/null +++ b/docs/advanced/authentication.md @@ -0,0 +1,144 @@ +# Authentication + +Kubex supports three authentication methods: kubeconfig files, in-cluster service account tokens, and exec-provider credentials. Configuration is represented by `ClientConfiguration` and loaded by one of two async factory functions. + +## Auto-detection + +`create_client()` calls `configure_from_kubeconfig()` first. If the kubeconfig file is not found, it falls back to `configure_from_pod_env()`. Other kubeconfig errors (malformed file, missing context, permission denied, etc.) are propagated to the caller: + +```python +from kubex.client import create_client + +async with await create_client() as client: + # automatically picked kubeconfig or in-cluster config + ... +``` + +To skip auto-detection and supply configuration explicitly, pass a `ClientConfiguration` directly: + +```python +from kubex.client import create_client +from kubex.configuration import ClientConfiguration + +config = ClientConfiguration( + url="https://my-cluster:6443", + token="my-bearer-token", +) +async with await create_client(configuration=config) as client: + ... +``` + +## Kubeconfig file + +`configure_from_kubeconfig()` reads the file at `~/.kube/config` by default, or the path from the `KUBECONFIG` environment variable. + +```python +from kubex.client import create_client +from kubex.configuration.file_config import configure_from_kubeconfig + +config = await configure_from_kubeconfig() +async with await create_client(configuration=config) as client: + ... +``` + +To load a specific file or context: + +```python +from kubex.configuration.configuration import KubeConfig +from kubex.configuration.file_config import configure_from_kubeconfig +from pathlib import Path +from yaml import safe_load + +raw = safe_load(Path("/path/to/kubeconfig").read_text()) +kube_config = KubeConfig.model_validate(raw) +config = await configure_from_kubeconfig(config=kube_config, use_context="staging") +``` + +Kubeconfig supports: +- Bearer token (`users[].user.token`) +- Token file (`users[].user.tokenFile`) +- Client certificate + key (`users[].user.client-certificate` / `client-key`) +- Inline base64 certificate data (decoded to temp files automatically) +- Exec provider (see below) +- OIDC (see below) + +## In-cluster (pod environment) + +When your code runs inside a Kubernetes pod with a service account mounted, use `configure_from_pod_env()`: + +```python +from kubex.client import create_client +from kubex.configuration.incluster_config import configure_from_pod_env + +config = await configure_from_pod_env() +async with await create_client(configuration=config) as client: + ... +``` + +This reads: +- Token: `/var/run/secrets/kubernetes.io/serviceaccount/token` +- CA certificate: `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt` +- Namespace: `/var/run/secrets/kubernetes.io/serviceaccount/namespace` +- Server URL: from `KUBERNETES_SERVICE_HOST` and `KUBERNETES_SERVICE_PORT` environment variables + +Token auto-refresh is enabled by default (`try_refresh_token=True`). The token is re-read from disk every 60 seconds, which matches the default rotation window used by Kubernetes projected volume tokens. + +## Exec provider + +Exec provider authentication runs an external command to obtain a bearer token. This is the standard mechanism for cloud-provider CLI tools (AWS, GCP, Azure) and other external credential sources. + +The exec provider config lives in the kubeconfig `users[].user.exec` block: + +```yaml +users: +- name: my-user + user: + exec: + apiVersion: client.authentication.k8s.io/v1 + command: aws + args: + - eks + - get-token + - --cluster-name + - my-cluster +``` + +Kubex reads this via `configure_from_kubeconfig()`. The `ExecAuthProvider` class runs the command using `anyio.run_process`, parses the `ExecCredential` JSON response, and extracts the token. The token is then used as a bearer token in subsequent requests. + +`ExecCredential.status.expirationTimestamp` is noted but token refresh on expiry is not yet fully implemented — the token is refreshed on the next call after the current one fails with `Unauthorized` (the auto-retry logic is planned). For long-running processes, re-configure periodically if needed. + +## OIDC + +OIDC (`auth-provider: oidc`) is parsed from the kubeconfig `auth-provider` block but token refresh is not yet implemented. OIDC support is listed as a planned feature in the roadmap. For current OIDC clusters, the exec provider (via `kubelogin` or similar) is the recommended workaround. + +## Manual `ClientConfiguration` + +For programmatic or testing use cases, construct `ClientConfiguration` directly: + +```python +from kubex.configuration import ClientConfiguration + +# Bearer token +config = ClientConfiguration( + url="https://my-cluster:6443", + server_ca_file="/path/to/ca.crt", + token="eyJhbGciOiJSUzI1NiIs...", +) + +# Client certificate +config = ClientConfiguration( + url="https://my-cluster:6443", + server_ca_file="/path/to/ca.crt", + client_cert_file="/path/to/client.crt", + client_key_file="/path/to/client.key", +) + +# Skip TLS verification (not recommended for production) +config = ClientConfiguration( + url="https://my-cluster:6443", + insecure_skip_tls_verify=True, + token="my-token", +) +``` + +The `namespace` parameter sets the default namespace for all `Api` instances created from this client. It defaults to `"default"` if not specified. diff --git a/docs/advanced/benchmarks.md b/docs/advanced/benchmarks.md new file mode 100644 index 00000000..fa76a118 --- /dev/null +++ b/docs/advanced/benchmarks.md @@ -0,0 +1,129 @@ +# Benchmarks + +Kubex is dramatically faster than [kubernetes-asyncio](https://github.com/tomplus/kubernetes_asyncio), the most widely used async Kubernetes client for Python. + +## Summary + +Benchmarks run against a K3s 1.35.4 cluster (K3s testcontainer, same hardware, same server): + +| Scenario | kubernetes-asyncio | kubex (aiohttp) | kubex (httpx) | Speedup | +|---|---|---|---|---| +| Single GET | 61 ms | 6 ms | 26 ms | **10×** | +| List 100 pods | 2,813 ms | 73 ms | 102 ms | **38×** | +| List 500 pods | 14,441 ms | 351 ms | 410 ms | **41×** | +| Watch 50 events | 3,957 ms | 562 ms | 1,764 ms | **7×** | + +Kubex also uses **~47% less heap memory** and makes **up to ~5× fewer allocations**, reducing GC pressure in long-running controllers and operators. + +## Detailed results + +Results below are from `benchmarks/report.md` in the repository. All numbers use a K3s 1.35.4 testcontainer. See the caveats section for measurement details. + +### Single GET + +> Single pod GET against a pre-seeded namespace. + +| metric | k8s-asyncio | kubex-aiohttp | kubex-httpx | kubex-httpx-trio | +|---|---|---|---|---| +| wall p50 (ms) | 60.8 | 5.7 | 25.6 | 27.0 | +| wall p95 (ms) | 66.9 | 7.4 | 26.9 | 27.7 | +| steady heap (MB) | 55.4 | 27.9 | 31.7 | 30.2 | +| total allocations | 15,517,716 | 4,152,111 | 3,226,073 | 3,268,915 | + +### List 100 pods + +> List ~100 pods in bench namespace. + +| metric | k8s-asyncio | kubex-aiohttp | kubex-httpx | kubex-httpx-trio | +|---|---|---|---|---| +| wall p50 (ms) | 2,813 | 73 | 102 | 100 | +| wall p95 (ms) | 2,920 | 79 | 107 | 109 | +| steady heap (MB) | 52.1 | 27.5 | 31.7 | 30.2 | +| total allocations | 7,934,267 | 3,619,184 | 3,936,894 | 3,870,615 | + +### List 500 pods + +> List ~500 pods in bench namespace. + +| metric | k8s-asyncio | kubex-aiohttp | kubex-httpx | kubex-httpx-trio | +|---|---|---|---|---| +| wall p50 (ms) | 14,441 | 351 | 410 | 390 | +| wall p95 (ms) | 14,574 | 618 | 533 | 456 | +| steady heap (MB) | 52.2 | 27.6 | 31.8 | 30.3 | +| total allocations | 29,948,177 | 6,506,349 | 6,940,526 | 6,893,850 | + +### Watch 50 events + +> Receive N watch events driven by a sibling create/delete burst. + +| metric | k8s-asyncio | kubex-aiohttp | kubex-httpx | kubex-httpx-trio | +|---|---|---|---|---| +| wall p50 (ms) | 3,957 | 562 | 1,764 | 1,863 | +| evt p50 (µs) | 24,069 | 1,611 | 3,581 | 4,729 | +| evt p99 (µs) | 56,655 | 9,163 | 5,855 | 15,563 | +| steady heap (MB) | 52.2 | 27.6 | 31.7 | 30.2 | +| total allocations | 4,977,137 | 3,472,840 | 4,685,643 | 4,714,213 | + +### PartialObjectMetadata (asymmetric) + +> Kubex metadata-only list vs kubernetes-asyncio full list. These are asymmetric — kubernetes-asyncio has no metadata-only equivalent, so its numbers reflect a full object list for contrast. The metadata adapter uses the aiohttp backend so the comparison isolates the `?as=PartialObjectMetadata` saving from any HTTP-stack speed difference. + +| metric | k8s-asyncio (full list, 100 pods) | kubex-metadata-aiohttp | +|---|---|---| +| wall p50 (ms) | 2,813 | 14.0 | +| wall p95 (ms) | 3,274 | 15.6 | +| steady heap (MB) | 52.1 | 27.6 | +| total allocations | 7,934,232 | 3,061,371 | + +## Why the gap is so large + +kubernetes-asyncio deserializes every response into Python dicts, validates fields with hand-written code, and constructs `V1*` objects via keyword arguments — an extremely allocation-heavy path. Kubex uses Pydantic v2's Rust-backed validator, which parses JSON directly into typed Python objects in a single pass with far fewer intermediate allocations. + +The list scenario magnifies this: deserializing 500 pods iterates the kubernetes-asyncio path 500 times. Kubex parses the entire list response in one Pydantic call. + +## Reproducing the benchmarks + +Requirements: Docker (for the K3s testcontainer). + +```bash +# Install the benchmark dependency group +uv sync --group benchmark --python 3.13 + +# Run the full suite (starts K3s, seeds pods, measures) +uv run --group benchmark python -m benchmarks.run \ + --report benchmarks/report.md \ + --csv benchmarks/report.csv +``` + +Run only specific adapters or scenarios: + +```bash +uv run --group benchmark python -m benchmarks.run \ + --adapters kubex-aiohttp-asyncio k8s-asyncio \ + --scenarios single_get list_large \ + --report benchmarks/report.md +``` + +Skip memory instrumentation for faster CPU-focused numbers: + +```bash +uv run --group benchmark python -m benchmarks.run \ + --no-memory --cpu-profile --report benchmarks/report.md +``` + +## Measurement methodology + +- Each `(adapter, scenario)` runs in a **fresh subprocess** so library imports never mix (the two libraries have very different import-time footprints). +- Warm-up: 3 untimed iterations (1 for streaming scenarios), then 10 measured (3 for streaming). +- Wall-time: `time.perf_counter_ns` per iteration; reported as p50 / p95 / p99. +- Memory: `memray.Tracker` wraps the measured loop; provides peak RSS, total bytes allocated, and allocation count. +- Steady heap: `gc.collect()` + `tracemalloc.get_traced_memory()` after the loop. +- CPU: `time.process_time()` delta over the measured loop (always captured, cheap). + +## Caveats + +- Memory numbers are Linux-only — peak RSS accounting differs on macOS. +- `--cpu-profile` (pyinstrument) slightly inflates wall-time. For clean wall-time, omit it. +- Asymmetric scenarios (`list_metadata_only`, `single_get_metadata`) compare non-equivalent code paths — kubernetes-asyncio rows on those scenarios show a full list/get for reference only. +- K3s boot takes ~20s per session; this cost is paid once per `python -m benchmarks.run` invocation. +- For mutation scenarios (`single_create_delete`, `watch_n_events`), the K3s API server is the common bottleneck — differences between adapters appear mainly in CPU + allocations, not wall-time. diff --git a/docs/advanced/clients-runtimes.md b/docs/advanced/clients-runtimes.md new file mode 100644 index 00000000..09198f41 --- /dev/null +++ b/docs/advanced/clients-runtimes.md @@ -0,0 +1,100 @@ +# Clients & Runtimes + +Kubex supports two HTTP client backends and two async runtimes. This page explains the trade-offs so you can pick the right combination for your use case. + +## HTTP clients + +| Client | Install | WebSocket | Trio | +|---|---|---|---| +| `aiohttp` | `kubex[aiohttp]` | built-in | no | +| `httpx` | `kubex[httpx]` | requires `kubex[httpx-ws]` | yes | + +### aiohttp + +`aiohttp` is the faster backend for asyncio workloads. Benchmarks show it is consistently **~10× faster than `kubernetes-asyncio`** on single-request paths and **~40× faster** on large list/deserialise paths, and it is faster than the httpx backend on most list and watch scenarios. It has built-in WebSocket support, so `exec`, `attach`, and `portforward` work without any extra dependencies. + +```bash +pip install "kubex[aiohttp,k8s-1.35]" +``` + +### httpx + +`httpx` is the recommended backend when you need **trio** support, or when you prefer the httpx ecosystem. It also supports WebSocket via the `httpx-ws` extra. + +```bash +# asyncio only, no WebSocket +pip install "kubex[httpx,k8s-1.35]" + +# asyncio + WebSocket (exec, attach, portforward) +pip install "kubex[httpx-ws,k8s-1.35]" +``` + +The plain `kubex[httpx]` extra intentionally omits `httpx-ws` so that non-WebSocket installs stay slim. If you call `api.exec.run()` or `api.attach.stream()` without `httpx-ws` installed, a `ConfgiurationError` is raised at runtime. + +### Auto-detection + +`create_client()` picks a backend automatically. It tries `aiohttp` first, then `httpx`, and raises `ImportError` if neither is installed: + +```python +from kubex.client import create_client + +async with await create_client() as client: + ... +``` + +To force a specific backend, pass the `client_class` parameter: + +```python +from kubex.client import create_client, ClientChoise + +async with await create_client(client_class=ClientChoise.HTTPX) as client: + ... +``` + +## Async runtimes + +| Runtime | aiohttp | httpx | +|---|---|---| +| asyncio | yes | yes | +| trio | no | yes | + +### asyncio + +Both backends work with asyncio. This is the default for most Python applications and all CI environments. + +### trio + +Trio is supported only with the `httpx` client. The `aiohttp` backend relies on asyncio internals and raises an error if used with trio. + +```python +import trio +from kubex.api import Api +from kubex.client import create_client, ClientChoise +from kubex.k8s.v1_35.core.v1.pod import Pod + +async def main(): + async with await create_client(client_class=ClientChoise.HTTPX) as client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + pod = await api.get("my-pod") + print(pod.metadata.name) + +trio.run(main) +``` + +## WebSocket support matrix + +WebSocket-based subresources (`exec`, `attach`, `portforward`) require a client that supports WebSocket upgrades: + +| Backend | WebSocket extra needed | Trio WebSocket | +|---|---|---| +| aiohttp | none (built-in) | no | +| httpx | `kubex[httpx-ws]` | yes | + +## Choosing a combination + +For most applications running on asyncio, `aiohttp` is the best choice — lowest latency, fewest dependencies, built-in WebSocket. + +Use `httpx` when: +- Your application uses trio. +- You already use httpx elsewhere and want a consistent HTTP layer. +- You need the httpx middleware / transport ecosystem (e.g., custom retry or proxy transports). diff --git a/docs/advanced/custom-resources.md b/docs/advanced/custom-resources.md new file mode 100644 index 00000000..1174d3e5 --- /dev/null +++ b/docs/advanced/custom-resources.md @@ -0,0 +1,189 @@ +# Custom Resources + +Custom Resources extend the Kubernetes API with your own resource types, defined by a [CustomResourceDefinition (CRD)](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/). Kubex supports them the same way it supports built-in resources: you define a Pydantic model and pass it to `Api[T]`. No generated code, no special registry — just a class. + +## Defining a CRD model + +A custom resource model is a regular Pydantic class that inherits from a scope marker and declares `__RESOURCE_CONFIG__`: + +```python +from __future__ import annotations + +from typing import ClassVar, Literal + +from pydantic import Field + +from kubex_core.models.base import BaseK8sModel +from kubex_core.models.interfaces import HasStatusSubresource, NamespaceScopedEntity +from kubex_core.models.resource_config import ResourceConfig, Scope + + +class WidgetSpec(BaseK8sModel): + replicas: int = 1 + image: str = "nginx:latest" + + +class WidgetStatus(BaseK8sModel): + ready_replicas: int | None = None + + +class Widget(NamespaceScopedEntity, HasStatusSubresource): + __RESOURCE_CONFIG__: ClassVar[ResourceConfig["Widget"]] = ResourceConfig( + version="v1alpha1", + kind="Widget", + group="example.io", + plural="widgets", + scope=Scope.NAMESPACE, + ) + api_version: Literal["example.io/v1alpha1"] = Field( + default="example.io/v1alpha1", + alias="apiVersion", + ) + kind: Literal["Widget"] = Field(default="Widget") + spec: WidgetSpec | None = None + status: WidgetStatus | None = None +``` + +`ResourceConfig` arguments: + +| Argument | Required | Description | +|---|---|---| +| `group` | no | API group, e.g. `"example.io"`; auto-derived from `api_version` Literal if omitted | +| `version` | no | API version, e.g. `"v1alpha1"`; auto-derived from `api_version` Literal if omitted | +| `kind` | no | Singular PascalCase kind, e.g. `"Widget"`; auto-derived from the `kind` field default if omitted | +| `plural` | no | Plural URL path segment; auto-derived from `kind` if omitted | +| `scope` | no | `Scope.NAMESPACE` (default) or `Scope.CLUSTER` | + +`api_version` and `kind` fields use `Literal` types so Pydantic serializes them as fixed strings and mypy can verify type safety on API responses. + +## Cluster-scoped CRDs + +For cluster-scoped resources, inherit from `ClusterScopedEntity` and set `scope=Scope.CLUSTER`: + +```python +# Same imports as above, plus: +from kubex_core.models.interfaces import ClusterScopedEntity + + +class ClusterWidgetSpec(BaseK8sModel): + description: str = "" + + +class ClusterWidget(ClusterScopedEntity): + __RESOURCE_CONFIG__: ClassVar[ResourceConfig["ClusterWidget"]] = ResourceConfig( + version="v1alpha1", + kind="ClusterWidget", + group="example.io", + plural="clusterwidgets", + scope=Scope.CLUSTER, + ) + api_version: Literal["example.io/v1alpha1"] = Field( + default="example.io/v1alpha1", + alias="apiVersion", + ) + kind: Literal["ClusterWidget"] = Field(default="ClusterWidget") + spec: ClusterWidgetSpec | None = None +``` + +Cluster-scoped resources do not accept a `namespace` argument on `Api`. Passing one raises a `ValueError` at construction time. + +## Adding subresource support + +Kubex subresource accessors (`api.status`, `api.scale`) are enabled by marker interfaces from `kubex_core.models.interfaces`. Add the marker to the class inheritance to unlock the corresponding accessor: + +```python +from kubex_core.models.interfaces import HasStatusSubresource, NamespaceScopedEntity + +class Widget(NamespaceScopedEntity, HasStatusSubresource): + ... +``` + +For standard CRDs, only `HasStatusSubresource` and `HasScaleSubresource` are meaningful — the Kubernetes API server exposes `status` and `scale` subresources for CRDs that declare them in the CRD spec. The remaining markers are Pod-only and not available on custom resources. Adding them to a CRD model compiles and type-checks correctly, but the API server will return a `404` or `405` at runtime. `HasLogs`, `HasExec`, `HasAttach`, and `HasPortForward` are kubelet-proxied operations; `Evictable`, `HasEphemeralContainers`, and `HasResize` are ordinary API-server subresources that exist only for Pods. + +Available markers: + +| Marker | Accessor unlocked | CRD support | +|---|---|---| +| `HasStatusSubresource` | `api.status.get()`, `.replace()`, `.patch()` | Yes — requires `status: {}` in CRD spec | +| `HasScaleSubresource` | `api.scale.get()`, `.replace()`, `.patch()` | Yes — requires `scale:` in CRD spec | +| `HasLogs` | `api.logs.get()`, `.stream()` | No — Pod/kubelet only | +| `Evictable` | `api.eviction.create()` | No — Pod-only, not CRD-supported | +| `HasEphemeralContainers` | `api.ephemeral_containers.get()`, `.replace()`, `.patch()` | No — Pod-only, not CRD-supported | +| `HasResize` | `api.resize.get()`, `.replace()`, `.patch()` | No — Pod-only, not CRD-supported | +| `HasExec` | `api.exec.run()`, `.stream()` | No — Pod/kubelet only | +| `HasAttach` | `api.attach.stream()` | No — Pod/kubelet only | +| `HasPortForward` | `api.portforward.forward()`, `.listen()` | No — Pod/kubelet only | + +Accessing an unregistered subresource on a model that lacks the marker raises `NotImplementedError` at runtime and resolves to `SubresourceNotAvailable` for type checkers. + +## CRUD operations + +Before running any operations, the CRD must be installed on the cluster. Without a `CustomResourceDefinition` object registered for `widgets.example.io`, the API server will return a `404 Not Found` on the first request. Apply the CRD manifest before running the code below — see the [Kubernetes CRD documentation](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) for how to create one. + +Once the CRD is installed and the model is defined, use it exactly like any built-in resource: + +```python +# Widget, WidgetSpec, WidgetStatus defined in the "Defining a CRD model" section above +from kubex.api import Api +from kubex.client import create_client +from kubex.core.patch import ApplyPatch +from kubex_core.models.metadata import ObjectMetadata + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Widget] = Api(Widget, client=client, namespace="default") + + # Create + widget = await api.create( + Widget( + metadata=ObjectMetadata(name="my-widget"), + spec=WidgetSpec(replicas=2), + ) + ) + + # Get + fetched = await api.get("my-widget") + print(f"replicas={fetched.spec and fetched.spec.replicas}") + + # List + widget_list = await api.list() + print(f"{len(widget_list.items)} widget(s) found") + + # Server-side apply patch + patched = await api.patch( + "my-widget", + ApplyPatch( + Widget( + api_version="example.io/v1alpha1", + kind="Widget", + metadata=ObjectMetadata(name="my-widget"), + spec=WidgetSpec(replicas=3), + ) + ), + field_manager="my-controller", + force=True, + ) + print(f"patched replicas={patched.spec and patched.spec.replicas}") + + # Status subresource (requires HasStatusSubresource marker) + status = await api.status.get("my-widget") + print(f"ready={status.status and status.status.ready_replicas}") + + # Delete + await api.delete("my-widget") +``` + +See `examples/custom_resource.py` in the repository for a complete runnable version. It requires a running cluster with both the `widgets.example.io` and `clusterwidgets.example.io` CRDs installed. + +## Automatic pluralization + +When `plural` is omitted from `ResourceConfig`, kubex derives it from `kind` at first access using simple English rules: + +| Kind ends with | Rule | Example | +|---|---|---| +| `y` | replace `y` → `ies` | `Category` → `categories` | +| `s` or `x` | append `es` | `Ingress` → `ingresses` | +| anything else | append `s` | `Widget` → `widgets` | + +Override `plural` explicitly when the CRD declares a plural that the auto-derivation rules would not produce — for example, a `FooInstance` resource whose CRD registers `plural: foo-instances` rather than the auto-derived `fooinstances`. diff --git a/docs/advanced/index.md b/docs/advanced/index.md new file mode 100644 index 00000000..723372b0 --- /dev/null +++ b/docs/advanced/index.md @@ -0,0 +1,47 @@ +# Advanced + +This section covers advanced topics for production use and deeper integration. + +
+ +- **Multi-version Kubernetes** + + --- + + Use separate model packages (`kubex-k8s-1-32` through `kubex-k8s-1-37`) to target specific cluster versions, or mix versions in a single application. + + [Multi-version K8s](multi-version-k8s.md) + +- **Custom Resources** + + --- + + Define Pydantic models for your CRDs and use `Api[T]` for full CRUD and status subresource support — no code generation required. + + [Custom Resources](custom-resources.md) + +- **Clients & Runtimes** + + --- + + Choose between `aiohttp` (faster, asyncio-only) and `httpx` (asyncio + trio), and understand the WebSocket support matrix for `exec`, `attach`, and `portforward`. + + [Clients & Runtimes](clients-runtimes.md) + +- **Authentication** + + --- + + Configure kubeconfig-based, in-cluster, and exec-provider authentication. Learn how `create_client()` auto-detects the right method. + + [Authentication](authentication.md) + +- **Benchmarks** + + --- + + Performance comparison against `kubernetes-asyncio`: latency, memory, and allocations across list, watch, and streaming scenarios, with instructions to reproduce results. + + [Benchmarks](benchmarks.md) + +
diff --git a/docs/advanced/multi-version-k8s.md b/docs/advanced/multi-version-k8s.md new file mode 100644 index 00000000..43a9f208 --- /dev/null +++ b/docs/advanced/multi-version-k8s.md @@ -0,0 +1,73 @@ +# Multi-version Kubernetes + +Kubex ships a separate model package for each supported Kubernetes minor version. This lets you pin the exact API surface you target and use multiple versions in the same application. + +## Available packages + +| Package | K8s version | Install extra | +|---|---|---| +| `kubex-k8s-1-32` | 1.32 | `kubex[k8s-1.32]` | +| `kubex-k8s-1-33` | 1.33 | `kubex[k8s-1.33]` | +| `kubex-k8s-1-34` | 1.34 | `kubex[k8s-1.34]` | +| `kubex-k8s-1-35` | 1.35 | `kubex[k8s-1.35]` | +| `kubex-k8s-1-36` | 1.36 | `kubex[k8s-1.36]` | +| `kubex-k8s-1-37` | 1.37 | `kubex[k8s-1.37]` | + +Each package is generated from the official Kubernetes OpenAPI spec, so models exactly match the wire schema of the target cluster. + +## Picking a version + +Install the package that matches your cluster: + +```bash +pip install "kubex[httpx,k8s-1.35]" +``` + +Then import resources from that version's namespace: + +```python +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.apps.v1.deployment import Deployment +from kubex.k8s.v1_35.batch.v1.job import Job +``` + +## Using multiple versions in one application + +If your application manages clusters at different Kubernetes versions, you can import from multiple packages simultaneously. Python namespaces do not conflict — each version lives under its own `kubex.k8s.v1_NN` subpackage. + +```python +from kubex.k8s.v1_34.apps.v1.deployment import Deployment as Deployment134 +from kubex.k8s.v1_35.apps.v1.deployment import Deployment as Deployment135 + +async with await create_client(config_for_cluster_34) as client34: + api34 = Api(Deployment134, client=client34, namespace="default") + deploy = await api34.get("my-app") + +async with await create_client(config_for_cluster_35) as client35: + api35 = Api(Deployment135, client=client35, namespace="default") + deploy = await api35.get("my-app") +``` + +## Installing multiple version packages + +You can install multiple extras at once: + +```bash +pip install "kubex[httpx,k8s-1.34,k8s-1.35]" +``` + +Or with `uv`: + +```bash +uv add "kubex[httpx,k8s-1.34,k8s-1.35]" +``` + +## API compatibility across versions + +Most Kubernetes APIs are stable across minor versions. Fields added in newer versions are `None` by default in older models, so code written against 1.34 models generally works unchanged against a 1.35 cluster. Fields removed between versions will surface as validation warnings or be silently dropped depending on Pydantic's model configuration. + +For strict compatibility, always match the model package version to your cluster version. + +## OpenAPI spec correspondence + +Each model package is generated from the official `swagger.json` (OpenAPI v2) spec published with each Kubernetes release. The generator maps every OpenAPI schema to a Pydantic v2 model with proper field types — no `dict[str, Any]` catch-alls. Marker interfaces (`HasLogs`, `HasExec`, etc.) are added based on which subresource endpoints exist in the spec for that version. diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 00000000..01787192 Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/concepts/api.md b/docs/concepts/api.md new file mode 100644 index 00000000..61b37a5b --- /dev/null +++ b/docs/concepts/api.md @@ -0,0 +1,103 @@ +# Api[T] + +`Api[ResourceType]` is the central class in Kubex. Every operation — get, list, create, delete, watch, patch — goes through it. + +## Creating an Api instance + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.pod import Pod + +client = await create_client() +api: Api[Pod] = Api(Pod, client=client, namespace="default") +``` + +The type parameter (`Pod` here) is resolved at construction time and binds the return types of every method. Your IDE and mypy can infer that `api.get(...)` returns a `Pod`, `api.list(...)` returns a `ListEntity[Pod]`, and so on. + +### Using `create_api()` + +`create_api()` is a convenience factory that creates both a client and an `Api` in one call: + +```python +from kubex.api import create_api +from kubex.k8s.v1_35.core.v1.pod import Pod + +api = await create_api(Pod, namespace="default") +``` + +If you already have a `BaseClient` instance you want to reuse across multiple resource types, pass it explicitly: + +```python +client = await create_client() +pod_api = await create_api(Pod, client=client, namespace="default") +deploy_api = await create_api(Deployment, client=client, namespace="default") +``` + +## Namespace vs cluster scope + +Kubernetes resources are either **namespace-scoped** (Pods, Deployments, Services, …) or **cluster-scoped** (Namespaces, ClusterRoles, Nodes, …). + +Kubex enforces this at the model level. When you create an `Api[Pod]`, the library knows `Pod` is namespace-scoped and will raise an error if you pass a namespace to a cluster-scoped resource's method. + +### Setting a default namespace + +Pass `namespace=` to the `Api` constructor to set a default for every method call: + +```python +api: Api[Pod] = Api(Pod, client=client, namespace="production") +pod = await api.get("web-server") # uses "production" +``` + +### Overriding the namespace per-call + +Any method that accepts a namespace argument lets you override the default: + +```python +pod = await api.get("web-server", namespace="staging") +``` + +### The Ellipsis sentinel + +The namespace parameter on every method defaults to `...` (Ellipsis), not `None`. This distinction matters: + +| Value | Meaning | +|---|---| +| `...` (Ellipsis, the default) | Use the namespace set on the `Api` instance | +| `None` | Explicitly no namespace (cluster-scoped query or all-namespaces list) | +| `"my-namespace"` | Use this specific namespace for this call | + +This is why you can pass `namespace=None` to list across all namespaces even when the `Api` has a default namespace set. + +```python +# List pods in all namespaces, even though api was created with namespace="default" +pods = await api.list(namespace=None) +``` + +## Timeout overrides + +Like namespace, the `request_timeout` parameter on every method defaults to `...` (Ellipsis), meaning "use the client-level default." Pass `None` to disable timeouts for a specific call (useful for long-running watch streams), or pass a number (seconds) for a per-call override: + +```python +# Disable timeout for a long-running list +big_list = await api.list(request_timeout=None) + +# Override to 5 seconds for this call only +pod = await api.get("my-pod", request_timeout=5) +``` + +See [Timeouts](../operations/timeouts.md) for full details. + +## Subresource access + +Subresources (logs, exec, portforward, scale, …) are accessed as attributes on the `Api` instance. They are only available when the resource type declares the appropriate marker interface: + +```python +# Pod supports logs — this works +await pod_api.logs.get("my-pod") + +# Deployment does not support logs — mypy error + runtime NotImplementedError +await deploy_api.logs.get("my-deploy") # type: ignore[attr-defined] +``` + +See [Subresources](subresources.md) for details on the descriptor pattern and marker interfaces. diff --git a/docs/concepts/clients.md b/docs/concepts/clients.md new file mode 100644 index 00000000..3cd433b8 --- /dev/null +++ b/docs/concepts/clients.md @@ -0,0 +1,246 @@ +# Clients + +Kubex separates the API layer (`Api[T]`) from the HTTP transport layer. You can swap transports without changing any application code. + +## `create_client()` + +The `create_client()` factory is the recommended way to get a client. It auto-detects which HTTP library is installed and returns an async context manager — `async with` ensures the underlying connection pool is closed cleanly: + +```python +from kubex.client import create_client + +async with await create_client() as client: + # use the client + ... +``` + +Auto-detection order: **aiohttp** is tried first, then **httpx**. If neither is installed, `create_client()` raises `ImportError`. + +To force a specific client: + +```python +from kubex.client import create_client, ClientChoise + +# Force aiohttp +client = await create_client(client_class=ClientChoise.AIOHTTP) + +# Force httpx +client = await create_client(client_class=ClientChoise.HTTPX) +``` + +Pass a pre-built `ClientConfiguration` to skip the auto-loading of kubeconfig / in-cluster credentials: + +```python +from kubex.configuration import ClientConfiguration + +config = ClientConfiguration(url="https://my-cluster:6443", token="my-token") +client = await create_client(configuration=config) +``` + +Pass `options=` to control how the client behaves at request time (timeouts, proxy, pool size, etc.). See [ClientOptions](#clientoptions) below for details: + +```python +from kubex.client import ClientOptions, create_client +from kubex.core.params import Timeout + +client = await create_client( + configuration=config, + options=ClientOptions(timeout=Timeout(total=30.0), log_api_warnings=False), +) +``` + +## ClientOptions + +`ClientOptions` carries per-process choices about how the HTTP client behaves. These settings have nothing to do with kubeconfig or the in-cluster environment — connection parameters live on [`ClientConfiguration`](configuration.md). + +```python +from kubex.client import ClientOptions +from kubex.core.params import Timeout + +options = ClientOptions( + timeout=Timeout(total=30.0), + log_api_warnings=True, # default +) +``` + +### `timeout` + +Controls the default HTTP timeout applied to every request made by this client. Accepted values: + +| Value | Meaning | +|---|---| +| `...` (the default) | Use the HTTP library's own default (httpx: 5 s total; aiohttp: 300 s total, 30 s sock_connect) | +| `None` | Disable timeouts entirely | +| `int` or `float` | Treat as a `total` timeout in seconds; coerced to `Timeout` automatically | +| `Timeout(...)` | Use as-is for fine-grained per-phase control | + +Individual calls can override this via the `request_timeout=` parameter. See [Timeouts](../operations/timeouts.md) for the full picture. + +### `log_api_warnings` + +When `True` (the default), kubex emits a Python `UserWarning` for every `Warning:` HTTP response header returned by the API server. The Kubernetes API server uses these headers to flag deprecated API usage (e.g. calling a removed API version). Set to `False` to silence them. + +### `proxy` + +Configures an outbound HTTP proxy for all requests. Accepted values: + +| Value | Meaning | +|---|---| +| `None` (the default) | No proxy | +| `str` | Single proxy URL for all requests, e.g. `"http://proxy.corp.example.com:8080"` | +| `dict[str, str]` | Per-scheme map with `"http"` and/or `"https"` keys | + +```python +# Single proxy URL (basic auth via userinfo) +options = ClientOptions(proxy="http://user:pass@proxy.corp.example.com:8080") + +# Per-scheme map +options = ClientOptions(proxy={"https": "http://proxy.corp.example.com:8080"}) +``` + +### `keep_alive` + +Whether to reuse idle connections (connection keep-alive). Set to `False` to close each connection immediately after use. + +```python +# Disable keep-alive +options = ClientOptions(keep_alive=False) +``` + +### `keep_alive_timeout` + +Idle-connection lifetime in seconds. Uses the three-state sentinel pattern: + +| Value | Meaning | +|---|---| +| `...` (the default) | Library default (httpx: 5 s; aiohttp: 15 s) | +| `None` | Keep idle connections indefinitely (httpx only; aiohttp warns) | +| `float >= 0` | Explicit lifetime in seconds | + +```python +# Keep idle connections for 60 seconds +options = ClientOptions(keep_alive_timeout=60.0) + +# Indefinite (httpx only) +options = ClientOptions(keep_alive_timeout=None) +``` + +### `buffer_size` + +HTTP-response read buffer size in bytes. Uses the three-state sentinel pattern: + +| Value | Meaning | +|---|---| +| `...` (the default) | Kubex default of `2**21` bytes (preserves current aiohttp behavior) | +| `None` | Library default (aiohttp: `2**16` bytes) | +| `int > 0` | Explicit buffer size in bytes | + +```python +# Use a 1 MiB read buffer +options = ClientOptions(buffer_size=1024 * 1024) + +# Use aiohttp's own default +options = ClientOptions(buffer_size=None) +``` + +!!! note "httpx asymmetry" + httpx has no equivalent buffer-size knob. Setting `buffer_size` to anything other + than `...` on an httpx-backed client emits a `UserWarning` and is otherwise ignored. + +### `ws_max_message_size` + +Maximum WebSocket frame size in bytes for `exec`, `attach`, and `portforward`. Uses the three-state sentinel pattern: + +| Value | Meaning | +|---|---| +| `...` (the default) | Kubex default of `2**21` bytes (preserves current behavior on both backends) | +| `None` | No cap (passes `0` on the wire) | +| `int > 0` | Explicit cap in bytes | + +```python +# Allow frames up to 8 MiB (for large exec output) +options = ClientOptions(ws_max_message_size=8 * 1024 * 1024) + +# No cap +options = ClientOptions(ws_max_message_size=None) +``` + +### `pool_size` + +Total connection pool size (all hosts combined). Uses the three-state sentinel pattern: + +| Value | Meaning | +|---|---| +| `...` (the default) | Library default (both backends: 100) | +| `None` | Unlimited | +| `int > 0` | Explicit connection limit | + +```python +# Reduce pool to 10 connections total +options = ClientOptions(pool_size=10) + +# Unlimited +options = ClientOptions(pool_size=None) +``` + +### `pool_size_per_host` + +Per-host connection pool size. Uses the three-state sentinel pattern: + +| Value | Meaning | +|---|---| +| `...` (the default) | Library default (aiohttp: 0, meaning no per-host limit) | +| `None` | Unlimited | +| `int > 0` | Explicit per-host limit | + +```python +# Limit to 5 connections per host +options = ClientOptions(pool_size_per_host=5) +``` + +!!! note "httpx asymmetry" + httpx has no per-host pool limit. Setting `pool_size_per_host` to anything other + than `...` on an httpx-backed client emits a `UserWarning` and is otherwise ignored. + +## Backend asymmetries + +Some `ClientOptions` fields behave differently (or are unsupported) depending on which HTTP backend is in use. A `UserWarning` is emitted on first use when a field has no effect. + +| Field | httpx | aiohttp | +|---|---|---| +| `proxy=str` | `proxy=str` on `AsyncClient` | `proxy=str` on `ClientSession` | +| `proxy=dict` | All entries applied via `mounts=` | Only the entry matching the API server's URL scheme is used; other entries are dropped with a warning | +| `keep_alive=False` | `Limits(max_keepalive_connections=0)` | `TCPConnector(force_close=True)` | +| `keep_alive_timeout` | `Limits(keepalive_expiry=float\|None)` | `TCPConnector(keepalive_timeout=float)` — `None` is unsupported; warning emitted | +| `buffer_size` | **Ignored** — warning emitted | `ClientSession(read_bufsize=int)` | +| `ws_max_message_size` | `aconnect_ws(max_message_size_bytes=int)` | `ws_connect(max_msg_size=int)` | +| `pool_size` | `Limits(max_connections=int\|None)` | `TCPConnector(limit=int)` — `None` maps to `0` (unlimited) | +| `pool_size_per_host` | **Ignored** — warning emitted | `TCPConnector(limit_per_host=int)` — `None` maps to `0` (unlimited) | + +Cross-reference: see [Timeouts](../operations/timeouts.md) for the note on `Timeout.write` and `Timeout.pool` being httpx-only fields. + +## `AioHttpClient` + +Requires: `pip install "kubex[aiohttp]"` + +aiohttp has WebSocket support built in — no extra package needed for `exec`, `attach`, and `portforward`. Supports asyncio only. + +## `HttpxClient` + +Requires: `pip install "kubex[httpx]"` + +WebSocket support requires the extra `httpx-ws` package: +`pip install "kubex[httpx-ws]"` (includes httpx). + +The httpx client is the only client that supports **trio** (in addition to asyncio). + +## Client selection trade-offs + +| Feature | aiohttp | httpx | +|---|---|---| +| asyncio support | yes | yes | +| trio support | no | yes | +| WebSocket (exec/attach/portforward) | built-in | requires `httpx-ws` extra | +| Auto-detection priority | 1st | 2nd | + +For detailed guidance on choosing a client and runtime, see [Clients & Runtimes](../advanced/clients-runtimes.md). diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md new file mode 100644 index 00000000..8a608570 --- /dev/null +++ b/docs/concepts/configuration.md @@ -0,0 +1,77 @@ +# Configuration + +Kubex builds cluster credentials from `ClientConfiguration`. You can construct it manually or let the library auto-load it from your environment. + +## Auto-loading + +`create_client()` (and `create_api()`) call `_try_read_configuration()` when no configuration is provided. The lookup order is: + +1. **kubeconfig file** — `configure_from_kubeconfig()` reads `~/.kube/config` (or the file pointed to by `$KUBECONFIG`). +2. **In-cluster environment** — `configure_from_pod_env()` reads the service-account token and CA bundle mounted inside a Pod (`/var/run/secrets/kubernetes.io/serviceaccount/`). + +If the kubeconfig file is not found, the library falls back to in-cluster automatically. Other kubeconfig errors (malformed file, missing context, permission denied, etc.) are propagated to the caller. + +## `ClientConfiguration` + +!!! note "Operational options live elsewhere" + Timeouts and API-warning logging are client-level concerns, not kubeconfig data. They belong on [`ClientOptions`](clients.md#clientoptions), not here. + +`ClientConfiguration` holds all connection parameters: + +```python +from kubex.configuration import ClientConfiguration + +config = ClientConfiguration( + url="https://my-cluster:6443", + token="my-bearer-token", # or token_file="/path/to/token" + server_ca_file="/path/to/ca.crt", # or insecure_skip_tls_verify=True + namespace="default", +) +``` + +Key parameters: + +| Parameter | Type | Description | +|---|---|---| +| `url` | `str` | Kubernetes API server URL | +| `token` | `str` | Static bearer token | +| `token_file` | `Path | str` | Path to a file containing the bearer token | +| `server_ca_file` | `Path | str` | CA certificate for TLS verification | +| `insecure_skip_tls_verify` | `bool` | Disable TLS verification (not for production) | +| `client_cert_file` / `client_key_file` | `Path | str` | Mutual TLS client certificate + key | +| `namespace` | `str` | Default namespace (used by `configure_from_pod_env`) | +| `try_refresh_token` | `bool` | Re-read `token_file` every 60 s (for projected service-account tokens) | + +## `configure_from_kubeconfig()` + +Reads a kubeconfig file and returns a `ClientConfiguration`. Resolves the current context and supports the following auth mechanisms: + +- Bearer token (inline or from file) +- Client certificate + key (inline data or file paths) +- Exec credential provider (e.g., `aws eks get-token`, `gke-gcloud-auth-plugin`) + +```python +from kubex.configuration.file_config import configure_from_kubeconfig + +config = await configure_from_kubeconfig() +# or specify a path explicitly: +config = await configure_from_kubeconfig(path="/home/user/.kube/my-config") +``` + +## `configure_from_pod_env()` + +Reads in-cluster credentials from the standard Kubernetes service-account mount: + +```python +from kubex.configuration.incluster_config import configure_from_pod_env + +config = await configure_from_pod_env() +``` + +This is used automatically when your code runs inside a Pod and kubeconfig is not available. + +## Exec credential provider + +When a kubeconfig context uses an `exec:` credential plugin (common with AWS EKS, GKE, and other managed clusters), `configure_from_kubeconfig()` resolves it by running the configured command and extracting the returned token. Token refresh is handled automatically on expiry. + +For full details on the exec provider and OIDC authentication, see [Authentication](../advanced/authentication.md). diff --git a/docs/concepts/exceptions.md b/docs/concepts/exceptions.md new file mode 100644 index 00000000..3594a922 --- /dev/null +++ b/docs/concepts/exceptions.md @@ -0,0 +1,75 @@ +# Exceptions + +All Kubex exceptions inherit from `KubexException`. The hierarchy mirrors the HTTP status codes returned by the Kubernetes API server. + +## Exception hierarchy + +``` +KubexException +├── ConfgiurationError # bad/missing config (note: intentional typo in class name) +└── KubexClientException # any client-side problem + └── KubexApiError[C] # non-2xx HTTP response from the API server + ├── BadRequest # 400 + ├── Unauthorized # 401 + ├── Forbidden # 403 + ├── NotFound # 404 + ├── MethodNotAllowed # 405 + ├── Conflict # 409 + ├── Gone # 410 + ├── UnprocessableEntity # 422 + └── KubernetesError # 500 +``` + +## `KubexApiError` + +`KubexApiError` is generic over the response body type (`str | Status`). When the API server returns a JSON `Status` object, `error.content` is a parsed `Status` instance. For plain-text error responses, it is a `str`. + +```python +from kubex.core.exceptions import NotFound, KubexApiError + +try: + pod = await api.get("missing-pod") +except NotFound as e: + print(e.status) # HTTPStatus.NOT_FOUND + print(e.content) # Status object or str with the error message +``` + +Every `KubexApiError` subclass has a `status` class attribute (an `HTTPStatus` value) that matches the HTTP status code it represents. + +## Handling errors + +Catch the specific subclass you care about, or the base `KubexApiError` for any API error: + +```python +from kubex.core.exceptions import ( + NotFound, + Conflict, + Forbidden, + KubexApiError, +) + +try: + await api.create(pod) +except Conflict: + print("resource already exists") +except Forbidden: + print("insufficient permissions") +except KubexApiError as e: + print(f"unexpected API error: {e.status} — {e.content}") +``` + +For a worked example see `examples/error_handling.py`. + +## `ConfgiurationError` + +Raised when the client cannot be configured — for example, when `httpx-ws` is not installed and you attempt to use `api.exec`, or when required configuration fields are missing. Note: the class name has an intentional typo preserved from the original codebase. + +```python +from kubex.core.exceptions import ConfgiurationError + +try: + async with api.exec.stream("my-pod", command=["sh"]) as session: + ... +except ConfgiurationError as e: + print("missing dependency or bad config:", e) +``` diff --git a/docs/concepts/index.md b/docs/concepts/index.md new file mode 100644 index 00000000..7da061f8 --- /dev/null +++ b/docs/concepts/index.md @@ -0,0 +1,27 @@ +# Concepts + +This section explains the core design patterns behind Kubex. Read these pages to understand *why* the library is structured the way it is before diving into the operation and subresource guides. + +
+ +- :material-code-braces: **[Api\[T\]](api.md)** + + The generic `Api[ResourceType]` class, namespace vs cluster scope, the Ellipsis sentinel, and the `create_api()` factory. + +- :material-transit-connection-variant: **[Clients](clients.md)** + + `BaseClient` ABC, `create_client()` auto-detection, and the difference between `HttpxClient` and `AioHttpClient`. + +- :material-cog: **[Configuration](configuration.md)** + + `ClientConfiguration`, auto-loading from kubeconfig or in-cluster pod environment, and exec credential providers. + +- :material-source-branch: **[Subresources](subresources.md)** + + The descriptor pattern, marker interfaces (`HasLogs`, `HasExec`, …), and how mypy enforces subresource availability at compile time. + +- :material-alert-circle: **[Exceptions](exceptions.md)** + + The full exception hierarchy from `KubexException` down to HTTP-specific errors like `NotFound` and `Conflict`. + +
diff --git a/docs/concepts/subresources.md b/docs/concepts/subresources.md new file mode 100644 index 00000000..0716e279 --- /dev/null +++ b/docs/concepts/subresources.md @@ -0,0 +1,62 @@ +# Subresources + +Kubernetes resources expose additional operations beyond CRUD through *subresources* — for example, reading logs, scaling a Deployment, or exec-ing into a Pod. Kubex models these as typed attributes on `Api[T]`, available only when the resource type supports them. + +## Marker interfaces + +Each subresource capability is declared as a marker class in `kubex_core.models.interfaces`. Resource models opt in by inheriting from the relevant markers: + +| Marker class | Subresource | Example resources | +|---|---|---| +| `HasLogs` | `api.logs` | `Pod` | +| `HasStatusSubresource` | `api.status` | `Pod`, `Deployment`, `Service`, … | +| `HasScaleSubresource` | `api.scale` | `Deployment`, `StatefulSet`, `ReplicaSet` | +| `Evictable` | `api.eviction` | `Pod` | +| `HasEphemeralContainers` | `api.ephemeral_containers` | `Pod` | +| `HasResize` | `api.resize` | `Pod` | +| `HasExec` | `api.exec` | `Pod` | +| `HasAttach` | `api.attach` | `Pod` | +| `HasPortForward` | `api.portforward` | `Pod` | + +A resource declares multiple capabilities through multiple inheritance: + +```python +class Pod( + NamespaceScopedEntity, + HasLogs, + HasStatusSubresource, + Evictable, + HasEphemeralContainers, + HasResize, + HasExec, + HasAttach, + HasPortForward, +): + ... +``` + +## Type safety + +Accessing a subresource the resource does not declare is a static type error and raises `NotImplementedError` at runtime — there is no silent fallback. Pick the right resource and the IDE/type-checker will guide you the rest of the way: + +```python +pod_api: Api[Pod] = Api(Pod, client=client, namespace="default") +deploy_api: Api[Deployment] = Api(Deployment, client=client, namespace="default") + +# OK — Pod has HasLogs +logs = await pod_api.logs.get("my-pod") + +# Type error (mypy / pyright) + NotImplementedError at runtime — Deployment has no HasLogs +logs = await deploy_api.logs.get("my-deploy") +``` + +## Metadata accessor + +`api.metadata` is always available regardless of the resource type. It provides lightweight read operations (`get`, `list`, `patch`, `watch`) that return partial metadata objects instead of full resources, saving bandwidth and parse time for large lists. + +```python +meta = await api.metadata.get("my-pod") +metas = await api.metadata.list() +``` + +See [Metadata](../subresources/metadata.md) for full documentation. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..5a4002d2 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,162 @@ +# Contributing to Kubex + +This page covers local documentation preview, strict builds, link checking, versioned deploys, and maintainer-only workflows. + +## Prerequisites + +Install the docs dependency group: + +```bash +uv sync --group docs +``` + +This installs `mkdocs`, `mkdocs-material`, `mkdocstrings[python]`, `mike`, and `pymdown-extensions` into your project virtualenv. + +## Local preview + +Start a live-reload server: + +```bash +mise run docs:serve +``` + +The site is served at `http://127.0.0.1:8000/`. Changes to any file under `docs/` or to `mkdocs.yml` are reflected immediately without restarting. + +## Strict build + +Validate the entire site with `--strict` mode (warnings become errors): + +```bash +mise run docs:build +``` + +This is the same command CI runs. It catches broken internal links, unresolved `mkdocstrings` references, and misconfigured nav entries. The output is written to `site/` (git-ignored). + +**Run this before opening a PR** — a clean strict build is required to merge docs changes. + +## Link checking + +Check external links with [lychee](https://github.com/lycheeverse/lychee): + +```bash +# Offline (checks internal links only) +lychee --offline docs/ + +# Full external check +lychee --config lychee.toml docs/ +``` + +CI runs the full external check on every push and pull request (via `lycheeverse/lychee-action`). The `lychee.toml` at the repo root configures retries, accepted status codes, and exclusions for example-only URLs such as `localhost` and `kubernetes.default.svc`. + +## Writing docs + +- Pages live under `docs/` and are plain Markdown with [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) extensions. +- Code blocks use triple-backtick fences with a language tag (`python`, `bash`, `yaml`, etc.). +- Admonitions use the `!!! note` / `!!! warning` / `!!! tip` syntax. +- Tabbed blocks use `=== "Tab"` syntax from `pymdownx.tabbed`. +- API reference uses `:::module.path` directives rendered by `mkdocstrings`. + +## Versioned deploys with mike + +[mike](https://github.com/jimporter/mike) manages per-release version directories on the `gh-pages` branch. Each deployment is a subdirectory (`0.1.0-beta.1/`, `dev/`, etc.) with a `versions.json` index that powers the Material version selector. + +### Deploy commands + +```bash +# Deploy the current docs as a tagged release (no alias change) +uv run --group docs mike deploy --push 0.2.0 + +# Deploy the current docs to the rolling 'dev' channel +uv run --group docs mike deploy --push --update-aliases dev + +# Promote a release to the 'latest' alias (manual — see warning below) +uv run --group docs mike alias --push 0.2.0 latest + +# Set the default version (what users land on at the root URL) +uv run --group docs mike set-default --push latest + +# List all deployed versions +uv run --group docs mike list +``` + +All `--push` variants require push access to the repository. Omit `--push` to test locally against a temporary branch. + +### CI deploy workflow + +`.github/workflows/docs.yaml` handles automated deploys: + +- **Push to `main`** → `mike deploy dev` (updates the rolling development channel) +- **Push a `v*` tag** → `mike deploy ` only (publishes the versioned site, does *not* touch `latest`) +- **`workflow_dispatch`** → deploys the `dev` channel (or a custom version via workflow input) + +The workflow uses the same `astral-sh/setup-uv` + `uv sync --group docs` pattern as the lint and test workflows. + +!!! warning "Promotion to `latest` is manual" + The tag deploy intentionally does *not* update the `latest` alias or + `set-default`. If kubex ever ships parallel release lines (e.g., v1.x + patches alongside v2.x), an automatic promotion would let a v1 patch tag + silently demote v2 to "old" — users landing on the docs root would see the + wrong major version. + + After tagging, verify the release is the project's newest line, then + promote manually from your maintainer machine: + + ```bash + uv run --group docs mike alias --push latest + uv run --group docs mike set-default --push latest + ``` + +## First-time bootstrap + +The `gh-pages` branch must exist before the CI deploy workflow can run `mike set-default`. Seed it from a maintainer's machine: + +```bash +# Clone a fresh copy or use your existing checkout +git fetch origin + +# Deploy the initial release — this creates the gh-pages branch if it does not exist +uv run --group docs mike deploy --push --update-aliases 0.1.0-beta.1 latest + +# Confirm the version list +uv run --group docs mike list +``` + +After this first push: + +1. Go to the repository's **Settings → Pages** and set **Source = Deploy from a branch**, **Branch = `gh-pages`**, **Folder = `/ (root)`**. +2. Set **Custom domain = `kubex.codemageddon.me`** and tick **Enforce HTTPS**. GitHub will write a root-level `CNAME` file on the `gh-pages` branch automatically; `mike` preserves root-level files across subsequent deploys. + +If GitHub does not write the `CNAME` automatically, add it manually: + +```bash +git checkout gh-pages +echo 'kubex.codemageddon.me' > CNAME +git add CNAME && git commit -m "set custom domain" && git push +git checkout - +``` + +## Maintainer: regenerating K8s models + +!!! note "Maintainer-only" + This section is relevant only when updating or adding Kubernetes API version support. It is not needed for normal documentation contributions. + +Kubernetes resource models under `packages/kubex-k8s-*/` are generated from the official OpenAPI spec. To regenerate: + +```bash +# Regenerate all configured K8s versions (downloads specs, runs codegen, verifies with mypy) +mise run regenerate-models +``` + +The list of minor versions is configured via `K8S_VERSIONS` in `mise.toml`. Downloaded specs are cached in `.cache/schemas//`. + +To regenerate a single version manually: + +```bash +# Generate from a local swagger.json +uv run python -m scripts.codegen generate --swagger path/to/swagger.json --k8s-version 1.36 + +# Verify the generated package type-checks +uv run python -m scripts.codegen verify packages/kubex-k8s-1-36 +``` + +After adding a new K8s version, update `pyproject.toml` to add it to `[tool.uv.sources]` and `[project.optional-dependencies]` — see `CLAUDE.md` for the full checklist. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 00000000..32e586d0 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,110 @@ +# Installation + +Kubex requires **Python 3.10–3.14** and ships as a [PEP 561](https://peps.python.org/pep-0561/) typed package. + +## Install + +=== "uv" + + ```shell + uv add kubex + ``` + +=== "pip" + + ```shell + pip install kubex + ``` + +=== "poetry" + + ```shell + poetry add kubex + ``` + +=== "pdm" + + ```shell + pdm add kubex + ``` + +This installs the core library. To make actual requests to a Kubernetes cluster you need at least one HTTP client extra and one set of Kubernetes model packages. + +## HTTP client extras + +Kubex supports two HTTP client backends. Install the one you prefer: + +| Extra | Installs | WebSocket (exec/attach/portforward) | trio support | +|---|---|---|---| +| `kubex[aiohttp]` | aiohttp | Yes (built-in) | No | +| `kubex[httpx]` | httpx | No — add `httpx-ws` | Yes | +| `kubex[httpx-ws]` | httpx + httpx-ws | Yes | Yes | + +- `kubex[aiohttp]` is the default and the first one auto-detected by `create_client()`. WebSocket support is built in. +- Use `kubex[httpx]` for the trio backend without WebSocket subresources. +- Use `kubex[httpx-ws]` when you need `exec`, `attach`, or `portforward` with the httpx backend. + +!!! note "Trio" + Trio support is provided through the httpx backend only. `kubex[aiohttp]` works with asyncio only. + +## Kubernetes model packages + +Kubex ships separate model packages per Kubernetes minor version. Install the package matching your cluster: + +| Extra | Kubernetes version | +|---|---| +| `kubex[k8s-1.32]` | 1.32 | +| `kubex[k8s-1.33]` | 1.33 | +| `kubex[k8s-1.34]` | 1.34 | +| `kubex[k8s-1.35]` | 1.35 | +| `kubex[k8s-1.36]` | 1.36 | +| `kubex[k8s-1.37]` | 1.37 | + +You can install multiple versions simultaneously when managing clusters at different upgrade stages: + +=== "uv" + + ```shell + uv add "kubex[k8s-1.34,k8s-1.35]" + ``` + +=== "pip" + + ```shell + pip install "kubex[k8s-1.34,k8s-1.35]" + ``` + +See [Multi-version Kubernetes](../advanced/multi-version-k8s.md) for details. + +## Recommended combinations + +For most users, pick one HTTP client and one K8s version: + +=== "aiohttp" + + ```shell + uv add "kubex[aiohttp,k8s-1.35]" + # or: pip install "kubex[aiohttp,k8s-1.35]" + ``` + +=== "httpx (no WebSocket)" + + ```shell + uv add "kubex[httpx,k8s-1.35]" + # or: pip install "kubex[httpx,k8s-1.35]" + ``` + +=== "httpx with WebSocket" + + ```shell + uv add "kubex[httpx-ws,k8s-1.35]" + # or: pip install "kubex[httpx-ws,k8s-1.35]" + ``` + +## Python version support + +Kubex is tested and supported on Python 3.10 through 3.14. + +## Next steps + +Once installed, head to the [Quickstart](quickstart.md) to connect to your cluster and make your first request. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 00000000..9a608d3a --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,103 @@ +# Quickstart + +This guide shows how to connect to a Kubernetes cluster and make your first request with Kubex. + +## Prerequisites + +Install Kubex with an HTTP client and Kubernetes model package: + +```shell +pip install "kubex[aiohttp,k8s-1.35]" +``` + +See [Installation](installation.md) for the full extras matrix. + +## Auto-detecting the cluster + +`create_client()` resolves the cluster configuration automatically: + +```python +from kubex.client import create_client + +async with await create_client() as client: + # ready to use +``` + +| Scenario | Resolution | +|---|---| +| `~/.kube/config` exists | Reads kubeconfig and uses the current context | +| `KUBECONFIG` env variable | Uses that file path | +| Running in a pod | Reads the service account token mounted at `/var/run/secrets/kubernetes.io/serviceaccount/` | + +See [Configuration](../concepts/configuration.md) for kubeconfig file handling and in-cluster auth details. + +## List namespaces + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.namespace import Namespace + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Namespace] = Api(Namespace, client=client) + namespaces = await api.list() + print(namespaces) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) +``` + +## Create, inspect, and delete a Pod + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex_core.models.metadata import ObjectMetadata + +NAMESPACE = "default" + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace=NAMESPACE) + pod = await api.create( + Pod( + metadata=ObjectMetadata(generate_name="example-pod-"), + spec=PodSpec(containers=[Container(name="example", image="nginx")]), + ), + ) + assert pod.metadata.name is not None + + try: + print(pod) + print(await api.metadata.get(pod.metadata.name)) + print(await api.metadata.list()) + finally: + await api.delete(pod.metadata.name) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) +``` + +!!! note + All Kubernetes resources are fully typed Pydantic models. `pod.spec`, `pod.status`, and `pod.metadata` all have proper type annotations — no `dict[str, Any]` anywhere. + +## What's next + +- [Concepts: Api\[T\]](../concepts/api.md) — generics, namespace/cluster scope, the `Ellipsis` sentinel +- [Concepts: Configuration](../concepts/configuration.md) — kubeconfig file parsing, in-cluster auth, exec provider +- [Operations: CRUD](../operations/crud.md) — get, list, create, replace, delete, delete_collection +- [Operations: Watch](../operations/watch.md) — streaming resource events diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..98bd3b85 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,81 @@ +

+ Kubex logo +

+ +# Kubex + +**Async-first Kubernetes client library for Python**, inspired by [kube.rs](https://kube.rs/). Built on Pydantic v2, async-runtime agnostic (asyncio and trio). + +[Get Started](getting-started/installation.md){ .md-button .md-button--primary } +[GitHub](https://github.com/codemageddon/kubex){ .md-button } + +--- + +## Why Kubex? + +### Performance + +Kubex is dramatically faster than [kubernetes-asyncio](https://github.com/tomplus/kubernetes_asyncio), the most popular async Kubernetes client for Python. Benchmarks against a K3s 1.35.4 cluster (see `benchmarks/`): + +| Scenario | kubernetes-asyncio | kubex (aiohttp) | kubex (httpx) | Speedup | +|---|---|---|---|---| +| Single GET | 61 ms | 6 ms | 26 ms | **10×** | +| List 100 pods | 2,813 ms | 73 ms | 102 ms | **38×** | +| List 500 pods | 14,441 ms | 351 ms | 410 ms | **41×** | +| Watch 50 events | 3,957 ms | 562 ms | 1,764 ms | **7×** | + +Kubex also uses **~47% less heap memory** and makes **up to ~5x fewer allocations**, reducing GC pressure in long-running controllers and operators. + +### Fully typed + +Every Kubernetes resource is a Pydantic v2 model with proper type annotations — spec fields, status fields, enums, and nested objects are all typed, not `dict[str, Any]`. Combined with `mypy --strict` support, you get IDE autocompletion and compile-time safety instead of runtime `KeyError`s. + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.pod import Pod + +async with await create_client() as client: + api: Api[Pod] = Api(Pod, client=client) + pod = await api.get("my-pod", namespace="default") + # pod.spec, pod.status, pod.metadata are all fully typed +``` + +### Multi-version Kubernetes support + +Kubex ships separate model packages for Kubernetes 1.32 through 1.37. You can depend on exactly the versions you need, or use multiple versions simultaneously — useful when managing clusters at different upgrade stages: + +```python +from kubex.k8s.v1_34.apps.v1.deployment import Deployment as Deployment134 +from kubex.k8s.v1_35.apps.v1.deployment import Deployment as Deployment135 +``` + +Each package is generated from the official OpenAPI spec, so models always match the wire schema of the target cluster. + +### Async-runtime agnostic + +Kubex works with both **asyncio** and **trio** (via httpx), with no framework lock-in. + +--- + +## Quick example + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.pod import Pod + +async with await create_client() as client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + pods = await api.list() + for pod in pods.items: + print(pod.metadata.name, pod.status.phase) +``` + +--- + +## Next steps + +- [Installation](getting-started/installation.md) — install Kubex and its optional extras +- [Quickstart](getting-started/quickstart.md) — connect to your cluster and make your first request +- [Concepts](concepts/index.md) — understand the API design, client model, and resource configuration diff --git a/docs/operations/crud.md b/docs/operations/crud.md new file mode 100644 index 00000000..60e87898 --- /dev/null +++ b/docs/operations/crud.md @@ -0,0 +1,164 @@ +# CRUD Operations + +`Api[ResourceType]` exposes the full set of Kubernetes CRUD operations as `async` methods. Every method returns the server-side resource (or a `Status` for deletions), fully parsed by Pydantic. + +## get + +Fetch a single resource by name: + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.pod import Pod + +client = await create_client() +async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + pod = await api.get("my-pod") + print(pod.metadata.name) +``` + +Pass `resource_version=` to pin the read to a specific version; omit it for the current state. + +## list + +List all resources in a namespace (or cluster-wide): + +```python +pods = await api.list() +for pod in pods.items: + print(pod.metadata.name) +``` + +Filter with `label_selector=` (e.g. `"app=nginx"`) or `field_selector=` (e.g. `"status.phase=Running"`). Use `limit=` and `continue_token=` for paginated results: + +```python +page = await api.list(label_selector="app=nginx", limit=100) +while page.metadata.continue_: + page = await api.list(label_selector="app=nginx", limit=100, continue_token=page.metadata.continue_) +``` + +Pass `namespace=None` to list across all namespaces even when the `Api` instance has a default namespace set: + +```python +all_pods = await api.list(namespace=None) +``` + +## create + +Create a new resource. The returned object is the server-assigned representation (including `metadata.name` when `generate_name` was used): + +```python +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex_core.models.metadata import ObjectMetadata + +pod = await api.create( + Pod( + metadata=ObjectMetadata(generate_name="example-pod-"), + spec=PodSpec(containers=[Container(name="example", image="nginx")]), + ), +) +print(pod.metadata.name) # server-assigned name, e.g. "example-pod-7g4k2" +``` + +Pass `dry_run=True` to validate the request without persisting anything. Pass `field_manager=` to set the field manager for server-side apply. + +## replace + +Replace a resource in its entirety (the get → modify → replace pattern): + +```python +current = await api.get("example-pod") +current.metadata.labels = {**(current.metadata.labels or {}), "env": "staging"} +updated = await api.replace("example-pod", current) +print(updated.metadata.labels) +``` + +The full object (including `resourceVersion`) must be present in the payload — the API server uses `resourceVersion` as an optimistic-concurrency check. + +## delete + +Delete a resource by name. The return type is `Status | ResourceType`: if the resource has finalizers the API server returns the updated object (with `deletionTimestamp` set) rather than a `Status`: + +```python +result = await api.delete("example-pod") +``` + +Control deletion behaviour with optional parameters: + +| Parameter | Description | +|---|---| +| `grace_period_seconds` | Override the resource's `terminationGracePeriodSeconds` | +| `propagation_policy` | `"Foreground"`, `"Background"`, or `"Orphan"` | +| `preconditions` | `Precondition(uid=..., resource_version=...)` for safe deletes | +| `dry_run` | Validate without actually deleting | + +```python +from kubex.core.params import Precondition + +await api.delete( + "example-pod", + grace_period_seconds=0, + propagation_policy="Foreground", + preconditions=Precondition(uid=pod.metadata.uid), +) +``` + +## delete_collection + +Delete multiple resources matching a selector in a single call: + +```python +result = await api.delete_collection(label_selector="app=example-batch") +``` + +`delete_collection` accepts the same filter parameters as `list` (`label_selector`, `field_selector`, `limit`, `continue_token`) plus the same deletion options as `delete` (`grace_period_seconds`, `propagation_policy`, `preconditions`, `dry_run`). + +The return type is `Status | ListEntity[ResourceType]` — some versions of the Kubernetes API return the deleted list rather than a `Status`. + +Full example from `examples/delete_collection.py`: + +```python +import uuid +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex_core.models.metadata import ObjectMetadata + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + run_id = uuid.uuid4().hex[:8] + label_selector = f"app=example-batch-{run_id}" + + for i in range(3): + await api.create( + Pod( + metadata=ObjectMetadata( + generate_name=f"example-delete-collection-{i}-", + labels={"app": f"example-batch-{run_id}"}, + ), + spec=PodSpec(containers=[Container(name="nginx", image="nginx:latest")]), + ), + ) + + result = await api.delete_collection(label_selector=label_selector) + print(f"delete_collection result: {type(result).__name__}") +``` + +## Error handling + +All non-2xx responses raise a subclass of `KubexApiError`. The most common errors for CRUD operations: + +| Exception | HTTP status | Typical cause | +|---|---|---| +| `NotFound` | 404 | Resource does not exist | +| `Conflict` | 409 | `create` on an existing resource, or `replace` with a stale `resourceVersion` | +| `Forbidden` | 403 | RBAC: the service account lacks permission | +| `UnprocessableEntity` | 422 | Validation failure (schema mismatch, required field missing) | + +See [Exceptions](../concepts/exceptions.md) for the full hierarchy. diff --git a/docs/operations/index.md b/docs/operations/index.md new file mode 100644 index 00000000..504928ea --- /dev/null +++ b/docs/operations/index.md @@ -0,0 +1,23 @@ +# Operations + +This section covers the full set of operations you can perform on Kubernetes resources with Kubex. Read the [Concepts](../concepts/index.md) section first if you are new to the library. + +
+ +- :material-database-edit: **[CRUD](crud.md)** + + `get`, `list`, `create`, `replace`, `delete`, and `delete_collection` — the core Kubernetes resource lifecycle operations with filtering, pagination, and deletion options. + +- :material-eye: **[Watch](watch.md)** + + Long-lived `watch()` streams, `WatchEvent` and `EventType`, bookmark events, the `sendInitialEvents` pattern, and the restart-on-410-Gone recipe. + +- :material-file-edit: **[Patch](patch.md)** + + All three patch strategies: `MergePatch`, `StrategicMergePatch`, and `JsonPatch` (RFC 6902) with its fluent builder API and JSON Pointer paths. + +- :material-timer: **[Timeouts](timeouts.md)** + + `Timeout`, `TimeoutTypes`, client-level vs per-call configuration, the Ellipsis sentinel, and guidance for long-lived watch streams. + +
diff --git a/docs/operations/patch.md b/docs/operations/patch.md new file mode 100644 index 00000000..278b9c1e --- /dev/null +++ b/docs/operations/patch.md @@ -0,0 +1,212 @@ +# Patch + +`api.patch(name, patch)` applies a partial update to an existing resource. Kubex supports all three patch strategies used by the Kubernetes API. + +## Patch types + +| Type | Import | `Content-Type` | +|---|---|---| +| `MergePatch` | `kubex.core.patch` | `application/merge-patch+json` | +| `StrategicMergePatch` | `kubex.core.patch` | `application/strategic-merge-patch+json` | +| `JsonPatch` | `kubex.core.patch` | `application/json-patch+json` | + +## `MergePatch` + +Merge patch (RFC 7396) replaces the specified keys and removes any key set to `null`. Keys not present in the patch are left unchanged. Wrap any resource model in `MergePatch(...)` to apply it: + +```python +from kubex.core.patch import MergePatch +from kubex.k8s.v1_35.apps.v1.deployment import Deployment +from kubex.k8s.v1_35.apps.v1.deployment_spec import DeploymentSpec +from kubex.k8s.v1_35.meta.v1.label_selector import LabelSelector +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex.k8s.v1_35.core.v1.pod_template_spec import PodTemplateSpec +from kubex.k8s.v1_35.core.v1.container import Container +from kubex_core.models.metadata import ObjectMetadata + +merge_patch = MergePatch( + Deployment( + spec=DeploymentSpec( + replicas=3, + selector=LabelSelector(match_labels={"app": "example"}), + template=PodTemplateSpec( + metadata=ObjectMetadata(labels={"app": "example"}), + spec=PodSpec(containers=[Container(name="nginx", image="nginx:latest")]), + ), + ) + ) +) +patched = await api.patch("example-deploy", merge_patch) +print(patched.spec.replicas) # 3 +``` + +## `StrategicMergePatch` + +Strategic merge patch is a Kubernetes extension that understands list-merging semantics (e.g., merging containers by name rather than replacing the whole array). Use it when you want to add or update a list element without specifying the full list: + +```python +from kubex.core.patch import StrategicMergePatch + +strategic_patch = StrategicMergePatch( + Deployment( + metadata=ObjectMetadata( + annotations={"example.com/patched": "true"}, + ) + ) +) +patched = await api.patch("example-deploy", strategic_patch) +print(patched.metadata.annotations) +``` + +Strategic merge patch is not available for custom resources — use `MergePatch` or `JsonPatch` there. + +## `JsonPatch` + +JSON Patch (RFC 6902) describes a sequence of operations (`add`, `remove`, `replace`, `move`, `copy`, `test`) using JSON Pointer paths. Build patches incrementally using the fluent API: + +```python +from kubex.core.patch import JsonPatch + +json_patch = JsonPatch().add("/metadata/labels/version", "v1") +patched = await api.patch("example-deploy", json_patch) +print(patched.metadata.labels) +``` + +All six RFC 6902 operations are supported: + +```python +patch = ( + JsonPatch() + .add("/metadata/labels/env", "staging") + .replace("/spec/replicas", 2) + .remove("/metadata/annotations/example.com~1old-key") + .test("/metadata/name", "example-deploy") +) +``` + +### Building paths with `JsonPointer` + +`JsonPointer` is accepted anywhere a path string is, and it handles RFC 6901 escaping for you. Build a pointer by chaining the `/` operator from a base, or from a tuple of unescaped tokens: + +```python +from kubex.core.patch import JsonPatch, JsonPointer + +# Chained operator — ideal when a fixed prefix is reused +annotation_path = JsonPointer("/metadata") / "annotations" / "example.com/patched" +# annotation_path == "/metadata/annotations/example.com~1patched" + +patch = JsonPatch().add(annotation_path, "true") + +# Or from raw tokens +label_path = JsonPointer.from_tokens("metadata", "labels", "version") +patch = patch.replace(label_path, "v2") +``` + +!!! note "JSON Pointer escaping" + Per RFC 6901, `/` inside a key is escaped as `~1` and `~` is escaped as `~0`. For example, the annotation key `example.com/patched` becomes the path segment `example.com~1patched`. + + Kubex can do this for you — pass a `JsonPointer` instead of a raw string: + `JsonPointer.from_tokens("metadata", "annotations", "example.com/patched")` + or `JsonPointer("/metadata") / "annotations" / "example.com/patched"`. Both + yield `/metadata/annotations/example.com~1patched` with the slash escaped + automatically. + +You can also construct a `JsonPatch` from a list of operation objects directly: + +```python +from kubex.core.patch import JsonPatch +from kubex.core.json_patch import JsonPatchAdd, JsonPatchRemove + +patch = JsonPatch([ + JsonPatchAdd(path="/metadata/labels/env", value="staging"), + JsonPatchRemove(path="/metadata/labels/old-env"), +]) +``` + +## Patch options + +All three patch types accept these optional keyword arguments on `api.patch()`: + +| Parameter | Description | +|---|---| +| `dry_run` | Validate without persisting (`True` or `DryRun.ALL`) | +| `field_manager` | Field manager name for server-side apply tracking | +| `force` | Force apply even when field ownership conflicts (server-side apply only) | +| `field_validation` | `FieldValidation.STRICT` (default), `WARN`, or `IGNORE` | + +```python +patched = await api.patch( + "example-deploy", + merge_patch, + dry_run=True, + field_manager="my-controller", +) +``` + +## Full example from `examples/patch_deployment.py` + +```python +from typing import cast + +from kubex.api import Api +from kubex.client import create_client +from kubex.core.patch import JsonPatch, MergePatch, StrategicMergePatch +from kubex.k8s.v1_35.apps.v1.deployment import Deployment +from kubex.k8s.v1_35.apps.v1.deployment_spec import DeploymentSpec +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex.k8s.v1_35.core.v1.pod_template_spec import PodTemplateSpec +from kubex.k8s.v1_35.meta.v1.label_selector import LabelSelector +from kubex_core.models.metadata import ObjectMetadata + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Deployment] = Api(Deployment, client=client, namespace="default") + + deployment = await api.create( + Deployment( + metadata=ObjectMetadata(name="example-deploy", labels={"app": "example"}), + spec=DeploymentSpec( + replicas=1, + selector=LabelSelector(match_labels={"app": "example"}), + template=PodTemplateSpec( + metadata=ObjectMetadata(labels={"app": "example"}), + spec=PodSpec(containers=[Container(name="nginx", image="nginx:latest")]), + ), + ), + ), + ) + name = cast(str, deployment.metadata.name) + + try: + # MergePatch — update replicas + merge_patch = MergePatch( + Deployment( + spec=DeploymentSpec( + replicas=3, + selector=LabelSelector(match_labels={"app": "example"}), + template=PodTemplateSpec( + metadata=ObjectMetadata(labels={"app": "example"}), + spec=PodSpec(containers=[Container(name="nginx", image="nginx:latest")]), + ), + ) + ) + ) + patched = await api.patch(name, merge_patch) + print(f"After MergePatch: replicas={patched.spec and patched.spec.replicas}") + + # StrategicMergePatch — add an annotation + strategic_patch = StrategicMergePatch( + Deployment(metadata=ObjectMetadata(annotations={"example.com/patched": "true"})) + ) + patched = await api.patch(name, strategic_patch) + print(f"After StrategicMergePatch: annotations={patched.metadata.annotations}") + + # JsonPatch — add a label + json_patch = JsonPatch().add("/metadata/labels/version", "v1") + patched = await api.patch(name, json_patch) + print(f"After JsonPatch: labels={patched.metadata.labels}") + finally: + await api.delete(name) +``` diff --git a/docs/operations/timeouts.md b/docs/operations/timeouts.md new file mode 100644 index 00000000..2a669896 --- /dev/null +++ b/docs/operations/timeouts.md @@ -0,0 +1,111 @@ +# Timeouts + +Kubex separates two independent timeout concepts: + +- **HTTP client timeout** (`request_timeout`) — how long to wait for the HTTP call to complete (connect + response). +- **Kubernetes server-side timeout** (`timeout_seconds`) — sent as the `timeoutSeconds` query parameter; the API server closes the stream or returns a result after this many seconds. Spelled `timeout_seconds` consistently across `list()`, `delete_collection()`, `watch()`, and `metadata.list()`. + +## `Timeout` and `TimeoutTypes` + +`Timeout` is the structured object for configuring HTTP-level timeouts: + +```python +from kubex.core.params import Timeout + +t = Timeout(total=30.0) # 30 s total +t = Timeout(connect=5.0, read=60.0) # separate connect / read +t = Timeout(total=30.0, connect=5.0, read=25.0) # granular override +``` + +| Field | Description | +|---|---| +| `total` | Overall timeout in seconds. Acts as the default for unset granular fields. | +| `connect` | Timeout for establishing the TCP connection. | +| `read` | Timeout for reading the response body. | +| `write` | Timeout for writing the request body (httpx only). | +| `pool` | Timeout for acquiring a connection from the pool (httpx only). | + +`TimeoutTypes = Timeout | float | int | None` — wherever a timeout is accepted, you can pass a number (treated as `total` seconds), a `Timeout` object, or `None` to disable timeouts entirely. + +## Setting a client-level default + +Pass `ClientOptions(timeout=…)` to `create_client()` to apply a default to every request made by that client: + +```python +from kubex.client import ClientOptions, create_client +from kubex.core.params import Timeout + +client = await create_client( + options=ClientOptions(timeout=Timeout(total=30.0)), +) +``` + +If no `options` is provided (or `timeout=...`, the default), the underlying HTTP library's own default applies (httpx: 5 s total; aiohttp: 300 s total, 30 s sock_connect). + +## Per-call override with `request_timeout` + +Every `Api` method accepts `request_timeout=` to override the client default for that call alone. The parameter accepts `TimeoutTypes`: + +```python +# Use the client default (Ellipsis = "inherit from client") +pod = await api.get("my-pod") + +# Override to 5 seconds for this call +pod = await api.get("my-pod", request_timeout=5) + +# Fine-grained override +pod = await api.get("my-pod", request_timeout=Timeout(connect=2.0, read=10.0)) + +# Disable timeouts for this call (long-running operation) +big_list = await api.list(request_timeout=None) +``` + +## The Ellipsis sentinel + +| Value | Meaning | +|---|---| +| `...` (Ellipsis, the default) | Use the client-level timeout (or the HTTP library default if none was configured) | +| `None` | Disable timeouts for this call | +| `5` / `5.0` | 5 seconds total for this call | +| `Timeout(...)` | Structured per-field timeout for this call | + +Passing `request_timeout=...` explicitly is the same as omitting it — both mean "use the client default." + +## Watch and long-lived streams + +!!! warning "Server-side default closes the stream" + The Kubernetes API server applies its own default `timeoutSeconds` (typically + around 5 minutes) when the client does not provide one. A `watch()` call + will *not* stream forever — your loop must reconnect when the server closes + the stream. Pass an explicit `timeout_seconds=` to make the bound visible + in code, and combine with the [restart-on-`Gone` pattern](watch.md#restart-on-gone-pattern) + to keep the watch alive across reconnects. + +For `api.watch()` and log streaming, a short `read` timeout will terminate the stream prematurely. Either disable the HTTP timeout for these calls or set a generous `read` value: + +```python +# No HTTP timeout on watch — the Kubernetes server-side timeout controls duration +async for event in api.watch(request_timeout=None, timeout_seconds=300): + ... + +# Or use a long read timeout +async for event in api.watch(request_timeout=Timeout(connect=5.0, read=600.0)): + ... +``` + +!!! warning "Two timeout parameters on `watch()`" + `watch(timeout_seconds=N)` tells the *API server* to close the stream after N seconds. + `watch(request_timeout=M)` tells the *HTTP client* to abort the connection after M seconds. + They are independent — set both if you want server-side control *and* a client-side safety net. + +## `timeout_seconds` on `list()` and `delete_collection()` + +The `timeout_seconds` parameter on `list()` and `delete_collection()` is the Kubernetes server-side `timeoutSeconds` parameter, not the HTTP client timeout. It limits how long the API server will process the request: + +```python +# Server-side: abort the list after 10 s (Kubernetes timeoutSeconds) +pods = await api.list(timeout_seconds=10) + +# Client-side: abort the HTTP call after 15 s (HTTP read timeout) +pods = await api.list(request_timeout=15) +``` diff --git a/docs/operations/watch.md b/docs/operations/watch.md new file mode 100644 index 00000000..61d560b2 --- /dev/null +++ b/docs/operations/watch.md @@ -0,0 +1,155 @@ +# Watch + +`api.watch()` opens a long-lived HTTP stream and yields `WatchEvent` objects as the Kubernetes API server emits them. It is an async generator — use it inside an `async for` loop. + +## Basic usage + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.pod import Pod + +client = await create_client() +async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + async for event in api.watch(): + print(event.type, event.object.metadata.name) +``` + +## `WatchEvent` + +Each yielded value is a `WatchEvent[ResourceType]`: + +| Attribute | Type | Description | +|---|---|---| +| `event.type` | `EventType` | `ADDED`, `MODIFIED`, `DELETED`, or `BOOKMARK` | +| `event.object` | `ResourceType | Bookmark` | Fully parsed resource (or `Bookmark` for bookmark events) | + +```python +from kubex_core.models.watch_event import EventType + +async for event in api.watch(): + match event.type: + case EventType.ADDED: + print(f"new pod: {event.object.metadata.name}") + case EventType.DELETED: + print(f"pod gone: {event.object.metadata.name}") +``` + +## Filtering + +Pass `label_selector=` or `field_selector=` to narrow the stream: + +```python +async for event in api.watch(label_selector="app=nginx"): + ... +``` + +## Bookmarks and `allow_bookmarks` + +Pass `allow_bookmarks=True` to request periodic `BOOKMARK` events from the server. Bookmark events carry an up-to-date `resourceVersion` in `event.object.metadata.resource_version` but no other data — they are checkpoints, not data. + +The main reason to enable bookmarks is to keep an up-to-date `resourceVersion` even when the watched resources rarely change. Without bookmarks the saved `resourceVersion` ages with every quiet minute, and is far more likely to have been compacted (HTTP 410 `Gone`) by the time you reconnect. + +```python +async for event in api.watch(allow_bookmarks=True, namespace=None): + if event.type == EventType.BOOKMARK: + rv = event.object.metadata.resource_version + print(f"checkpoint: resourceVersion={rv}") +``` + +## Watching across all namespaces + +Pass `namespace=None` to watch across every namespace, even when the `Api` was created with a default namespace: + +```python +async for event in api.watch(namespace=None): + ns = event.object.metadata.namespace + name = event.object.metadata.name + print(f"{event.type}: {ns}/{name}") +``` + +## `send_initial_events` pattern + +Kubernetes 1.27+ supports `sendInitialEvents=true`, which causes the watch stream to first emit `ADDED` events for every existing resource before switching to live updates. Set `send_initial_events=True` together with `allow_bookmarks=True` to get a single stream that covers both current state and future changes: + +```python +async for event in api.watch(send_initial_events=True, allow_bookmarks=True): + if event.type == EventType.BOOKMARK: + print("initial list complete, watching for changes now") + else: + print(event.type, event.object.metadata.name) +``` + +## Restart-on-`Gone` pattern + +The Kubernetes API server expires watch streams with HTTP 410 `Gone` when the `resourceVersion` becomes too old, and closes streams on its own default `timeoutSeconds`. The simplest robust pattern is to re-call `watch()` with `send_initial_events=True` on every reconnect — the server replays a synthetic `ADDED` snapshot before resuming live updates, so you do not need a separate `list()` step: + +```python +from kubex.core.exceptions import Gone + +while True: + try: + async for event in api.watch( + allow_bookmarks=True, + send_initial_events=True, + ): + handle(event) + except Gone: + # resourceVersion expired — just re-watch. + # send_initial_events=True replays the snapshot before live updates. + continue +``` + +## Server-side timeout + +Pass `timeout_seconds=` to set a server-side timeout on the watch call (sent as the Kubernetes `timeoutSeconds` query parameter). The server closes the stream after this many seconds; your loop can then reconnect: + +```python +async for event in api.watch(timeout_seconds=60): + ... +``` + +## Example from `examples/watch_pods.py` + +!!! note "asyncio-only example" + This example uses `asyncio.create_task` and `asyncio.CancelledError`, which are asyncio-specific. + Trio users should replace these with [`anyio.create_task_group()`](https://anyio.readthedocs.io/en/stable/tasks.html) instead. + +```python +import asyncio +from contextlib import suppress +from typing import cast + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex_core.models.metadata import ObjectMetadata + +async def watcher(pod_api: Api[Pod]) -> None: + async for event in pod_api.watch(allow_bookmarks=True, namespace=None): + print(event) + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + _watcher = asyncio.create_task(watcher(api)) + pod_name: str | None = None + try: + _pod = await api.create( + Pod( + metadata=ObjectMetadata(generate_name="example-pod-"), + spec=PodSpec(containers=[Container(name="example", image="nginx")]), + ), + ) + pod_name = cast(str, _pod.metadata.name) + finally: + if pod_name is not None: + print(await api.delete(pod_name)) + _watcher.cancel() + with suppress(asyncio.CancelledError): + await _watcher +``` diff --git a/docs/reference/api.md b/docs/reference/api.md new file mode 100644 index 00000000..da1921ac --- /dev/null +++ b/docs/reference/api.md @@ -0,0 +1,59 @@ +# kubex.api + +Auto-generated reference for the `kubex.api` module. Private descriptors (e.g. `_LogsDescriptor`) are excluded; they exist to wire the subresource accessors onto `Api[T]` and are not part of the public API. + +## Api class + +::: kubex.api.api + +## Logs subresource + +::: kubex.api._logs + +## Scale subresource + +::: kubex.api._scale + +## Status subresource + +::: kubex.api._status + +## Eviction subresource + +::: kubex.api._eviction + +## Ephemeral containers subresource + +::: kubex.api._ephemeral_containers + +## Resize subresource + +::: kubex.api._resize + +## Exec subresource + +::: kubex.api._exec + +## Attach subresource + +::: kubex.api._attach + +## Portforward subresource + +::: kubex.api._portforward + +## Metadata accessor + +::: kubex.api._metadata + +## Stream session + +::: kubex.api._stream_session + +## Portforward session + +::: kubex.api._portforward_session + +## Protocol helpers + +::: kubex.api._protocol diff --git a/docs/reference/client.md b/docs/reference/client.md new file mode 100644 index 00000000..cb121977 --- /dev/null +++ b/docs/reference/client.md @@ -0,0 +1,23 @@ +# kubex.client + +Auto-generated reference for the `kubex.client` module. + +## BaseClient and factory + +::: kubex.client.client + +## Client options + +::: kubex.client.options + +## WebSocket abstraction + +::: kubex.client.websocket + +## Httpx client + +::: kubex.client.httpx + +## Aiohttp client + +::: kubex.client.aiohttp diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 00000000..3e19a04c --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,27 @@ +# kubex.configuration + +Auto-generated reference for the `kubex.configuration` module. + +## ClientConfiguration model + +::: kubex.configuration.configuration + +## Kubeconfig file loading + +::: kubex.configuration.file_config + +## In-cluster configuration + +::: kubex.configuration.incluster_config + +## Exec provider authentication + +::: kubex.configuration.auth.exec + +## OIDC authentication + +::: kubex.configuration.auth.oidc + +## Refreshable token + +::: kubex.configuration.auth.refreshable_token diff --git a/docs/reference/core.md b/docs/reference/core.md new file mode 100644 index 00000000..b9820ae7 --- /dev/null +++ b/docs/reference/core.md @@ -0,0 +1,41 @@ +# kubex.core + +Auto-generated reference for the `kubex.core` module. + +## Exceptions + +::: kubex.core.exceptions + +## Request and response + +::: kubex.core.request + +::: kubex.core.response + +## API parameters + +::: kubex.core.params + +## Patch types + +::: kubex.core.patch + +## JSON Patch (RFC 6902) + +::: kubex.core.json_patch + +## JSON Pointer (RFC 6901) + +::: kubex.core.json_pointer + +## Subresource definitions + +::: kubex.core.subresource + +## WebSocket channel protocol + +::: kubex.core.exec_channels + +## Request builder + +::: kubex.core.request_builder.builder diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 00000000..fcce15c1 --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,14 @@ +# API Reference + +Auto-generated API reference for `kubex` and `kubex-core`, rendered from source docstrings by mkdocstrings. + +| Page | Covers | +|------|--------| +| [kubex.api](api.md) | `Api[T]`, `create_api()`, subresource accessors (`LogsAccessor`, `ScaleAccessor`, `ExecAccessor`, …) | +| [kubex.client](client.md) | `BaseClient`, `create_client()`, `HttpxClient`, `AioHttpClient`, `WebSocketConnection` | +| [kubex.configuration](configuration.md) | `ClientConfiguration`, kubeconfig loading, in-cluster auth, exec provider, OIDC | +| [kubex.core](core.md) | exceptions, request/response models, API params, patch types, channel protocol, request builder | +| [kubex-core](kubex-core.md) | base Pydantic models, marker interfaces, `ResourceConfig`, metadata, list/watch, subresource models | + +!!! note "Generated K8s resource models" + The generated resource models under `kubex.k8s.v1_*` (Pod, Deployment, Service, …) are not rendered here — there are ~666 files across 6 Kubernetes versions. See the [source on GitHub](https://github.com/codemageddon/kubex/tree/main/packages) or install `kubex[k8s-1.35]` and browse with your IDE for full type information. diff --git a/docs/reference/kubex-core.md b/docs/reference/kubex-core.md new file mode 100644 index 00000000..4546620d --- /dev/null +++ b/docs/reference/kubex-core.md @@ -0,0 +1,43 @@ +# kubex-core + +Auto-generated reference for the `kubex_core` package — the shared base models used by both `kubex` and the generated `kubex-k8s-*` packages. + +## Base models + +::: kubex_core.models.base + +::: kubex_core.models.base_entity + +## Marker interfaces + +::: kubex_core.models.interfaces + +## Resource configuration + +::: kubex_core.models.resource_config + +## Metadata models + +::: kubex_core.models.metadata + +## List and watch + +::: kubex_core.models.list_entity + +::: kubex_core.models.watch_event + +## Subresource models + +::: kubex_core.models.status + +::: kubex_core.models.scale + +::: kubex_core.models.eviction + +## Partial metadata + +::: kubex_core.models.partial_object_meta + +## Type aliases + +::: kubex_core.models.typing diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 00000000..b8cc2328 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,5 @@ +/* Kubex documentation custom styles */ + +.md-typeset pre { + max-width: 100%; +} diff --git a/docs/subresources/attach.md b/docs/subresources/attach.md new file mode 100644 index 00000000..de7bfba5 --- /dev/null +++ b/docs/subresources/attach.md @@ -0,0 +1,135 @@ +# Attach + +The `attach` subresource opens a WebSocket connection to the kubelet and attaches to a **running** container process — without starting a new command. + +!!! warning "Beta / experimental" + The attach WebSocket implementation is functional and tested against K3S, but the underlying + channel-protocol layer is relatively new. The API may change between minor releases. + Requires Kubernetes ≥ 1.30 (v5 channel protocol). + +## Exec vs Attach + +| | `exec` | `attach` | +|-|--------|---------| +| Starts a new command | yes | no | +| Connects to | new process | existing container entrypoint | +| `run()` (one-shot) | yes | no | +| `stream()` (interactive) | yes | yes | + +Use `attach` when you want to connect to a container that was started with `stdin: true` in its spec — for example, an interactive REPL, a legacy daemon that reads from stdin, or a container designed for interactive debugging. Use `exec` to run a one-off command. + +## Installation requirement + +Attach uses a WebSocket upgrade. You need **one** of: + +- `kubex[httpx-ws]` — httpx client with the `httpx-ws` WebSocket extension +- `kubex[aiohttp]` — aiohttp client with built-in WebSocket support + +```bash +pip install "kubex[httpx-ws]" +# or +pip install "kubex[aiohttp]" +``` + +## Availability + +Only resources that implement the `HasAttach` marker interface expose `api.attach`. In practice this means `Pod`. Accessing `api.attach` on any other resource type raises `NotImplementedError` at runtime and resolves to `SubresourceNotAvailable` for the type-checker. + +## Container requirement + +The target container must have been created with `stdin: true` in its spec. If the container was not configured to accept stdin, attaching will succeed at the protocol level but your writes will be discarded. + +## Attaching to a container + +`api.attach.stream()` is an async context manager that returns a `StreamSession` — the same type used by `api.exec.stream()`. + +```python +from typing import cast + +import anyio + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex_core.models.metadata import ObjectMetadata + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + pod = await api.create( + Pod( + metadata=ObjectMetadata(generate_name="example-attach-"), + spec=PodSpec( + containers=[ + Container( + name="main", + image="busybox:1.36", + command=[ + "sh", + "-c", + 'while IFS= read -r line; do printf "echo: %s\\n" "$line"; done', + ], + stdin=True, + ) + ] + ), + ), + ) + pod_name = cast(str, pod.metadata.name) + try: + async with api.attach.stream( + pod_name, + stdin=True, + stdout=True, + ) as session: + await session.stdin.write(b"hello\n") + + buf = bytearray() + with anyio.fail_after(10): + async for chunk in session.stdout: + buf.extend(chunk) + if b"echo: hello" in buf: + break + print("attach output:", bytes(buf).decode(errors="replace")) + + await session.close_stdin() + finally: + await api.delete(pod_name) +``` + +### StreamSession API + +`api.attach.stream()` returns the same `StreamSession` as `api.exec.stream()`: + +| Member | Type | Description | +|--------|------|-------------| +| `stdin` | writer | Call `await session.stdin.write(data)` to send bytes to the container | +| `stdout` | `MemoryObjectReceiveStream[bytes]` | Async iterable yielding stdout chunks | +| `stderr` | `MemoryObjectReceiveStream[bytes]` | Async iterable yielding stderr chunks | +| `resize(width, height)` | coroutine | Send a terminal resize event | +| `close_stdin()` | coroutine | Half-close the stdin channel (idempotent) | +| `wait_for_status()` | coroutine | Await the final status frame; returns `Status | None` | + +### TTY mode and stderr + +When `tty=True`, the kubelet merges stderr into stdout. `session.stderr` closes immediately. Read only `session.stdout` when `tty=True`. + +### `stream()` options + +| Option | Type | Description | +|--------|------|-------------| +| `stdin` | `bool` | Whether to attach to the stdin channel | +| `stdout` | `bool` | Whether to attach to the stdout channel (default `True`) | +| `stderr` | `bool` | Whether to attach to the stderr channel | +| `tty` | `bool` | Whether the container stdin is a TTY | +| `container` | `str | None` | Container name — required for multi-container pods | +| `namespace` | `str | None | ...` | Override the `Api` instance namespace | +| `request_timeout` | `Timeout | float | None | ...` | Override the client-level timeout | + +## Exiting early + +Exiting the `async with` block cancels the read loop before the WebSocket closes. You can break out of the attach session at any point without deadlocking. diff --git a/docs/subresources/ephemeral-containers.md b/docs/subresources/ephemeral-containers.md new file mode 100644 index 00000000..70bc3f48 --- /dev/null +++ b/docs/subresources/ephemeral-containers.md @@ -0,0 +1,136 @@ +# Ephemeral Containers + +Ephemeral containers are temporary containers that can be injected into a running Pod for debugging purposes. Unlike regular containers they are not defined at Pod creation time, cannot be restarted, and have no resource guarantees. They are most commonly used to attach a debug toolset to a Pod that runs a distroless or scratch image. + +!!! note "Kubernetes version requirement" + Ephemeral containers require Kubernetes 1.23 or later (stable since 1.25). + +## Availability + +Only resources with the `HasEphemeralContainers` marker interface expose `api.ephemeral_containers`. In practice this means `Pod`. + +```python +from kubex.k8s.v1_35.core.v1.pod import Pod + +pod_api.ephemeral_containers.get(...) # OK + +from kubex.k8s.v1_35.apps.v1.deployment import Deployment + +deploy_api.ephemeral_containers.get(...) # type error + runtime NotImplementedError +``` + +## Reading ephemeral containers + +`api.ephemeral_containers.get()` returns the full `Pod` object with the `ephemeral_containers` field populated. The other spec fields are also present, but only `ephemeral_containers` can be modified via this subresource. + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.pod import Pod + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + + pod = await api.ephemeral_containers.get("my-pod") + containers = pod.spec.ephemeral_containers or [] + for c in containers: + print(c.name, c.image) +``` + +## Adding an ephemeral container + +Retrieve the pod, append the new ephemeral container to `spec.ephemeral_containers`, then call `replace()`. Ephemeral containers can only be added — never removed or modified after creation. + +```python +from typing import cast + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.ephemeral_container import EphemeralContainer +from kubex.k8s.v1_35.core.v1.pod import Pod + + +async def inject_debug_container(pod_name: str) -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + + pod = await api.ephemeral_containers.get(pod_name) + + existing = list(pod.spec.ephemeral_containers or []) + existing.append( + EphemeralContainer( + name="debugger", + image="busybox:latest", + stdin=True, + tty=True, + ) + ) + pod.spec.ephemeral_containers = existing + + updated = await api.ephemeral_containers.replace(pod_name, pod) + names = [c.name for c in (updated.spec.ephemeral_containers or [])] + print(f"Ephemeral containers: {names}") +``` + +## Patching ephemeral containers + +`api.ephemeral_containers.patch()` applies a partial update. Use it when you want to merge a new container entry without reserializing the entire pod: + +```python +from kubex.core.patch import MergePatch + +updated = await api.ephemeral_containers.patch( + "my-pod", + MergePatch({ + "spec": { + "ephemeralContainers": [ + {"name": "debugger", "image": "busybox:latest", "stdin": True, "tty": True} + ] + } + }), +) +``` + +`JsonPatch` is also accepted — useful when you want to append by index without re-sending the whole list: + +```python +from kubex.core.patch import JsonPatch + +updated = await api.ephemeral_containers.patch( + "my-pod", + JsonPatch().add( + "/spec/ephemeralContainers/-", + {"name": "debugger", "image": "busybox:latest", "stdin": True, "tty": True}, + ), +) +``` + +### Options for replace and patch + +| Option | Type | Description | +|--------|------|-------------| +| `dry_run` | `DryRun | bool | None` | Validate without persisting | +| `field_manager` | `str | None` | Field manager name | +| `force` | `bool | None` | Force apply (patch only, for server-side apply) | +| `field_validation` | `FieldValidation | None` | Schema validation mode | +| `namespace` | `str | None | ...` | Override the `Api` instance namespace | +| `request_timeout` | `Timeout | float | None | ...` | Override the client-level timeout | + +## Interacting with an ephemeral container + +After adding the ephemeral container you can attach to it using the `exec` or `attach` subresources. Specify `container="debugger"` to target it explicitly: + +```python +result = await api.exec.run( + pod_name, + command=["sh", "-c", "ls /proc/1/fd"], + container="debugger", +) +print(result.stdout) +``` + +See [Exec](exec.md) and [Attach](attach.md) for full details. diff --git a/docs/subresources/eviction.md b/docs/subresources/eviction.md new file mode 100644 index 00000000..bb43ea9f --- /dev/null +++ b/docs/subresources/eviction.md @@ -0,0 +1,91 @@ +# Eviction + +The `eviction` subresource triggers graceful pod termination while respecting [PodDisruptionBudgets](https://kubernetes.io/docs/concepts/workloads/pods/disruptions/). It is the mechanism behind `kubectl drain` and is preferred over calling `api.delete()` directly when you need disruption-budget-aware eviction. + +## Availability + +Only resources with the `Evictable` marker interface expose `api.eviction`. In practice this means `Pod`. + +```python +from kubex.k8s.v1_35.core.v1.pod import Pod + +pod_api.eviction.create(...) # OK: Pod has Evictable + +from kubex.k8s.v1_35.apps.v1.deployment import Deployment + +deploy_api.eviction.create(...) # type error + runtime NotImplementedError +``` + +## Creating an eviction + +`api.eviction.create()` submits an `Eviction` object to the API server. If the pod is protected by a `PodDisruptionBudget` that would be violated, the API server returns `429 Too Many Requests` and you should retry after a delay. + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.core.exceptions import KubexApiError +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex_core.models.eviction import Eviction +from kubex_core.models.metadata import ObjectMetadata +from kubex_core.models.status import Status + + +async def evict_pod(pod_name: str, namespace: str) -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace=namespace) + + eviction = Eviction( + metadata=ObjectMetadata(name=pod_name, namespace=namespace), + ) + status: Status = await api.eviction.create(pod_name, eviction) + print(f"Eviction status: {status.status}") +``` + +## Handling PodDisruptionBudget violations + +When a PDB blocks the eviction the API server responds with HTTP 429. Kubex surfaces this as a `KubexApiError`. Retry with an exponential back-off until the PDB allows the eviction: + +```python +import anyio + +from kubex.core.exceptions import KubexApiError +from kubex_core.models.eviction import Eviction +from kubex_core.models.metadata import ObjectMetadata + + +async def evict_with_retry(api: Api[Pod], pod_name: str, namespace: str) -> None: + eviction = Eviction( + metadata=ObjectMetadata(name=pod_name, namespace=namespace), + ) + for attempt in range(10): + try: + await api.eviction.create(pod_name, eviction) + return + except KubexApiError as exc: + if exc.status.value == 429: + await anyio.sleep(2**attempt) + else: + raise + raise RuntimeError(f"Could not evict {pod_name} after 10 attempts") +``` + +## Options + +| Option | Type | Description | +|--------|------|-------------| +| `dry_run` | `DryRun | bool | None` | Validate without performing the eviction | +| `field_manager` | `str | None` | Field manager name | +| `namespace` | `str | None | ...` | Override the `Api` instance namespace | +| `request_timeout` | `Timeout | float | None | ...` | Override the client-level timeout | + +## Eviction vs deletion + +| | `api.delete()` | `api.eviction.create()` | +|---|---|---| +| Respects PodDisruptionBudgets | No | Yes | +| Returns 429 when PDB blocks | No | Yes | +| Kubernetes mechanism | DELETE on the pod | POST to the eviction subresource | +| Typical use | Force removal | Graceful drain | + +Use `api.delete()` only when you need to forcibly remove a pod regardless of PDBs (for example, during cluster decommission). Use eviction for controlled workload migrations. diff --git a/docs/subresources/exec.md b/docs/subresources/exec.md new file mode 100644 index 00000000..f2c13e1c --- /dev/null +++ b/docs/subresources/exec.md @@ -0,0 +1,233 @@ +# Exec + +The `exec` subresource opens a WebSocket connection to the kubelet and runs a command inside a running container. + +!!! warning "Beta / experimental" + The exec WebSocket implementation is functional and tested against K3S, but the underlying + channel-protocol layer is relatively new. The API may change between minor releases. + Requires Kubernetes ≥ 1.30 (v5 channel protocol). + +## Installation requirement + +Exec (and attach) use a WebSocket upgrade. You need **one** of: + +- `kubex[httpx-ws]` — httpx client with the `httpx-ws` WebSocket extension +- `kubex[aiohttp]` — aiohttp client with built-in WebSocket support + +```bash +pip install "kubex[httpx-ws]" +# or +pip install "kubex[aiohttp]" +``` + +Missing the WebSocket dependency raises `ConfgiurationError` at call time, not at import time. + +## Availability + +Only resources that implement the `HasExec` marker interface expose `api.exec`. In practice this means `Pod`. Accessing `api.exec` on any other resource type raises `NotImplementedError` at runtime and resolves to `SubresourceNotAvailable` for the type-checker. + +```python +from kubex.k8s.v1_35.core.v1.pod import Pod + +pod_api.exec.run(...) # OK: Pod has HasExec + +from kubex.k8s.v1_35.apps.v1.deployment import Deployment + +deploy_api.exec.run(...) # type error + runtime NotImplementedError +``` + +## One-shot execution + +`api.exec.run()` collects all output and waits for the command to finish, then returns an `ExecResult`. + +```python +from typing import cast + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex_core.models.metadata import ObjectMetadata + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + pod = await api.create( + Pod( + metadata=ObjectMetadata(generate_name="example-exec-"), + spec=PodSpec( + containers=[ + Container( + name="main", + image="busybox:1.36", + command=["sleep", "3600"], + ) + ] + ), + ), + ) + pod_name = cast(str, pod.metadata.name) + try: + result = await api.exec.run(pod_name, command=["ls", "-la", "/"]) + print(f"exit code: {result.exit_code}") + print(result.stdout.decode()) + if result.stderr: + print("stderr:", result.stderr.decode()) + finally: + await api.delete(pod_name) +``` + +### ExecResult + +`api.exec.run()` returns an `ExecResult` with: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `stdout` | `bytes` | All stdout output collected from the command | +| `stderr` | `bytes` | All stderr output collected from the command | +| `exit_code` | `int | None` | Exit code of the command — see semantics below | + +### Exit code semantics + +`ExecResult.exit_code` has three possible states: + +- `0` — the command exited with `Status.status == "Success"` +- an `int` — the non-zero exit code parsed from `status.details.causes` (where `reason == "ExitCode"`) +- `None` — the status frame was missing or carried no recognisable exit information + +**`None` does not imply success.** If the WebSocket connection closed unexpectedly before a status frame arrived, `exit_code` is `None`. + +### Passing stdin to `run()` + +Pass `stdin=None` (default) to skip opening a stdin channel entirely. Pass `stdin=b""` to open the channel, write zero bytes, and immediately close it — useful for commands that check whether stdin is a terminal: + +```python +result = await api.exec.run(pod_name, command=["cat", "/etc/hostname"], stdin=None) +``` + +### `run()` options + +| Option | Type | Description | +|--------|------|-------------| +| `command` | `list[str]` | Command and arguments to execute | +| `container` | `str | None` | Container name — required when the Pod has more than one container | +| `stdin` | `bytes | None` | Bytes to write to stdin, or `None` to skip the stdin channel | +| `stdout` | `bool` | Capture stdout (default `True`) | +| `stderr` | `bool` | Capture stderr (default `True`) | +| `namespace` | `str | None | ...` | Override the `Api` instance namespace for this call | +| `request_timeout` | `Timeout | float | None | ...` | Override the client-level timeout for this call | + +## Interactive streaming + +`api.exec.stream()` is an async context manager that opens a bidirectional WebSocket session and returns a `StreamSession`. Use it for interactive shells, long-running commands with live output, or anything that needs to resize the terminal. + +```python +from typing import cast + +import anyio + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex_core.models.metadata import ObjectMetadata + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + pod = await api.create( + Pod( + metadata=ObjectMetadata(generate_name="example-exec-"), + spec=PodSpec( + containers=[ + Container( + name="main", + image="busybox:1.36", + command=["sleep", "3600"], + ) + ] + ), + ), + ) + pod_name = cast(str, pod.metadata.name) + try: + async with api.exec.stream( + pod_name, + command=["sh"], + stdin=True, + stdout=True, + stderr=True, + tty=True, + ) as session: + await session.resize(width=120, height=40) + await session.stdin.write(b"echo MARK-$$\n") + + buf = bytearray() + with anyio.fail_after(5): + async for chunk in session.stdout: + buf.extend(chunk) + if b"MARK-" in buf: + break + print("interactive output:", bytes(buf).decode(errors="replace")) + + await session.stdin.write(b"exit 0\n") + await session.close_stdin() + + status = await session.wait_for_status() + print(f"session status: {status.status if status else 'unknown'}") + finally: + await api.delete(pod_name) +``` + +### StreamSession API + +| Member | Type | Description | +|--------|------|-------------| +| `stdin` | writer | Call `await session.stdin.write(data)` to send bytes to the container | +| `stdout` | `MemoryObjectReceiveStream[bytes]` | Async iterable yielding stdout chunks | +| `stderr` | `MemoryObjectReceiveStream[bytes]` | Async iterable yielding stderr chunks | +| `resize(width, height)` | coroutine | Send a terminal resize event | +| `close_stdin()` | coroutine | Half-close the stdin channel (idempotent) | +| `wait_for_status()` | coroutine | Await the final status frame; returns `Status | None` | + +### TTY mode and stderr + +When `tty=True`, the kubelet merges stderr into stdout — only the stdout channel is opened. `session.stderr` will close immediately. Always read only `session.stdout` when `tty=True`. + +### Exiting a stream early + +Exiting the `async with api.exec.stream(...)` block cancels the read loop before the WebSocket is closed. You can break out early (for example, once you have seen the marker you were waiting for) without deadlocking even when the server is still holding the connection open. + +### `stream()` options + +| Option | Type | Description | +|--------|------|-------------| +| `command` | `list[str]` | Command and arguments to execute | +| `stdin` | `bool` | Whether to open the stdin channel | +| `stdout` | `bool` | Whether to open the stdout channel (default `True`) | +| `stderr` | `bool` | Whether to open the stderr channel | +| `tty` | `bool` | Whether to allocate a pseudo-terminal | +| `container` | `str | None` | Container name — required for multi-container pods | +| `namespace` | `str | None | ...` | Override the `Api` instance namespace | +| `request_timeout` | `Timeout | float | None | ...` | Override the client-level timeout | + +## Error handling + +WebSocket handshake failures, abnormal close codes, and per-call timeouts surface as `KubexClientException`. A missing WebSocket dependency raises `ConfgiurationError`. + +```python +from kubex.core.exceptions import KubexClientException + +try: + result = await api.exec.run(pod_name, command=["false"]) + if result.exit_code != 0: + print(f"command failed with exit code {result.exit_code}") +except KubexClientException as e: + print(f"WebSocket error: {e}") +``` diff --git a/docs/subresources/index.md b/docs/subresources/index.md new file mode 100644 index 00000000..30cd5bc9 --- /dev/null +++ b/docs/subresources/index.md @@ -0,0 +1,47 @@ +# Subresources + +This section covers all Kubernetes subresource APIs exposed by Kubex. + +Subresource operations are accessed through typed descriptors on `Api[T]`. Each descriptor checks whether the resource type declares the required marker interface — available operations are enforced both at type-check time (mypy) and at runtime. + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.apps.v1.deployment import Deployment + +async with await create_client() as client: + pod_api: Api[Pod] = Api(Pod, client=client, namespace="default") + deploy_api: Api[Deployment] = Api(Deployment, client=client, namespace="default") + + await pod_api.logs.get("my-pod") # OK — Pod has HasLogs + await deploy_api.scale.get("my-deploy") # OK — Deployment has HasScaleSubresource + await pod_api.scale.get("my-pod") # runtime NotImplementedError + type error +``` + +See [Subresources](../concepts/subresources.md) in the Concepts section for a full explanation of the descriptor pattern and marker interfaces. + +## Standard subresources + +| Page | Accessor | Marker | Resources | +|------|----------|--------|-----------| +| [Logs](logs.md) | `api.logs` | `HasLogs` | Pod | +| [Metadata](metadata.md) | `api.metadata` | *(always available)* | All | +| [Scale](scale.md) | `api.scale` | `HasScaleSubresource` | Deployment, StatefulSet, ReplicaSet, ReplicationController | +| [Status](status.md) | `api.status` | `HasStatusSubresource` | Most workload resources | +| [Eviction](eviction.md) | `api.eviction` | `Evictable` | Pod | +| [Ephemeral Containers](ephemeral-containers.md) | `api.ephemeral_containers` | `HasEphemeralContainers` | Pod | +| [Resize](resize.md) | `api.resize` | `HasResize` | Pod | + +## WebSocket subresources + +These subresources open a bidirectional WebSocket connection to the kubelet for interactive or streaming operations. + +!!! warning "Beta / experimental" + WebSocket subresources (`exec`, `attach`, `portforward`) are functional and tested against K3S, but the underlying channel-protocol implementation is relatively new. The API may change between minor releases. + +| Page | Accessor | Marker | Resources | +|------|----------|--------|-----------| +| [Exec](exec.md) | `api.exec` | `HasExec` | Pod | +| [Attach](attach.md) | `api.attach` | `HasAttach` | Pod | +| [Portforward](portforward.md) | `api.portforward` | `HasPortForward` | Pod | diff --git a/docs/subresources/logs.md b/docs/subresources/logs.md new file mode 100644 index 00000000..428e38fb --- /dev/null +++ b/docs/subresources/logs.md @@ -0,0 +1,112 @@ +# Logs + +The `logs` subresource lets you read or stream the stdout/stderr output of a container running inside a Pod. + +## Availability + +Only resources that implement the `HasLogs` marker interface expose `api.logs`. In practice this means `Pod`. Accessing `api.logs` on any other resource type raises `NotImplementedError` at runtime and resolves to `SubresourceNotAvailable` for the type-checker. + +```python +from kubex.k8s.v1_35.core.v1.pod import Pod + +pod_api.logs.get(...) # OK: Pod has HasLogs + +from kubex.k8s.v1_35.apps.v1.deployment import Deployment + +deploy_api.logs.get(...) # type error + runtime NotImplementedError +``` + +## Reading logs in one call + +`api.logs.get()` fetches the current log buffer as a single string. Use it for short-lived containers or when tailing a fixed number of lines. + +```python +from typing import cast + +import anyio + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex_core.models.metadata import ObjectMetadata + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + pod = await api.create( + Pod( + metadata=ObjectMetadata(generate_name="example-pod-"), + spec=PodSpec(containers=[Container(name="example", image="nginx")]), + ), + ) + pod_name = cast(str, pod.metadata.name) + await anyio.sleep(5) + + logs = await api.logs.get(pod_name) + print(logs) + + await api.delete(pod_name) +``` + +### Options + +All options are keyword-only: + +| Option | Type | Description | +|--------|------|-------------| +| `container` | `str | None` | Container name — required when the Pod has more than one container | +| `tail_lines` | `int | None` | Return only the last N lines | +| `since_seconds` | `int | None` | Return logs newer than this many seconds | +| `previous` | `bool | None` | Return logs from the previously terminated container instance | +| `timestamps` | `bool | None` | Prefix each line with its RFC 3339 timestamp | +| `limit_bytes` | `int | None` | Cap response body size in bytes | +| `namespace` | `str | None | ...` | Override the `Api` instance namespace for this call | +| `request_timeout` | `Timeout | float | None | ...` | Override the client-level timeout for this call | + +## Streaming logs + +`api.logs.stream()` is an async generator that yields one decoded line per iteration. The Kubernetes API sets `follow=true` internally, so the stream continues until the container exits or the caller breaks out. + +```python +async def consume_logs(api: Api[Pod], pod_name: str) -> None: + async for line in api.logs.stream(pod_name): + print(line) +``` + +Because `stream()` can run indefinitely, wrap it in a timeout for scripts: + +```python +import anyio + +async def timed_stream(api: Api[Pod], pod_name: str) -> None: + with anyio.fail_after(30): + async for line in api.logs.stream(pod_name): + print(line) +``` + +`stream()` accepts the same options as `get()`. + +## Multi-container pods + +When a Pod runs more than one container you must specify `container=`: + +```python +logs = await api.logs.get(pod_name, container="sidecar") + +async for line in api.logs.stream(pod_name, container="main"): + print(line) +``` + +Omitting `container` on a multi-container Pod produces a 400 error from the Kubernetes API. + +## Previous container logs + +Use `previous=True` to read logs from the most recent terminated container instance — useful for crash-loop debugging: + +```python +logs = await api.logs.get(pod_name, previous=True, tail_lines=200) +``` diff --git a/docs/subresources/metadata.md b/docs/subresources/metadata.md new file mode 100644 index 00000000..d9b17ff9 --- /dev/null +++ b/docs/subresources/metadata.md @@ -0,0 +1,126 @@ +# Metadata + +The `metadata` accessor exposes a lightweight API for reading, listing, patching, and watching resource metadata without transferring full resource bodies. This is especially useful when you only need labels, annotations, or `resourceVersion` and want to avoid the bandwidth cost of large spec/status payloads. + +Unlike the other subresource accessors, `metadata` is always available on every `Api[T]` instance — no marker interface is required. + +## Partial object metadata + +All `metadata` operations return `PartialObjectMetadata` instead of the full resource type. `PartialObjectMetadata` contains only: + +- `api_version` / `kind` +- `metadata` — the full `ObjectMetadata` (name, namespace, labels, annotations, resourceVersion, uid, ownerReferences, …) + +The spec and status fields are absent, which keeps payloads small when you are working at scale. + +## Reading a single resource's metadata + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex_core.models.partial_object_meta import PartialObjectMetadata + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + + meta: PartialObjectMetadata = await api.metadata.get("my-pod") + print(meta.metadata.resource_version) + print(meta.metadata.labels) +``` + +## Listing metadata for all resources + +`api.metadata.list()` returns a `ListEntity[PartialObjectMetadata]`. It accepts the same filtering options as `api.list()`: + +```python +from kubex_core.models.list_entity import ListEntity +from kubex_core.models.partial_object_meta import PartialObjectMetadata + +metas: ListEntity[PartialObjectMetadata] = await api.metadata.list( + label_selector="app=my-app", + limit=500, +) +for item in metas.items: + print(item.metadata.name, item.metadata.resource_version) +``` + +### List options + +| Option | Type | Description | +|--------|------|-------------| +| `label_selector` | `str | None` | Label selector expression | +| `field_selector` | `str | None` | Field selector expression | +| `timeout_seconds` | `int | None` | Server-side `timeoutSeconds` query parameter | +| `limit` | `int | None` | Maximum items per page | +| `continue_token` | `str | None` | Pagination token from a previous response | +| `resource_version` | `str | None` | Minimum resource version for the response | +| `namespace` | `str | None | ...` | Override the `Api` instance namespace | +| `request_timeout` | `Timeout | float | None | ...` | Override the client-level HTTP timeout | + +## Patching metadata + +Use `api.metadata.patch()` to update labels or annotations without touching the resource spec. All three patch types work: + +```python +from kubex.core.patch import MergePatch + +updated = await api.metadata.patch( + "my-pod", + MergePatch({"metadata": {"labels": {"version": "v2"}}}), +) +print(updated.metadata.labels) +``` + +`JsonPatch` is also accepted — useful when you want to set or remove a single label without re-sending the full label map: + +```python +from kubex.core.patch import JsonPatch, JsonPointer + +# Single label, with `/` in the key safely escaped via JsonPointer. +label_path = JsonPointer("/metadata/labels") / "example.com/version" +updated = await api.metadata.patch( + "my-pod", + JsonPatch().add(label_path, "v2"), +) +``` + +For server-side apply, use `ApplyPatch` with `force=True`: + +```python +from kubex.core.patch import ApplyPatch + +await api.metadata.patch( + "my-pod", + ApplyPatch({"metadata": {"labels": {"managed-by": "kubex"}}}), + force=True, + field_manager="my-controller", +) +``` + +### Patch options + +| Option | Type | Description | +|--------|------|-------------| +| `dry_run` | `DryRun | bool | None` | Validate without persisting | +| `field_manager` | `str | None` | Field manager name for server-side apply | +| `force` | `bool | None` | Force apply even if fields are owned by another manager | +| `field_validation` | `FieldValidation | None` | Schema validation mode (`Strict`, `Warn`, `Ignore`) | + +## Watching metadata changes + +`api.metadata.watch()` is an async generator that yields `WatchEvent[PartialObjectMetadata]`: + +```python +from kubex_core.models.watch_event import WatchEvent, EventType +from kubex_core.models.partial_object_meta import PartialObjectMetadata + +async for event in api.metadata.watch(label_selector="app=my-app"): + if event.type == EventType.MODIFIED: + print("modified:", event.object.metadata.name) +``` + +Watch options mirror those in `api.watch()`. See [Watch](../operations/watch.md) for the restart-on-410-Gone pattern, which applies here too. diff --git a/docs/subresources/portforward.md b/docs/subresources/portforward.md new file mode 100644 index 00000000..c9113f20 --- /dev/null +++ b/docs/subresources/portforward.md @@ -0,0 +1,202 @@ +# Portforward + +The `portforward` subresource opens a WebSocket tunnel to the kubelet and forwards TCP traffic between your local machine (or in-process code) and a port inside a running Pod. + +!!! warning "Beta / experimental" + The portforward WebSocket implementation is functional and tested against K3S, but the underlying + channel-protocol layer is relatively new. The API may change between minor releases. + +## Availability + +Only resources that implement the `HasPortForward` marker interface expose `api.portforward`. In practice this means `Pod`. Accessing `api.portforward` on any other resource type raises `NotImplementedError` at runtime and resolves to `SubresourceNotAvailable` for the type-checker. + +## Installation requirement + +Portforward uses a WebSocket upgrade. You need **one** of: + +- `kubex[httpx-ws]` — httpx client with the `httpx-ws` WebSocket extension +- `kubex[aiohttp]` — aiohttp client with built-in WebSocket support + +```bash +pip install "kubex[httpx-ws]" +# or +pip install "kubex[aiohttp]" +``` + +## Two-level API + +Kubex provides two levels of port-forwarding: + +| Level | Method | Use case | +|-------|--------|----------| +| Low-level | `api.portforward.forward()` | Python code reads/writes bytes directly — no local TCP socket | +| High-level | `api.portforward.listen()` | Binds a local TCP port; any process can connect (kubectl-style) | + +## Low-level: `forward()` + +`api.portforward.forward()` is an async context manager that yields a `PortForwarder`. The `PortForwarder` exposes per-port `ByteStream` objects for direct byte-level access, plus per-port error iterators. + +```python +from typing import cast + +import anyio + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.container_port import ContainerPort +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex_core.models.metadata import ObjectMetadata + +HTTP_REQUEST = b"GET / HTTP/1.0\r\nHost: localhost\r\n\r\n" + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + pod = await api.create( + Pod( + metadata=ObjectMetadata(generate_name="example-portforward-"), + spec=PodSpec( + containers=[ + Container( + name="main", + image="nginx:1.25", + ports=[ContainerPort(container_port=80)], + ) + ] + ), + ), + ) + pod_name = cast(str, pod.metadata.name) + try: + async with api.portforward.forward(pod_name, ports=[80]) as pf: + stream = pf.streams[80] + with anyio.fail_after(10): + await stream.send(HTTP_REQUEST) + buf = bytearray() + while True: + try: + chunk = await stream.receive() + buf.extend(chunk) + if b"\r\n\r\n" in buf: + break + except anyio.EndOfStream: + break + print(buf.decode(errors="replace").split("\r\n")[0]) + finally: + await api.delete(pod_name) +``` + +### PortForwarder + +`PortForwarder` has two mappings: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `streams` | `Mapping[int, PortForwardStream]` | One `anyio.abc.ByteStream` per forwarded port | +| `errors` | `Mapping[int, MemoryObjectReceiveStream[str]]` | Per-port kubelet error messages (typically empty on success) | + +`PortForwardStream` is an `anyio.abc.ByteStream`, so you can use `send()` and `receive()` on it directly. + +### Forwarding multiple ports + +Pass multiple ports to `forward()`: + +```python +async with api.portforward.forward(pod_name, ports=[80, 443]) as pf: + http_stream = pf.streams[80] + https_stream = pf.streams[443] + ... +``` + +## High-level: `listen()` + +`api.portforward.listen()` binds real local TCP sockets and proxies connections kubectl-style. Each accepted TCP connection gets its own WebSocket session. Use this when an external process (browser, `curl`, `psql`) needs to reach the pod. + +```python +from typing import cast + +import anyio + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.container_port import ContainerPort +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex_core.models.metadata import ObjectMetadata + +LOCAL_PORT = 18080 +HTTP_REQUEST = b"GET / HTTP/1.0\r\nHost: localhost\r\n\r\n" + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + pod = await api.create( + Pod( + metadata=ObjectMetadata(generate_name="example-portforward-"), + spec=PodSpec( + containers=[ + Container( + name="main", + image="nginx:1.25", + ports=[ContainerPort(container_port=80)], + ) + ] + ), + ), + ) + pod_name = cast(str, pod.metadata.name) + try: + async with api.portforward.listen(pod_name, port_map={80: LOCAL_PORT}): + async with await anyio.connect_tcp("127.0.0.1", LOCAL_PORT) as conn: + with anyio.fail_after(10): + await conn.send(HTTP_REQUEST) + buf = bytearray() + while True: + try: + chunk = await conn.receive() + buf.extend(chunk) + if b"\r\n\r\n" in buf: + break + except anyio.EndOfStream: + break + print(buf.decode(errors="replace").split("\r\n")[0]) + finally: + await api.delete(pod_name) +``` + +### Port map + +`listen()` takes a `port_map` dict mapping remote port (inside the pod) to local port (on the host): + +```python +# Remote 5432 (postgres) → local 15432 +async with api.portforward.listen(pod_name, port_map={5432: 15432}): + # connect with: psql -h 127.0.0.1 -p 15432 ... + ... +``` + +### Error logging + +`listen()` logs kubelet error frames via the `kubex.portforward` logger at `WARNING` level. Configure Python logging to capture them: + +```python +import logging +logging.basicConfig(level=logging.WARNING) +``` + +## `forward()` vs `listen()` decision guide + +- **Only Python code needs to talk to the pod?** Use `forward()`. It avoids binding a host socket and keeps traffic in-process. +- **External tools need the port?** Use `listen()`. It behaves exactly like `kubectl port-forward`. +- **Multiple concurrent connections to the same pod port?** Use `listen()` — each accepted connection gets its own WebSocket session automatically. + +## Advanced: port-prefix protocol + +For readers interested in the wire-level detail: the kubelet prepends a 2-byte little-endian port number to the **first** frame on each channel (data and error independently). Kubex validates and strips this prefix transparently. Subsequent frames carry raw bytes — the channel ID alone addresses the kubelet. Outbound writes from Kubex carry no port prefix. diff --git a/docs/subresources/resize.md b/docs/subresources/resize.md new file mode 100644 index 00000000..540c708a --- /dev/null +++ b/docs/subresources/resize.md @@ -0,0 +1,135 @@ +# Resize + +The `resize` subresource allows you to change the CPU and memory resource requests and limits of a running Pod's containers in-place, without restarting the Pod. This is the Kubernetes [In-Place Pod Vertical Scaling](https://kubernetes.io/docs/tasks/configure-pod-container/resize-container-resources/) feature. + +!!! note "Kubernetes version requirement" + In-place resize is stable in Kubernetes 1.33. Earlier versions require the `InPlacePodVerticalScaling` feature gate to be enabled. + +## Availability + +Only resources with the `HasResize` marker interface expose `api.resize`. In practice this means `Pod`. + +```python +from kubex.k8s.v1_35.core.v1.pod import Pod + +pod_api.resize.get(...) # OK + +from kubex.k8s.v1_35.apps.v1.deployment import Deployment + +deploy_api.resize.get(...) # type error + runtime NotImplementedError +``` + +## Reading the current resource allocation + +`api.resize.get()` returns the full `Pod` object. The relevant fields for in-place resize are `spec.containers[*].resources` and the corresponding `status.containerStatuses[*].resources` (which reflects what the kubelet has actually applied). + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.pod import Pod + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + + pod = await api.resize.get("my-pod") + for container in pod.spec.containers: + resources = container.resources + if resources: + print(f"{container.name}: requests={resources.requests}, limits={resources.limits}") +``` + +## Replacing resource allocation + +`api.resize.replace()` is the primary write path. Retrieve the pod, update `spec.containers[*].resources`, then write it back: + +```python +from typing import cast + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.core.v1.pod import Pod +from kubex.k8s.v1_35.core.v1.resource_requirements import ResourceRequirements + + +async def resize_pod(pod_name: str) -> None: + client = await create_client() + async with client: + api: Api[Pod] = Api(Pod, client=client, namespace="default") + + pod = await api.resize.get(pod_name) + + for container in pod.spec.containers: + if container.name == "main": + container.resources = ResourceRequirements( + requests={"cpu": "500m", "memory": "256Mi"}, + limits={"cpu": "1", "memory": "512Mi"}, + ) + + updated = await api.resize.replace(pod_name, pod) + for c in updated.spec.containers: + print(f"{c.name}: {c.resources}") +``` + +### Replace options + +| Option | Type | Description | +|--------|------|-------------| +| `dry_run` | `DryRun | bool | None` | Validate without persisting | +| `field_manager` | `str | None` | Field manager name | +| `namespace` | `str | None | ...` | Override the `Api` instance namespace | +| `request_timeout` | `Timeout | float | None | ...` | Override the client-level timeout | + +## Patching resource allocation + +`api.resize.patch()` applies a partial update. `MergePatch` is the natural fit for targeting specific containers: + +```python +from kubex.core.patch import MergePatch + +updated = await api.resize.patch( + "my-pod", + MergePatch({ + "spec": { + "containers": [ + { + "name": "main", + "resources": { + "requests": {"cpu": "500m", "memory": "256Mi"}, + "limits": {"cpu": "1", "memory": "512Mi"}, + }, + } + ] + } + }), +) +``` + +`JsonPatch` is also accepted — it lets you replace a single nested field without re-sending the rest of the container spec: + +```python +from kubex.core.patch import JsonPatch + +# Bump main container memory request to 256Mi (containers[0] = "main") +updated = await api.resize.patch( + "my-pod", + JsonPatch().replace("/spec/containers/0/resources/requests/memory", "256Mi"), +) +``` + +`patch()` accepts the same options as `replace()` plus `force` and `field_validation` for server-side apply. + +## Checking resize status + +After a replace or patch the kubelet may take some time to apply the new resources. Check `status.containerStatuses[*].resources` to see the currently active allocation. The `status.resize` field (when present) indicates whether the resize is `Proposed`, `InProgress`, `Deferred`, or `Infeasible`: + +```python +pod = await api.resize.get(pod_name) +for cs in (pod.status.container_statuses or []): + print(f"{cs.name}: allocated={cs.resources}") +``` + +!!! tip + A resize is `Deferred` when the node has insufficient capacity at the moment. The kubelet will apply it automatically once resources become available — no retry is needed from your side. diff --git a/docs/subresources/scale.md b/docs/subresources/scale.md new file mode 100644 index 00000000..a024777a --- /dev/null +++ b/docs/subresources/scale.md @@ -0,0 +1,134 @@ +# Scale + +The `scale` subresource lets you read and update the replica count of scalable resources without modifying the full resource spec. + +## Availability + +Only resources with the `HasScaleSubresource` marker interface expose `api.scale`. This includes `Deployment`, `ReplicaSet`, `StatefulSet`, and `ReplicationController`. Accessing `api.scale` on a resource that does not have the marker raises `NotImplementedError` at runtime and is a type error for the type-checker. + +```python +from kubex.k8s.v1_35.apps.v1.deployment import Deployment + +deploy_api.scale.get(...) # OK: Deployment has HasScaleSubresource + +from kubex.k8s.v1_35.core.v1.pod import Pod + +pod_api.scale.get(...) # type error + runtime NotImplementedError +``` + +## Reading the current scale + +`api.scale.get()` returns a `Scale` object with `spec.replicas` set to the current desired replica count and `status.replicas` reflecting the observed count. + +```python +from typing import cast + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.apps.v1.deployment import Deployment +from kubex_core.models.scale import Scale + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Deployment] = Api(Deployment, client=client, namespace="default") + + scale: Scale = await api.scale.get("my-deployment") + print(f"desired replicas: {scale.spec.replicas}") + print(f"observed replicas: {scale.status.replicas}") +``` + +## Replacing the scale + +`api.scale.replace()` is the canonical way to set a new replica count. Retrieve the current `Scale`, mutate `spec.replicas`, and write it back: + +```python +from typing import cast + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.apps.v1.deployment import Deployment +from kubex.k8s.v1_35.apps.v1.deployment_spec import DeploymentSpec +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex.k8s.v1_35.core.v1.pod_template_spec import PodTemplateSpec +from kubex.k8s.v1_35.meta.v1.label_selector import LabelSelector +from kubex_core.models.metadata import ObjectMetadata +from kubex_core.models.scale import ScaleSpec + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Deployment] = Api(Deployment, client=client, namespace="default") + + deployment = await api.create( + Deployment( + metadata=ObjectMetadata( + name="example-scale", + labels={"app": "example-scale"}, + ), + spec=DeploymentSpec( + replicas=1, + selector=LabelSelector(match_labels={"app": "example-scale"}), + template=PodTemplateSpec( + metadata=ObjectMetadata(labels={"app": "example-scale"}), + spec=PodSpec( + containers=[Container(name="nginx", image="nginx:latest")], + ), + ), + ), + ), + ) + name = cast(str, deployment.metadata.name) + + try: + current_scale = await api.scale.get(name) + print(f"Current replicas: {current_scale.spec.replicas}") + + current_scale.spec = ScaleSpec(replicas=3) + updated_scale = await api.scale.replace(name, current_scale) + print(f"Updated replicas: {updated_scale.spec.replicas}") + + final_scale = await api.scale.get(name) + print(f"Confirmed replicas: {final_scale.spec.replicas}") + finally: + await api.delete(name) +``` + +### Replace options + +| Option | Type | Description | +|--------|------|-------------| +| `dry_run` | `DryRun | bool | None` | Validate without persisting | +| `field_manager` | `str | None` | Field manager name | +| `namespace` | `str | None | ...` | Override the `Api` instance namespace | +| `request_timeout` | `Timeout | float | None | ...` | Override the client-level timeout | + +## Patching the scale + +Use `api.scale.patch()` when you want a partial update rather than a full replace. `MergePatch` is the natural choice for setting a value: + +```python +from kubex.core.patch import MergePatch + +updated = await api.scale.patch( + "my-deployment", + MergePatch({"spec": {"replicas": 5}}), +) +print(f"New desired replicas: {updated.spec.replicas}") +``` + +`JsonPatch` is also accepted — useful when you want operation-level control such as `test` for optimistic concurrency: + +```python +from kubex.core.patch import JsonPatch + +updated = await api.scale.patch( + "my-deployment", + JsonPatch().replace("/spec/replicas", 5), +) +``` + +`patch()` accepts the same options as `replace()` plus `force` and `field_validation` for server-side apply semantics. diff --git a/docs/subresources/status.md b/docs/subresources/status.md new file mode 100644 index 00000000..57ea9f57 --- /dev/null +++ b/docs/subresources/status.md @@ -0,0 +1,160 @@ +# Status + +The `status` subresource provides isolated read/write access to a resource's `.status` field. Controllers and operators use it to update observed state without inadvertently modifying user-managed spec fields. + +## Availability + +Only resources with the `HasStatusSubresource` marker interface expose `api.status`. Most workload resources implement it: `Deployment`, `StatefulSet`, `DaemonSet`, `ReplicaSet`, `Job`, `CronJob`, `Pod`, `Node`, `Namespace`, and many more. + +```python +from kubex.k8s.v1_35.apps.v1.deployment import Deployment + +deploy_api.status.get(...) # OK: Deployment has HasStatusSubresource + +from kubex.k8s.v1_35.core.v1.config_map import ConfigMap + +cm_api.status.get(...) # type error + runtime NotImplementedError +``` + +## Reading status + +`api.status.get()` returns the full resource object. The returned object contains the current `.status` field populated by the control plane. The `.spec` is also present, but write operations via this subresource only persist changes to `.status`. + +```python +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.apps.v1.deployment import Deployment + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Deployment] = Api(Deployment, client=client, namespace="default") + + current = await api.status.get("my-deployment") + print( + f"Status: replicas={current.status and current.status.replicas}, " + f"available={current.status and current.status.available_replicas}" + ) +``` + +## Replacing status + +`api.status.replace()` writes a new status. Only `.status` is persisted — changes to `.spec` in the submitted object are silently ignored by the Kubernetes API. + +This pattern is typical for controllers: read the current object, compute the new status, write it back: + +```python +from typing import cast + +from kubex.api import Api +from kubex.client import create_client +from kubex.k8s.v1_35.apps.v1.deployment import Deployment +from kubex.k8s.v1_35.apps.v1.deployment_spec import DeploymentSpec +from kubex.k8s.v1_35.apps.v1.deployment_status import DeploymentStatus +from kubex.k8s.v1_35.core.v1.container import Container +from kubex.k8s.v1_35.core.v1.pod_spec import PodSpec +from kubex.k8s.v1_35.core.v1.pod_template_spec import PodTemplateSpec +from kubex.k8s.v1_35.meta.v1.label_selector import LabelSelector +from kubex_core.models.metadata import ObjectMetadata + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Deployment] = Api(Deployment, client=client, namespace="default") + + deployment = await api.create( + Deployment( + metadata=ObjectMetadata( + name="example-status", + labels={"app": "example-status"}, + ), + spec=DeploymentSpec( + replicas=1, + selector=LabelSelector(match_labels={"app": "example-status"}), + template=PodTemplateSpec( + metadata=ObjectMetadata(labels={"app": "example-status"}), + spec=PodSpec( + containers=[Container(name="nginx", image="nginx:latest")], + ), + ), + ), + ), + ) + name = cast(str, deployment.metadata.name) + + try: + current = await api.status.get(name) + print( + f"Status: replicas={current.status and current.status.replicas}, " + f"available={current.status and current.status.available_replicas}" + ) + + current.status = DeploymentStatus(replicas=1, available_replicas=1) + updated = await api.status.replace(name, current) + print( + f"After replace: " + f"replicas={updated.status and updated.status.replicas}, " + f"available={updated.status and updated.status.available_replicas}" + ) + finally: + await api.delete(name) +``` + +### Replace options + +| Option | Type | Description | +|--------|------|-------------| +| `dry_run` | `DryRun | bool | None` | Validate without persisting | +| `field_manager` | `str | None` | Field manager name | +| `namespace` | `str | None | ...` | Override the `Api` instance namespace | +| `request_timeout` | `Timeout | float | None | ...` | Override the client-level timeout | + +## Patching status + +`api.status.patch()` applies a partial status update. Only the fields present in the patch body are merged into `.status`. `MergePatch` is usually the right choice for status updates: + +```python +from kubex.core.patch import MergePatch + +updated = await api.status.patch( + "my-deployment", + MergePatch({"status": {"availableReplicas": 3}}), +) +``` + +`JsonPatch` is also accepted for operation-level control (e.g., conditional updates with `test`): + +```python +from kubex.core.patch import JsonPatch + +updated = await api.status.patch( + "my-deployment", + JsonPatch().replace("/status/availableReplicas", 3), +) +``` + +For server-side apply on status, use `ApplyPatch` with `force=True` and a `field_manager`: + +```python +from kubex.core.patch import ApplyPatch + +updated = await api.status.patch( + "my-deployment", + ApplyPatch({"status": {"conditions": [...]}}), + force=True, + field_manager="my-controller", +) +``` + +`patch()` accepts the same options as `replace()` plus `force` and `field_validation`. + +## Status vs spec updates + +Kubernetes splits resource management into two separate write paths: + +- Write to the resource itself (via `api.replace()` or `api.patch()`) to update **spec** — user intent. +- Write via `api.status.replace()` or `api.status.patch()` to update **status** — observed state. + +RBAC rules can be configured to allow a controller to write status without being able to modify spec, and vice versa. Keeping the two paths separate also prevents accidental spec overwrites when a controller only intends to report progress. diff --git a/examples/client_options.py b/examples/client_options.py new file mode 100644 index 00000000..736954af --- /dev/null +++ b/examples/client_options.py @@ -0,0 +1,54 @@ +"""Demonstrates every ClientOptions knob. + +Run against a real cluster (or any reachable API server) to see the effects. +Most settings here are intentionally set to non-default values for illustration; +in production you would only set the ones you need. +""" + +import asyncio + +from kubex.api import Api +from kubex.client import ClientOptions, create_client +from kubex.core.params import Timeout +from kubex.k8s.v1_35.core.v1.namespace import Namespace + + +async def main() -> None: + # All new fields shown with representative values. + # Replace proxy URL and pool sizes to match your environment. + options = ClientOptions( + # HTTP timeouts — 30 s total, 5 s connect + timeout=Timeout(total=30.0, connect=5.0), + # Silence deprecated-API warnings from the server + log_api_warnings=False, + # Route all traffic through a corporate HTTPS proxy. + # Use a dict to apply different proxies per scheme: + # proxy={"http": "http://...", "https": "http://..."} + proxy="http://proxy.corp.example.com:8080", + # Keep idle connections alive + keep_alive=True, + # Close idle connections after 60 s (library default: httpx=5 s, aiohttp=15 s) + keep_alive_timeout=60.0, + # HTTP-response read buffer: 4 MiB + # (ignored on httpx — UserWarning emitted; aiohttp default is 2**21) + buffer_size=4 * 1024 * 1024, + # Max WebSocket frame for exec/attach/portforward: 8 MiB + # Pass None for no cap; default is 2**21 on both backends. + ws_max_message_size=8 * 1024 * 1024, + # Total connection pool: 50 connections across all hosts + # Pass None for unlimited; library default is 100. + pool_size=50, + # Per-host connection limit: 10 + # (ignored on httpx — UserWarning emitted; aiohttp default is 0 = no limit) + pool_size_per_host=10, + ) + + async with await create_client(options=options) as client: + api: Api[Namespace] = Api(Namespace, client=client) + namespaces = await api.list() + for ns in namespaces.items: + print(ns.metadata.name if ns.metadata else "") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/custom_resource.py b/examples/custom_resource.py new file mode 100644 index 00000000..31787a01 --- /dev/null +++ b/examples/custom_resource.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from typing import ClassVar, Literal + +from pydantic import Field + +from kubex.api import Api +from kubex.client import create_client +from kubex.core.patch import ApplyPatch +from kubex_core.models.base import BaseK8sModel +from kubex_core.models.interfaces import ( + ClusterScopedEntity, + HasStatusSubresource, + NamespaceScopedEntity, +) +from kubex_core.models.metadata import ObjectMetadata +from kubex_core.models.resource_config import ResourceConfig, Scope + +NAMESPACE = "default" + + +class WidgetSpec(BaseK8sModel): + replicas: int = 1 + image: str = "nginx:latest" + + +class WidgetStatus(BaseK8sModel): + ready_replicas: int | None = None + + +class Widget(NamespaceScopedEntity, HasStatusSubresource): + """A namespace-scoped custom resource with a status subresource.""" + + __RESOURCE_CONFIG__: ClassVar[ResourceConfig["Widget"]] = ResourceConfig( + version="v1alpha1", + kind="Widget", + group="example.io", + plural="widgets", + scope=Scope.NAMESPACE, + ) + api_version: Literal["example.io/v1alpha1"] = Field( + default="example.io/v1alpha1", + alias="apiVersion", + ) + kind: Literal["Widget"] = Field(default="Widget") + spec: WidgetSpec | None = None + status: WidgetStatus | None = None + + +class ClusterWidgetSpec(BaseK8sModel): + description: str = "" + + +class ClusterWidget(ClusterScopedEntity): + """A cluster-scoped custom resource.""" + + __RESOURCE_CONFIG__: ClassVar[ResourceConfig["ClusterWidget"]] = ResourceConfig( + version="v1alpha1", + kind="ClusterWidget", + group="example.io", + plural="clusterwidgets", + scope=Scope.CLUSTER, + ) + api_version: Literal["example.io/v1alpha1"] = Field( + default="example.io/v1alpha1", + alias="apiVersion", + ) + kind: Literal["ClusterWidget"] = Field(default="ClusterWidget") + spec: ClusterWidgetSpec | None = None + + +async def main() -> None: + client = await create_client() + async with client: + api: Api[Widget] = Api(Widget, client=client, namespace=NAMESPACE) + + widget = await api.create( + Widget( + metadata=ObjectMetadata(name="my-widget"), + spec=WidgetSpec(replicas=2), + ), + ) + assert widget.metadata.name is not None + name = widget.metadata.name + + try: + fetched = await api.get(name) + print( + f"Got widget: {fetched.metadata.name}, replicas={fetched.spec and fetched.spec.replicas}" + ) + + widget_list = await api.list() + print(f"Listed {len(widget_list.items)} widget(s)") + + patched = await api.patch( + name, + ApplyPatch( + Widget( + api_version="example.io/v1alpha1", + kind="Widget", + metadata=ObjectMetadata(name=name), + spec=WidgetSpec(replicas=3), + ) + ), + field_manager="kubex-example", + force=True, + ) + print(f"Patched widget: replicas={patched.spec and patched.spec.replicas}") + + status = await api.status.get(name) + print( + f"Status: ready_replicas={status.status and status.status.ready_replicas}" + ) + finally: + await api.delete(name) + print("Deleted widget") + + cluster_api: Api[ClusterWidget] = Api(ClusterWidget, client=client) + cluster_widget = await cluster_api.create( + ClusterWidget( + metadata=ObjectMetadata(name="my-cluster-widget"), + spec=ClusterWidgetSpec(description="a cluster-wide widget"), + ), + ) + assert cluster_widget.metadata.name is not None + try: + print(f"Got cluster widget: {cluster_widget.metadata.name}") + finally: + await cluster_api.delete(cluster_widget.metadata.name) + print("Deleted cluster widget") + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/examples/portforward_pod.py b/examples/portforward_pod.py index 872080db..5d918377 100644 --- a/examples/portforward_pod.py +++ b/examples/portforward_pod.py @@ -38,6 +38,9 @@ async def _wait_for_running(api: Api[Pod], name: str, timeout: int = 120) -> Non async def demo_low_level(api: Api[Pod], pod_name: str) -> None: + # forward() is the in-process variant: no host socket is opened — we + # read/write bytes directly through pf.streams[port]. Pick this when + # only Python code needs to talk to the pod. print("--- low-level forward() ---") async with api.portforward.forward(pod_name, ports=[80]) as pf: stream = pf.streams[80] @@ -56,6 +59,10 @@ async def demo_low_level(api: Api[Pod], pod_name: str) -> None: async def demo_high_level(api: Api[Pod], pod_name: str) -> None: + # listen() is the kubectl-style variant: it binds a real local TCP port + # so any process on the host could connect — here we use anyio.connect_tcp, + # but `curl 127.0.0.1:18080` would work just as well. Pick this when an + # external tool (browser, psql, curl, …) needs to reach the pod. print("--- high-level listen() ---") async with api.portforward.listen(pod_name, port_map={80: LOCAL_PORT}): async with await anyio.connect_tcp("127.0.0.1", LOCAL_PORT) as conn: diff --git a/kubex/__version__.py b/kubex/__version__.py index 4a9a7f14..6d80cda8 100644 --- a/kubex/__version__.py +++ b/kubex/__version__.py @@ -1 +1 @@ -VERSION = "0.1.0-alpha.2" +VERSION = "0.1.0-beta.1" diff --git a/kubex/api/_attach.py b/kubex/api/_attach.py index 1f0058a4..0b94525f 100644 --- a/kubex/api/_attach.py +++ b/kubex/api/_attach.py @@ -33,7 +33,14 @@ class AttachAccessor(Generic[ResourceType]): - """Accessor for the Pod ``attach`` subresource.""" + """Accessor for the Pod ``attach`` subresource. + + .. warning:: + + **Experimental.** The WebSocket-based subresources (``exec``, + ``attach``, ``portforward``) are still under active development and + their API may change in future releases without notice. + """ def __init__( self, @@ -109,7 +116,13 @@ async def stream( tty: bool = False, request_timeout: ApiRequestTimeoutTypes = Ellipsis, ) -> AsyncIterator[StreamSession]: - """Open a bidirectional attach session as an async context manager.""" + """Open a bidirectional attach session as an async context manager. + + .. warning:: + + **Experimental.** This WebSocket-based API is still under active + development and may change in future releases without notice. + """ session = await self._open_session( name, container=container, diff --git a/kubex/api/_exec.py b/kubex/api/_exec.py index fbf85848..17104cea 100644 --- a/kubex/api/_exec.py +++ b/kubex/api/_exec.py @@ -83,7 +83,14 @@ def _parse_exit_code(status: Status | None) -> int | None: class ExecAccessor(Generic[ResourceType]): - """Accessor for the Pod ``exec`` subresource.""" + """Accessor for the Pod ``exec`` subresource. + + .. warning:: + + **Experimental.** The WebSocket-based subresources (``exec``, + ``attach``, ``portforward``) are still under active development and + their API may change in future releases without notice. + """ def __init__( self, @@ -170,7 +177,13 @@ async def stream( tty: bool = False, request_timeout: ApiRequestTimeoutTypes = Ellipsis, ) -> AsyncIterator[StreamSession]: - """Open a bidirectional exec session as an async context manager.""" + """Open a bidirectional exec session as an async context manager. + + .. warning:: + + **Experimental.** This WebSocket-based API is still under active + development and may change in future releases without notice. + """ session = await self._open_session( name, command=command, @@ -200,6 +213,11 @@ async def run( ) -> ExecResult: """Run a command and collect stdout/stderr until the channel closes. + .. warning:: + + **Experimental.** This WebSocket-based API is still under active + development and may change in future releases without notice. + Unlike :meth:`stream`, the ``request_timeout`` bound (when provided) applies to both the handshake (via the per-call HTTP timeout propagated to the WebSocket upgrade) and the post-handshake command diff --git a/kubex/api/_metadata.py b/kubex/api/_metadata.py index 9621f725..bcf7aa23 100644 --- a/kubex/api/_metadata.py +++ b/kubex/api/_metadata.py @@ -71,7 +71,7 @@ async def list( namespace: ApiNamespaceTypes = Ellipsis, label_selector: str | None = None, field_selector: str | None = None, - timeout: int | None = None, + timeout_seconds: int | None = None, limit: int | None = None, continue_token: str | None = None, version_match: VersionMatch | None = None, @@ -83,7 +83,7 @@ async def list( options = ListOptions( label_selector=label_selector, field_selector=field_selector, - timeout=timeout, + timeout_seconds=timeout_seconds, limit=limit, continue_token=continue_token, version_match=version_match, diff --git a/kubex/api/_portforward.py b/kubex/api/_portforward.py index 18c836e8..0302c57d 100644 --- a/kubex/api/_portforward.py +++ b/kubex/api/_portforward.py @@ -51,8 +51,8 @@ async def _copy( On natural EOF on the source, sends EOF on the destination so the peer can keep streaming in the opposite direction (TCP half-close support). - Errors propagate to the surrounding task group, which cancels the other - copy task — full teardown happens via the outer ``async with`` blocks. + Broken or closed stream errors are silently ignored — the task exits and + the other direction tears down naturally via the outer ``async with`` blocks. """ try: while True: @@ -137,7 +137,7 @@ async def receive(self, max_bytes: int = 65536) -> bytes: while not self._buffer: try: self._buffer = await self._recv_stream.receive() - except anyio.ClosedResourceError: + except (anyio.ClosedResourceError, anyio.EndOfStream): raise anyio.EndOfStream() data = self._buffer[:max_bytes] self._buffer = self._buffer[max_bytes:] @@ -163,8 +163,10 @@ async def send_eof(self) -> None: async def aclose(self) -> None: async with self._session._write_lock: self._send_closed = True - await self._session.close_port_data(self._port) - self._recv_stream.close() + try: + await self._session.close_port_data(self._port) + finally: + self._recv_stream.close() class PortForwarder: @@ -195,7 +197,14 @@ def port_data_truncated(self) -> Mapping[int, bool]: class PortforwardAccessor(Generic[ResourceType]): - """Accessor for the Pod ``portforward`` subresource.""" + """Accessor for the Pod ``portforward`` subresource. + + .. warning:: + + **Experimental.** The WebSocket-based subresources (``exec``, + ``attach``, ``portforward``) are still under active development and + their API may change in future releases without notice. + """ def __init__( self, @@ -221,6 +230,7 @@ async def _open_session( namespace: ApiNamespaceTypes, request_timeout: ApiRequestTimeoutTypes, buffer_size: float = 128, + block_on_full: bool = False, ) -> PortForwardSession: _namespace = ensure_required_namespace(namespace, self._namespace, self._scope) options = PortForwardOptions(ports=ports) @@ -234,7 +244,11 @@ async def _open_session( try: protocol = _resolve_protocol(connection, self._channel_protocols) return PortForwardSession( - connection, protocol, ports, buffer_size=buffer_size + connection, + protocol, + ports, + buffer_size=buffer_size, + block_on_full=block_on_full, ) except BaseException: try: @@ -254,6 +268,21 @@ async def forward( ) -> AsyncIterator[PortForwarder]: """Open portforward streams to the given ports as an async context manager. + .. warning:: + + **Experimental.** This WebSocket-based API is still under active + development and may change in future releases without notice. + + This is the **low-level** entry point: a single WebSocket multiplexes + all requested ports, and the caller drives I/O directly in Python via + per-port ``anyio.abc.ByteStream`` objects. No sockets are bound on the + host — bytes never leave the process. Use this when your own code + speaks to the pod (custom protocols, embedded clients, tests). + + For the kubectl-style mode where external processes connect through + a real local TCP port, use :meth:`listen` instead (which is built on + top of this method). + Yields a ``PortForwarder`` exposing per-port ``ByteStream`` objects (``pf.streams[port]``) and per-port error iterators (``pf.errors[port]``). """ @@ -278,12 +307,26 @@ async def listen( ) -> AsyncIterator[None]: """Open local TCP listeners and forward bytes bidirectionally to remote ports. + .. warning:: + + **Experimental.** This WebSocket-based API is still under active + development and may change in future releases without notice. + + This is the **high-level**, kubectl-style entry point: real OS sockets + are bound on ``local_host:local_port`` so that any process on the host + (``curl``, ``psql``, a browser, …) can connect to the pod through a + local port. Each accepted local connection opens its own portforward + WebSocket session bound to that single remote port — one session per + connection, matching ``kubectl port-forward`` semantics. The method + itself yields ``None``; you don't drive I/O through it. + + For the low-level mode where your own Python code reads/writes bytes + directly without binding any sockets, use :meth:`forward` instead + (which this method is built on top of). + ``port_map`` maps **remote port** (kubelet-side) to **local port**. Example: ``{80: 18080}`` opens a local listener on port 18080 that forwards to the pod's port 80. - - Each accepted local connection opens its own portforward WebSocket session - bound to that single remote port (one session per connection). """ if not port_map: raise ValueError("port_map must contain at least one entry") @@ -351,6 +394,7 @@ async def _handle(stream: anyio.abc.ByteStream) -> None: ports=[remote_port], namespace=namespace, request_timeout=request_timeout, + block_on_full=True, ) async with session: pf = PortForwarder(session) @@ -373,13 +417,6 @@ async def _handle(stream: anyio.abc.ByteStream) -> None: copy_tg.start_soon(_copy, stream, port_stream) copy_tg.start_soon(_copy, port_stream, stream) conn_tg.cancel_scope.cancel() - if pf.port_data_truncated.get(remote_port): - _logger.warning( - "portforward: data dropped for port %d due " - "to local backpressure (buffer overflow); " - "the local connection received truncated bytes", - remote_port, - ) except Exception: _logger.exception( "portforward connection error on port %d", remote_port diff --git a/kubex/api/_portforward_session.py b/kubex/api/_portforward_session.py index d208244b..19966843 100644 --- a/kubex/api/_portforward_session.py +++ b/kubex/api/_portforward_session.py @@ -33,6 +33,17 @@ class PortForwardSession(_BaseChannelSession): carries a 2-byte little-endian port-number prefix which is stripped and validated; subsequent frames are routed as raw bytes (data) or UTF-8 text (error) without any prefix. + + When ``block_on_full=True`` the read loop awaits space in each per-port + buffer instead of closing the stream on overflow. This propagates + backpressure all the way to the WebSocket — correct for single-port TCP + proxy sessions created by ``listen()`` where data loss is unacceptable. + When ``block_on_full=False`` (the default) the read loop uses a non-blocking + send: if a port's buffer is full the port stream is closed locally and + ``_truncated[port]`` is set, leaving all other ports unaffected. This + prevents head-of-line blocking across ports in multi-port ``forward()`` + sessions at the cost of surfacing overflow as ``EndOfStream`` rather than + stalling traffic. """ def __init__( @@ -42,9 +53,11 @@ def __init__( ports: Sequence[int], *, buffer_size: float = _DEFAULT_BUFFER, + block_on_full: bool = False, ) -> None: super().__init__(connection, protocol) self._ports: tuple[int, ...] = tuple(ports) + self._block_on_full = block_on_full # Channel-id → port lookup tables (built once, queried in _read_loop) self._data_ch_to_port: dict[int, int] = {} @@ -143,12 +156,18 @@ async def _read_loop(self) -> None: self._data_first_seen.add(channel) payload = payload[2:] if payload: - still_open, truncated = _dispatch_bytes_nowait( - self._streams_send[port], payload - ) - self._data_open[port] = still_open - if truncated: - self._truncated[port] = True + if self._block_on_full: + still_open = await _dispatch_bytes( + self._streams_send[port], payload + ) + self._data_open[port] = still_open + else: + still_open, truncated = _dispatch_bytes_nowait( + self._streams_send[port], payload + ) + self._data_open[port] = still_open + if truncated: + self._truncated[port] = True elif channel in self._error_ch_to_port: port = self._error_ch_to_port[channel] @@ -166,10 +185,16 @@ async def _read_loop(self) -> None: payload = payload[2:] if payload: error_text = payload.decode("utf-8", errors="replace") - still_open, _ = _dispatch_str_nowait( - self._errors_send[port], error_text - ) - self._error_open[port] = still_open + if self._block_on_full: + still_open = await _dispatch_str( + self._errors_send[port], error_text + ) + self._error_open[port] = still_open + else: + still_open, _ = _dispatch_str_nowait( + self._errors_send[port], error_text + ) + self._error_open[port] = still_open finally: for port in self._ports: self._streams_send[port].close() @@ -193,7 +218,9 @@ def _dispatch_bytes_nowait( Returns ``(still_open, truncated)``. ``still_open`` is ``False`` when the channel is closed; ``truncated`` is ``True`` only when *this* call closed - the channel due to a full buffer. + the channel due to a full buffer. Using ``send_nowait`` here means the + read loop never stalls on a single port's consumer, so a slow consumer on + port A cannot block frame delivery to port B (no head-of-line blocking). """ try: send_stream.send_nowait(payload) @@ -220,3 +247,32 @@ def _dispatch_str_nowait( return False, True except (anyio.BrokenResourceError, anyio.ClosedResourceError): return False, False + + +async def _dispatch_bytes( + send_stream: MemoryObjectSendStream[bytes], payload: bytes +) -> bool: + """Push ``payload`` to ``send_stream``, blocking until space is available. + + Returns ``True`` if the send succeeded, ``False`` if the stream is closed. + Blocking naturally propagates backpressure from a slow consumer through the + memory buffer to the WebSocket read loop, preventing data loss. Only used + when ``block_on_full=True`` (the ``listen()`` TCP-proxy path). + """ + try: + await send_stream.send(payload) + return True + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + return False + + +async def _dispatch_str(send_stream: MemoryObjectSendStream[str], text: str) -> bool: + """Push ``text`` to ``send_stream``, blocking until space is available. + + Returns ``True`` if the send succeeded, ``False`` if the stream is closed. + """ + try: + await send_stream.send(text) + return True + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + return False diff --git a/kubex/api/api.py b/kubex/api/api.py index 68884ff5..615f9db9 100644 --- a/kubex/api/api.py +++ b/kubex/api/api.py @@ -131,7 +131,7 @@ async def list( namespace: ApiNamespaceTypes = Ellipsis, label_selector: str | None = None, field_selector: str | None = None, - timeout: int | None = None, + timeout_seconds: int | None = None, limit: int | None = None, continue_token: str | None = None, version_match: VersionMatch | None = None, @@ -148,7 +148,7 @@ async def list( For details look at [Label Selectors documentation](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors). field_selector: A selector to restrict the list of returned objects by their fields. For details look at [Field Selectors documentation](https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/). - timeout: Server-side timeout (in seconds) for the list/watch call; + timeout_seconds: Server-side timeout (in seconds) for the list/watch call; sent as the Kubernetes ``timeoutSeconds`` query parameter. For an HTTP client-side timeout, use ``request_timeout``. limit: The maximum number of items to return. @@ -169,7 +169,7 @@ async def list( options = ListOptions( label_selector=label_selector, field_selector=field_selector, - timeout=timeout, + timeout_seconds=timeout_seconds, limit=limit, continue_token=continue_token, version_match=version_match, @@ -277,7 +277,7 @@ async def delete_collection( namespace: ApiNamespaceTypes = Ellipsis, label_selector: str | None = None, field_selector: str | None = None, - timeout: int | None = None, + timeout_seconds: int | None = None, limit: int | None = None, continue_token: str | None = None, version_match: VersionMatch | None = None, @@ -291,6 +291,9 @@ async def delete_collection( """Delete collection of resources. Args: + timeout_seconds: Server-side timeout (in seconds) for the call; + sent as the Kubernetes ``timeoutSeconds`` query parameter. For an + HTTP client-side timeout, use ``request_timeout``. request_timeout: HTTP-level timeout override for this call. A number is interpreted as the total timeout in seconds. Pass ``None`` to disable timeouts entirely for this call. Omit to use the client default. @@ -301,7 +304,7 @@ async def delete_collection( list_options = ListOptions( label_selector=label_selector, field_selector=field_selector, - timeout=timeout, + timeout_seconds=timeout_seconds, limit=limit, continue_token=continue_token, version_match=version_match, diff --git a/kubex/client/__init__.py b/kubex/client/__init__.py index 0177ace6..32a1b1a0 100644 --- a/kubex/client/__init__.py +++ b/kubex/client/__init__.py @@ -1,3 +1,4 @@ from .client import BaseClient, ClientChoise, create_client +from .options import ClientOptions -__all__ = ["create_client", "BaseClient", "ClientChoise"] +__all__ = ["create_client", "BaseClient", "ClientChoise", "ClientOptions"] diff --git a/kubex/client/aiohttp.py b/kubex/client/aiohttp.py index 4ef27967..3533b018 100644 --- a/kubex/client/aiohttp.py +++ b/kubex/client/aiohttp.py @@ -1,6 +1,9 @@ +import contextlib import ssl import warnings -from typing import Any, AsyncGenerator, Sequence +from types import EllipsisType +from typing import Any, AsyncGenerator, Sequence, cast +from urllib.parse import urlparse import anyio from aiohttp import ( @@ -13,6 +16,7 @@ ) from aiohttp.connector import TCPConnector +from kubex.client.options import ClientOptions, resolve_ws_max_message_size from kubex.client.websocket import WebSocketConnection from kubex.configuration import ClientConfiguration from kubex.core.exceptions import KubexClientException @@ -41,14 +45,59 @@ def _to_aiohttp_timeout(timeout: Timeout | None) -> ClientTimeout: ) +def _apply_aiohttp_proxy( + kwargs: dict[str, Any], + proxy: str | dict[str, str] | None, + base_url: str, +) -> None: + """Mutate *kwargs* to add ``proxy=`` for an aiohttp session if applicable. + + aiohttp accepts only a single session-level proxy URL. When *proxy* is a + ``dict`` the entry matching the API server's URL scheme is used; other + entries are dropped and a :class:`UserWarning` is emitted. When no entry + matches the active scheme, no proxy is applied and a warning is emitted. + """ + if proxy is None: + return + if isinstance(proxy, str): + kwargs["proxy"] = proxy + return + scheme = urlparse(base_url).scheme + if scheme in proxy: + kwargs["proxy"] = proxy[scheme] + dropped = sorted(k for k in proxy if k != scheme) + if dropped: + warnings.warn( + f"aiohttp supports only a single session-level proxy URL; " + f"using the {scheme!r} entry. " + f"Dropped proxy scheme entries: {dropped}. " + f"Use the httpx backend for per-scheme proxy routing.", + UserWarning, + stacklevel=4, + ) + else: + warnings.warn( + f"aiohttp proxy dict has no entry for URL scheme {scheme!r}; " + f"no proxy applied. " + f"Available keys: {sorted(proxy.keys())}. " + f"Use the httpx backend for per-scheme proxy routing.", + UserWarning, + stacklevel=4, + ) + + class AioHttpClient(BaseClient): - def __init__(self, configuration: ClientConfiguration) -> None: - self._configuration = configuration + def __init__( + self, + configuration: ClientConfiguration, + options: ClientOptions | None = None, + ) -> None: self._default_headers = { constants.CONTENT_TYPE_HEADER: constants.APPLICATION_JSON_MIME_TYPE, constants.ACCEPT_HEADER: constants.APPLICATION_JSON_MIME_TYPE, } - self._inner_client: ClientSession = self._create_inner_client() + self._resolved_proxy: str | None = None + super().__init__(configuration, options) @property def configuration(self) -> ClientConfiguration: @@ -75,21 +124,80 @@ def _create_inner_client(self) -> ClientSession: ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE - connector = TCPConnector( - verify_ssl=not bool(self.configuration.insecure_skip_tls_verify), - ssl=ssl_context, - ) + connector_kwargs: dict[str, Any] = {"ssl": ssl_context} + + pool_size = self.options.pool_size + if pool_size is None: + connector_kwargs["limit"] = 0 + elif not isinstance(pool_size, EllipsisType): + connector_kwargs["limit"] = pool_size + + pool_size_per_host = self.options.pool_size_per_host + if pool_size_per_host is None: + connector_kwargs["limit_per_host"] = 0 + elif not isinstance(pool_size_per_host, EllipsisType): + connector_kwargs["limit_per_host"] = pool_size_per_host + + if not self.options.keep_alive: + connector_kwargs["force_close"] = True + else: + keep_alive_timeout = self.options.keep_alive_timeout + if keep_alive_timeout is None: + warnings.warn( + "ClientOptions.keep_alive_timeout=None is not supported by aiohttp; " + "aiohttp has no 'unlimited keep-alive timeout' mode. " + "The setting is ignored and aiohttp uses its own default (15 s).", + UserWarning, + stacklevel=3, + ) + elif not isinstance(keep_alive_timeout, EllipsisType): + connector_kwargs["keepalive_timeout"] = keep_alive_timeout + + connector = TCPConnector(**connector_kwargs) + kwargs: dict[str, Any] = { "base_url": str(self.configuration.base_url), "connector": connector, - "read_bufsize": 2**21, "headers": self._default_headers, } - configured_timeout = self.configuration.timeout + + _apply_aiohttp_proxy( + kwargs, self.options.proxy, str(self.configuration.base_url) + ) + self._resolved_proxy = cast("str | None", kwargs.get("proxy")) + + buffer_size = self.options.buffer_size + if isinstance(buffer_size, EllipsisType): + kwargs["read_bufsize"] = 2**21 + elif buffer_size is not None: + kwargs["read_bufsize"] = buffer_size + # buffer_size=None → omit read_bufsize (aiohttp library default) + + configured_timeout = self.options.timeout if configured_timeout is not Ellipsis: - kwargs["timeout"] = _to_aiohttp_timeout(configured_timeout) + kwargs["timeout"] = _to_aiohttp_timeout( + cast("Timeout | None", configured_timeout) + ) return ClientSession(**kwargs) + def _session_kwargs(self) -> dict[str, Any]: + """Return base kwargs for temporary ``ClientSession`` instances. + + Used in ``connect_websocket()`` to build per-call sessions that share + the persistent connector. Proxy is propagated so timeout overrides do + not silently lose it. ``read_bufsize`` and ``timeout`` are excluded — + those are handled by the persistent session or added at the call site. + """ + kwargs: dict[str, Any] = { + "base_url": str(self._configuration.base_url), + "connector": self._inner_client.connector, + "connector_owner": False, + "headers": self._default_headers, + } + if self._resolved_proxy is not None: + kwargs["proxy"] = self._resolved_proxy + return kwargs + async def request(self, request: Request) -> Response: headers = self._get_headers() if request.headers: @@ -111,11 +219,12 @@ async def request(self, request: Request) -> Response: headers=HeadersWrapper(_response.headers), content=await _response.read(), ) - if self.configuration.log_api_warnings and ( - api_warnings := _response.headers.get("Warning") - ): - for warning in api_warnings.split(","): - warnings.warn(f"API Warning: {warning}") + if self.options.log_api_warnings: + for api_warning in _response.headers.getall("warning", []): + for warning in api_warning.split(","): + warnings.warn( + f"API Warning: {warning.strip()}", UserWarning, stacklevel=2 + ) if 400 <= status < 600: handle_request_error(response) return response @@ -144,6 +253,14 @@ async def stream_lines(self, request: Request) -> AsyncGenerator[str, None]: content=await _response.read(), ) handle_request_error(response) + if self.options.log_api_warnings: + for api_warning in _response.headers.getall("warning", []): + for warning in api_warning.split(","): + warnings.warn( + f"API Warning: {warning.strip()}", + UserWarning, + stacklevel=2, + ) while line := await _response.content.readline(): yield line.decode("utf-8") finally: @@ -203,10 +320,7 @@ async def connect_websocket( upgrade_session = self._inner_client else: temp_session = ClientSession( - base_url=str(self._configuration.base_url), - connector=self._inner_client.connector, - connector_owner=False, - headers=self._default_headers, + **self._session_kwargs(), timeout=_to_aiohttp_timeout(request.timeout), ) upgrade_session = temp_session @@ -215,23 +329,21 @@ async def connect_websocket( # the httpx-ws adapter caps frames at 2 MiB; explicitly match here so # large exec stdout/stderr chunks fail (or succeed) the same way on # both backends. + timeout_scope = ( + anyio.fail_after(handshake_timeout) + if handshake_timeout is not None + else contextlib.nullcontext() + ) try: - if handshake_timeout is not None: - with anyio.fail_after(handshake_timeout): - ws = await upgrade_session.ws_connect( - request.url, - protocols=tuple(subprotocols), - headers=headers, - params=params, - max_msg_size=2**21, - ) - else: + with timeout_scope: ws = await upgrade_session.ws_connect( request.url, protocols=tuple(subprotocols), headers=headers, params=params, - max_msg_size=2**21, + max_msg_size=resolve_ws_max_message_size( + self.options.ws_max_message_size + ), ) except WSServerHandshakeError as exc: raise KubexClientException(f"WebSocket handshake failed: {exc}") from exc @@ -253,7 +365,12 @@ async def connect_websocket( raise KubexClientException(f"WebSocket connection failed: {exc}") from exc finally: if temp_session is not None: - await temp_session.close() + # Suppress any close error so a ws_connect failure is what + # the caller sees, not a secondary cleanup exception. + try: + await temp_session.close() + except Exception: + pass if subprotocols and ws.protocol is None: # Suppress any cleanup error so the descriptive subprotocol diff --git a/kubex/client/client.py b/kubex/client/client.py index 3c891cc2..a4e50640 100644 --- a/kubex/client/client.py +++ b/kubex/client/client.py @@ -13,6 +13,7 @@ from pydantic import ValidationError +from kubex.client.options import ClientOptions from kubex.configuration import ClientConfiguration from kubex.configuration.file_config import configure_from_kubeconfig from kubex.configuration.incluster_config import configure_from_pod_env @@ -31,9 +32,12 @@ async def _try_read_configuration() -> ClientConfiguration: try: return await configure_from_kubeconfig() + except FileNotFoundError: + logger.debug("No kubeconfig file found, falling back to in-cluster config") + return await configure_from_pod_env() except Exception as e: logger.error("Failed to read configuration from kubeconfig", exc_info=e) - return await configure_from_pod_env() + raise class ClientChoise(str, Enum): @@ -43,11 +47,20 @@ class ClientChoise(str, Enum): class BaseClient(ABC): - def __init__(self, configuration: ClientConfiguration) -> None: + def __init__( + self, + configuration: ClientConfiguration, + options: ClientOptions | None = None, + ) -> None: super().__init__() self._configuration = configuration + self._options = options if options is not None else ClientOptions() self._inner_client: Any = self._create_inner_client() + @property + def options(self) -> ClientOptions: + return self._options + @abstractmethod def _create_inner_client(self) -> Any: ... @@ -80,30 +93,46 @@ async def connect_websocket( request: Request, subprotocols: Sequence[str], ) -> "WebSocketConnection": + """Open a WebSocket connection for a streaming subresource. + + .. warning:: + + **Experimental.** The WebSocket transport (used by ``exec``, + ``attach`` and ``portforward``) is still under active development + and the surrounding API may change in future releases without + notice. + """ raise NotImplementedError("WebSocket not supported by this client") async def create_client( configuration: ClientConfiguration | None = None, client_class: ClientChoise = ClientChoise.AUTO, + options: ClientOptions | None = None, ) -> BaseClient: + if options is not None and not isinstance(options, ClientOptions): + raise TypeError( + f"options must be a ClientOptions instance or None, got {type(options).__name__!r}" + ) if configuration is None: configuration = await _try_read_configuration() match client_class: case ClientChoise.HTTPX: from .httpx import HttpxClient - return HttpxClient(configuration) + return HttpxClient(configuration, options) case ClientChoise.AIOHTTP: from .aiohttp import AioHttpClient - return AioHttpClient(configuration) + return AioHttpClient(configuration, options) case ClientChoise.AUTO: try: - return await create_client(configuration, ClientChoise.AIOHTTP) + return await create_client(configuration, ClientChoise.AIOHTTP, options) except ImportError: try: - return await create_client(configuration, ClientChoise.HTTPX) + return await create_client( + configuration, ClientChoise.HTTPX, options + ) except ImportError: raise ImportError( "You need to install either httpx or aiohttp to use the client" diff --git a/kubex/client/httpx.py b/kubex/client/httpx.py index 9c1d5430..99f3b4e4 100644 --- a/kubex/client/httpx.py +++ b/kubex/client/httpx.py @@ -3,10 +3,12 @@ import ssl import warnings from contextlib import AbstractAsyncContextManager -from typing import TYPE_CHECKING, Any, AsyncGenerator, Sequence +from types import EllipsisType +from typing import TYPE_CHECKING, Any, AsyncGenerator, Sequence, cast import httpx +from kubex.client.options import ClientOptions, resolve_ws_max_message_size from kubex.client.websocket import WebSocketConnection from kubex.configuration import ClientConfiguration from kubex.core.exceptions import ConfgiurationError, KubexClientException @@ -36,10 +38,71 @@ def _to_httpx_timeout(timeout: Timeout | None) -> httpx.Timeout: ) +def _build_httpx_limits(options: ClientOptions) -> dict[str, Any]: + """Collect only the explicitly-set pool/keep-alive fields into a Limits kwargs dict. + + Returns an empty dict when all three fields are still ``...`` so that the + caller can skip creating an ``httpx.Limits`` object entirely (letting httpx + apply its own full default ``Limits``). + """ + kw: dict[str, Any] = {} + + pool_size = options.pool_size + if pool_size is None: + kw["max_connections"] = None + elif not isinstance(pool_size, EllipsisType): + kw["max_connections"] = pool_size + + if not options.keep_alive: + kw["max_keepalive_connections"] = 0 + + keep_alive_timeout = options.keep_alive_timeout + if keep_alive_timeout is None: + kw["keepalive_expiry"] = None + elif not isinstance(keep_alive_timeout, EllipsisType): + kw["keepalive_expiry"] = keep_alive_timeout + + return kw + + +def _build_httpx_proxy_kwargs( + proxy: str | dict[str, str] | None, + verify: ssl.SSLContext | bool, + limits: httpx.Limits | None = None, +) -> dict[str, Any]: + """Build proxy-related kwargs for ``httpx.AsyncClient``. + + On httpx >= 0.27.2, ``AsyncHTTPTransport`` accepts ``verify=ssl.SSLContext`` + directly (verified against the 0.27.2 source), so the existing ssl_context + (which may carry custom CA, client cert, or insecure-skip-verify settings) + is forwarded to each per-scheme transport entry in the dict case. + + When ``limits`` is provided it is applied to each per-scheme transport so + that pool-size and keep-alive settings take effect for proxied traffic too. + Without this, mounted transports ignore the client-level ``Limits`` object. + """ + if proxy is None: + return {} + if isinstance(proxy, str): + return {"proxy": proxy} + transport_kw: dict[str, Any] = {"verify": verify} + if limits is not None: + transport_kw["limits"] = limits + return { + "mounts": { + f"{scheme}://": httpx.AsyncHTTPTransport(proxy=url, **transport_kw) + for scheme, url in proxy.items() + } + } + + class HttpxClient(BaseClient): - def __init__(self, configuration: ClientConfiguration) -> None: - self._configuration = configuration - self._inner_client = self._create_inner_client() + def __init__( + self, + configuration: ClientConfiguration, + options: ClientOptions | None = None, + ) -> None: + super().__init__(configuration, options) @property def configuration(self) -> ClientConfiguration: @@ -51,29 +114,76 @@ def _get_headers(self) -> dict[str, str]: return {"Authorization": f"Bearer {self.configuration.token}"} def _create_inner_client(self) -> httpx.AsyncClient: - verify = self.configuration.verify - if verify is False: - _verify: ssl.SSLContext | bool = False - elif isinstance(verify, str): - ssl_context = ssl.create_default_context(cafile=verify) - if (client_cert := self.configuration.client_cert) is not None: + cafile = ( + str(self.configuration.server_ca_file) + if self.configuration.server_ca_file + else None + ) + client_cert = self.configuration.client_cert + needs_custom_ssl = bool( + cafile or self.configuration.insecure_skip_tls_verify or client_cert + ) + _verify: ssl.SSLContext | bool + if needs_custom_ssl: + ssl_context = ssl.create_default_context(cafile=cafile) + if client_cert is not None: if isinstance(client_cert, tuple): ssl_context.load_cert_chain( certfile=client_cert[0], keyfile=client_cert[1] ) else: ssl_context.load_cert_chain(certfile=client_cert) + if self.configuration.insecure_skip_tls_verify: + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE _verify = ssl_context else: + # No custom TLS settings — let httpx use its default trust bundle + # (certifi), which is consistent with the pre-ClientOptions behavior. _verify = True kwargs: dict[str, Any] = { "base_url": str(self.configuration.base_url), "verify": _verify, } - configured_timeout = self.configuration.timeout + configured_timeout = self.options.timeout if configured_timeout is not Ellipsis: - kwargs["timeout"] = _to_httpx_timeout(configured_timeout) + kwargs["timeout"] = _to_httpx_timeout( + cast("Timeout | None", configured_timeout) + ) + + limits_kw = _build_httpx_limits(self.options) + limits = httpx.Limits(**limits_kw) if limits_kw else None + if limits is not None: + kwargs["limits"] = limits + + kwargs.update(_build_httpx_proxy_kwargs(self.options.proxy, _verify, limits)) + + if not isinstance(self.options.buffer_size, EllipsisType): + warnings.warn( + "ClientOptions.buffer_size is set but httpx has no equivalent " + "buffer-size knob; the value is ignored on the httpx backend.", + UserWarning, + stacklevel=3, + ) + + if not isinstance(self.options.pool_size_per_host, EllipsisType): + warnings.warn( + "ClientOptions.pool_size_per_host is set but httpx has no " + "per-host pool limit; the value is ignored on the httpx backend.", + UserWarning, + stacklevel=3, + ) + + if self.options.ws_max_message_size is None: + warnings.warn( + "ClientOptions.ws_max_message_size=None is not supported on the " + "httpx backend; falls back to httpx-ws default (65536 bytes). " + "The value is ignored.", + UserWarning, + stacklevel=3, + ) + return httpx.AsyncClient(**kwargs) async def request(self, request: Request) -> Response: @@ -97,11 +207,13 @@ async def request(self, request: Request) -> Response: headers=HeadersWrapper(_response.headers), content=_response.content, ) - if self.configuration.log_api_warnings and ( + if self.options.log_api_warnings and ( api_warnings := _response.headers.get("warning") ): for warning in api_warnings.split(","): - warnings.warn(f"API Warning: {warning}") + warnings.warn( + f"API Warning: {warning.strip()}", UserWarning, stacklevel=2 + ) if 400 <= status < 600: handle_request_error(response) return response @@ -129,6 +241,15 @@ async def stream_lines(self, request: Request) -> AsyncGenerator[str, None]: content=await _response.aread(), ) handle_request_error(response) + if self.options.log_api_warnings and ( + api_warnings := _response.headers.get("warning") + ): + for warning in api_warnings.split(","): + warnings.warn( + f"API Warning: {warning.strip()}", + UserWarning, + stacklevel=2, + ) async for line in _response.aiter_lines(): yield line @@ -162,17 +283,15 @@ async def connect_websocket( # Forwarded as-is to httpx.stream() during the WebSocket upgrade; # bounds the handshake but not the streaming exec session itself. extra["timeout"] = _to_httpx_timeout(request.timeout) + ws_opt = self.options.ws_max_message_size + if ws_opt is not None: + extra["max_message_size_bytes"] = resolve_ws_max_message_size(ws_opt) cm: AbstractAsyncContextManager[AsyncWebSocketSession] = httpx_ws.aconnect_ws( request.url, client=self._inner_client, subprotocols=list(subprotocols) if subprotocols else None, headers=headers, params=params, - # httpx-ws defaults to 64 KiB; raise to match the aiohttp adapter's - # ``read_bufsize=2**21`` so a single large exec stdout chunk does - # not trigger ``WebSocketNetworkError`` on one backend but not the - # other. - max_message_size_bytes=2**21, **extra, ) diff --git a/kubex/client/options.py b/kubex/client/options.py new file mode 100644 index 00000000..a93550c2 --- /dev/null +++ b/kubex/client/options.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +from types import EllipsisType + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from kubex.core.params import Timeout, TimeoutTypes + + +class ClientOptions(BaseModel): + """Operational options for a kubex HTTP client. + + These settings are per-process choices about how the HTTP client should + behave at request time. They do not come from kubeconfig or the in-cluster + environment — use :class:`~kubex.configuration.ClientConfiguration` for + those. + + Example:: + + from kubex.client import ClientOptions, create_client + from kubex.core.params import Timeout + + client = await create_client( + configuration=..., + options=ClientOptions( + log_api_warnings=False, + timeout=Timeout(total=30.0), + proxy="http://proxy.corp.example.com:8080", + keep_alive_timeout=60.0, + pool_size=50, + ws_max_message_size=8 * 1024 * 1024, + ), + ) + + **Backend asymmetries** + + Some fields are silently ignored on certain backends (a :class:`UserWarning` + is emitted at client construction time): + + - ``buffer_size``: httpx has no equivalent buffer-size knob; the value is + ignored and a warning is raised. + - ``pool_size_per_host``: httpx has no per-host pool limit; the value is + ignored and a warning is raised. + - ``keep_alive_timeout=None``: aiohttp has no "unlimited keep-alive" mode. + The value is ignored (aiohttp falls back to its own default) and a warning + is raised. + - ``proxy=dict``: aiohttp supports only a single session-level proxy URL. + The entry whose key matches the API server's URL scheme is used; all other + entries are dropped with a warning. httpx applies all dict entries via + ``mounts=``. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + log_api_warnings: bool = True + """Emit Python :mod:`warnings` for any ``Warning`` HTTP headers returned by + the API server. + + The Kubernetes API server emits ``Warning:`` response headers to signal + deprecated API usage (e.g. calling a removed API version). When this is + ``True`` (the default), kubex converts each header value into a + :class:`UserWarning` via :func:`warnings.warn`. Set to ``False`` + to silence those warnings. + """ + + timeout: TimeoutTypes | EllipsisType = Field(default_factory=lambda: ...) + """Default HTTP timeout for every request made by this client. + + Accepted values: + + - ``...`` (default) — use the HTTP library's own default timeout. + - ``None`` — disable timeouts entirely. + - ``int`` or ``float`` — treat the value as a ``total`` timeout in seconds; + coerced to :class:`~kubex.core.params.Timeout` automatically. + - :class:`~kubex.core.params.Timeout` — used as-is for fine-grained + per-phase control. + + Individual calls can override this via the ``request_timeout=`` parameter. + """ + + proxy: str | dict[str, str] | None = None + """Outbound HTTP proxy for all requests. + + Accepted values: + + - ``None`` (default) — no proxy. + - ``str`` — a single proxy URL applied to every request, e.g. + ``"http://proxy.example.com:8080"`` or + ``"http://user:pass@proxy.example.com:8080"`` for basic auth. + - ``dict[str, str]`` — per-scheme map with ``"http"`` and/or ``"https"`` + keys, e.g. ``{"https": "http://proxy.example.com:8080"}``. Only + ``"http"`` and ``"https"`` scheme keys are accepted. + + **Backend asymmetry**: httpx routes all dict entries via ``mounts=``; + aiohttp supports only a single session-level proxy URL, so only the entry + matching the API server's URL scheme is used (others are dropped with a + warning). + """ + + keep_alive: bool = True + """Whether to reuse idle connections (connection keep-alive). + + Set to ``False`` to close each connection immediately after use. This maps + to ``Limits(max_keepalive_connections=0)`` on httpx and + ``TCPConnector(force_close=True)`` on aiohttp. + """ + + keep_alive_timeout: float | None | EllipsisType = Field(default_factory=lambda: ...) + """Idle-connection lifetime in seconds. + + Accepted values: + + - ``...`` (default) — use the HTTP library's own default (httpx: 5 s; + aiohttp: 15 s). + - ``None`` — keep idle connections indefinitely. On httpx this passes + ``keepalive_expiry=None``; on aiohttp this is not supported (aiohttp + has no "unlimited" mode) and a :class:`UserWarning` is emitted. + - ``float >= 0`` — explicit lifetime in seconds. + """ + + buffer_size: int | None | EllipsisType = Field(default_factory=lambda: ...) + """HTTP-response read buffer size in bytes. + + Accepted values: + + - ``...`` (default) — kubex default of ``2**21`` bytes (preserves current + aiohttp behavior). + - ``None`` — use the HTTP library's own default (aiohttp default: ``2**16`` + bytes). + - ``int > 0`` — explicit buffer size in bytes. + + **Backend asymmetry**: httpx has no equivalent buffer-size knob. This field + is ignored on httpx and a :class:`UserWarning` is emitted if it is not + ``...``. + """ + + ws_max_message_size: int | None | EllipsisType = Field(default_factory=lambda: ...) + """Maximum WebSocket message size in bytes for ``exec``/``attach``/``portforward``. + + Accepted values: + + - ``...`` (default) — kubex default of ``2**21`` bytes (preserves current + behavior on both backends). + - ``None`` — no cap on aiohttp (passes ``0`` to ``max_msg_size``). On httpx, + ``None`` is not supported; falls back to httpx-ws default (65536 bytes) and a + :class:`UserWarning` is emitted. + - ``int > 0`` — explicit cap in bytes. + """ + + pool_size: int | None | EllipsisType = Field(default_factory=lambda: ...) + """Total connection pool size (all hosts combined). + + Accepted values: + + - ``...`` (default) — use the HTTP library's own default (httpx: 100; + aiohttp: 100). + - ``None`` — unlimited (passes ``None`` to httpx, ``0`` to aiohttp). + - ``int > 0`` — explicit connection limit. + """ + + pool_size_per_host: int | None | EllipsisType = Field(default_factory=lambda: ...) + """Per-host connection pool size. + + Accepted values: + + - ``...`` (default) — use the HTTP library's own default (aiohttp: 0, + meaning no per-host limit). + - ``None`` — unlimited (passes ``0`` to aiohttp). + - ``int > 0`` — explicit per-host connection limit. + + **Backend asymmetry**: httpx has no per-host pool limit. This field is + ignored on httpx and a :class:`UserWarning` is emitted if it is not ``...``. + """ + + @field_validator("timeout", mode="before") + @classmethod + def _normalize_timeout(cls, value: object) -> object: + if value is Ellipsis or value is None or isinstance(value, Timeout): + return value + if isinstance(value, bool): + raise ValueError( + "timeout must not be a bool; pass a number (seconds), Timeout, None, or ..." + ) + try: + return Timeout.coerce(value) # type: ignore[arg-type] + except TypeError as e: + raise ValueError(str(e)) from e + + @field_validator("proxy", mode="before") + @classmethod + def _normalize_proxy(cls, value: object) -> object: + if value is None: + return value + if isinstance(value, str): + if not value: + raise ValueError("proxy str must not be empty") + return value + if isinstance(value, dict): + if not value: + raise ValueError("proxy dict must not be empty") + allowed_schemes = {"http", "https"} + for k, v in value.items(): + if not isinstance(k, str): + raise ValueError( + f"proxy dict keys must be strings, got {type(k).__name__!r}" + ) + if not isinstance(v, str): + raise ValueError( + f"proxy dict values must be strings, got {type(v).__name__!r}" + ) + if not v: + raise ValueError( + f"proxy dict value for scheme {k!r} must not be empty" + ) + if k not in allowed_schemes: + raise ValueError( + f"proxy dict key {k!r} is not a recognised scheme; " + f"allowed keys are {sorted(allowed_schemes)!r}" + ) + return value + raise ValueError( + f"proxy must be None, a str URL, or a dict[str, str]; got {type(value).__name__!r}" + ) + + @field_validator("keep_alive_timeout", mode="before") + @classmethod + def _normalize_keep_alive_timeout(cls, value: object) -> object: + if value is Ellipsis or value is None: + return value + if isinstance(value, bool): + raise ValueError( + "keep_alive_timeout must not be a bool; pass a float (seconds), None, or ..." + ) + if isinstance(value, (int, float)): + f = float(value) + if f < 0: + raise ValueError(f"keep_alive_timeout must be >= 0, got {f}") + return f + raise ValueError( + f"keep_alive_timeout must be a float, None, or ...; got {type(value).__name__!r}" + ) + + @field_validator("buffer_size", "ws_max_message_size", "pool_size", mode="before") + @classmethod + def _normalize_positive_int_or_sentinel(cls, value: object) -> object: + if value is Ellipsis or value is None: + return value + if isinstance(value, bool): + raise ValueError("value must not be a bool; pass an int > 0, None, or ...") + if not isinstance(value, int): + raise ValueError( + f"value must be an int > 0, None, or ...; got {type(value).__name__!r}" + ) + if value <= 0: + raise ValueError(f"value must be > 0, got {value}") + return value + + @field_validator("pool_size_per_host", mode="before") + @classmethod + def _normalize_pool_size_per_host(cls, value: object) -> object: + if value is Ellipsis or value is None: + return value + if isinstance(value, bool): + raise ValueError( + "pool_size_per_host must not be a bool; pass an int > 0, None, or ..." + ) + if not isinstance(value, int): + raise ValueError( + f"pool_size_per_host must be an int > 0, None, or ...; " + f"got {type(value).__name__!r}" + ) + if value <= 0: + raise ValueError( + f"pool_size_per_host must be > 0 (use None for unlimited), got {value}" + ) + return value + + +def resolve_ws_max_message_size(ws_max_message_size: int | None | EllipsisType) -> int: + """Resolve ``ws_max_message_size`` to a concrete integer for HTTP backends. + + - ``...`` → ``2**21`` (kubex default; preserves pre-option behavior on both backends) + - ``None`` → ``0`` (aiohttp treats 0 as "no cap"; httpx does not pass this at all) + - ``int`` → that int + """ + if isinstance(ws_max_message_size, EllipsisType): + return 2**21 + if ws_max_message_size is None: + return 0 + return ws_max_message_size diff --git a/kubex/configuration/configuration.py b/kubex/configuration/configuration.py index 28298f93..477dc2ae 100644 --- a/kubex/configuration/configuration.py +++ b/kubex/configuration/configuration.py @@ -2,11 +2,9 @@ from enum import Enum from pathlib import Path from time import time -from types import EllipsisType from pydantic import Field, FilePath, HttpUrl, SecretStr -from kubex.core.params import Timeout, TimeoutTypes from kubex_core.models.base import BaseK8sModel @@ -206,8 +204,6 @@ def __init__( token: str | None = None, namespace: str | None = None, try_refresh_token: bool = False, - log_api_warnings: bool = True, - timeout: TimeoutTypes | EllipsisType = ..., ) -> None: if try_refresh_token and token_file is None: raise ValueError("Token file must be provided to refresh token") @@ -226,7 +222,6 @@ def __init__( client_key_file = Path(client_key_file) self.client_key_file = client_key_file self.namespace = namespace or "default" - self.log_api_warnings = log_api_warnings if isinstance(token_file, str): token_file = Path(token_file) @@ -236,19 +231,6 @@ def __init__( self._last_token_read: float | None = None self._current_token: str | None = None self._token = token - self._timeout: Timeout | None | EllipsisType = ( - timeout if timeout is Ellipsis else Timeout.coerce(timeout) - ) - - @property - def timeout(self) -> Timeout | None | EllipsisType: - """Default HTTP timeout for this client. - - ``Ellipsis`` means no timeout was configured (backend library default - applies). ``None`` means timeouts are explicitly disabled. A - :class:`Timeout` instance is used as-is. - """ - return self._timeout @property def verify(self) -> bool | str | None: diff --git a/kubex/core/params.py b/kubex/core/params.py index 681e1f96..2b1c7dd8 100644 --- a/kubex/core/params.py +++ b/kubex/core/params.py @@ -120,7 +120,7 @@ def __init__( self, label_selector: str | None = None, field_selector: str | None = None, - timeout: int | None = None, + timeout_seconds: int | None = None, limit: int | None = None, continue_token: str | None = None, version_match: VersionMatch | None = None, @@ -128,7 +128,7 @@ def __init__( ) -> None: self.label_selector = label_selector self.field_selector = field_selector - self.timeout = timeout + self.timeout_seconds = timeout_seconds self.limit = limit self.continue_token = continue_token self.version_match = version_match @@ -144,8 +144,8 @@ def as_query_params(self) -> dict[str, str] | None: query_params["labelSelector"] = self.label_selector if self.field_selector is not None: query_params["fieldSelector"] = self.field_selector - if self.timeout is not None: - query_params["timeoutSeconds"] = str(self.timeout) + if self.timeout_seconds is not None: + query_params["timeoutSeconds"] = str(self.timeout_seconds) if self.limit is not None: query_params["limit"] = str(self.limit) if self.continue_token is not None: diff --git a/lychee.toml b/lychee.toml new file mode 100644 index 00000000..cde84f3e --- /dev/null +++ b/lychee.toml @@ -0,0 +1,11 @@ +cache = true +max_retries = 3 +accept = [200, 206, 429] +exclude_path = [".ralphex/", "site/", ".venv/"] +exclude = [ + "^http://localhost", + "^http://127\\.0\\.0\\.1", + "^https?://example\\.com", + "kubernetes\\.default\\.svc", + "my-cluster", +] diff --git a/mise.toml b/mise.toml index 3b594fb2..462bfae9 100644 --- a/mise.toml +++ b/mise.toml @@ -5,6 +5,14 @@ python = ["3.14", "3.13", "3.12", "3.11", "3.10"] [env] K8S_VERSIONS = "1.32,1.33,1.34,1.35,1.36,1.37" +[tasks."docs:serve"] +description = "Serve the docs site locally with live reload (also reloads on docstring changes in source packages)" +run = "uv run --group docs mkdocs serve --watch kubex --watch packages/kubex-core" + +[tasks."docs:build"] +description = "Build the docs site in strict mode" +run = "uv run --group docs mkdocs build --strict" + [tasks.regenerate-models] description = "Download latest K8s OpenAPI specs and regenerate all model packages" run = "uv run python -m scripts.codegen regenerate --versions {{env.K8S_VERSIONS}}" diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..19340f13 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,121 @@ +site_name: Kubex +site_url: https://kubex.codemageddon.me/ +site_description: Async-first Kubernetes client library for Python. +repo_url: https://github.com/codemageddon/kubex +repo_name: codemageddon/kubex +edit_uri: edit/main/docs/ + +docs_dir: docs + +theme: + name: material + logo: assets/logo.png + favicon: assets/logo.png + features: + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - search.suggest + - search.highlight + - content.code.copy + - content.action.edit + - content.tabs.link + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + toggle: + icon: material/weather-sunny + name: Switch to light mode + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: [".", "packages/kubex-core"] + options: + show_signature_annotations: true + separate_signature: true + docstring_style: google + show_source: true + merge_init_into_class: true + show_root_heading: true + show_if_no_docstring: false + filters: ["!^_"] + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - toc: + permalink: true + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.details + +extra: + version: + provider: mike + default: latest + +extra_css: + - stylesheets/extra.css + +nav: + - Home: index.md + - Getting Started: + - Installation: getting-started/installation.md + - Quickstart: getting-started/quickstart.md + - Concepts: + - concepts/index.md + - Api[T]: concepts/api.md + - Clients: concepts/clients.md + - Configuration: concepts/configuration.md + - Subresources: concepts/subresources.md + - Exceptions: concepts/exceptions.md + - Operations: + - operations/index.md + - CRUD: operations/crud.md + - Watch: operations/watch.md + - Patch: operations/patch.md + - Timeouts: operations/timeouts.md + - Subresources: + - subresources/index.md + - Logs: subresources/logs.md + - Metadata: subresources/metadata.md + - Scale: subresources/scale.md + - Status: subresources/status.md + - Eviction: subresources/eviction.md + - Ephemeral Containers: subresources/ephemeral-containers.md + - Resize: subresources/resize.md + - Exec: subresources/exec.md + - Attach: subresources/attach.md + - Portforward: subresources/portforward.md + - Advanced: + - advanced/index.md + - Multi-version K8s: advanced/multi-version-k8s.md + - Custom Resources: advanced/custom-resources.md + - Clients & Runtimes: advanced/clients-runtimes.md + - Authentication: advanced/authentication.md + - Benchmarks: advanced/benchmarks.md + - API Reference: + - reference/index.md + - kubex.api: reference/api.md + - kubex.client: reference/client.md + - kubex.configuration: reference/configuration.md + - kubex.core: reference/core.md + - kubex-core: reference/kubex-core.md + - Contributing: contributing.md diff --git a/packages/kubex-core/pyproject.toml b/packages/kubex-core/pyproject.toml index a7ab1e4d..51a3d6d0 100644 --- a/packages/kubex-core/pyproject.toml +++ b/packages/kubex-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kubex-core" -version = "0.1.0-alpha.1" +version = "0.1.0-beta.1" description = "Core model types shared by Kubex and its generated resource packages" readme = "README.md" requires-python = ">=3.10" @@ -13,7 +13,7 @@ dependencies = [ "pyyaml>=6.0.2", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/packages/kubex-k8s-1-32/pyproject.toml b/packages/kubex-k8s-1-32/pyproject.toml index 77505adc..a1ad1650 100644 --- a/packages/kubex-k8s-1-32/pyproject.toml +++ b/packages/kubex-k8s-1-32/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kubex-k8s-1-32" -version = "0.1.0-alpha.1" +version = "0.1.0-beta.1" description = "Pydantic v2 Kubernetes 1.32 resource models for Kubex" readme = "README.md" requires-python = ">=3.10" @@ -13,7 +13,7 @@ dependencies = [ "kubex-core", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/packages/kubex-k8s-1-33/pyproject.toml b/packages/kubex-k8s-1-33/pyproject.toml index 3c231759..c892dec1 100644 --- a/packages/kubex-k8s-1-33/pyproject.toml +++ b/packages/kubex-k8s-1-33/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kubex-k8s-1-33" -version = "0.1.0-alpha.1" +version = "0.1.0-beta.1" description = "Pydantic v2 Kubernetes 1.33 resource models for Kubex" readme = "README.md" requires-python = ">=3.10" @@ -13,7 +13,7 @@ dependencies = [ "kubex-core", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/packages/kubex-k8s-1-34/pyproject.toml b/packages/kubex-k8s-1-34/pyproject.toml index 060a4519..618fd1a1 100644 --- a/packages/kubex-k8s-1-34/pyproject.toml +++ b/packages/kubex-k8s-1-34/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kubex-k8s-1-34" -version = "0.1.0-alpha.1" +version = "0.1.0-beta.1" description = "Pydantic v2 Kubernetes 1.34 resource models for Kubex" readme = "README.md" requires-python = ">=3.10" @@ -13,7 +13,7 @@ dependencies = [ "kubex-core", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/packages/kubex-k8s-1-35/pyproject.toml b/packages/kubex-k8s-1-35/pyproject.toml index 604a844f..c309cffb 100644 --- a/packages/kubex-k8s-1-35/pyproject.toml +++ b/packages/kubex-k8s-1-35/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kubex-k8s-1-35" -version = "0.1.0-alpha.1" +version = "0.1.0-beta.1" description = "Pydantic v2 Kubernetes 1.35 resource models for Kubex" readme = "README.md" requires-python = ">=3.10" @@ -13,7 +13,7 @@ dependencies = [ "kubex-core", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/packages/kubex-k8s-1-36/pyproject.toml b/packages/kubex-k8s-1-36/pyproject.toml index 0f318414..1fa6234d 100644 --- a/packages/kubex-k8s-1-36/pyproject.toml +++ b/packages/kubex-k8s-1-36/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kubex-k8s-1-36" -version = "0.1.0-alpha.1" +version = "0.1.0-beta.1" description = "Pydantic v2 Kubernetes 1.36 resource models for Kubex" readme = "README.md" requires-python = ">=3.10" @@ -13,7 +13,7 @@ dependencies = [ "kubex-core", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/packages/kubex-k8s-1-37/pyproject.toml b/packages/kubex-k8s-1-37/pyproject.toml index b9b0fdd6..fe6e599b 100644 --- a/packages/kubex-k8s-1-37/pyproject.toml +++ b/packages/kubex-k8s-1-37/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kubex-k8s-1-37" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" description = "Pydantic v2 Kubernetes 1.37 resource models for Kubex" readme = "README.md" requires-python = ">=3.10" diff --git a/pyproject.toml b/pyproject.toml index f4d81c45..8e561a4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ 'exceptiongroup>=1.2; python_version < "3.11"', ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Software Development :: Build Tools", "License :: OSI Approved :: MIT License", @@ -35,6 +35,7 @@ Homepage = "https://github.com/codemageddon/kubex" Repository = "https://github.com/codemageddon/kubex.git" Issues = "https://github.com/codemageddon/kubex/issues" Changelog = "https://github.com/codemageddon/kubex/blob/main/CHANGELOG.md" +Documentation = "https://kubex.codemageddon.me/" [project.optional-dependencies] aiohttp = [ @@ -87,6 +88,13 @@ module = ["kubernetes_asyncio.*", "pyinstrument.*", "memray"] ignore_missing_imports = true [dependency-groups] +docs = [ + "mkdocs>=1.6", + "mkdocs-material>=9.5", + "mkdocstrings[python]>=0.26", + "mike>=2.1", + "pymdown-extensions>=10.11", +] dev = [ "kubex-k8s-1-35", "anyio>=4.6.0", @@ -106,7 +114,7 @@ dev = [ ] benchmark = [ "kubex-k8s-1-35", - "kubernetes-asyncio>=35.0.1,<36", + "kubernetes-asyncio>=35.0.0,<36", "memray>=1.14", "pytest-memray>=1.7", "pytest-benchmark>=4.0", diff --git a/test/e2e/conftest.py b/test/e2e/conftest.py index 1e52c0ef..64f10d00 100644 --- a/test/e2e/conftest.py +++ b/test/e2e/conftest.py @@ -61,7 +61,7 @@ async def client( ) -> AsyncGenerator[BaseClient, None]: if anyio_backend == "trio" and request.param != ClientChoise.HTTPX: pytest.skip("Skipping AIOHTTP client for trio backend") - client = await create_client(kubernetes_config, request.param) + client = await create_client(kubernetes_config, client_class=request.param) async with client as client: yield client diff --git a/test/stub_client.py b/test/stub_client.py index 9ea21092..ada4e5d6 100644 --- a/test/stub_client.py +++ b/test/stub_client.py @@ -19,11 +19,13 @@ def __init__( status_code: int = 200, stream_lines: Iterable[str] = (), ) -> None: - self._configuration = configuration or ClientConfiguration( - url="https://example.invalid", - insecure_skip_tls_verify=True, + super().__init__( + configuration + or ClientConfiguration( + url="https://example.invalid", + insecure_skip_tls_verify=True, + ) ) - self._inner_client = object() self.requests: list[Request] = [] self._response_content = response_content self._status_code = status_code diff --git a/test/test_api.py b/test/test_api.py index d4041533..40424ecd 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -228,7 +228,7 @@ async def test_delete_collection_cluster_scoped() -> None: "status.phase=Failed", id="field_selector", ), - pytest.param({"timeout": 30}, "timeoutSeconds", "30", id="timeout"), + pytest.param({"timeout_seconds": 30}, "timeoutSeconds", "30", id="timeout_seconds"), pytest.param({"limit": 50}, "limit", "50", id="limit"), pytest.param({"continue_token": "tok"}, "continue", "tok", id="continue_token"), ] diff --git a/test/test_attach/test_accessor.py b/test/test_attach/test_accessor.py index fb9c8a52..1d521089 100644 --- a/test/test_attach/test_accessor.py +++ b/test/test_attach/test_accessor.py @@ -6,6 +6,7 @@ import pytest from kubex.client.client import BaseClient +from kubex.client.options import ClientOptions from kubex.client.websocket import WebSocketConnection from kubex.core.exceptions import KubexClientException from kubex.core.exec_channels import V5ChannelProtocol @@ -73,6 +74,7 @@ class _FakeClient(BaseClient): def __init__(self, websocket: WebSocketConnection) -> None: self._websocket = websocket self.connect_calls: list[tuple[Request, list[str]]] = [] + self._options = ClientOptions() def _create_inner_client(self) -> Any: # pragma: no cover return object() diff --git a/test/test_client/test_aiohttp_options.py b/test/test_client/test_aiohttp_options.py new file mode 100644 index 00000000..90117e62 --- /dev/null +++ b/test/test_client/test_aiohttp_options.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import warnings +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from kubex.client.options import ClientOptions +from kubex.configuration import ClientConfiguration + +pytest.importorskip("aiohttp") + +from kubex.client.aiohttp import ( # noqa: E402 + AioHttpClient, + _apply_aiohttp_proxy, +) +from kubex.client.options import resolve_ws_max_message_size # noqa: E402 +from kubex.core.request import Request # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +def _config() -> ClientConfiguration: + return ClientConfiguration( + url="https://example.invalid", insecure_skip_tls_verify=True + ) + + +# --- resolve_ws_max_message_size --- + + +def test_resolve_ws_max_message_size_ellipsis_returns_kubex_default() -> None: + assert resolve_ws_max_message_size(...) == 2**21 + + +def test_resolve_ws_max_message_size_none_returns_zero() -> None: + assert resolve_ws_max_message_size(None) == 0 + + +def test_resolve_ws_max_message_size_explicit_int() -> None: + assert resolve_ws_max_message_size(8 * 1024 * 1024) == 8 * 1024 * 1024 + + +# --- _apply_aiohttp_proxy --- + + +def test_apply_aiohttp_proxy_none_no_op() -> None: + kwargs: dict[str, Any] = {} + _apply_aiohttp_proxy(kwargs, None, "https://api.example.com") + assert "proxy" not in kwargs + + +def test_apply_aiohttp_proxy_str_sets_proxy() -> None: + kwargs: dict[str, Any] = {} + _apply_aiohttp_proxy(kwargs, "http://proxy.corp:8080", "https://api.example.com") + assert kwargs["proxy"] == "http://proxy.corp:8080" + + +def test_apply_aiohttp_proxy_dict_matching_scheme_sets_proxy() -> None: + kwargs: dict[str, Any] = {} + _apply_aiohttp_proxy( + kwargs, + {"https": "http://proxy.corp:8080"}, + "https://api.example.com", + ) + assert kwargs["proxy"] == "http://proxy.corp:8080" + + +def test_apply_aiohttp_proxy_dict_matching_scheme_drops_extra_with_warning() -> None: + kwargs: dict[str, Any] = {} + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _apply_aiohttp_proxy( + kwargs, + {"http": "http://proxy.corp:8080", "https": "http://proxy.corp:8443"}, + "https://api.example.com", + ) + assert kwargs["proxy"] == "http://proxy.corp:8443" + assert any( + issubclass(w.category, UserWarning) + and "Dropped proxy scheme entries" in str(w.message) + for w in caught + ) + + +def test_apply_aiohttp_proxy_dict_no_matching_scheme_warns_no_proxy() -> None: + kwargs: dict[str, Any] = {} + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _apply_aiohttp_proxy( + kwargs, + {"http": "http://proxy.corp:8080"}, + "https://api.example.com", + ) + assert "proxy" not in kwargs + assert any( + issubclass(w.category, UserWarning) + and "no entry for URL scheme" in str(w.message) + for w in caught + ) + + +# --- connector: pool_size --- + + +@pytest.mark.anyio +async def test_aiohttp_pool_size_default_uses_library_default() -> None: + # Ellipsis (default) must NOT pass limit at all — let aiohttp decide. + from aiohttp.connector import TCPConnector as _RealTCPConnector + + with patch("kubex.client.aiohttp.TCPConnector", wraps=_RealTCPConnector) as spy: + AioHttpClient(_config(), ClientOptions()) + init_kwargs = spy.call_args.kwargs + assert "limit" not in init_kwargs + + +@pytest.mark.anyio +async def test_aiohttp_pool_size_none_sets_unlimited() -> None: + client = AioHttpClient(_config(), ClientOptions(pool_size=None)) + assert client._inner_client.connector.limit == 0 + + +@pytest.mark.anyio +async def test_aiohttp_pool_size_explicit_int() -> None: + client = AioHttpClient(_config(), ClientOptions(pool_size=50)) + assert client._inner_client.connector.limit == 50 + + +# --- connector: pool_size_per_host --- + + +@pytest.mark.anyio +async def test_aiohttp_pool_size_per_host_default_uses_library_default() -> None: + # Ellipsis (default) must NOT pass limit_per_host at all — let aiohttp decide. + from aiohttp.connector import TCPConnector as _RealTCPConnector + + with patch("kubex.client.aiohttp.TCPConnector", wraps=_RealTCPConnector) as spy: + AioHttpClient(_config(), ClientOptions()) + init_kwargs = spy.call_args.kwargs + assert "limit_per_host" not in init_kwargs + + +@pytest.mark.anyio +async def test_aiohttp_pool_size_per_host_none_sets_zero() -> None: + # pool_size_per_host=None must explicitly pass limit_per_host=0; the default + # (Ellipsis) does NOT pass the kwarg at all. Both produce the same observable + # connector attribute (0 == aiohttp's default), so we spy on TCPConnector to + # verify the kwarg was actually forwarded. + from aiohttp.connector import TCPConnector as _RealTCPConnector + + with patch("kubex.client.aiohttp.TCPConnector", wraps=_RealTCPConnector) as spy: + AioHttpClient(_config(), ClientOptions(pool_size_per_host=None)) + init_kwargs = spy.call_args.kwargs + assert "limit_per_host" in init_kwargs + assert init_kwargs["limit_per_host"] == 0 + + +@pytest.mark.anyio +async def test_aiohttp_pool_size_per_host_explicit_int() -> None: + client = AioHttpClient(_config(), ClientOptions(pool_size_per_host=5)) + assert client._inner_client.connector.limit_per_host == 5 + + +# --- connector: keep_alive --- + + +@pytest.mark.anyio +async def test_aiohttp_keep_alive_true_default_no_force_close() -> None: + client = AioHttpClient(_config(), ClientOptions()) + force_close = getattr(client._inner_client.connector, "_force_close", False) + assert force_close is False + + +@pytest.mark.anyio +async def test_aiohttp_keep_alive_false_sets_force_close() -> None: + client = AioHttpClient(_config(), ClientOptions(keep_alive=False)) + force_close = getattr(client._inner_client.connector, "_force_close", None) + assert force_close is True + + +# --- connector: keep_alive_timeout --- + + +@pytest.mark.anyio +async def test_aiohttp_keep_alive_timeout_default_uses_library_default() -> None: + client = AioHttpClient(_config(), ClientOptions()) + timeout = getattr(client._inner_client.connector, "_keepalive_timeout", None) + assert timeout == 15.0 # aiohttp default + + +@pytest.mark.anyio +async def test_aiohttp_keep_alive_timeout_none_warns_and_uses_library_default() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client = AioHttpClient(_config(), ClientOptions(keep_alive_timeout=None)) + assert any( + issubclass(w.category, UserWarning) + and "keep_alive_timeout=None is not supported" in str(w.message) + for w in caught + ) + # aiohttp uses its own default (15 s) when keep_alive_timeout=None + timeout = getattr(client._inner_client.connector, "_keepalive_timeout", None) + assert timeout == 15.0 + + +@pytest.mark.anyio +async def test_aiohttp_keep_alive_timeout_explicit_float() -> None: + client = AioHttpClient(_config(), ClientOptions(keep_alive_timeout=60.0)) + timeout = getattr(client._inner_client.connector, "_keepalive_timeout", None) + assert timeout == 60.0 + + +@pytest.mark.anyio +async def test_aiohttp_keep_alive_false_with_timeout_does_not_raise() -> None: + # force_close=True and keepalive_timeout together would raise ValueError in aiohttp; + # when keep_alive=False the timeout setting must be silently ignored. + client = AioHttpClient( + _config(), ClientOptions(keep_alive=False, keep_alive_timeout=60.0) + ) + force_close = getattr(client._inner_client.connector, "_force_close", None) + assert force_close is True + + +# --- session: buffer_size --- + + +@pytest.mark.anyio +async def test_aiohttp_buffer_size_default_is_kubex_default() -> None: + client = AioHttpClient(_config(), ClientOptions()) + assert client._inner_client._read_bufsize == 2**21 + + +@pytest.mark.anyio +async def test_aiohttp_buffer_size_none_uses_library_default() -> None: + client = AioHttpClient(_config(), ClientOptions(buffer_size=None)) + # aiohttp library default is 2**16 + assert client._inner_client._read_bufsize == 2**16 + + +@pytest.mark.anyio +async def test_aiohttp_buffer_size_explicit_int() -> None: + client = AioHttpClient(_config(), ClientOptions(buffer_size=4096)) + assert client._inner_client._read_bufsize == 4096 + + +# --- ws_max_message_size --- + + +@pytest.mark.anyio +async def test_aiohttp_ws_max_message_size_default() -> None: + client = AioHttpClient(_config(), ClientOptions()) + mock_ws = AsyncMock() + mock_ws.protocol = None + mock_ws.closed = False + mock_ws_connect = AsyncMock(return_value=mock_ws) + with patch.object(client._inner_client, "ws_connect", new=mock_ws_connect): + request = Request(method="GET", url="/exec") + await client.connect_websocket(request, []) + assert mock_ws_connect.call_args.kwargs["max_msg_size"] == 2**21 + + +@pytest.mark.anyio +async def test_aiohttp_ws_max_message_size_none_no_cap() -> None: + client = AioHttpClient(_config(), ClientOptions(ws_max_message_size=None)) + mock_ws = AsyncMock() + mock_ws.protocol = None + mock_ws.closed = False + mock_ws_connect = AsyncMock(return_value=mock_ws) + with patch.object(client._inner_client, "ws_connect", new=mock_ws_connect): + request = Request(method="GET", url="/exec") + await client.connect_websocket(request, []) + assert mock_ws_connect.call_args.kwargs["max_msg_size"] == 0 + + +@pytest.mark.anyio +async def test_aiohttp_ws_max_message_size_explicit() -> None: + client = AioHttpClient( + _config(), ClientOptions(ws_max_message_size=8 * 1024 * 1024) + ) + mock_ws = AsyncMock() + mock_ws.protocol = None + mock_ws.closed = False + mock_ws_connect = AsyncMock(return_value=mock_ws) + with patch.object(client._inner_client, "ws_connect", new=mock_ws_connect): + request = Request(method="GET", url="/exec") + await client.connect_websocket(request, []) + assert mock_ws_connect.call_args.kwargs["max_msg_size"] == 8 * 1024 * 1024 + + +# --- proxy integration: propagated through _session_kwargs --- + + +@pytest.mark.anyio +async def test_aiohttp_session_kwargs_includes_proxy_str() -> None: + client = AioHttpClient(_config(), ClientOptions(proxy="http://proxy.corp:8080")) + session_kw = client._session_kwargs() + assert session_kw.get("proxy") == "http://proxy.corp:8080" + + +@pytest.mark.anyio +async def test_aiohttp_session_kwargs_no_proxy_by_default() -> None: + client = AioHttpClient(_config(), ClientOptions()) + session_kw = client._session_kwargs() + assert "proxy" not in session_kw + + +@pytest.mark.anyio +async def test_aiohttp_session_kwargs_includes_proxy_dict_matching_scheme() -> None: + client = AioHttpClient( + _config(), ClientOptions(proxy={"https": "http://proxy.corp:8080"}) + ) + session_kw = client._session_kwargs() + assert session_kw.get("proxy") == "http://proxy.corp:8080" + + +# --- regression: defaults preserve prior behavior --- + + +@pytest.mark.anyio +async def test_aiohttp_default_options_regression() -> None: + client = AioHttpClient(_config(), ClientOptions()) + session = client._inner_client + connector = session.connector + + # read buffer: 2**21 (kubex default — must not regress to aiohttp's 2**16) + assert session._read_bufsize == 2**21 + + # pool total: aiohttp default 100 + assert connector.limit == 100 + + # per-host: aiohttp default 0 (unlimited) + assert connector.limit_per_host == 0 + + # keep-alive: on by default + force_close = getattr(connector, "_force_close", False) + assert force_close is False + + # no proxy + session_kw = client._session_kwargs() + assert "proxy" not in session_kw diff --git a/test/test_client/test_aiohttp_websocket.py b/test/test_client/test_aiohttp_websocket.py index 0d5843e9..3005c4f5 100644 --- a/test/test_client/test_aiohttp_websocket.py +++ b/test/test_client/test_aiohttp_websocket.py @@ -7,6 +7,7 @@ from aiohttp.test_utils import TestServer from kubex.client.aiohttp import AioHttpClient +from kubex.client.options import ClientOptions from kubex.client.websocket import WebSocketConnection from kubex.configuration.configuration import ClientConfiguration from kubex.core.exceptions import KubexClientException @@ -373,11 +374,9 @@ async def slow_handshake(request: web.Request) -> web.WebSocketResponse: server = TestServer(app) async with server: # Configure session timeout *shorter* than the handshake delay. - config = ClientConfiguration( - url=str(server.make_url("/")), - timeout=Timeout(total=0.1), - ) - async with AioHttpClient(config) as client: + config = ClientConfiguration(url=str(server.make_url("/"))) + opts = ClientOptions(timeout=Timeout(total=0.1)) + async with AioHttpClient(config, opts) as client: # Sanity check: without the per-call override, the session # timeout fires and the handshake fails fast. request_default = Request(method="GET", url="/ws") diff --git a/test/test_client/test_base_client.py b/test/test_client/test_base_client.py new file mode 100644 index 00000000..d5ba517f --- /dev/null +++ b/test/test_client/test_base_client.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import warnings +from unittest.mock import AsyncMock, patch + +import pytest + +from kubex.client.options import ClientOptions +from kubex.configuration import ClientConfiguration + +httpx = pytest.importorskip("httpx") + +from kubex.client.httpx import HttpxClient # noqa: E402 + + +def _config() -> ClientConfiguration: + return ClientConfiguration( + url="https://example.invalid", insecure_skip_tls_verify=True + ) + + +def test_base_client_defaults_options_when_none() -> None: + client = HttpxClient(_config()) + assert isinstance(client.options, ClientOptions) + assert client.options.timeout is Ellipsis + assert client.options.log_api_warnings is True + + +def test_base_client_stores_explicit_options() -> None: + opts = ClientOptions(log_api_warnings=False, timeout=10) + client = HttpxClient(_config(), opts) + assert client.options is opts + + +@pytest.mark.anyio +async def test_log_api_warnings_true_emits_warning() -> None: + client = HttpxClient(_config(), ClientOptions(log_api_warnings=True)) + fake_response = httpx.Response( + status_code=200, + headers={"warning": "299 - deprecated"}, + content=b"{}", + ) + with patch.object( + client._inner_client, "request", new=AsyncMock(return_value=fake_response) + ): + from kubex.core.request import Request + + req = Request(method="GET", url="/api/v1/pods", query_params={}) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await client.request(req) + assert any( + issubclass(w.category, UserWarning) and "deprecated" in str(w.message) + for w in caught + ) + + +@pytest.mark.anyio +async def test_log_api_warnings_false_suppresses_warning() -> None: + client = HttpxClient(_config(), ClientOptions(log_api_warnings=False)) + fake_response = httpx.Response( + status_code=200, + headers={"warning": "299 - deprecated"}, + content=b"{}", + ) + with patch.object( + client._inner_client, "request", new=AsyncMock(return_value=fake_response) + ): + from kubex.core.request import Request + + req = Request(method="GET", url="/api/v1/pods", query_params={}) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await client.request(req) + assert not any( + issubclass(w.category, UserWarning) and "API Warning" in str(w.message) + for w in caught + ) diff --git a/test/test_client/test_base_client_aiohttp.py b/test/test_client/test_base_client_aiohttp.py new file mode 100644 index 00000000..badc05d5 --- /dev/null +++ b/test/test_client/test_base_client_aiohttp.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import warnings +from unittest.mock import AsyncMock, patch + +import pytest + +from kubex.client.options import ClientOptions +from kubex.configuration import ClientConfiguration + +pytest.importorskip("aiohttp") + +from multidict import CIMultiDict # noqa: E402 + +from kubex.client.aiohttp import AioHttpClient # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +def _config() -> ClientConfiguration: + return ClientConfiguration( + url="https://example.invalid", insecure_skip_tls_verify=True + ) + + +@pytest.mark.anyio +async def test_aiohttp_log_api_warnings_true_emits_warning() -> None: + client = AioHttpClient(_config(), ClientOptions(log_api_warnings=True)) + mock_response_obj = type( + "FakeResp", + (), + { + "status": 200, + "headers": CIMultiDict({"warning": "299 - deprecated"}), + "read": AsyncMock(return_value=b"{}"), + }, + )() + with patch.object( + client._inner_client, "request", new=AsyncMock(return_value=mock_response_obj) + ): + from kubex.core.request import Request + + req = Request(method="GET", url="/api/v1/pods", query_params={}) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await client.request(req) + assert any( + issubclass(w.category, UserWarning) and "deprecated" in str(w.message) + for w in caught + ) + + +@pytest.mark.anyio +async def test_aiohttp_log_api_warnings_false_suppresses_warning() -> None: + client = AioHttpClient(_config(), ClientOptions(log_api_warnings=False)) + mock_response_obj = type( + "FakeResp", + (), + { + "status": 200, + "headers": CIMultiDict({"warning": "299 - deprecated"}), + "read": AsyncMock(return_value=b"{}"), + }, + )() + with patch.object( + client._inner_client, "request", new=AsyncMock(return_value=mock_response_obj) + ): + from kubex.core.request import Request + + req = Request(method="GET", url="/api/v1/pods", query_params={}) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await client.request(req) + assert not any( + issubclass(w.category, UserWarning) and "API Warning" in str(w.message) + for w in caught + ) diff --git a/test/test_client/test_create_client.py b/test/test_client/test_create_client.py new file mode 100644 index 00000000..1fef1118 --- /dev/null +++ b/test/test_client/test_create_client.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from kubex.client.client import ClientChoise, _try_read_configuration, create_client +from kubex.client.options import ClientOptions +from kubex.configuration import ClientConfiguration + +httpx = pytest.importorskip("httpx") + + +def _config() -> ClientConfiguration: + return ClientConfiguration( + url="https://example.invalid", insecure_skip_tls_verify=True + ) + + +@pytest.mark.anyio +async def test_create_client_defaults_options() -> None: + client = await create_client(_config(), client_class=ClientChoise.HTTPX) + assert isinstance(client.options, ClientOptions) + assert client.options.timeout is Ellipsis + assert client.options.log_api_warnings is True + await client.close() + + +@pytest.mark.anyio +async def test_create_client_propagates_explicit_options() -> None: + opts = ClientOptions(log_api_warnings=False, timeout=10) + client = await create_client( + _config(), options=opts, client_class=ClientChoise.HTTPX + ) + assert client.options is opts + await client.close() + + +@pytest.mark.anyio +@pytest.mark.parametrize("anyio_backend", ["asyncio"]) +async def test_create_client_auto_propagates_options() -> None: + pytest.importorskip("aiohttp") + from kubex.client.aiohttp import AioHttpClient + + opts = ClientOptions(log_api_warnings=False) + client = await create_client( + _config(), options=opts, client_class=ClientChoise.AUTO + ) + assert isinstance(client, AioHttpClient) + assert client.options is opts + await client.close() + + +@pytest.mark.anyio +async def test_create_client_none_options_gives_defaults() -> None: + client = await create_client( + _config(), options=None, client_class=ClientChoise.HTTPX + ) + assert isinstance(client.options, ClientOptions) + assert client.options.timeout is Ellipsis + await client.close() + + +@pytest.mark.anyio +async def test_create_client_rejects_non_options_as_options() -> None: + with pytest.raises(TypeError, match="options must be a ClientOptions instance"): + await create_client( + _config(), + options=ClientChoise.HTTPX, # type: ignore[arg-type] + client_class=ClientChoise.HTTPX, + ) + + +@pytest.mark.anyio +async def test_try_read_configuration_falls_back_to_incluster_on_missing_kubeconfig() -> ( + None +): + in_cluster_config = ClientConfiguration( + url="https://kubernetes.default.svc", insecure_skip_tls_verify=True + ) + with ( + patch( + "kubex.client.client.configure_from_kubeconfig", + new_callable=AsyncMock, + side_effect=FileNotFoundError, + ), + patch( + "kubex.client.client.configure_from_pod_env", + new_callable=AsyncMock, + return_value=in_cluster_config, + ) as mock_pod_env, + ): + config = await _try_read_configuration() + mock_pod_env.assert_awaited_once() + assert config is in_cluster_config + + +@pytest.mark.anyio +async def test_try_read_configuration_propagates_non_file_not_found_errors() -> None: + with patch( + "kubex.client.client.configure_from_kubeconfig", + new_callable=AsyncMock, + side_effect=ValueError("malformed kubeconfig"), + ): + with pytest.raises(ValueError, match="malformed kubeconfig"): + await _try_read_configuration() + + +@pytest.mark.anyio +@pytest.mark.parametrize("anyio_backend", ["asyncio"]) +async def test_create_client_aiohttp_propagates_options() -> None: + pytest.importorskip("aiohttp") + from kubex.client.aiohttp import AioHttpClient + + opts = ClientOptions(log_api_warnings=False, timeout=5) + client = await create_client( + _config(), options=opts, client_class=ClientChoise.AIOHTTP + ) + assert isinstance(client, AioHttpClient) + assert client.options is opts + await client.close() diff --git a/test/test_client/test_httpx_options.py b/test/test_client/test_httpx_options.py new file mode 100644 index 00000000..2c761ebd --- /dev/null +++ b/test/test_client/test_httpx_options.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +import warnings +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +pytest.importorskip("httpx") +pytest.importorskip("httpx_ws") + +import httpx # noqa: E402 + +from kubex.client.httpx import ( # noqa: E402 + HttpxClient, + _build_httpx_limits, + _build_httpx_proxy_kwargs, +) +from kubex.client.options import resolve_ws_max_message_size # noqa: E402 +from kubex.client.options import ClientOptions # noqa: E402 +from kubex.configuration import ClientConfiguration # noqa: E402 +from kubex.core.request import Request # noqa: E402 + + +def _config() -> ClientConfiguration: + return ClientConfiguration( + url="https://example.invalid", insecure_skip_tls_verify=True + ) + + +def _client(options: ClientOptions | None = None) -> HttpxClient: + return HttpxClient(_config(), options) + + +# --------------------------------------------------------------------------- +# resolve_ws_max_message_size +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "val,expected", + [ + pytest.param(..., 2**21, id="ellipsis_kubex_default"), + pytest.param(None, 0, id="none_no_cap"), + pytest.param(4 * 1024 * 1024, 4 * 1024 * 1024, id="explicit_int"), + ], +) +def test_resolve_ws_max_message_size(val: Any, expected: int) -> None: + assert resolve_ws_max_message_size(val) == expected + + +# --------------------------------------------------------------------------- +# _build_httpx_limits +# --------------------------------------------------------------------------- + + +def test_build_httpx_limits_all_defaults_returns_empty() -> None: + opts = ClientOptions() + assert _build_httpx_limits(opts) == {} + + +def test_build_httpx_limits_keep_alive_false() -> None: + opts = ClientOptions(keep_alive=False) + kw = _build_httpx_limits(opts) + assert kw["max_keepalive_connections"] == 0 + + +def test_build_httpx_limits_pool_size_int() -> None: + opts = ClientOptions(pool_size=50) + kw = _build_httpx_limits(opts) + assert kw["max_connections"] == 50 + + +def test_build_httpx_limits_pool_size_none_unlimited() -> None: + opts = ClientOptions(pool_size=None) + kw = _build_httpx_limits(opts) + assert kw["max_connections"] is None + + +def test_build_httpx_limits_keep_alive_timeout_float() -> None: + opts = ClientOptions(keep_alive_timeout=30.0) + kw = _build_httpx_limits(opts) + assert kw["keepalive_expiry"] == 30.0 + + +def test_build_httpx_limits_keep_alive_timeout_none() -> None: + opts = ClientOptions(keep_alive_timeout=None) + kw = _build_httpx_limits(opts) + assert kw["keepalive_expiry"] is None + + +def test_build_httpx_limits_combined() -> None: + opts = ClientOptions(pool_size=20, keep_alive=False, keep_alive_timeout=60.0) + kw = _build_httpx_limits(opts) + assert kw["max_connections"] == 20 + assert kw["max_keepalive_connections"] == 0 + assert kw["keepalive_expiry"] == 60.0 + + +# --------------------------------------------------------------------------- +# _build_httpx_proxy_kwargs +# --------------------------------------------------------------------------- + + +def test_build_httpx_proxy_kwargs_none_returns_empty() -> None: + import ssl + + ctx = ssl.create_default_context() + assert _build_httpx_proxy_kwargs(None, ctx) == {} + + +def test_build_httpx_proxy_kwargs_str_returns_proxy_key() -> None: + import ssl + + ctx = ssl.create_default_context() + url = "http://proxy.example.com:8080" + kw = _build_httpx_proxy_kwargs(url, ctx) + assert kw == {"proxy": url} + + +def test_build_httpx_proxy_kwargs_dict_builds_mounts() -> None: + import ssl + + ctx = ssl.create_default_context() + proxy_dict = {"https": "http://proxy.example.com:8080"} + kw = _build_httpx_proxy_kwargs(proxy_dict, ctx) + assert "mounts" in kw + mounts = kw["mounts"] + assert "https://" in mounts + assert isinstance(mounts["https://"], httpx.AsyncHTTPTransport) + + +def test_build_httpx_proxy_kwargs_dict_both_schemes() -> None: + import ssl + + ctx = ssl.create_default_context() + proxy_dict = { + "http": "http://proxy.example.com:8080", + "https": "http://proxy.example.com:8080", + } + kw = _build_httpx_proxy_kwargs(proxy_dict, ctx) + mounts = kw["mounts"] + assert "http://" in mounts + assert "https://" in mounts + + +# --------------------------------------------------------------------------- +# HttpxClient._create_inner_client — option wiring +# --------------------------------------------------------------------------- + + +def _pool(client: HttpxClient) -> Any: + """Navigate httpx.AsyncClient -> AsyncHTTPTransport -> httpcore pool.""" + return client._inner_client._transport._pool + + +def test_create_inner_client_defaults_no_limits_kwarg() -> None: + client = _client(ClientOptions()) + assert isinstance(client._inner_client, httpx.AsyncClient) + + +def test_create_inner_client_pool_size_wired() -> None: + client = _client(ClientOptions(pool_size=42)) + assert _pool(client)._max_connections == 42 + + +def test_create_inner_client_pool_size_none_means_unlimited() -> None: + import sys + + client = _client(ClientOptions(pool_size=None)) + # httpcore converts None → sys.maxsize internally (unlimited sentinel) + assert _pool(client)._max_connections == sys.maxsize + + +def test_create_inner_client_keep_alive_false_sets_zero_keepalive() -> None: + client = _client(ClientOptions(keep_alive=False)) + assert _pool(client)._max_keepalive_connections == 0 + + +def test_create_inner_client_keep_alive_timeout() -> None: + client = _client(ClientOptions(keep_alive_timeout=45.0)) + assert _pool(client)._keepalive_expiry == 45.0 + + +def test_create_inner_client_proxy_str() -> None: + import httpcore + + client = _client(ClientOptions(proxy="http://proxy.example.com:8080")) + inner = client._inner_client + # A str proxy creates an httpcore.AsyncHTTPProxy pool behind at least one + # of the transports in _mounts. + assert any( + isinstance(getattr(v, "_pool", None), httpcore.AsyncHTTPProxy) + for v in inner._mounts.values() + ) + + +def test_create_inner_client_proxy_dict() -> None: + proxy_dict = {"https": "http://proxy.example.com:8080"} + client = _client(ClientOptions(proxy=proxy_dict)) + inner = client._inner_client + # Dict proxy uses mounts=; httpx stores URLPattern objects as keys so we + # match by the pattern attribute. + https_transport = next( + ( + v + for k, v in inner._mounts.items() + if getattr(k, "pattern", None) == "https://" + ), + None, + ) + assert isinstance(https_transport, httpx.AsyncHTTPTransport) + + +def test_create_inner_client_no_proxy_by_default() -> None: + client = _client(ClientOptions()) + assert isinstance(client._inner_client, httpx.AsyncClient) + + +def test_create_inner_client_buffer_size_not_ellipsis_warns() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _client(ClientOptions(buffer_size=65536)) + assert any( + issubclass(w.category, UserWarning) and "buffer_size" in str(w.message) + for w in caught + ) + + +def test_create_inner_client_buffer_size_none_warns() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _client(ClientOptions(buffer_size=None)) + assert any( + issubclass(w.category, UserWarning) and "buffer_size" in str(w.message) + for w in caught + ) + + +def test_create_inner_client_buffer_size_ellipsis_no_warn() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _client(ClientOptions(buffer_size=...)) + buffer_warns = [ + w + for w in caught + if issubclass(w.category, UserWarning) and "buffer_size" in str(w.message) + ] + assert not buffer_warns + + +def test_create_inner_client_pool_size_per_host_not_ellipsis_warns() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _client(ClientOptions(pool_size_per_host=10)) + assert any( + issubclass(w.category, UserWarning) and "pool_size_per_host" in str(w.message) + for w in caught + ) + + +def test_create_inner_client_pool_size_per_host_none_warns() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _client(ClientOptions(pool_size_per_host=None)) + assert any( + issubclass(w.category, UserWarning) and "pool_size_per_host" in str(w.message) + for w in caught + ) + + +def test_create_inner_client_pool_size_per_host_ellipsis_no_warn() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _client(ClientOptions(pool_size_per_host=...)) + per_host_warns = [ + w + for w in caught + if issubclass(w.category, UserWarning) + and "pool_size_per_host" in str(w.message) + ] + assert not per_host_warns + + +# --------------------------------------------------------------------------- +# connect_websocket — ws_max_message_size +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_connect_websocket_default_max_message_size() -> None: + client = _client(ClientOptions()) + request = Request(method="GET", url="/ws") + + with patch("httpx_ws.aconnect_ws") as mock_connect: + mock_session = MagicMock() + mock_session.subprotocol = "v5.channel.k8s.io" + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_cm.__aexit__ = AsyncMock(return_value=None) + mock_connect.return_value = mock_cm + + conn = await client.connect_websocket(request, ["v5.channel.k8s.io"]) + await conn.close() + + _, kwargs = mock_connect.call_args + assert kwargs.get("max_message_size_bytes") == 2**21 + + +@pytest.mark.anyio +async def test_connect_websocket_none_max_message_size_warns_and_skips_param() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client = _client(ClientOptions(ws_max_message_size=None)) + + assert any( + issubclass(w.category, UserWarning) and "ws_max_message_size" in str(w.message) + for w in caught + ) + + request = Request(method="GET", url="/ws") + + with patch("httpx_ws.aconnect_ws") as mock_connect: + mock_session = MagicMock() + mock_session.subprotocol = "v5.channel.k8s.io" + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_cm.__aexit__ = AsyncMock(return_value=None) + mock_connect.return_value = mock_cm + + conn = await client.connect_websocket(request, ["v5.channel.k8s.io"]) + await conn.close() + + _, kwargs = mock_connect.call_args + assert "max_message_size_bytes" not in kwargs + + +@pytest.mark.anyio +async def test_connect_websocket_explicit_max_message_size() -> None: + explicit_size = 8 * 1024 * 1024 + client = _client(ClientOptions(ws_max_message_size=explicit_size)) + request = Request(method="GET", url="/ws") + + with patch("httpx_ws.aconnect_ws") as mock_connect: + mock_session = MagicMock() + mock_session.subprotocol = "v5.channel.k8s.io" + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_cm.__aexit__ = AsyncMock(return_value=None) + mock_connect.return_value = mock_cm + + conn = await client.connect_websocket(request, ["v5.channel.k8s.io"]) + await conn.close() + + _, kwargs = mock_connect.call_args + assert kwargs.get("max_message_size_bytes") == explicit_size + + +# --------------------------------------------------------------------------- +# Regression: ClientOptions() defaults produce identical behavior to pre-option code +# --------------------------------------------------------------------------- + + +def test_regression_defaults_no_limits_no_proxy() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client = _client(ClientOptions()) + + # No user-facing warnings with all defaults + user_warnings = [w for w in caught if issubclass(w.category, UserWarning)] + assert not user_warnings + + inner = client._inner_client + assert isinstance(inner, httpx.AsyncClient) + # Default limits should be httpx's own defaults (not our custom object) + # when all pool/keep-alive fields are ... + pool = _pool(client) + # httpx default: max_connections=100, max_keepalive_connections=20, keepalive_expiry=5.0 + assert pool._max_connections == 100 + assert pool._max_keepalive_connections == 20 + assert pool._keepalive_expiry == 5.0 diff --git a/test/test_client/test_options.py b/test/test_client/test_options.py new file mode 100644 index 00000000..92651f57 --- /dev/null +++ b/test/test_client/test_options.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from kubex.client import ClientOptions +from kubex.core.params import Timeout + + +def test_defaults() -> None: + opts = ClientOptions() + assert opts.timeout is Ellipsis + assert opts.log_api_warnings is True + assert opts.proxy is None + assert opts.keep_alive is True + assert opts.keep_alive_timeout is Ellipsis + assert opts.buffer_size is Ellipsis + assert opts.ws_max_message_size is Ellipsis + assert opts.pool_size is Ellipsis + assert opts.pool_size_per_host is Ellipsis + + +def test_explicit_none_preserved() -> None: + opts = ClientOptions(timeout=None) + assert opts.timeout is None + + +def test_log_api_warnings_false() -> None: + opts = ClientOptions(log_api_warnings=False) + assert opts.log_api_warnings is False + + +_NORMALIZE_CASES = [ + pytest.param(3, Timeout(total=3.0), id="int"), + pytest.param(2.5, Timeout(total=2.5), id="float"), + pytest.param( + Timeout(connect=1, read=2), Timeout(connect=1, read=2), id="timeout_instance" + ), +] + + +@pytest.mark.parametrize("input_val,expected", _NORMALIZE_CASES) +def test_timeout_normalized(input_val: object, expected: Timeout) -> None: + opts = ClientOptions(timeout=input_val) + if isinstance(input_val, Timeout): + assert opts.timeout is input_val + else: + assert opts.timeout == expected + + +def test_ellipsis_sentinel_roundtrip() -> None: + opts = ClientOptions(timeout=...) + assert opts.timeout is Ellipsis + + +def test_bogus_timeout_raises() -> None: + with pytest.raises(ValidationError): + ClientOptions(timeout="bogus") + + +@pytest.mark.parametrize("value", [True, False]) +def test_bool_timeout_raises(value: bool) -> None: + with pytest.raises(ValidationError): + ClientOptions(timeout=value) + + +# --------------------------------------------------------------------------- +# proxy field +# --------------------------------------------------------------------------- + + +def test_proxy_default_none() -> None: + assert ClientOptions().proxy is None + + +def test_proxy_none_preserved() -> None: + assert ClientOptions(proxy=None).proxy is None + + +def test_proxy_str_preserved() -> None: + url = "http://proxy.example.com:8080" + assert ClientOptions(proxy=url).proxy == url + + +def test_proxy_str_with_userinfo() -> None: + url = "http://user:pass@proxy.example.com:8080" + assert ClientOptions(proxy=url).proxy == url + + +@pytest.mark.parametrize( + "proxy_dict", + [ + pytest.param({"http": "http://p.example.com"}, id="http_only"), + pytest.param({"https": "http://p.example.com"}, id="https_only"), + pytest.param( + {"http": "http://p.example.com", "https": "http://p.example.com"}, + id="both_schemes", + ), + ], +) +def test_proxy_dict_valid(proxy_dict: dict[str, str]) -> None: + opts = ClientOptions(proxy=proxy_dict) + assert opts.proxy == proxy_dict + + +@pytest.mark.parametrize( + "bad_proxy", + [ + pytest.param(42, id="int"), + pytest.param(3.14, id="float"), + pytest.param(["http://p.com"], id="list"), + pytest.param({}, id="empty_dict"), + pytest.param({"ftp": "ftp://p.example.com"}, id="unknown_scheme"), + pytest.param( + {"http": "http://p.com", "ftp": "ftp://p.com"}, id="mixed_unknown_scheme" + ), + pytest.param({1: "http://p.com"}, id="non_str_key"), + pytest.param({"http": 123}, id="non_str_value"), + ], +) +def test_proxy_invalid_raises(bad_proxy: object) -> None: + with pytest.raises(ValidationError): + ClientOptions(proxy=bad_proxy) + + +# --------------------------------------------------------------------------- +# keep_alive field +# --------------------------------------------------------------------------- + + +def test_keep_alive_default_true() -> None: + assert ClientOptions().keep_alive is True + + +def test_keep_alive_false() -> None: + assert ClientOptions(keep_alive=False).keep_alive is False + + +# --------------------------------------------------------------------------- +# keep_alive_timeout field +# --------------------------------------------------------------------------- + + +def test_keep_alive_timeout_default_ellipsis() -> None: + assert ClientOptions().keep_alive_timeout is Ellipsis + + +def test_keep_alive_timeout_ellipsis_roundtrip() -> None: + assert ClientOptions(keep_alive_timeout=...).keep_alive_timeout is Ellipsis + + +def test_keep_alive_timeout_none_preserved() -> None: + assert ClientOptions(keep_alive_timeout=None).keep_alive_timeout is None + + +@pytest.mark.parametrize( + "val,expected", + [ + pytest.param(30, 30.0, id="int_coerced_to_float"), + pytest.param(60.5, 60.5, id="float_preserved"), + pytest.param(0.0, 0.0, id="zero_allowed"), + pytest.param(0, 0.0, id="zero_int_allowed"), + ], +) +def test_keep_alive_timeout_valid(val: object, expected: float) -> None: + opts = ClientOptions(keep_alive_timeout=val) + assert opts.keep_alive_timeout == expected + + +@pytest.mark.parametrize( + "bad_val", + [ + pytest.param(True, id="bool_true"), + pytest.param(False, id="bool_false"), + pytest.param(-1.0, id="negative"), + pytest.param(-0.1, id="small_negative"), + pytest.param("30", id="string"), + ], +) +def test_keep_alive_timeout_invalid_raises(bad_val: object) -> None: + with pytest.raises(ValidationError): + ClientOptions(keep_alive_timeout=bad_val) + + +# --------------------------------------------------------------------------- +# buffer_size field +# --------------------------------------------------------------------------- + + +def test_buffer_size_default_ellipsis() -> None: + assert ClientOptions().buffer_size is Ellipsis + + +def test_buffer_size_ellipsis_roundtrip() -> None: + assert ClientOptions(buffer_size=...).buffer_size is Ellipsis + + +def test_buffer_size_none_preserved() -> None: + assert ClientOptions(buffer_size=None).buffer_size is None + + +def test_buffer_size_positive_int_preserved() -> None: + assert ClientOptions(buffer_size=4096).buffer_size == 4096 + + +@pytest.mark.parametrize( + "bad_val", + [ + pytest.param(True, id="bool_true"), + pytest.param(False, id="bool_false"), + pytest.param(0, id="zero"), + pytest.param(-1, id="negative"), + pytest.param(3.14, id="float"), + pytest.param("4096", id="string"), + ], +) +def test_buffer_size_invalid_raises(bad_val: object) -> None: + with pytest.raises(ValidationError): + ClientOptions(buffer_size=bad_val) + + +# --------------------------------------------------------------------------- +# ws_max_message_size field +# --------------------------------------------------------------------------- + + +def test_ws_max_message_size_default_ellipsis() -> None: + assert ClientOptions().ws_max_message_size is Ellipsis + + +def test_ws_max_message_size_ellipsis_roundtrip() -> None: + assert ClientOptions(ws_max_message_size=...).ws_max_message_size is Ellipsis + + +def test_ws_max_message_size_none_preserved() -> None: + assert ClientOptions(ws_max_message_size=None).ws_max_message_size is None + + +def test_ws_max_message_size_positive_int_preserved() -> None: + assert ClientOptions(ws_max_message_size=2**21).ws_max_message_size == 2**21 + + +@pytest.mark.parametrize( + "bad_val", + [ + pytest.param(True, id="bool_true"), + pytest.param(False, id="bool_false"), + pytest.param(0, id="zero"), + pytest.param(-1, id="negative"), + pytest.param(1.5, id="float"), + pytest.param("big", id="string"), + ], +) +def test_ws_max_message_size_invalid_raises(bad_val: object) -> None: + with pytest.raises(ValidationError): + ClientOptions(ws_max_message_size=bad_val) + + +# --------------------------------------------------------------------------- +# pool_size field +# --------------------------------------------------------------------------- + + +def test_pool_size_default_ellipsis() -> None: + assert ClientOptions().pool_size is Ellipsis + + +def test_pool_size_ellipsis_roundtrip() -> None: + assert ClientOptions(pool_size=...).pool_size is Ellipsis + + +def test_pool_size_none_preserved() -> None: + assert ClientOptions(pool_size=None).pool_size is None + + +def test_pool_size_positive_int_preserved() -> None: + assert ClientOptions(pool_size=50).pool_size == 50 + + +@pytest.mark.parametrize( + "bad_val", + [ + pytest.param(True, id="bool_true"), + pytest.param(False, id="bool_false"), + pytest.param(0, id="zero"), + pytest.param(-5, id="negative"), + pytest.param(2.0, id="float"), + pytest.param("100", id="string"), + ], +) +def test_pool_size_invalid_raises(bad_val: object) -> None: + with pytest.raises(ValidationError): + ClientOptions(pool_size=bad_val) + + +# --------------------------------------------------------------------------- +# pool_size_per_host field +# --------------------------------------------------------------------------- + + +def test_pool_size_per_host_default_ellipsis() -> None: + assert ClientOptions().pool_size_per_host is Ellipsis + + +def test_pool_size_per_host_ellipsis_roundtrip() -> None: + assert ClientOptions(pool_size_per_host=...).pool_size_per_host is Ellipsis + + +def test_pool_size_per_host_none_preserved() -> None: + assert ClientOptions(pool_size_per_host=None).pool_size_per_host is None + + +def test_pool_size_per_host_positive_int_preserved() -> None: + assert ClientOptions(pool_size_per_host=10).pool_size_per_host == 10 + + +@pytest.mark.parametrize( + "bad_val", + [ + pytest.param(True, id="bool_true"), + pytest.param(False, id="bool_false"), + pytest.param(0, id="zero_rejected_use_none_instead"), + pytest.param(-1, id="negative"), + pytest.param(2.5, id="float"), + pytest.param("10", id="string"), + ], +) +def test_pool_size_per_host_invalid_raises(bad_val: object) -> None: + with pytest.raises(ValidationError): + ClientOptions(pool_size_per_host=bad_val) diff --git a/test/test_configuration/test_client_configuration.py b/test/test_configuration/test_client_configuration.py new file mode 100644 index 00000000..e0b658b1 --- /dev/null +++ b/test/test_configuration/test_client_configuration.py @@ -0,0 +1,13 @@ +import pytest + +from kubex.configuration import ClientConfiguration + + +def test_timeout_param_removed() -> None: + with pytest.raises(TypeError, match="timeout"): + ClientConfiguration(url="https://example.com", timeout=30) # type: ignore[call-arg] + + +def test_log_api_warnings_param_removed() -> None: + with pytest.raises(TypeError, match="log_api_warnings"): + ClientConfiguration(url="https://example.com", log_api_warnings=False) # type: ignore[call-arg] diff --git a/test/test_exec/test_accessor.py b/test/test_exec/test_accessor.py index e861b72e..7a671a76 100644 --- a/test/test_exec/test_accessor.py +++ b/test/test_exec/test_accessor.py @@ -8,6 +8,7 @@ from kubex.api._exec import ExecAccessor, ExecResult from kubex.client.client import BaseClient +from kubex.client.options import ClientOptions from kubex.client.websocket import WebSocketConnection from kubex.core.exceptions import KubexClientException from kubex.core.exec_channels import V5ChannelProtocol @@ -75,6 +76,7 @@ class _FakeClient(BaseClient): def __init__(self, websocket: WebSocketConnection) -> None: self._websocket = websocket self.connect_calls: list[tuple[Request, list[str]]] = [] + self._options = ClientOptions() def _create_inner_client(self) -> Any: # pragma: no cover - never invoked return object() diff --git a/test/test_portforward/test_accessor.py b/test/test_portforward/test_accessor.py index 94dfd4bd..c3825a5c 100644 --- a/test/test_portforward/test_accessor.py +++ b/test/test_portforward/test_accessor.py @@ -7,6 +7,7 @@ from kubex.client.client import BaseClient from kubex.client.websocket import WebSocketConnection +from kubex.configuration import ClientConfiguration from kubex.core.exceptions import KubexClientException from kubex.core.exec_channels import V4ChannelProtocol from kubex.core.request import Request @@ -71,6 +72,11 @@ class _FakeClient(BaseClient): """Stub BaseClient that yields a preconfigured WebSocketConnection.""" def __init__(self, websocket: WebSocketConnection) -> None: + super().__init__( + ClientConfiguration( + url="https://example.invalid", insecure_skip_tls_verify=True + ) + ) self._websocket = websocket self.connect_calls: list[tuple[Request, list[str]]] = [] diff --git a/test/test_portforward/test_listener.py b/test/test_portforward/test_listener.py index 6c7df03c..4a953913 100644 --- a/test/test_portforward/test_listener.py +++ b/test/test_portforward/test_listener.py @@ -12,6 +12,7 @@ from kubex.api._portforward import PortforwardAccessor from kubex.client.client import BaseClient from kubex.client.websocket import WebSocketConnection +from kubex.configuration import ClientConfiguration from kubex.core.exec_channels import CHANNEL_CLOSE from kubex.core.request import Request from kubex.core.request_builder.builder import RequestBuilder @@ -76,6 +77,11 @@ class _DynamicFakeClient(BaseClient): """Stub client that creates a new WebSocket via factory on each connect.""" def __init__(self, ws_factory: Callable[[], _FakeWebSocket]) -> None: + super().__init__( + ClientConfiguration( + url="https://example.invalid", insecure_skip_tls_verify=True + ) + ) self._ws_factory = ws_factory self.connect_count = 0 @@ -418,11 +424,16 @@ def ws_factory() -> _FakeWebSocket: @pytest.mark.anyio -async def test_listen_logs_warning_on_buffer_overflow( +async def test_listen_applies_backpressure_without_data_loss( caplog: pytest.LogCaptureFixture, ) -> None: - """Per-port data buffer overflow must be surfaced via a warning log so - silent truncation does not corrupt streams unnoticed.""" + """A slow local TCP reader must not cause data loss. + + The read loop now blocks (awaits) when the per-port memory buffer is full + instead of closing the stream and dropping bytes. Closing the local socket + while the read loop is stalled should unblock it cleanly with no truncation + warning logged. + """ local_port = _get_free_port() ws_ready = anyio.Event() @@ -430,13 +441,12 @@ def ws_factory() -> _FakeWebSocket: ws = _FakeWebSocket(buffer=4096) ws.feed(bytes([0]) + _port_prefix(80)) ws.feed(bytes([1]) + _port_prefix(80)) - # Pre-feed many data frames to overflow the per-port buffer. + # Feed frames that will stall the read loop via backpressure. for _ in range(200): ws.feed(bytes([0]) + b"X" * 64) ws_ready.set() return ws - # Tiny per-port buffer makes overflow inevitable. accessor = PortforwardAccessor( client=_DynamicFakeClient(ws_factory), request_builder=RequestBuilder(resource_config=Pod.__RESOURCE_CONFIG__), @@ -445,8 +455,7 @@ def ws_factory() -> _FakeWebSocket: resource_type=Pod, ) - # Patch buffer_size by wrapping _open_session — the public API does not - # expose it for listen(), but we exercise the warning path directly. + # Tiny per-port buffer so backpressure kicks in quickly. original_open = accessor._open_session async def _open_with_tiny_buffer(*args: Any, **kwargs: Any) -> Any: @@ -460,12 +469,12 @@ async def _open_with_tiny_buffer(*args: Any, **kwargs: Any) -> Any: sock = await anyio.connect_tcp("127.0.0.1", local_port) await ws_ready.wait() try: - # Don't read — force the buffer to overflow, then close. + # Don't read — let the read loop stall under backpressure. await anyio.sleep(0.1) finally: await sock.aclose() await anyio.sleep(0.1) - assert any( - "data dropped" in r.message and "80" in r.message for r in caplog.records - ) + # Backpressure must not produce a "data dropped" warning — data is stalled, + # not silently discarded. + assert not any("data dropped" in r.message for r in caplog.records) diff --git a/test/test_portforward/test_session.py b/test/test_portforward/test_session.py index d5fcc75a..f6a8b4c4 100644 --- a/test/test_portforward/test_session.py +++ b/test/test_portforward/test_session.py @@ -190,22 +190,49 @@ async def test_data_buffer_overflow_closes_port_stream_and_sets_truncated_flag() None ): fake = _FakeWebSocket(buffer=2048) - # buffer_size=0: the first payload that doesn't fit raises WouldBlock; - # the read loop closes that port's stream locally, sets _truncated, and - # continues processing subsequent frames without blocking. + # buffer_size=1: first data payload fills the buffer; the second raises + # WouldBlock so the read loop closes that port's stream locally and sets + # _truncated without stalling frame delivery for any other port. fake.feed(_data_frame(0, port_prefix_encode(8080) + b"data")) - fake.feed(_data_frame(0, b"second")) # discarded — port 8080 already truncated + fake.feed( + _data_frame(0, b"second") + ) # triggers WouldBlock, port closed as truncated + fake.feed_eof() + + # anyio.receive() calls checkpoint() unconditionally, so each frame + # requires its own scheduling slot to be processed. + # sleep #1: read loop starts and yields inside receive() for frame 1 + # sleep #2: frame 1 processed, yields inside receive() for frame 2 + # sleep #3: frame 2 processed (WouldBlock → truncated=True), yields + async with PortForwardSession( + fake, V5ChannelProtocol(), ports=[8080], buffer_size=1 + ) as session: + await anyio.sleep(0) + await anyio.sleep(0) + await anyio.sleep(0) + + assert session._truncated[8080] is True + + +@pytest.mark.anyio +async def test_blocking_read_loop_unblocks_on_session_exit_when_buffer_full() -> None: + fake = _FakeWebSocket(buffer=2048) + # block_on_full=True: after the first data payload fills the buffer, the + # read loop blocks awaiting the consumer. Session teardown must cancel + # the blocked send cleanly without deadlocking. + fake.feed(_data_frame(0, port_prefix_encode(8080) + b"data")) + fake.feed(_data_frame(0, b"second")) # read loop blocks here (buffer full) fake.feed_eof() - # With a blocking await send() this would deadlock on the first data frame. with anyio.fail_after(2.0): async with PortForwardSession( - fake, V5ChannelProtocol(), ports=[8080], buffer_size=0 + fake, V5ChannelProtocol(), ports=[8080], buffer_size=1, block_on_full=True ) as session: await anyio.sleep(0) await anyio.sleep(0) - assert session._truncated[8080] is True + # Backpressure does not set the truncated flag — data is stalled, not dropped. + assert session._truncated[8080] is False @pytest.mark.anyio diff --git a/test/test_request_builder/test_builder.py b/test/test_request_builder/test_builder.py index 62f94732..006e154b 100644 --- a/test/test_request_builder/test_builder.py +++ b/test/test_request_builder/test_builder.py @@ -170,7 +170,7 @@ def test_list_query_params_all(ns_builder: RequestBuilder) -> None: opts = ListOptions( label_selector="app=web", field_selector="status.phase=Running", - timeout=30, + timeout_seconds=30, limit=50, continue_token="tok", version_match=VersionMatch.EXACT, diff --git a/test/test_request_builder/test_metadata.py b/test/test_request_builder/test_metadata.py index 47295934..710edf7f 100644 --- a/test/test_request_builder/test_metadata.py +++ b/test/test_request_builder/test_metadata.py @@ -163,7 +163,7 @@ def test_list_metadata_query_params_all(ns_builder: RequestBuilder) -> None: opts = ListOptions( label_selector="app=web", field_selector="status.phase=Running", - timeout=30, + timeout_seconds=30, limit=50, continue_token="tok", resource_version="999", diff --git a/test/test_timeout/test_aiohttp_client.py b/test/test_timeout/test_aiohttp_client.py index 293b8da2..54453229 100644 --- a/test/test_timeout/test_aiohttp_client.py +++ b/test/test_timeout/test_aiohttp_client.py @@ -2,6 +2,7 @@ import pytest +from kubex.client.options import ClientOptions from kubex.configuration import ClientConfiguration from kubex.core.params import Timeout @@ -10,19 +11,24 @@ from kubex.client.aiohttp import AioHttpClient, _to_aiohttp_timeout # noqa: E402 -def _configuration(**kwargs: object) -> ClientConfiguration: - # Leave ``insecure_skip_tls_verify`` unset so the aiohttp connector builds a - # valid ``SSLContext`` (the code path would otherwise combine - # ``verify_ssl=False`` with an ``ssl`` context and raise). - return ClientConfiguration( - url="https://example.invalid", - **kwargs, # type: ignore[arg-type] - ) +def _configuration() -> ClientConfiguration: + return ClientConfiguration(url="https://example.invalid") + + +@pytest.mark.anyio +async def test_create_inner_session_with_ellipsis_uses_aiohttp_default() -> None: + from aiohttp.client import DEFAULT_TIMEOUT + + client = AioHttpClient(_configuration()) + try: + assert client._inner_client.timeout == DEFAULT_TIMEOUT + finally: + await client.close() @pytest.mark.anyio async def test_create_inner_session_with_timeout() -> None: - client = AioHttpClient(_configuration(timeout=5)) + client = AioHttpClient(_configuration(), ClientOptions(timeout=5)) try: assert client._inner_client.timeout.total == 5 finally: @@ -31,7 +37,7 @@ async def test_create_inner_session_with_timeout() -> None: @pytest.mark.anyio async def test_create_inner_session_with_none_disables_timeout() -> None: - client = AioHttpClient(_configuration(timeout=None)) + client = AioHttpClient(_configuration(), ClientOptions(timeout=None)) try: assert client._inner_client.timeout.total is None finally: diff --git a/test/test_timeout/test_api_overrides.py b/test/test_timeout/test_api_overrides.py index 02acd29a..50a05112 100644 --- a/test/test_timeout/test_api_overrides.py +++ b/test/test_timeout/test_api_overrides.py @@ -60,7 +60,7 @@ async def test_list_override_flows_through_without_colliding_with_server_timeout b'{"apiVersion": "v1", "kind": "PodList", "metadata": {}, "items": []}' ) ) - await _pod_api(client).list(timeout=30, request_timeout=4.0) + await _pod_api(client).list(timeout_seconds=30, request_timeout=4.0) req = client.last_request assert req.timeout == Timeout(total=4.0) assert req.query_params is not None diff --git a/test/test_timeout/test_configuration.py b/test/test_timeout/test_configuration.py deleted file mode 100644 index 6aa1639f..00000000 --- a/test/test_timeout/test_configuration.py +++ /dev/null @@ -1,40 +0,0 @@ -import pytest - -from kubex.configuration import ClientConfiguration -from kubex.core.params import Timeout - - -def _base_config(**kwargs: object) -> ClientConfiguration: - return ClientConfiguration( - url="https://example.invalid", - insecure_skip_tls_verify=True, - **kwargs, # type: ignore[arg-type] - ) - - -def test_default_timeout_is_ellipsis() -> None: - config = _base_config() - assert config.timeout is Ellipsis - - -def test_explicit_none_preserved() -> None: - config = _base_config(timeout=None) - assert config.timeout is None - - -_NORMALIZE_CASES = [ - pytest.param(3, Timeout(total=3.0), id="int"), - pytest.param(2.5, Timeout(total=2.5), id="float"), - pytest.param( - Timeout(connect=1, read=2), Timeout(connect=1, read=2), id="timeout_instance" - ), -] - - -@pytest.mark.parametrize("input_val,expected", _NORMALIZE_CASES) -def test_timeout_normalized(input_val: object, expected: Timeout) -> None: - config = _base_config(timeout=input_val) - if isinstance(input_val, Timeout): - assert config.timeout is input_val - else: - assert config.timeout == expected diff --git a/test/test_timeout/test_httpx_client.py b/test/test_timeout/test_httpx_client.py index 8479bd7a..61724c98 100644 --- a/test/test_timeout/test_httpx_client.py +++ b/test/test_timeout/test_httpx_client.py @@ -2,6 +2,7 @@ import pytest +from kubex.client.options import ClientOptions from kubex.configuration import ClientConfiguration from kubex.core.params import Timeout @@ -10,27 +11,25 @@ from kubex.client.httpx import HttpxClient, _to_httpx_timeout # noqa: E402 -def _configuration(**kwargs: object) -> ClientConfiguration: +def _configuration() -> ClientConfiguration: return ClientConfiguration( url="https://example.invalid", insecure_skip_tls_verify=True, - **kwargs, # type: ignore[arg-type] ) def test_create_inner_client_without_timeout_uses_httpx_default() -> None: client = HttpxClient(_configuration()) - # Constructing without a timeout should leave httpx's default in place. assert client._inner_client.timeout == httpx.Timeout(5.0) def test_create_inner_client_with_timeout() -> None: - client = HttpxClient(_configuration(timeout=5)) + client = HttpxClient(_configuration(), ClientOptions(timeout=5)) assert client._inner_client.timeout == httpx.Timeout(5.0) def test_create_inner_client_with_none_disables_timeout() -> None: - client = HttpxClient(_configuration(timeout=None)) + client = HttpxClient(_configuration(), ClientOptions(timeout=None)) assert client._inner_client.timeout == httpx.Timeout(None)