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
195 changes: 186 additions & 9 deletions openapi/postprocess_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@
)
parser.add_argument("output_directory", type=Path)
parser.add_argument("package_name")
parser.add_argument("library", choices=("urllib3", "asyncio"))
parser.add_argument(
"namespace",
choices=("kubernetes", "kubernetes.aio", "kubernetes_asyncio"),
)
args = parser.parse_args()

qualified_package_name = f"{args.namespace}.{args.package_name}"

# 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:
Expand Down Expand Up @@ -91,16 +98,18 @@
if file_type == "markdown":
if path.name == "README.md":
text = re.sub(
r"\A# client(?=\r?\n|\Z)", "# kubernetes.client", text
r"\A# client(?=\r?\n|\Z)",
f"# {qualified_package_name}",
text,
)
text = re.sub(
r"(?<![\w.-])client(?=\.[A-Za-z_])",
"kubernetes.client",
qualified_package_name,
text,
)
text = re.sub(
r"(?m)^(\s*(?:from|import)\s+)client(?=[.\s]|$)",
r"\1kubernetes.client",
rf"\1{qualified_package_name}",
text,
)
for field in int_or_string_fields.get(path.stem.lower(), ()):
Expand All @@ -117,10 +126,15 @@
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"
"import client.", f"import {qualified_package_name}."
)
text = text.replace(
"from client", f"from {qualified_package_name}"
)
text = text.replace(
"getattr(client.models",
f"getattr({qualified_package_name}.models",
)
# Building every API method's Pydantic schema at import time is
# expensive. defer_build is supported for validate_call since 2.11:
Expand All @@ -129,6 +143,164 @@
"@validate_call\n",
"@validate_call(config={'defer_build': True})\n",
)
if args.library == "asyncio":
# Carry forward the v6 asyncio compatibility patches:
# https://github.com/kubernetes-client/python/tree/ed9ffd4c3c4bee083c1a585a9559257ea02700eb/scripts/asyncio
if path.name == "configuration.py":
# client_configuration_async_refresh_api_key_hook.diff:
# Await token hooks and the resulting authentication settings.
if "import asyncio\n" not in text:
text = text.replace(
"import aiohttp\n", "import asyncio\nimport aiohttp\n"
)
text = text.replace(
" def get_api_key_with_prefix(",
" async def get_api_key_with_prefix(",
)
text = text.replace(
Comment thread
tamird marked this conversation as resolved.
" if self.refresh_api_key_hook is not None:\n"
" self.refresh_api_key_hook(self)\n",
" if self.refresh_api_key_hook is not None:\n"
" result = self.refresh_api_key_hook(self)\n"
" if asyncio.iscoroutine(result):\n"
" await result\n",
)
text = text.replace(
" def auth_settings(", " async def auth_settings("
)
text = text.replace(
"'value': self.get_api_key_with_prefix(\n",
"'value': await self.get_api_key_with_prefix(\n",
)
# client_configuration_disable_ssl_strict_verification_patch.diff:
# Expose the opt-in setting consumed by the REST transport.
if "self.disable_strict_ssl_verification = False\n" not in text:
Comment thread
tamird marked this conversation as resolved.
text = text.replace(
" self.verify_ssl = verify_ssl\n",
" self.verify_ssl = verify_ssl\n"
" self.disable_strict_ssl_verification = False\n",
)
elif path.name == "api_client.py":
# api_client_async_refresh_api_key_hook.diff:
# Propagate asynchronous authentication into serialization.
text = text.replace(
" def param_serialize(",
" async def param_serialize(",
)
text = text.replace(
" self.update_params_for_auth(\n",
" await self.update_params_for_auth(\n",
)
Comment thread
tamird marked this conversation as resolved.
text = text.replace(
" def update_params_for_auth(\n",
" async def update_params_for_auth(\n",
)
text = text.replace(
"self.configuration.auth_settings().get(auth)",
"(await self.configuration.auth_settings()).get(auth)",
)
# Keep raw WebSocket responses available to exec and attach.
text = text.replace(
" _request_timeout=None\n"
" ) -> rest.RESTResponse:\n",
" _request_timeout=None,\n"
" _preload_content=True,\n"
" ) -> rest.RESTResponse:\n",
)
elif path.name == "rest.py":
# rest_client_patch_read_bufsize.diff:
# Accept Kubernetes watch events larger than the aiohttp default.
if '"read_bufsize": 2**21,' not in text:
Comment thread
tamird marked this conversation as resolved.
text = text.replace(
' "trust_env": True,\n',
' "trust_env": True,\n'
" # Kubernetes watch events can exceed "
"aiohttp's default buffer.\n"
' "read_bufsize": 2**21,\n',
)
# rest_client_disable_ssl_strict_verification_patch.diff:
# Apply the explicit certificate-verification opt-out.
if "if configuration.disable_strict_ssl_verification:\n" not in text:
text = text.replace(
" self.proxy = configuration.proxy\n",
" if configuration.disable_strict_ssl_verification:\n"
" self.ssl_context.verify_flags "
"&= ~ssl.VERIFY_X509_STRICT\n\n"
" self.proxy = configuration.proxy\n",
)
# rest_client_server_hostname_patch.diff:
# Forward the configured Kubernetes API TLS server name.
if 'args["server_hostname"]' not in text:
text = text.replace(
" if self.proxy:\n",
" if self.configuration.tls_server_name:\n"
' args["server_hostname"] = '
"self.configuration.tls_server_name\n\n"
" if self.proxy:\n",
)
# api_client_strategic_merge_patch.diff:
# Select strategic merge for object-valued PATCH requests.
if (
"# JSON Patch contains operations; "
"object patches use strategic merge.\n"
) not in text:
text = text.replace(
" # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE`\n",
" # JSON Patch contains operations; "
"object patches use strategic merge.\n"
" # https://kubernetes.io/docs/reference/using-api/api-concepts/#updates-to-existing-resources\n"
" if (\n"
" method == 'PATCH'\n"
" and headers['Content-Type'] == "
"'application/json-patch+json'\n"
" and not isinstance(body, list)\n"
" ):\n"
" headers['Content-Type'] = "
"'application/strategic-merge-patch+json'\n\n"
" # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE`\n",
)
# rest_client_apply_patch_patch.diff:
# Serialize server-side apply bodies as YAML.
if (
"headers['Content-Type'] == "
"'application/apply-patch+yaml'"
) not in text:
text = text.replace(
" if re.search('json', headers['Content-Type'], "
"re.IGNORECASE):\n",
" if (\n"
" re.search('json', headers['Content-Type'], "
"re.IGNORECASE)\n"
" or headers['Content-Type'] == "
"'application/apply-patch+yaml'\n"
" ):\n",
)
elif path.parent.name == "api":
text = re.sub(
r"(?m)^ def (_[A-Za-z_][A-Za-z0-9_]*_serialize\()",
r" async def \1",
text,
)
text = text.replace(
" _param = self._",
" _param = await self._",
)
text = text.replace(
" return self.api_client.param_serialize(\n",
" return await self.api_client.param_serialize(\n",
)
# Websocket exec and attach must distinguish buffered calls
# from the generated raw-response operations.
text = re.sub(
r"(?ms)(^ async def "
r"[A-Za-z_][A-Za-z0-9_]*_without_preload_content\("
r".*?^ response_data = "
r"await self\.api_client\.call_api\(\n"
r" \*_param,\n)"
r"(?! _preload_content=False,\n)",
r"\1 _preload_content=False,\n",
text,
)
# Generated files otherwise fail git diff --check on trailing whitespace
# and extra blank lines at EOF.
lines = text.splitlines()
Expand Down Expand Up @@ -172,7 +344,10 @@
)
for line in lines
]
if any(line.startswith(" def patch_") for line in lines):
if any(
line.startswith((" def patch_", " async def patch_"))
for line in lines
):
lines = [
line.replace(
"from pydantic import validate_call, ",
Expand All @@ -182,8 +357,10 @@
]
patch_method = False
for index, line in enumerate(lines):
if line.startswith(" def "):
patch_method = line.startswith(" def patch_")
if line.startswith((" def ", " async def ")):
patch_method = line.startswith(
(" def patch_", " async 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:
Expand Down
31 changes: 27 additions & 4 deletions openapi/preprocess_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,25 @@ def fix_python_namespace_delete_response(operation, _):
response['schema'] = {'type': 'object'}


def fix_python_aio_delete_response(operation, _):
if operation.get('x-kubernetes-action') != 'delete':
return
# Resource deletion can return the deleted object or Status, but Swagger 2
# cannot describe that union. Preserve either response as an object:
# 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
and response.get('schema', {}).get('$ref') in {
'#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.Status',
'#/definitions/v1.Status',
}
):
response['schema'] = {'type': 'object'}


def clean_crd_meta(spec):
for k, v in spec['definitions'].items():
if k.endswith('List'):
Expand All @@ -216,7 +235,7 @@ def clean_crd_meta(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':
if client_language in ('python', 'python-aio'):
for path_item in custom_objects_spec.values():
patch = path_item.get('patch')
if patch is not None:
Expand Down Expand Up @@ -249,7 +268,7 @@ def add_apidiscovery_definitions(spec):


def add_bearer_token_alias(spec, client_language):
if client_language != 'python':
if client_language not in ('python', 'python-aio'):
return spec
bearer_token = spec.get('securityDefinitions', {}).get('BearerToken')
if bearer_token is not None:
Expand Down Expand Up @@ -322,12 +341,16 @@ def process_swagger(spec, client_language, crd_mode=False):

expand_parameters(spec)

if client_language == 'python':
if client_language in ('python', 'python-aio'):
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)
spec,
fix_python_aio_delete_response
if client_language == 'python-aio'
else fix_python_namespace_delete_response,
)

apply_func_to_spec_operations(spec, strip_tags_from_operation_id)

Expand Down
34 changes: 9 additions & 25 deletions openapi/python-aio.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,34 +45,18 @@ source "${SETTING_FILE}"

# use openapi-generator to generate library
source "${SCRIPT_ROOT}/openapi-generator/client-generator.sh"
OPENAPI_GENERATOR_COMMIT="${OPENAPI_GENERATOR_COMMIT:-v6.6.0}"
OPENAPI_GENERATOR_COMMIT="${OPENAPI_GENERATOR_COMMIT:-830e9d156960bb7f51a5337f31636a7e73226474}"

CLIENT_LANGUAGE=python-asyncio
CLEANUP_DIRS=(client/apis client/models docs test)
CLIENT_LANGUAGE=python-aio
CLEANUP_DIRS=(client/api client/models docs)
kubeclient::generator::generate_client "${OUTPUT_DIR}"

# Generic patches to the generated Python code, most notably renaming the library.
echo "--- Patching generated code..."
echo "--- Post-processing generated code..."

if [ ${PACKAGE_NAME} == "client" ]; then

# Post-processing of the generated Python wrapper.
find "${OUTPUT_DIR}/test" -type f -name \*.py -exec sed -i 's/\bclient/kubernetes.aio.client/g' {} +
find "${OUTPUT_DIR}" -path "${OUTPUT_DIR}/base" -prune -o -type f -a -name \*.md -exec sed -i 's/\bclient/kubernetes.aio.client/g' {} +
find "${OUTPUT_DIR}" -path "${OUTPUT_DIR}/base" -prune -o -type f -a -name \*.md -exec sed -i 's/kubernetes.aio.client-python/client-python/g' {} +

# fix imports
find "${OUTPUT_DIR}/client/" -type f -name \*.py -exec sed -i 's/import client\./import kubernetes.aio.client./g' {} +
find "${OUTPUT_DIR}/client/" -type f -name \*.py -exec sed -i 's/from client/from kubernetes.aio.client/g' {} +
find "${OUTPUT_DIR}/client/" -type f -name \*.py -exec sed -i 's/getattr(client\.models/getattr(kubernetes.aio.client.models/g' {} +

else

# Post-processing of the generated Python wrapper.
find "${OUTPUT_DIR}/test" -type f -name \*.py -exec sed -i "s/\\bclient/${PACKAGE_NAME}.client/g" {} +
find "${OUTPUT_DIR}" -path "${OUTPUT_DIR}/base" -prune -o -type f -a -name \*.md -exec sed -i "s/\\bclient/${PACKAGE_NAME}.client/g" {} +
find "${OUTPUT_DIR}" -path "${OUTPUT_DIR}/base" -prune -o -type f -a -name \*.md -exec sed -i "s/${PACKAGE_NAME}.client-python/client-python/g" {} +

fi
python3 "${SCRIPT_ROOT}/postprocess_python.py" \
"${OUTPUT_DIR}" \
"${PACKAGE_NAME}" \
asyncio \
kubernetes.aio

echo "---Done."
Loading
Loading