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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ jobs:

- name: Run unit tests
working-directory: ./openapi
run: python3 test_apidiscovery.py
run: |
python3 test_apidiscovery.py
python3 -m unittest test_postprocess_python.py
python3 -m unittest test_preprocess.py

- name: Run integration tests
working-directory: ./openapi
Expand Down
17 changes: 16 additions & 1 deletion openapi/apidiscovery_definitions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
{
"io.k8s.apimachinery.pkg.apis.meta.v1.GroupVersionKind": {
"type": "object",
"description": "GroupVersionKind unambiguously identifies a kind.",
"properties": {
"group": {
"type": "string"
},
"version": {
"type": "string"
},
"kind": {
"type": "string"
}
}
},
"io.k8s.api.apidiscovery.v2beta1.APIGroupDiscoveryList": {
"type": "object",
"description": "APIGroupDiscoveryList is a resource containing a list of APIGroupDiscovery. This is one of the types able to be returned from the /api and /apis endpoint and contains an aggregated list of API resources (built-ins, Custom Resource Definitions, resources from aggregated servers) that a cluster supports.",
Expand Down Expand Up @@ -361,4 +376,4 @@
"verbs"
]
}
}
}
203 changes: 203 additions & 0 deletions openapi/postprocess_python.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
# Copyright 2026 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import argparse
import re
from pathlib import Path


parser = argparse.ArgumentParser(
description="Post-process the generated Python client."
)
parser.add_argument("output_directory", type=Path)
parser.add_argument("package_name")
args = parser.parse_args()

# Swagger 2 exposes these IntOrString fields as free-form objects, which makes
# the generated Pydantic validators reject valid scalar values. Kubernetes
# serializes them as the contained integer or string:
# https://github.com/kubernetes/kubernetes/blob/8ba6370120c1371ab70428be16341c3cf6ba8584/staging/src/k8s.io/apimachinery/pkg/util/intstr/intstr.go#L32-L45
int_or_string_fields = {
"v1httpgetaction": ("port",),
"v1networkpolicyport": ("port",),
"v1poddisruptionbudgetspec": ("max_unavailable", "min_available"),
"v1rollingupdatedaemonset": ("max_surge", "max_unavailable"),
"v1rollingupdatedeployment": ("max_surge", "max_unavailable"),
"v1rollingupdatestatefulsetstrategy": ("max_unavailable",),
"v1serviceport": ("target_port",),
"v1tcpsocketaction": ("port",),
}

# kube-openapi cannot describe these JSONSchemaProps unions until it supports
# anyOf. Preserve the Kubernetes JSON contract in generated validators/docs:
# https://github.com/kubernetes/kubernetes/blob/8ba6370120c1371ab70428be16341c3cf6ba8584/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/types_jsonschema.go#L75-L112
# https://github.com/kubernetes/kubernetes/blob/8ba6370120c1371ab70428be16341c3cf6ba8584/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/types_jsonschema.go#L348-L403
json_schema_props_fields = {
"additional_items": (
"Optional[Dict[str, Any]]",
"Optional[Dict[str, Any] | StrictBool]",
"object",
"Union[Dict[str, Any], bool]",
),
"additional_properties": (
"Optional[Dict[str, Any]]",
"Optional[Dict[str, Any] | StrictBool]",
"object",
"Union[Dict[str, Any], bool]",
),
"default": ("Optional[Dict[str, Any]]", "Any", "object", "Any"),
"dependencies": (
"Optional[Dict[str, Dict[str, Any]]]",
"Optional[Dict[str, Dict[str, Any] | List[str]]]",
"Dict[str, object]",
"Dict[str, Union[Dict[str, Any], List[str]]]",
),
"enum": (
"Optional[List[Dict[str, Any]]]",
"Optional[List[Any]]",
"List[object]",
"List[Any]",
),
"example": ("Optional[Dict[str, Any]]", "Any", "object", "Any"),
"items": (
"Optional[Dict[str, Any]]",
"Optional[Dict[str, Any] | List[Dict[str, Any]]]",
"object",
"Union[Dict[str, Any], List[Dict[str, Any]]]",
),
}

markdown_files = [args.output_directory / "README.md"]
markdown_files.extend((args.output_directory / "docs").rglob("*.md"))
python_files = (args.output_directory / args.package_name).rglob("*.py")

for path, file_type in [
*((path, "markdown") for path in markdown_files),
*((path, "python") for path in python_files),
]:
text = path.read_text()
if file_type == "markdown":
if path.name == "README.md":
text = re.sub(
r"\A# client(?=\r?\n|\Z)", "# kubernetes.client", text
)
text = re.sub(
r"(?<![\w.-])client(?=\.[A-Za-z_])",
"kubernetes.client",
text,
)
text = re.sub(
r"(?m)^(\s*(?:from|import)\s+)client(?=[.\s]|$)",
r"\1kubernetes.client",
text,
)
for field in int_or_string_fields.get(path.stem.lower(), ()):
text = text.replace(
f"**{field}** | **object** |",
f"**{field}** | **Union[int, str]** |",
)
if path.stem == "V1JSONSchemaProps":
for field, (_, _, old_type, new_type) in (
json_schema_props_fields.items()
):
text = text.replace(
f"**{field}** | **{old_type}** |",
f"**{field}** | **{new_type}** |",
)
elif args.package_name == "client":
text = text.replace("import client.", "import kubernetes.client.")
text = text.replace("from client", "from kubernetes.client")
text = text.replace(
"getattr(client.models", "getattr(kubernetes.client.models"
)
# Building every API method's Pydantic schema at import time is
# expensive. defer_build is supported for validate_call since 2.11:
# https://github.com/pydantic/pydantic/commit/8e98bc0a66379e693780eb6edd611f4177c60c30
text = text.replace(
"@validate_call\n",
"@validate_call(config={'defer_build': True})\n",
)
# Generated files otherwise fail git diff --check on trailing whitespace
# and extra blank lines at EOF.
lines = text.splitlines()
if file_type == "python":
int_or_string = int_or_string_fields.get(
path.stem.replace("_", "").lower(), ()
)
if int_or_string:
for index, line in enumerate(lines):
if line.startswith("from pydantic import "):
if "StrictInt" not in line:
if "StrictStr" in line:
line = line.replace(
", StrictStr", ", StrictInt, StrictStr"
)
else:
line += ", StrictInt"
if "StrictStr" not in line:
line += ", StrictStr"
lines[index] = line
break
for field in int_or_string:
lines = [
line.replace(
f" {field}: Optional[Dict[str, Any]]",
f" {field}: Optional[StrictInt | StrictStr]",
).replace(
f" {field}: Dict[str, Any]",
f" {field}: StrictInt | StrictStr",
)
for line in lines
]
if path.stem == "v1_json_schema_props":
for field, (old_type, new_type, _, _) in (
json_schema_props_fields.items()
):
lines = [
line.replace(
f" {field}: {old_type}",
f" {field}: {new_type}",
)
for line in lines
]
if any(line.startswith(" def patch_") for line in lines):
lines = [
line.replace(
"from pydantic import validate_call, ",
"from pydantic import BaseModel, validate_call, ",
)
for line in lines
]
patch_method = False
for index, line in enumerate(lines):
if line.startswith(" def "):
patch_method = line.startswith(" def patch_")
if patch_method:
# Swagger 2 describes PATCH bodies as objects, but RFC 6902
# represents a JSON Patch document as an array of operations:
# https://www.rfc-editor.org/rfc/rfc6902#section-3
# Generated request models are also valid object PATCH bodies.
lines[index] = line.replace(
"body: Annotated[Dict[str, Any]",
"body: Annotated[Union[Dict[str, Any], List[Dict[str, Any]], BaseModel]",
).replace(
"body: Dict[str, Any]",
"body: Union[Dict[str, Any], List[Dict[str, Any]], BaseModel]",
)
while lines and not lines[-1].strip(" \t"):
lines.pop()
path.write_text(
"\n".join(line.rstrip(" \t") for line in lines) + "\n"
)
88 changes: 86 additions & 2 deletions openapi/preprocess_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,58 @@ def strip_tags_from_operation_id(operation, _):
operation_id = operation_id.replace(_to_camel_case(t), '')
operation['operationId'] = operation_id


def fix_exec_command_parameter(operation, parent):
if operation.get('operationId') not in {
'connectCoreV1GetNamespacedPodExec',
'connectCoreV1PostNamespacedPodExec',
}:
return
for parameter in parent.get('parameters', []) + operation.get(
'parameters', []
):
if parameter.get('name') == 'command':
# PodExecOptions.Command is []string, but the published Swagger 2
# document describes it as a scalar:
# https://github.com/kubernetes/kubernetes/blob/1a4d068e60ecd4b467a04c37c86c4882e419f24d/staging/src/k8s.io/api/core/v1/types.go#L7361-L7363
parameter['type'] = 'array'
parameter['items'] = {'type': 'string'}
parameter['collectionFormat'] = 'multi'


def fix_python_portforward_ports_parameter(operation, parent):
if operation.get('operationId') not in {
'connectCoreV1GetNamespacedPodPortforward',
'connectCoreV1PostNamespacedPodPortforward',
}:
return
for parameter in parent.get('parameters', []) + operation.get(
'parameters', []
):
if parameter.get('name') == 'ports':
# This WebSocket query is comma-separated, while the published
# Swagger integer makes modern Python clients reject existing
# values such as "80,443":
# https://github.com/kubernetes/kubernetes/blob/1a4d068e60ecd4b467a04c37c86c4882e419f24d/staging/src/k8s.io/api/core/v1/types.go#L7373-L7383
parameter['type'] = 'string'
parameter.pop('format', None)


def fix_python_namespace_delete_response(operation, _):
if operation.get('operationId') != 'deleteCoreV1Namespace':
return
# Namespace storage normally returns the deleted Namespace, but unsafe
# deletion cannot recover the object and returns a Status. Swagger 2
# cannot describe that union, so preserve either response as an object:
# https://github.com/kubernetes/kubernetes/blob/8ba6370120c1371ab70428be16341c3cf6ba8584/pkg/registry/core/namespace/storage/storage.go#L59-L73
# https://github.com/kubernetes/kubernetes/blob/8ba6370120c1371ab70428be16341c3cf6ba8584/staging/src/k8s.io/apiserver/pkg/registry/generic/registry/corrupt_obj_deleter.go#L104-L127
# https://github.com/kubernetes/kubernetes/blob/8ba6370120c1371ab70428be16341c3cf6ba8584/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/delete.go#L194-L207
for status in ('200', '202'):
response = operation.get('responses', {}).get(status)
if response is not None:
response['schema'] = {'type': 'object'}


def clean_crd_meta(spec):
for k, v in spec['definitions'].items():
if k.endswith('List'):
Expand All @@ -161,9 +213,21 @@ def clean_crd_meta(spec):
find_rename_ref_recursive(spec, '#/definitions/io.k8s.api.autoscaling.v1.Scale_v2', '#/definitions/v1.Scale')


def add_custom_objects_spec(spec):
def add_custom_objects_spec(spec, client_language):
with open(CUSTOM_OBJECTS_SPEC_PATH, 'r') as custom_objects_spec_file:
custom_objects_spec = json.load(custom_objects_spec_file)
if client_language == 'python':
for path_item in custom_objects_spec.values():
patch = path_item.get('patch')
if patch is not None:
# Preserve merge patch as the default for Python custom
# objects; callers can still select JSON Patch explicitly:
# https://github.com/kubernetes-client/python/issues/866
patch['consumes'] = [
content_type
for content_type in patch.get('consumes', [])
if content_type != 'application/json-patch+json'
]
for path in custom_objects_spec.keys():
if path not in spec['paths'].keys():
spec['paths'][path] = custom_objects_spec[path]
Expand All @@ -183,6 +247,18 @@ def add_apidiscovery_definitions(spec):
spec['definitions'][definition_name] = definition
return spec


def add_bearer_token_alias(spec, client_language):
if client_language != 'python':
return spec
bearer_token = spec.get('securityDefinitions', {}).get('BearerToken')
if bearer_token is not None:
# Preserve the api_key['authorization'] key accepted before v36:
# https://github.com/kubernetes-client/python/issues/2595
bearer_token['x-auth-id-alias'] = 'authorization'
return spec


def add_codegen_request_body(operation, _):
if 'parameters' in operation and len(operation['parameters']) > 0:
if operation['parameters'][0].get('in') == 'body':
Expand Down Expand Up @@ -235,8 +311,9 @@ def expand_parameters(spec):
del spec['parameters']

def process_swagger(spec, client_language, crd_mode=False):
spec = add_custom_objects_spec(spec)
spec = add_custom_objects_spec(spec, client_language)
spec = add_apidiscovery_definitions(spec)
spec = add_bearer_token_alias(spec, client_language)

if crd_mode:
drop_paths(spec)
Expand All @@ -245,6 +322,13 @@ def process_swagger(spec, client_language, crd_mode=False):

expand_parameters(spec)

if client_language == 'python':
apply_func_to_spec_operations(spec, fix_exec_command_parameter)
apply_func_to_spec_operations(
spec, fix_python_portforward_ports_parameter)
apply_func_to_spec_operations(
spec, fix_python_namespace_delete_response)

apply_func_to_spec_operations(spec, strip_tags_from_operation_id)

if client_language == "csharp":
Expand Down
Loading
Loading