From 31aa73719c42da03b68b5cd727658cf0006dc9c5 Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Mon, 20 Jul 2026 18:56:46 -0400 Subject: [PATCH 1/2] Migrate Python client generation to v7.24.0 OpenAPI Generator v7 removed python-legacy. Select the modern Python generator with legacy compatibility, independent implicit clients, lazy package imports, and explicit name mappings so the synchronous client keeps its public API without eagerly loading every model and API. Preprocess the Kubernetes Swagger document to preserve exec arguments, port-forward ports, custom-object patch media types, bearer-token aliases, Namespace-delete responses, and the missing GroupVersionKind definition. Namespace deletion can return a Namespace or Status, so leave its successful response as a parsed object instead of validating it against the wrong model. Replace the broad shell rewrites with focused Python post-processing that preserves prose, qualifies package references, accepts Kubernetes IntOrString and CRD JSON-schema unions without invalid coercion, accepts JSON Patch operation lists and generated model instances in PATCH methods, defers eager Pydantic call validators, preserves importlib metadata on lazy packages, and normalizes generated whitespace. --- .github/workflows/tests.yml | 5 +- openapi/apidiscovery_definitions.json | 17 +- openapi/postprocess_python.py | 217 +++++++++++++++++++++++++ openapi/preprocess_spec.py | 88 +++++++++- openapi/python.sh | 21 +-- openapi/python.xml | 20 ++- openapi/test_apidiscovery.py | 12 +- openapi/test_postprocess_python.py | 221 ++++++++++++++++++++++++++ openapi/test_preprocess.py | 162 +++++++++++++++++++ 9 files changed, 739 insertions(+), 24 deletions(-) create mode 100644 openapi/postprocess_python.py create mode 100644 openapi/test_postprocess_python.py create mode 100644 openapi/test_preprocess.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a1ca3dc..6fc540a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/openapi/apidiscovery_definitions.json b/openapi/apidiscovery_definitions.json index 35bb5aa..9099b09 100644 --- a/openapi/apidiscovery_definitions.json +++ b/openapi/apidiscovery_definitions.json @@ -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.", @@ -361,4 +376,4 @@ "verbs" ] } -} \ No newline at end of file +} diff --git a/openapi/postprocess_python.py b/openapi/postprocess_python.py new file mode 100644 index 0000000..18f054d --- /dev/null +++ b/openapi/postprocess_python.py @@ -0,0 +1,217 @@ +#!/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"(? 0: if operation['parameters'][0].get('in') == 'body': @@ -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) @@ -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": diff --git a/openapi/python.sh b/openapi/python.sh index 46ab7e3..8ca48bb 100755 --- a/openapi/python.sh +++ b/openapi/python.sh @@ -45,25 +45,16 @@ popd > /dev/null source "${SCRIPT_ROOT}/openapi-generator/client-generator.sh" source "${SETTING_FILE}" -OPENAPI_GENERATOR_COMMIT="${OPENAPI_GENERATOR_COMMIT:-v6.6.0}" +OPENAPI_GENERATOR_COMMIT="${OPENAPI_GENERATOR_COMMIT:-v7.24.0}" CLIENT_LANGUAGE=python; \ -CLEANUP_DIRS=(client/api client/apis client/models docs test); \ +CLEANUP_DIRS=(client/api client/models docs); \ kubeclient::generator::generate_client "${OUTPUT_DIR}" -echo "--- Patching generated code..." +echo "--- Post-processing generated code..." -# Post-processing of the generated Python wrapper. -find "${OUTPUT_DIR}/test" -type f -name \*.py -exec sed -i 's/\bclient/kubernetes.client/g' {} + -find "${OUTPUT_DIR}" -path "${OUTPUT_DIR}/base" -prune -o -type f -a -name \*.md -exec sed -i 's/\bclient/kubernetes.client/g' {} + -find "${OUTPUT_DIR}" -path "${OUTPUT_DIR}/base" -prune -o -type f -a -name \*.md -exec sed -i 's/kubernetes.client-python/client-python/g' {} + -find "${OUTPUT_DIR}" -path "${OUTPUT_DIR}/base" -prune -o -type f -a -name \*.md -exec sed -i 's/kubernetes-kubernetes.client/kubernetes-client/g' {} + - -# fix imports -if [ "${PACKAGE_NAME}" = client ]; then - find "${OUTPUT_DIR}/client/" -type f -name \*.py -exec sed -i 's/import client\./import kubernetes.client./g' {} + - find "${OUTPUT_DIR}/client/" -type f -name \*.py -exec sed -i 's/from client/from kubernetes.client/g' {} + - find "${OUTPUT_DIR}/client/" -type f -name \*.py -exec sed -i 's/getattr(client\.models/getattr(kubernetes.client.models/g' {} + -fi +python3 "${SCRIPT_ROOT}/postprocess_python.py" \ + "${OUTPUT_DIR}" \ + "${PACKAGE_NAME}" echo "---Done." diff --git a/openapi/python.xml b/openapi/python.xml index 3659b3f..bf42888 100644 --- a/openapi/python.xml +++ b/openapi/python.xml @@ -18,14 +18,32 @@ ${generator.spec.path} - python-legacy + python kubernetes-client python true + false + false + + $schema=schema + continue=_continue + except=_except + exec=_exec + field=field + from=_from + not=_not + schema=schema + + + continue=_continue + ${generator.package.name} ${generator.client.version} true + true + true + true ${generator.output.path} diff --git a/openapi/test_apidiscovery.py b/openapi/test_apidiscovery.py index a31b902..4dd510f 100644 --- a/openapi/test_apidiscovery.py +++ b/openapi/test_apidiscovery.py @@ -34,6 +34,7 @@ def test_apidiscovery_definitions_loaded(): spec = add_apidiscovery_definitions(spec) expected_defs = [ + 'io.k8s.apimachinery.pkg.apis.meta.v1.GroupVersionKind', 'io.k8s.api.apidiscovery.v2beta1.APIGroupDiscoveryList', 'io.k8s.api.apidiscovery.v2beta1.APIGroupDiscovery', 'io.k8s.api.apidiscovery.v2beta1.APIVersionDiscovery', @@ -51,6 +52,13 @@ def test_apidiscovery_definitions_loaded(): print(f" ✗ FAILED: {def_name} not found") return False print(f" ✓ {def_name}") + + gvk = spec['definitions'][ + 'io.k8s.apimachinery.pkg.apis.meta.v1.GroupVersionKind' + ] + if set(gvk.get('properties', {})) != {'group', 'version', 'kind'}: + print(" ✗ FAILED: GroupVersionKind has incomplete properties") + return False print(" ✓ Test 1 PASSED\n") return True @@ -73,10 +81,6 @@ def test_apidiscovery_in_processed_spec(): 'io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta': { 'type': 'object', 'properties': {} - }, - 'io.k8s.apimachinery.pkg.apis.meta.v1.GroupVersionKind': { - 'type': 'object', - 'properties': {} } } } diff --git a/openapi/test_postprocess_python.py b/openapi/test_postprocess_python.py new file mode 100644 index 0000000..ca383d2 --- /dev/null +++ b/openapi/test_postprocess_python.py @@ -0,0 +1,221 @@ +#!/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 subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +class PythonPostprocessingTest(unittest.TestCase): + def test_rewrites_generated_package_references(self): + with tempfile.TemporaryDirectory() as temporary_directory: + output_directory = Path(temporary_directory) + docs_directory = output_directory / "docs" + package_directory = output_directory / "client" + docs_directory.mkdir() + package_directory.mkdir() + + readme = output_directory / "README.md" + model_doc = docs_directory / "V1Pod.md" + service_doc = docs_directory / "V1ServicePort.md" + json_schema_doc = docs_directory / "V1JSONSchemaProps.md" + package_init = package_directory / "__init__.py" + module = package_directory / "api_client.py" + service_model = package_directory / "v1_service_port.py" + tcp_socket_model = package_directory / "v1_tcp_socket_action.py" + json_schema_model = ( + package_directory / "v1_json_schema_props.py" + ) + readme.write_text( + "# client\n\n" + "Use client.CoreV1Api. \n" + "import client\n" + "from client.api_client import ApiClient\n" + "The client is configured here.\n" + "This is a generated client.\n" + "Install client-python or kubernetes-client.\n" + "Already qualified: kubernetes.client.CoreV1Api.\n\n" + ) + model_doc.write_text("See client.V1Pod.\t\n\n") + service_doc.write_text( + "**target_port** | **object** | target | [optional]\n" + "**metadata** | **object** | metadata | [optional]\n" + ) + json_schema_doc.write_text( + "**additional_items** | **object** | items | [optional]\n" + "**additional_properties** | **object** | props | [optional]\n" + "**default** | **object** | default | [optional]\n" + "**dependencies** | **Dict[str, object]** | deps | [optional]\n" + "**enum** | **List[object]** | enum | [optional]\n" + "**example** | **object** | example | [optional]\n" + "**items** | **object** | items | [optional]\n" + "**properties** | **Dict[str, object]** | props | [optional]\n" + ) + package_init.write_text( + 'if __import__("typing").TYPE_CHECKING:\n' + " from client.api_client import ApiClient\n" + "else:\n" + " from lazy_imports import LazyModule, as_package, load\n" + " load(LazyModule(*as_package(__file__), name=__name__))\n" + ) + module.write_text( + "from pydantic import validate_call, Field\n" + "import client.models \n" + "from client.api_client import ApiClient\n" + 'MODEL = getattr(client.models, "V1Pod")\n\n' + "class CoreV1Api:\n" + " @validate_call\n" + " def patch_namespaced_pod(\n" + " self,\n" + " body: Dict[str, Any],\n" + " ): ...\n\n" + " def patch_custom_object(\n" + " self,\n" + ' body: Annotated[Dict[str, Any], Field(description="patch")],\n' + " ): ...\n\n" + " def create_namespaced_pod(\n" + " self,\n" + " body: Dict[str, Any],\n" + " ): ...\n\n" + ) + service_model.write_text( + "from pydantic import Field, StrictInt\n\n" + "class V1ServicePort:\n" + " target_port: Optional[Dict[str, Any]] = None\n" + " metadata: Optional[Dict[str, Any]] = None\n" + ) + tcp_socket_model.write_text( + "from pydantic import Field, StrictStr\n\n" + "class V1TCPSocketAction:\n" + " port: Dict[str, Any]\n" + ) + json_schema_model.write_text( + "class V1JSONSchemaProps:\n" + " additional_items: Optional[Dict[str, Any]] = None\n" + " additional_properties: Optional[Dict[str, Any]] = None\n" + " default: Optional[Dict[str, Any]] = None\n" + " dependencies: Optional[Dict[str, Dict[str, Any]]] = None\n" + " enum: Optional[List[Dict[str, Any]]] = None\n" + " example: Optional[Dict[str, Any]] = None\n" + " items: Optional[Dict[str, Any]] = None\n" + " properties: Optional[Dict[str, Dict[str, Any]]] = None\n" + ) + + for _ in range(2): + subprocess.run( + [ + sys.executable, + str(Path(__file__).with_name("postprocess_python.py")), + str(output_directory), + "client", + ], + check=True, + ) + + self.assertEqual( + "# kubernetes.client\n\n" + "Use kubernetes.client.CoreV1Api.\n" + "import kubernetes.client\n" + "from kubernetes.client.api_client import ApiClient\n" + "The client is configured here.\n" + "This is a generated client.\n" + "Install client-python or kubernetes-client.\n" + "Already qualified: kubernetes.client.CoreV1Api.\n", + readme.read_text(), + ) + self.assertEqual( + "See kubernetes.client.V1Pod.\n", + model_doc.read_text(), + ) + self.assertEqual( + "**target_port** | **Union[int, str]** | target | [optional]\n" + "**metadata** | **object** | metadata | [optional]\n", + service_doc.read_text(), + ) + self.assertEqual( + "**additional_items** | **Union[Dict[str, Any], bool]** | items | [optional]\n" + "**additional_properties** | **Union[Dict[str, Any], bool]** | props | [optional]\n" + "**default** | **Any** | default | [optional]\n" + "**dependencies** | **Dict[str, Union[Dict[str, Any], List[str]]]** | deps | [optional]\n" + "**enum** | **List[Any]** | enum | [optional]\n" + "**example** | **Any** | example | [optional]\n" + "**items** | **Union[Dict[str, Any], List[Dict[str, Any]]]** | items | [optional]\n" + "**properties** | **Dict[str, object]** | props | [optional]\n", + json_schema_doc.read_text(), + ) + self.assertEqual( + 'if __import__("typing").TYPE_CHECKING:\n' + " from kubernetes.client.api_client import ApiClient\n" + "else:\n" + " from lazy_imports import LazyModule, as_package, load\n" + " load(LazyModule(*as_package(__file__), name=__name__))\n" + '\n _lazy_module = __import__("sys").modules[__name__]\n' + " _lazy_module.__package__ = __package__\n" + " _lazy_module.__loader__ = __loader__\n" + " _lazy_module.__spec__ = __spec__\n", + package_init.read_text(), + ) + self.assertEqual( + "from pydantic import BaseModel, validate_call, Field\n" + "import kubernetes.client.models\n" + "from kubernetes.client.api_client import ApiClient\n" + 'MODEL = getattr(kubernetes.client.models, "V1Pod")\n\n' + "class CoreV1Api:\n" + " @validate_call(config={'defer_build': True})\n" + " def patch_namespaced_pod(\n" + " self,\n" + " body: Union[Dict[str, Any], List[Dict[str, Any]], BaseModel],\n" + " ): ...\n\n" + " def patch_custom_object(\n" + " self,\n" + ' body: Annotated[Union[Dict[str, Any], List[Dict[str, Any]], BaseModel], Field(description="patch")],\n' + " ): ...\n\n" + " def create_namespaced_pod(\n" + " self,\n" + " body: Dict[str, Any],\n" + " ): ...\n", + module.read_text(), + ) + self.assertEqual( + "from pydantic import Field, StrictInt, StrictStr\n\n" + "class V1ServicePort:\n" + " target_port: Optional[StrictInt | StrictStr] = None\n" + " metadata: Optional[Dict[str, Any]] = None\n", + service_model.read_text(), + ) + self.assertEqual( + "from pydantic import Field, StrictInt, StrictStr\n\n" + "class V1TCPSocketAction:\n" + " port: StrictInt | StrictStr\n", + tcp_socket_model.read_text(), + ) + self.assertEqual( + "class V1JSONSchemaProps:\n" + " additional_items: Optional[Dict[str, Any] | StrictBool] = None\n" + " additional_properties: Optional[Dict[str, Any] | StrictBool] = None\n" + " default: Any = None\n" + " dependencies: Optional[Dict[str, Dict[str, Any] | List[str]]] = None\n" + " enum: Optional[List[Any]] = None\n" + " example: Any = None\n" + " items: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None\n" + " properties: Optional[Dict[str, Dict[str, Any]]] = None\n", + json_schema_model.read_text(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/openapi/test_preprocess.py b/openapi/test_preprocess.py new file mode 100644 index 0000000..bd67abe --- /dev/null +++ b/openapi/test_preprocess.py @@ -0,0 +1,162 @@ +#!/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 copy +import unittest + +from preprocess_spec import process_swagger + + +class PythonPreprocessingTest(unittest.TestCase): + def test_preserves_python_client_contracts(self): + exec_path = '/api/v1/namespaces/{namespace}/pods/{name}/exec' + portforward_path = ( + '/api/v1/namespaces/{namespace}/pods/{name}/portforward' + ) + namespace_path = '/api/v1/namespaces/{name}' + spec = { + 'swagger': '2.0', + 'info': {'version': 'v1.0.0'}, + 'paths': { + exec_path: { + 'parameters': [{ + 'description': 'argv array', + 'in': 'query', + 'name': 'command', + 'type': 'string', + }], + 'get': { + 'operationId': 'connectCoreV1GetNamespacedPodExec', + 'responses': {'200': {'description': 'OK'}}, + 'tags': ['core_v1'], + }, + }, + portforward_path: { + 'parameters': [{ + 'description': 'comma-separated ports', + 'format': 'int32', + 'in': 'query', + 'name': 'ports', + 'type': 'integer', + }], + 'get': { + 'operationId': ( + 'connectCoreV1GetNamespacedPodPortforward' + ), + 'responses': {'200': {'description': 'OK'}}, + 'tags': ['core_v1'], + }, + }, + namespace_path: { + 'delete': { + 'operationId': 'deleteCoreV1Namespace', + 'responses': { + status: { + 'description': 'OK', + 'schema': { + '$ref': ( + '#/definitions/io.k8s.apimachinery.' + 'pkg.apis.meta.v1.Status' + ), + }, + } + for status in ('200', '202') + }, + 'tags': ['core_v1'], + }, + }, + }, + 'definitions': { + 'io.k8s.apimachinery.pkg.apis.meta.v1.GroupVersionKind': { + 'type': 'object', + 'properties': {}, + }, + 'io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta': { + 'type': 'object', + 'properties': {}, + }, + 'io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta': { + 'type': 'object', + 'properties': {}, + }, + 'io.k8s.apimachinery.pkg.apis.meta.v1.Status': { + 'type': 'object', + 'properties': {}, + }, + 'io.k8s.api.core.v1.Namespace': { + 'type': 'object', + 'properties': {}, + }, + }, + 'securityDefinitions': { + 'BearerToken': { + 'in': 'header', + 'name': 'authorization', + 'type': 'apiKey', + }, + }, + } + + processed = process_swagger(copy.deepcopy(spec), 'python') + non_python = process_swagger(copy.deepcopy(spec), 'java') + + command = processed['paths'][exec_path]['parameters'][0] + self.assertEqual('array', command['type']) + self.assertEqual({'type': 'string'}, command['items']) + self.assertEqual('multi', command['collectionFormat']) + ports = processed['paths'][portforward_path]['parameters'][0] + self.assertEqual('string', ports['type']) + self.assertNotIn('format', ports) + self.assertEqual( + 'authorization', + processed['securityDefinitions']['BearerToken'][ + 'x-auth-id-alias' + ], + ) + self.assertEqual( + ['application/merge-patch+json'], + processed['paths'][ + '/apis/{group}/{version}/{plural}/{name}' + ]['patch']['consumes'], + ) + self.assertNotIn( + 'x-auth-id-alias', + non_python['securityDefinitions']['BearerToken'], + ) + self.assertEqual( + 'string', + non_python['paths'][exec_path]['parameters'][0]['type'], + ) + self.assertEqual( + 'integer', + non_python['paths'][portforward_path]['parameters'][0]['type'], + ) + for status in ('200', '202'): + self.assertEqual( + {'type': 'object'}, + processed['paths'][namespace_path]['delete'][ + 'responses' + ][status]['schema'], + ) + self.assertEqual( + {'$ref': '#/definitions/v1.Status'}, + non_python['paths'][namespace_path]['delete'][ + 'responses' + ][status]['schema'], + ) + + +if __name__ == '__main__': + unittest.main() From 6282bad1a808d03bba10dd633ae6fc0e228788cd Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Mon, 27 Jul 2026 10:00:22 -0400 Subject: [PATCH 2/2] Use dependency-free lazy Python client generation --- openapi/postprocess_python.py | 14 ------------ openapi/python.sh | 2 +- openapi/test_postprocess_python.py | 36 +++++++++++++++++++++--------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/openapi/postprocess_python.py b/openapi/postprocess_python.py index 18f054d..6727451 100644 --- a/openapi/postprocess_python.py +++ b/openapi/postprocess_python.py @@ -129,20 +129,6 @@ "@validate_call\n", "@validate_call(config={'defer_build': True})\n", ) - if ( - path.name == "__init__.py" - and "from lazy_imports import LazyModule, as_package, load" in text - and "_lazy_module.__spec__ = __spec__" not in text - ): - # LazyModule replaces sys.modules without carrying importlib's - # package metadata, which breaks find_spec and resources.files. - text += ( - '\n _lazy_module = __import__("sys").modules[__name__]\n' - " _lazy_module.__package__ = __package__\n" - " _lazy_module.__loader__ = __loader__\n" - " _lazy_module.__spec__ = __spec__\n" - ) - # Generated files otherwise fail git diff --check on trailing whitespace # and extra blank lines at EOF. lines = text.splitlines() diff --git a/openapi/python.sh b/openapi/python.sh index 8ca48bb..8be21c7 100755 --- a/openapi/python.sh +++ b/openapi/python.sh @@ -45,7 +45,7 @@ popd > /dev/null source "${SCRIPT_ROOT}/openapi-generator/client-generator.sh" source "${SETTING_FILE}" -OPENAPI_GENERATOR_COMMIT="${OPENAPI_GENERATOR_COMMIT:-v7.24.0}" +OPENAPI_GENERATOR_COMMIT="${OPENAPI_GENERATOR_COMMIT:-830e9d156960bb7f51a5337f31636a7e73226474}" CLIENT_LANGUAGE=python; \ CLEANUP_DIRS=(client/api client/models docs); \ diff --git a/openapi/test_postprocess_python.py b/openapi/test_postprocess_python.py index ca383d2..3b4ca91 100644 --- a/openapi/test_postprocess_python.py +++ b/openapi/test_postprocess_python.py @@ -66,11 +66,21 @@ def test_rewrites_generated_package_references(self): "**properties** | **Dict[str, object]** | props | [optional]\n" ) package_init.write_text( - 'if __import__("typing").TYPE_CHECKING:\n' + '__all__ = ["ApiClient"]\n\n' + "import typing as _typing\n\n" + "if _typing.TYPE_CHECKING:\n" " from client.api_client import ApiClient\n" "else:\n" - " from lazy_imports import LazyModule, as_package, load\n" - " load(LazyModule(*as_package(__file__), name=__name__))\n" + " from importlib import import_module\n\n" + ' _exports = {"ApiClient": ".api_client"}\n\n' + " def __getattr__(name: str) -> object:\n" + " if (module_name := _exports.get(name)) is None:\n" + ' raise AttributeError(f"module {__name__!r} has no attribute {name!r}")\n' + " value = getattr(import_module(module_name, __name__), name)\n" + " globals()[name] = value\n" + " return value\n\n" + " def __dir__() -> list[str]:\n" + " return sorted(globals().keys() | _exports.keys())\n" ) module.write_text( "from pydantic import validate_call, Field\n" @@ -158,15 +168,21 @@ def test_rewrites_generated_package_references(self): json_schema_doc.read_text(), ) self.assertEqual( - 'if __import__("typing").TYPE_CHECKING:\n' + '__all__ = ["ApiClient"]\n\n' + "import typing as _typing\n\n" + "if _typing.TYPE_CHECKING:\n" " from kubernetes.client.api_client import ApiClient\n" "else:\n" - " from lazy_imports import LazyModule, as_package, load\n" - " load(LazyModule(*as_package(__file__), name=__name__))\n" - '\n _lazy_module = __import__("sys").modules[__name__]\n' - " _lazy_module.__package__ = __package__\n" - " _lazy_module.__loader__ = __loader__\n" - " _lazy_module.__spec__ = __spec__\n", + " from importlib import import_module\n\n" + ' _exports = {"ApiClient": ".api_client"}\n\n' + " def __getattr__(name: str) -> object:\n" + " if (module_name := _exports.get(name)) is None:\n" + ' raise AttributeError(f"module {__name__!r} has no attribute {name!r}")\n' + " value = getattr(import_module(module_name, __name__), name)\n" + " globals()[name] = value\n" + " return value\n\n" + " def __dir__() -> list[str]:\n" + " return sorted(globals().keys() | _exports.keys())\n", package_init.read_text(), ) self.assertEqual(