diff --git a/.github/actions/generate-sdk/action.yaml b/.github/actions/generate-sdk/go/action.yaml
similarity index 100%
rename from .github/actions/generate-sdk/action.yaml
rename to .github/actions/generate-sdk/go/action.yaml
diff --git a/.github/actions/generate-sdk/python/action.yaml b/.github/actions/generate-sdk/python/action.yaml
new file mode 100644
index 0000000..8a293b6
--- /dev/null
+++ b/.github/actions/generate-sdk/python/action.yaml
@@ -0,0 +1,15 @@
+name: Generate SDK
+description: "Generates the Python SDK"
+inputs:
+ python-version:
+ description: "Python version to install"
+ required: true
+runs:
+ using: "composite"
+ steps:
+ - name: Download OAS
+ shell: bash
+ run: make download-oas
+ - name: Generate SDK
+ shell: bash
+ run: make generate-sdk LANGUAGE=python
\ No newline at end of file
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 4eeb9b0..503980e 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -7,8 +7,8 @@ env:
JAVA_VERSION: "11"
jobs:
- main:
- name: CI
+ main-go:
+ name: CI [Go]
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
@@ -32,7 +32,7 @@ jobs:
with:
go-version: ${{ env.GO_VERSION_BUILD }}
- name: Generate SDK
- uses: ./.github/actions/generate-sdk
+ uses: ./.github/actions/generate-sdk/go
- name: Install Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
with:
@@ -44,3 +44,43 @@ jobs:
- name: Test
working-directory: ./sdk-repo-updated
run: make test skip-non-generated-files=true
+ main-python:
+ name: CI [Python]
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - name: Install SSH Key
+ uses: shimataro/ssh-key-action@v2
+ with:
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ known_hosts: ${{ vars.SSH_KNOWN_HOSTS }}
+ - name: Install Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: "temurin"
+ java-version: ${{ env.JAVA_VERSION }}
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Build
+ uses: ./.github/actions/build
+ with:
+ go-version: ${{ env.GO_VERSION_BUILD }}
+ - name: Generate SDK
+ uses: ./.github/actions/generate-sdk/python
+ - name: Install Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install core
+ run: |
+ git clone https://github.com/stackitcloud/stackit-sdk-python-core.git core
+ cd core;make install-dev;
+ - name: Lint
+ working-directory: ./sdk-repo-updated
+ run: make lint
+ - name: Test
+ working-directory: ./sdk-repo-updated
+ run: make test
diff --git a/.github/workflows/sdk-pr.yaml b/.github/workflows/sdk-pr.yaml
index f3705d1..aed22cf 100644
--- a/.github/workflows/sdk-pr.yaml
+++ b/.github/workflows/sdk-pr.yaml
@@ -11,7 +11,7 @@ env:
JAVA_VERSION: '11'
jobs:
- main:
+ main-go:
name: Update SDK Repo
runs-on: ubuntu-latest
steps:
@@ -32,10 +32,38 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Generate SDK
- uses: ./.github/actions/generate-sdk
+ uses: ./.github/actions/generate-sdk/go
- name: Push SDK
env:
GH_REPO: 'stackitcloud/stackit-sdk-go'
GH_TOKEN: ${{ secrets.SDK_PR_TOKEN }}
run: |
- scripts/sdk-create-pr.sh "generator-bot-${{ github.run_id }}" "Generated from GitHub run [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
\ No newline at end of file
+ scripts/sdk-create-pr.sh "generator-bot-${{ github.run_id }}" "Generated from GitHub run [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
+ main-python:
+ name: Update SDK Repo
+ runs-on: ubuntu-latest
+ steps:
+ - name: Install SSH Key
+ uses: shimataro/ssh-key-action@v2
+ with:
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ known_hosts: ${{ vars.SSH_KNOWN_HOSTS }}
+ - name: Install Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: ${{ env.JAVA_VERSION }}
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Build
+ uses: ./.github/actions/build
+ with:
+ go-version: ${{ env.GO_VERSION }}
+ - name: Generate SDK
+ uses: ./.github/actions/generate-sdk/python
+ - name: Push SDK
+ env:
+ GH_REPO: 'stackitcloud/stackit-sdk-python'
+ GH_TOKEN: ${{ secrets.SDK_PR_TOKEN }}
+ run: |
+ scripts/sdk-create-pr.sh "generator-bot-${{ github.run_id }}" "Generated from GitHub run [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" "git@github.com:stackitcloud/stackit-sdk-python.git" "python"
\ No newline at end of file
diff --git a/scripts/generate-sdk/.openapi-generator-ignore b/scripts/generate-sdk/.openapi-generator-ignore
index b4a7ed7..a1491ee 100644
--- a/scripts/generate-sdk/.openapi-generator-ignore
+++ b/scripts/generate-sdk/.openapi-generator-ignore
@@ -5,4 +5,11 @@ git_push.sh
README.md
response.go
api/openapi.yaml
-.openapi-generator/*
\ No newline at end of file
+.openapi-generator/*
+.gitlab-ci.yml
+setup.cfg
+setup.py
+test-requirements.txt
+requirements.txt
+tox.ini
+*/.github/*
\ No newline at end of file
diff --git a/scripts/generate-sdk/generate-sdk.sh b/scripts/generate-sdk/generate-sdk.sh
index 0580fa8..89caff9 100755
--- a/scripts/generate-sdk/generate-sdk.sh
+++ b/scripts/generate-sdk/generate-sdk.sh
@@ -15,8 +15,6 @@ ROOT_DIR=$(git rev-parse --show-toplevel)
GENERATOR_PATH="${ROOT_DIR}/scripts/bin"
LANGUAGE_GENERATORS_FOLDER_PATH="${ROOT_DIR}/scripts/generate-sdk/languages/"
# Renovate: datasource=github-tags depName=OpenAPITools/openapi-generator versioning=semver
-GENERATOR_VERSION="v6.6.0"
-GENERATOR_VERSION_NUMBER="${GENERATOR_VERSION:1}"
# Check parameters and set defaults if not provided
if [[ -z ${GIT_HOST} ]]; then
@@ -46,6 +44,21 @@ if [ ! -d ${ROOT_DIR}/oas ]; then
echo "\"oas\" folder not found in root. Please add it manually or run \"make download-oas\"."
exit 1
fi
+# Choose generator version depending on the language
+# Renovate: datasource=github-tags depName=OpenAPITools/openapi-generator versioning=semver
+case "${LANGUAGE}" in
+go)
+ GENERATOR_VERSION="v6.6.0" # There are issues with GO SDK generation in version v7
+ ;;
+python)
+ GENERATOR_VERSION="v7.7.0"
+ ;;
+*)
+ echo "SDK language not supported."
+ exit 1
+ ;;
+esac
+GENERATOR_VERSION_NUMBER="${GENERATOR_VERSION:1}"
# Download OpenAPI generator if not already downloaded
jar_path="${GENERATOR_PATH}/openapi-generator-cli.jar"
@@ -67,6 +80,13 @@ go)
# Usage: generate_go_sdk GENERATOR_PATH GIT_HOST GIT_USER_ID [GIT_REPO_ID] [SDK_REPO_URL]
generate_go_sdk ${jar_path} ${GIT_HOST} ${GIT_USER_ID} ${GIT_REPO_ID} ${SDK_REPO_URL}
;;
+python)
+ echo -e "\nGenerating the Python SDK...\n"
+
+ source ${LANGUAGE_GENERATORS_FOLDER_PATH}/${LANGUAGE}.sh
+ # Usage: generate_python_sdk GENERATOR_PATH GIT_HOST GIT_USER_ID [GIT_REPO_ID] [SDK_REPO_URL]
+ generate_python_sdk ${jar_path} ${GIT_HOST} ${GIT_USER_ID} ${GIT_REPO_ID} ${SDK_REPO_URL}
+ ;;
*)
echo "SDK language not supported."
exit 1
diff --git a/scripts/generate-sdk/languages/python.sh b/scripts/generate-sdk/languages/python.sh
new file mode 100644
index 0000000..aa35204
--- /dev/null
+++ b/scripts/generate-sdk/languages/python.sh
@@ -0,0 +1,149 @@
+#!/bin/bash
+# This script clones the SDK repo and updates it with the generated API modules
+# Pre-requisites: Java, goimports, Go
+set -eo pipefail
+
+ROOT_DIR=$(git rev-parse --show-toplevel)
+SDK_REPO_LOCAL_PATH="${ROOT_DIR}/sdk-repo-updated"
+
+OAS_REPO=https://github.com/stackitcloud/stackit-api-specifications
+
+SERVICES_FOLDER="${SDK_REPO_LOCAL_PATH}/services"
+
+GENERATOR_LOG_LEVEL="error" # Must be a Java log level (error, warn, info...)
+
+generate_python_sdk() {
+ # Required parameters
+ local GENERATOR_JAR_PATH=$1
+ local GIT_HOST=$2
+ local GIT_USER_ID=$3
+
+ # Optional parameters
+ local GIT_REPO_ID=$4
+ local SDK_REPO_URL=$5
+
+ # Check required parameters
+ if [[ -z ${GIT_HOST} ]]; then
+ echo "GIT_HOST not specified."
+ exit 1
+ fi
+
+ if [[ -z ${GIT_USER_ID} ]]; then
+ echo "GIT_USER_ID id not specified."
+ exit 1
+ fi
+
+ # Check optional parameters and set defaults if not provided
+ if [[ -z ${GIT_REPO_ID} ]]; then
+ echo "GIT_REPO_ID not specified, default will be used."
+ GIT_REPO_ID="stackit-sdk-python"
+ fi
+
+ if [[ -z ${SDK_REPO_URL} ]]; then
+ echo "SDK_REPO_URL not specified, default will be used."
+ SDK_REPO_URL="https://github.com/stackitcloud/stackit-sdk-python.git"
+ fi
+
+ # Prepare folders
+ if [[ ! -d $SERVICES_FOLDER ]]; then
+ mkdir -p "$SERVICES_FOLDER"
+ fi
+
+ # Clone SDK repo
+ if [ -d ${SDK_REPO_LOCAL_PATH} ]; then
+ echo "Old SDK repo clone was found, it will be removed."
+ rm -rf ${SDK_REPO_LOCAL_PATH}
+ fi
+ git clone --quiet ${SDK_REPO_URL} ${SDK_REPO_LOCAL_PATH}
+
+ # Install SDK project tools
+ cd ${ROOT_DIR}
+ make project-tools
+
+ # Backup of the current state of the SDK services dir (services/)
+ sdk_services_backup_dir=$(mktemp -d)
+ if [[ ! ${sdk_services_backup_dir} || -d {sdk_services_backup_dir} ]]; then
+ echo "Unable to create temporary directory"
+ exit 1
+ fi
+ cleanup() {
+ rm -rf ${sdk_services_backup_dir}
+ }
+ cp -a "${SERVICES_FOLDER}/." ${sdk_services_backup_dir}
+
+ # Cleanup after we are done
+ trap cleanup EXIT
+
+ # Remove old contents of services dir (services/)
+ rm -rf ${SERVICES_FOLDER}
+
+ # Generate SDK for each service
+ for service_json in ${ROOT_DIR}/oas/*.json; do
+ service="${service_json##*/}"
+ service="${service%.json}"
+
+ # Remove invalid characters to ensure a valid Go pkg name
+ service="${service//-/}" # remove dashes
+ service="${service// /}" # remove empty spaces
+ service="${service//_/}" # remove underscores
+ service=$(echo "${service}" | tr '[:upper:]' '[:lower:]') # convert upper case letters to lower case
+ service=$(echo "${service}" | tr -d -c '[:alnum:]') # remove non-alphanumeric characters
+
+ echo "Generating \"${service}\" service..."
+ cd ${ROOT_DIR}
+
+ mkdir -p "${SERVICES_FOLDER}/${service}/"
+ cp "${ROOT_DIR}/scripts/generate-sdk/.openapi-generator-ignore" "${SERVICES_FOLDER}/${service}/"
+
+ # Run the generator
+ java -Dlog.level=${GENERATOR_LOG_LEVEL} -jar ${jar_path} generate \
+ --generator-name python \
+ --input-spec "${service_json}" \
+ --output "${SERVICES_FOLDER}/${service}" \
+ --package-name "stackit.${service}" \
+ --template-dir "${ROOT_DIR}/templates/python/" \
+ --git-host ${GIT_HOST} \
+ --git-user-id ${GIT_USER_ID} \
+ --git-repo-id ${GIT_REPO_ID} \
+ --global-property apis,models,modelTests=false,modelDocs=false,apiDocs=false,apiTests=false,supportingFiles \
+ --additional-properties=pythonPackageName="stackit-${service},removeEnumValuePrefix=false" >/dev/null
+
+ # Remove unnecessary files
+ rm "${SERVICES_FOLDER}/${service}/.openapi-generator-ignore"
+ rm -r "${SERVICES_FOLDER}/${service}/.openapi-generator/"
+ rm "${SERVICES_FOLDER}/${service}/stackit/__init__.py"
+ rm "${SERVICES_FOLDER}/${service}/.github/workflows/python.yml"
+
+
+ # If the service has a wait package files, move them inside the service folder
+ if [ -d ${sdk_services_backup_dir}/${service}/wait ]; then
+ echo "Found ${service} \"wait\" package"
+ cp -r ${sdk_services_backup_dir}/${service}/wait ${SERVICES_FOLDER}/${service}/wait
+ fi
+
+ # If the service has a CHANGELOG file, move it inside the service folder
+ if [ -f ${sdk_services_backup_dir}/${service}/CHANGELOG.md ]; then
+ echo "Found ${service} \"CHANGELOG\" file"
+ cp -r ${sdk_services_backup_dir}/${service}/CHANGELOG.md ${SERVICES_FOLDER}/${service}/CHANGELOG.md
+ fi
+
+ # If the service has a LICENSE file, move it inside the service folder
+ if [ -f ${sdk_services_backup_dir}/${service}/LICENSE.md ]; then
+ echo "Found ${service} \"LICENSE\" file"
+ cp -r ${sdk_services_backup_dir}/${service}/LICENSE.md ${SERVICES_FOLDER}/${service}/LICENSE.md
+ fi
+
+ # If the service has a NOTICE file, move it inside the service folder
+ if [ -f ${sdk_services_backup_dir}/${service}/NOTICE.txt ]; then
+ echo "Found ${service} \"NOTICE\" file"
+ cp -r ${sdk_services_backup_dir}/${service}/NOTICE.txt ${SERVICES_FOLDER}/${service}/NOTICE.txt
+ fi
+
+ cd ${SERVICES_FOLDER}/${service}
+ # Run formatter
+ isort .
+ autoimport --ignore-init-modules .
+ black .
+
+ done
+}
diff --git a/scripts/project.sh b/scripts/project.sh
index f5c1b95..32dde63 100755
--- a/scripts/project.sh
+++ b/scripts/project.sh
@@ -17,6 +17,7 @@ elif [ "$action" = "tools" ]; then
cd ${ROOT_DIR}
go install golang.org/x/tools/cmd/goimports@latest
+ pip install black==24.8.0 isort~=5.13.2 autoimport~=1.6.1
else
echo "Invalid action: '$action', please use $0 help for help"
fi
diff --git a/scripts/sdk-create-pr.sh b/scripts/sdk-create-pr.sh
index f649054..f55f7cf 100755
--- a/scripts/sdk-create-pr.sh
+++ b/scripts/sdk-create-pr.sh
@@ -33,6 +33,13 @@ else
REPO_URL_SSH=$3
fi
+if [[ -z $4 ]]; then
+ echo "LANGUAGE not specified, default will be used."
+ LANGUAGE="go"
+else
+ LANGUAGE=$4
+fi
+
# Create temp directory to work on
work_dir=$(mktemp -d)
if [[ ! ${work_dir} || -d {work_dir} ]]; then
@@ -83,7 +90,7 @@ for service_path in ${work_dir}/sdk_to_push/services/*; do
fi
git add services/${service}/
- if [ ! -d "${work_dir}/sdk_backup/services/${service}/" ]; then # Check if it is a newly added SDK module
+ if [ "${LANGUAGE}" == "go" ] && [ ! -d "${work_dir}/sdk_backup/services/${service}/" ]; then # Check if it is a newly added SDK module
# go work use -r adds a use directive to the go.work file for dir, if it exists, and removes the use directory if the argument directory doesn’t exist
# the -r flag examines subdirectories of dir recursively
# this prevents errors if there is more than one new module in the SDK generation
diff --git a/templates/python/README.mustache b/templates/python/README.mustache
new file mode 100644
index 0000000..bceb88f
--- /dev/null
+++ b/templates/python/README.mustache
@@ -0,0 +1,60 @@
+# {{{projectName}}}
+{{#appDescriptionWithNewLines}}
+{{{.}}}
+{{/appDescriptionWithNewLines}}
+
+This Python package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
+
+- API version: {{appVersion}}
+- Package version: {{packageVersion}}
+{{^hideGenerationTimestamp}}
+- Build date: {{generatedDate}}
+{{/hideGenerationTimestamp}}
+- Generator version: {{generatorVersion}}
+- Build package: {{generatorClass}}
+{{#infoUrl}}
+For more information, please visit [{{{infoUrl}}}]({{{infoUrl}}})
+{{/infoUrl}}
+
+## Requirements.
+
+Python {{{generatorLanguageVersion}}}
+
+## Installation & Usage
+### pip install
+
+If the python package is hosted on a repository, you can install directly using:
+
+```sh
+pip install git+https://{{gitHost}}/{{{gitUserId}}}/{{{gitRepoId}}}.git
+```
+(you may need to run `pip` with root permission: `sudo pip install git+https://{{gitHost}}/{{{gitUserId}}}/{{{gitRepoId}}}.git`)
+
+Then import the package:
+```python
+import {{{packageName}}}
+```
+
+### Setuptools
+
+Install via [Setuptools](http://pypi.python.org/pypi/setuptools).
+
+```sh
+python setup.py install --user
+```
+(or `sudo python setup.py install` to install the package for all users)
+
+Then import the package:
+```python
+import {{{packageName}}}
+```
+
+### Tests
+
+Execute `pytest` to run the tests.
+
+## Getting Started
+
+Please follow the [installation procedure](#installation--usage) and then run the following:
+
+{{> common_README }}
diff --git a/templates/python/README_onlypackage.mustache b/templates/python/README_onlypackage.mustache
new file mode 100644
index 0000000..ae547b1
--- /dev/null
+++ b/templates/python/README_onlypackage.mustache
@@ -0,0 +1,44 @@
+# {{{projectName}}}
+{{#appDescription}}
+{{{.}}}
+{{/appDescription}}
+
+The `{{packageName}}` package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
+
+- API version: {{appVersion}}
+- Package version: {{packageVersion}}
+{{^hideGenerationTimestamp}}
+- Build date: {{generatedDate}}
+{{/hideGenerationTimestamp}}
+- Generator version: {{generatorVersion}}
+- Build package: {{generatorClass}}
+{{#infoUrl}}
+For more information, please visit [{{{infoUrl}}}]({{{infoUrl}}})
+{{/infoUrl}}
+
+## Requirements.
+
+Python {{{generatorLanguageVersion}}}
+
+## Installation & Usage
+
+This python library package is generated without supporting files like setup.py or requirements files
+
+To be able to use it, you will need these dependencies in your own package that uses this library:
+
+* urllib3 >= 1.25.3
+* python-dateutil
+{{#asyncio}}
+* aiohttp
+{{/asyncio}}
+{{#tornado}}
+* tornado>=4.2,<5
+{{/tornado}}
+* pydantic
+
+## Getting Started
+
+In your own code, to use this library to connect and interact with {{{projectName}}},
+you can run the following:
+
+{{> common_README }}
diff --git a/templates/python/__init__.mustache b/templates/python/__init__.mustache
new file mode 100644
index 0000000..e69de29
diff --git a/templates/python/__init__api.mustache b/templates/python/__init__api.mustache
new file mode 100644
index 0000000..8870835
--- /dev/null
+++ b/templates/python/__init__api.mustache
@@ -0,0 +1,5 @@
+# flake8: noqa
+
+# import apis into api package
+{{#apiInfo}}{{#apis}}from {{apiPackage}}.{{classFilename}} import {{classname}}
+{{/apis}}{{/apiInfo}}
diff --git a/templates/python/__init__model.mustache b/templates/python/__init__model.mustache
new file mode 100644
index 0000000..0e1b55e
--- /dev/null
+++ b/templates/python/__init__model.mustache
@@ -0,0 +1,11 @@
+# coding: utf-8
+
+# flake8: noqa
+{{>partial_header}}
+
+# import models into model package
+{{#models}}
+{{#model}}
+from {{modelPackage}}.{{classFilename}} import {{classname}}
+{{/model}}
+{{/models}}
diff --git a/templates/python/__init__package.mustache b/templates/python/__init__package.mustache
new file mode 100644
index 0000000..df912a3
--- /dev/null
+++ b/templates/python/__init__package.mustache
@@ -0,0 +1,35 @@
+# coding: utf-8
+
+# flake8: noqa
+
+{{>partial_header}}
+
+__version__ = "{{packageVersion}}"
+
+# import apis into sdk package
+{{#apiInfo}}{{#apis}}from {{apiPackage}}.{{classFilename}} import {{classname}}
+{{/apis}}{{/apiInfo}}
+# import ApiClient
+from {{packageName}}.api_response import ApiResponse
+from {{packageName}}.api_client import ApiClient
+from {{packageName}}.configuration import HostConfiguration
+from {{packageName}}.exceptions import OpenApiException
+from {{packageName}}.exceptions import ApiTypeError
+from {{packageName}}.exceptions import ApiValueError
+from {{packageName}}.exceptions import ApiKeyError
+from {{packageName}}.exceptions import ApiAttributeError
+from {{packageName}}.exceptions import ApiException
+{{#hasHttpSignatureMethods}}
+from {{packageName}}.signing import HttpSigningConfiguration
+{{/hasHttpSignatureMethods}}
+
+# import models into sdk package
+{{#models}}
+{{#model}}
+from {{modelPackage}}.{{classFilename}} import {{classname}}
+{{/model}}
+{{/models}}
+{{#recursionLimit}}
+
+__import__('sys').setrecursionlimit({{{.}}})
+{{/recursionLimit}}
diff --git a/templates/python/api.mustache b/templates/python/api.mustache
new file mode 100644
index 0000000..d607df3
--- /dev/null
+++ b/templates/python/api.mustache
@@ -0,0 +1,237 @@
+# coding: utf-8
+
+{{>partial_header}}
+import warnings
+from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt
+from typing import Any, Dict, List, Optional, Tuple, Union
+from typing_extensions import Annotated
+
+{{#imports}}
+{{import}}
+{{/imports}}
+
+from stackit.core.configuration import Configuration
+from {{packageName}}.api_client import ApiClient, RequestSerialized
+from {{packageName}}.api_response import ApiResponse
+from {{packageName}}.rest import RESTResponseType
+
+
+{{#operations}}
+class {{classname}}:
+ """NOTE: This class is auto generated by OpenAPI Generator
+ Ref: https://openapi-generator.tech
+
+ Do not edit the class manually.
+ """
+
+ def __init__(self, configuration: Configuration = None) -> None:
+ if configuration is None:
+ configuration = Configuration()
+ self.configuration = configuration
+ self.api_client = ApiClient(self.configuration)
+{{#operation}}
+
+
+ @validate_call
+ {{#asyncio}}async {{/asyncio}}def {{operationId}}{{>partial_api_args}} -> {{{returnType}}}{{^returnType}}None{{/returnType}}:
+{{>partial_api}}
+ response_data = {{#asyncio}}await {{/asyncio}}self.api_client.call_api(
+ *_param,
+ _request_timeout=_request_timeout
+ )
+ {{#asyncio}}await {{/asyncio}}response_data.read()
+ return self.api_client.response_deserialize(
+ response_data=response_data,
+ response_types_map=_response_types_map,
+ ).data
+
+
+ @validate_call
+ {{#asyncio}}async {{/asyncio}}def {{operationId}}_with_http_info{{>partial_api_args}} -> ApiResponse[{{{returnType}}}{{^returnType}}None{{/returnType}}]:
+{{>partial_api}}
+ response_data = {{#asyncio}}await {{/asyncio}}self.api_client.call_api(
+ *_param,
+ _request_timeout=_request_timeout
+ )
+ {{#asyncio}}await {{/asyncio}}response_data.read()
+ return self.api_client.response_deserialize(
+ response_data=response_data,
+ response_types_map=_response_types_map,
+ )
+
+
+ @validate_call
+ {{#asyncio}}async {{/asyncio}}def {{operationId}}_without_preload_content{{>partial_api_args}} -> RESTResponseType:
+{{>partial_api}}
+ response_data = {{#asyncio}}await {{/asyncio}}self.api_client.call_api(
+ *_param,
+ _request_timeout=_request_timeout
+ )
+ return response_data.response
+
+
+ def _{{operationId}}_serialize(
+ self,
+ {{#allParams}}
+ {{paramName}},
+ {{/allParams}}
+ _request_auth,
+ _content_type,
+ _headers,
+ _host_index,
+ ) -> RequestSerialized:
+
+ {{#servers.0}}
+ _hosts = [{{#servers}}
+ '{{{url}}}'{{^-last}},{{/-last}}{{/servers}}
+ ]
+ _host = _hosts[_host_index]
+ {{/servers.0}}
+ {{^servers.0}}
+ _host = None
+ {{/servers.0}}
+
+ _collection_formats: Dict[str, str] = {
+ {{#allParams}}
+ {{#isArray}}
+ '{{baseName}}': '{{collectionFormat}}',
+ {{/isArray}}
+ {{/allParams}}
+ }
+
+ _path_params: Dict[str, str] = {}
+ _query_params: List[Tuple[str, str]] = []
+ _header_params: Dict[str, Optional[str]] = _headers or {}
+ _form_params: List[Tuple[str, str]] = []
+ _files: Dict[str, Union[str, bytes]] = {}
+ _body_params: Optional[bytes] = None
+
+ # process the path parameters
+{{#pathParams}}
+ if {{paramName}} is not None:
+ _path_params['{{baseName}}'] = {{paramName}}{{#isEnumRef}}.value{{/isEnumRef}}
+{{/pathParams}}
+ # process the query parameters
+{{#queryParams}}
+ if {{paramName}} is not None:
+ {{#isDateTime}}
+ if isinstance({{paramName}}, datetime):
+ _query_params.append(
+ (
+ '{{baseName}}',
+ {{paramName}}.strftime(
+ self.api_client.configuration.datetime_format
+ )
+ )
+ )
+ else:
+ _query_params.append(('{{baseName}}', {{paramName}}))
+ {{/isDateTime}}
+ {{#isDate}}
+ if isinstance({{paramName}}, date):
+ _query_params.append(
+ (
+ '{{baseName}}',
+ {{paramName}}.strftime(
+ self.api_client.configuration.date_format
+ )
+ )
+ )
+ else:
+ _query_params.append(('{{baseName}}', {{paramName}}))
+ {{/isDate}}
+ {{^isDateTime}}{{^isDate}}
+ _query_params.append(('{{baseName}}', {{paramName}}{{#isEnumRef}}.value{{/isEnumRef}}))
+ {{/isDate}}{{/isDateTime}}
+{{/queryParams}}
+ # process the header parameters
+{{#headerParams}}
+ if {{paramName}} is not None:
+ _header_params['{{baseName}}'] = {{paramName}}
+{{/headerParams}}
+ # process the form parameters
+{{#formParams}}
+ if {{paramName}} is not None:
+ {{#isFile}}
+ _files['{{{baseName}}}'] = {{paramName}}
+ {{/isFile}}
+ {{^isFile}}
+ _form_params.append(('{{{baseName}}}', {{paramName}}))
+ {{/isFile}}
+{{/formParams}}
+ # process the body parameter
+{{#bodyParam}}
+ if {{paramName}} is not None:
+ {{#isBinary}}
+ # convert to byte array if the input is a file name (str)
+ if isinstance({{paramName}}, str):
+ with open({{paramName}}, "rb") as _fp:
+ _body_params = _fp.read()
+ else:
+ _body_params = {{paramName}}
+ {{/isBinary}}
+ {{^isBinary}}
+ _body_params = {{paramName}}
+ {{/isBinary}}
+{{/bodyParam}}
+
+ {{#constantParams}}
+ {{#isQueryParam}}
+ # Set client side default value of Query Param "{{baseName}}".
+ _query_params['{{baseName}}'] = {{#_enum}}'{{{.}}}'{{/_enum}}
+ {{/isQueryParam}}
+ {{#isHeaderParam}}
+ # Set client side default value of Header Param "{{baseName}}".
+ _header_params['{{baseName}}'] = {{#_enum}}'{{{.}}}'{{/_enum}}
+ {{/isHeaderParam}}
+ {{/constantParams}}
+
+ {{#hasProduces}}
+ # set the HTTP header `Accept`
+ if 'Accept' not in _header_params:
+ _header_params['Accept'] = self.api_client.select_header_accept(
+ [{{#produces}}
+ '{{{mediaType}}}'{{^-last}}, {{/-last}}{{/produces}}
+ ]
+ )
+ {{/hasProduces}}
+
+ {{#hasConsumes}}
+ # set the HTTP header `Content-Type`
+ if _content_type:
+ _header_params['Content-Type'] = _content_type
+ else:
+ _default_content_type = (
+ self.api_client.select_header_content_type(
+ [{{#consumes}}
+ '{{{mediaType}}}'{{^-last}}, {{/-last}}{{/consumes}}
+ ]
+ )
+ )
+ if _default_content_type is not None:
+ _header_params['Content-Type'] = _default_content_type
+ {{/hasConsumes}}
+
+ # authentication setting
+ _auth_settings: List[str] = [{{#authMethods}}
+ '{{name}}'{{^-last}}, {{/-last}}{{/authMethods}}
+ ]
+
+ return self.api_client.param_serialize(
+ method='{{httpMethod}}',
+ resource_path='{{{path}}}',
+ path_params=_path_params,
+ query_params=_query_params,
+ header_params=_header_params,
+ body=_body_params,
+ post_params=_form_params,
+ files=_files,
+ auth_settings=_auth_settings,
+ collection_formats=_collection_formats,
+ _host=_host,
+ _request_auth=_request_auth
+ )
+
+
+{{/operation}}
+{{/operations}}
diff --git a/templates/python/api_client.mustache b/templates/python/api_client.mustache
new file mode 100644
index 0000000..8dd1f4b
--- /dev/null
+++ b/templates/python/api_client.mustache
@@ -0,0 +1,701 @@
+# coding: utf-8
+
+{{>partial_header}}
+
+import datetime
+from dateutil.parser import parse
+from enum import Enum
+import json
+import mimetypes
+import os
+import re
+import tempfile
+
+from urllib.parse import quote
+from typing import Tuple, Optional, List, Dict, Union
+from pydantic import SecretStr
+
+from stackit.core.configuration import Configuration
+from {{packageName}}.configuration import HostConfiguration
+from {{packageName}}.api_response import ApiResponse, T as ApiResponseT
+import {{modelPackage}}
+from {{packageName}} import rest
+from {{packageName}}.exceptions import (
+ ApiValueError,
+ ApiException,
+ BadRequestException,
+ UnauthorizedException,
+ ForbiddenException,
+ NotFoundException,
+ ServiceException
+)
+
+RequestSerialized = Tuple[str, str, Dict[str, str], Optional[str], List[str]]
+
+
+class ApiClient:
+ """Generic API client for OpenAPI client library builds.
+
+ OpenAPI generic API client. This client handles the client-
+ server communication, and is invariant across implementations. Specifics of
+ the methods and models for each application are generated from the OpenAPI
+ templates.
+
+ :param configuration: .Configuration object for this client
+ :param header_name: a header to pass when making calls to the API.
+ :param header_value: a header value to pass when making calls to
+ the API.
+ :param cookie: a cookie to include in the header when making calls
+ to the API
+ """
+
+ PRIMITIVE_TYPES = (float, bool, bytes, str, int)
+ NATIVE_TYPES_MAPPING = {
+ 'int': int,
+ 'long': int, # TODO remove as only py3 is supported?
+ 'float': float,
+ 'str': str,
+ 'bool': bool,
+ 'date': datetime.date,
+ 'datetime': datetime.datetime,
+ 'object': object,
+ }
+
+ def __init__(
+ self,
+ configuration,
+ header_name=None,
+ header_value=None,
+ cookie=None
+ ) -> None:
+ self.config: Configuration = configuration
+
+ if self.config.custom_endpoint is None:
+ host_config = HostConfiguration(
+ region=self.config.region,
+ server_index=self.config.server_index
+ )
+ self.host = host_config.host
+ else:
+ self.host = self.config.custom_endpoint
+
+ self.rest_client = rest.RESTClientObject(self.config)
+ self.default_headers = {}
+ if header_name is not None:
+ self.default_headers[header_name] = header_value
+ self.cookie = cookie
+ # Set default User-Agent.
+ self.user_agent = '{{{httpUserAgent}}}{{^httpUserAgent}}OpenAPI-Generator/{{{packageVersion}}}/python{{/httpUserAgent}}'
+
+{{#asyncio}}
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ await self.close()
+
+ async def close(self):
+ await self.rest_client.close()
+{{/asyncio}}
+{{^asyncio}}
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ pass
+{{/asyncio}}
+
+ @property
+ def user_agent(self):
+ """User agent for this API client"""
+ return self.default_headers['User-Agent']
+
+ @user_agent.setter
+ def user_agent(self, value):
+ self.default_headers['User-Agent'] = value
+
+ def set_default_header(self, header_name, header_value):
+ self.default_headers[header_name] = header_value
+
+
+ _default = None
+
+ @classmethod
+ def get_default(cls):
+ """Return new instance of ApiClient.
+
+ This method returns newly created, based on default constructor,
+ object of ApiClient class or returns a copy of default
+ ApiClient.
+
+ :return: The ApiClient object.
+ """
+ if cls._default is None:
+ cls._default = ApiClient()
+ return cls._default
+
+ @classmethod
+ def set_default(cls, default):
+ """Set default instance of ApiClient.
+
+ It stores default ApiClient.
+
+ :param default: object of ApiClient.
+ """
+ cls._default = default
+
+ def param_serialize(
+ self,
+ method,
+ resource_path,
+ path_params=None,
+ query_params=None,
+ header_params=None,
+ body=None,
+ post_params=None,
+ files=None, auth_settings=None,
+ collection_formats=None,
+ _host=None,
+ _request_auth=None
+ ) -> RequestSerialized:
+
+ """Builds the HTTP request params needed by the request.
+ :param method: Method to call.
+ :param resource_path: Path to method endpoint.
+ :param path_params: Path parameters in the url.
+ :param query_params: Query parameters in the url.
+ :param header_params: Header parameters to be
+ placed in the request header.
+ :param body: Request body.
+ :param post_params dict: Request post form parameters,
+ for `application/x-www-form-urlencoded`, `multipart/form-data`.
+ :param auth_settings list: Auth Settings names for the request.
+ :param files dict: key -> filename, value -> filepath,
+ for `multipart/form-data`.
+ :param collection_formats: dict of collection formats for path, query,
+ header, and post parameters.
+ :param _request_auth: set to override the auth_settings for an a single
+ request; this effectively ignores the authentication
+ in the spec for a single request.
+ :return: tuple of form (path, http_method, query_params, header_params,
+ body, post_params, files)
+ """
+
+ # header parameters
+ header_params = header_params or {}
+ header_params.update(self.default_headers)
+ if self.cookie:
+ header_params['Cookie'] = self.cookie
+ if header_params:
+ header_params = self.sanitize_for_serialization(header_params)
+ header_params = dict(
+ self.parameters_to_tuples(header_params,collection_formats)
+ )
+
+ # path parameters
+ if path_params:
+ path_params = self.sanitize_for_serialization(path_params)
+ path_params = self.parameters_to_tuples(
+ path_params,
+ collection_formats
+ )
+ for k, v in path_params:
+ # specified safe chars, encode everything
+ resource_path = resource_path.replace(
+ '{%s}' % k,
+ quote(str(v))
+ )
+
+ # post parameters
+ if post_params or files:
+ post_params = post_params if post_params else []
+ post_params = self.sanitize_for_serialization(post_params)
+ post_params = self.parameters_to_tuples(
+ post_params,
+ collection_formats
+ )
+ if files:
+ post_params.extend(self.files_parameters(files))
+
+ # body
+ if body:
+ body = self.sanitize_for_serialization(body)
+
+ # request url
+ if _host is None:
+ url = self.host + resource_path
+ else:
+ # use server/host defined in path or operation instead
+ url = _host + resource_path
+
+ # query parameters
+ if query_params:
+ query_params = self.sanitize_for_serialization(query_params)
+ url_query = self.parameters_to_url_query(
+ query_params,
+ collection_formats
+ )
+ url += "?" + url_query
+
+ return method, url, header_params, body, post_params
+
+ {{#asyncio}}async {{/asyncio}}def call_api(
+ self,
+ method,
+ url,
+ header_params=None,
+ body=None,
+ post_params=None,
+ _request_timeout=None
+ ) -> rest.RESTResponse:
+ """Makes the HTTP request (synchronous)
+ :param method: Method to call.
+ :param url: Path to method endpoint.
+ :param header_params: Header parameters to be
+ placed in the request header.
+ :param body: Request body.
+ :param post_params dict: Request post form parameters,
+ for `application/x-www-form-urlencoded`, `multipart/form-data`.
+ :param _request_timeout: timeout setting for this request.
+ :return: RESTResponse
+ """
+
+ try:
+ # perform request and return response
+ response_data = {{#asyncio}}await {{/asyncio}}{{#tornado}}yield {{/tornado}}self.rest_client.request(
+ method, url,
+ headers=header_params,
+ body=body, post_params=post_params,
+ _request_timeout=_request_timeout
+ )
+
+ except ApiException as e:
+ raise e
+
+ return response_data
+
+ def response_deserialize(
+ self,
+ response_data: rest.RESTResponse,
+ response_types_map: Optional[Dict[str, ApiResponseT]]=None
+ ) -> ApiResponse[ApiResponseT]:
+ """Deserializes response into an object.
+ :param response_data: RESTResponse object to be deserialized.
+ :param response_types_map: dict of response types.
+ :return: ApiResponse
+ """
+
+ msg = "RESTResponse.read() must be called before passing it to response_deserialize()"
+ if response_data.data is None:
+ raise ValueError(msg)
+
+ response_type = response_types_map.get(str(response_data.status), None)
+ if not response_type and isinstance(response_data.status, int) and 100 <= response_data.status <= 599:
+ # if not found, look for '1XX', '2XX', etc.
+ response_type = response_types_map.get(str(response_data.status)[0] + "XX", None)
+
+ # deserialize response data
+ response_text = None
+ return_data = None
+ try:
+ if response_type == "bytearray":
+ return_data = response_data.data
+ elif response_type == "file":
+ return_data = self.__deserialize_file(response_data)
+ elif response_type is not None:
+ match = None
+ content_type = response_data.getheader('content-type')
+ if content_type is not None:
+ match = re.search(r"charset=([a-zA-Z\-\d]+)[\s;]?", content_type)
+ encoding = match.group(1) if match else "utf-8"
+ response_text = response_data.data.decode(encoding)
+ return_data = self.deserialize(response_text, response_type, content_type)
+ finally:
+ if not 200 <= response_data.status <= 299:
+ raise ApiException.from_response(
+ http_resp=response_data,
+ body=response_text,
+ data=return_data,
+ )
+
+ return ApiResponse(
+ status_code = response_data.status,
+ data = return_data,
+ headers = response_data.getheaders(),
+ raw_data = response_data.data
+ )
+
+ def sanitize_for_serialization(self, obj):
+ """Builds a JSON POST object.
+
+ If obj is None, return None.
+ If obj is SecretStr, return obj.get_secret_value()
+ If obj is str, int, long, float, bool, return directly.
+ If obj is datetime.datetime, datetime.date
+ convert to string in iso8601 format.
+ If obj is list, sanitize each element in the list.
+ If obj is dict, return the dict.
+ If obj is OpenAPI model, return the properties dict.
+
+ :param obj: The data to serialize.
+ :return: The serialized form of data.
+ """
+ if obj is None:
+ return None
+ elif isinstance(obj, Enum):
+ return obj.value
+ elif isinstance(obj, SecretStr):
+ return obj.get_secret_value()
+ elif isinstance(obj, self.PRIMITIVE_TYPES):
+ return obj
+ elif isinstance(obj, list):
+ return [
+ self.sanitize_for_serialization(sub_obj) for sub_obj in obj
+ ]
+ elif isinstance(obj, tuple):
+ return tuple(
+ self.sanitize_for_serialization(sub_obj) for sub_obj in obj
+ )
+ elif isinstance(obj, (datetime.datetime, datetime.date)):
+ return obj.isoformat()
+
+ elif isinstance(obj, dict):
+ obj_dict = obj
+ else:
+ # Convert model obj to dict except
+ # attributes `openapi_types`, `attribute_map`
+ # and attributes which value is not None.
+ # Convert attribute name to json key in
+ # model definition for request.
+ if hasattr(obj, 'to_dict') and callable(obj.to_dict):
+ obj_dict = obj.to_dict()
+ else:
+ obj_dict = obj.__dict__
+
+ return {
+ key: self.sanitize_for_serialization(val)
+ for key, val in obj_dict.items()
+ }
+
+ def deserialize(self, response_text: str, response_type: str, content_type: Optional[str]):
+ """Deserializes response into an object.
+
+ :param response: RESTResponse object to be deserialized.
+ :param response_type: class literal for
+ deserialized object, or string of class name.
+ :param content_type: content type of response.
+
+ :return: deserialized object.
+ """
+
+ # fetch data from response object
+ if content_type is None:
+ try:
+ data = json.loads(response_text)
+ except ValueError:
+ data = response_text
+ elif content_type.startswith("application/json"):
+ if response_text == "":
+ data = ""
+ else:
+ data = json.loads(response_text)
+ elif content_type.startswith("text/plain"):
+ data = response_text
+ else:
+ raise ApiException(
+ status=0,
+ reason="Unsupported content type: {0}".format(content_type)
+ )
+
+ return self.__deserialize(data, response_type)
+
+ def __deserialize(self, data, klass):
+ """Deserializes dict, list, str into an object.
+
+ :param data: dict, list or str.
+ :param klass: class literal, or string of class name.
+
+ :return: object.
+ """
+ if data is None:
+ return None
+
+ if isinstance(klass, str):
+ if klass.startswith('List['):
+ m = re.match(r'List\[(.*)]', klass)
+ if m is None:
+ raise ValueError("Malformed List type definition")
+ sub_kls = m.group(1)
+ return [self.__deserialize(sub_data, sub_kls)
+ for sub_data in data]
+
+ if klass.startswith('Dict['):
+ m = re.match(r'Dict\[([^,]*), (.*)]', klass)
+ if m is None:
+ raise ValueError("Malformed Dict type definition")
+ sub_kls = m.group(2)
+ return {k: self.__deserialize(v, sub_kls)
+ for k, v in data.items()}
+
+ # convert str to class
+ if klass in self.NATIVE_TYPES_MAPPING:
+ klass = self.NATIVE_TYPES_MAPPING[klass]
+ else:
+ klass = getattr({{modelPackage}}, klass)
+
+ if klass in self.PRIMITIVE_TYPES:
+ return self.__deserialize_primitive(data, klass)
+ elif klass == object:
+ return self.__deserialize_object(data)
+ elif klass == datetime.date:
+ return self.__deserialize_date(data)
+ elif klass == datetime.datetime:
+ return self.__deserialize_datetime(data)
+ elif issubclass(klass, Enum):
+ return self.__deserialize_enum(data, klass)
+ else:
+ return self.__deserialize_model(data, klass)
+
+ def parameters_to_tuples(self, params, collection_formats):
+ """Get parameters as list of tuples, formatting collections.
+
+ :param params: Parameters as dict or list of two-tuples
+ :param dict collection_formats: Parameter collection formats
+ :return: Parameters as list of tuples, collections formatted
+ """
+ new_params: List[Tuple[str, str]] = []
+ if collection_formats is None:
+ collection_formats = {}
+ for k, v in params.items() if isinstance(params, dict) else params:
+ if k in collection_formats:
+ collection_format = collection_formats[k]
+ if collection_format == 'multi':
+ new_params.extend((k, value) for value in v)
+ else:
+ if collection_format == 'ssv':
+ delimiter = ' '
+ elif collection_format == 'tsv':
+ delimiter = '\t'
+ elif collection_format == 'pipes':
+ delimiter = '|'
+ else: # csv is the default
+ delimiter = ','
+ new_params.append(
+ (k, delimiter.join(str(value) for value in v)))
+ else:
+ new_params.append((k, v))
+ return new_params
+
+ def parameters_to_url_query(self, params, collection_formats):
+ """Get parameters as list of tuples, formatting collections.
+
+ :param params: Parameters as dict or list of two-tuples
+ :param dict collection_formats: Parameter collection formats
+ :return: URL query string (e.g. a=Hello%20World&b=123)
+ """
+ new_params: List[Tuple[str, str]] = []
+ if collection_formats is None:
+ collection_formats = {}
+ for k, v in params.items() if isinstance(params, dict) else params:
+ if isinstance(v, bool):
+ v = str(v).lower()
+ if isinstance(v, (int, float)):
+ v = str(v)
+ if isinstance(v, dict):
+ v = json.dumps(v)
+
+ if k in collection_formats:
+ collection_format = collection_formats[k]
+ if collection_format == 'multi':
+ new_params.extend((k, str(value)) for value in v)
+ else:
+ if collection_format == 'ssv':
+ delimiter = ' '
+ elif collection_format == 'tsv':
+ delimiter = '\t'
+ elif collection_format == 'pipes':
+ delimiter = '|'
+ else: # csv is the default
+ delimiter = ','
+ new_params.append(
+ (k, delimiter.join(quote(str(value)) for value in v))
+ )
+ else:
+ new_params.append((k, quote(str(v))))
+
+ return "&".join(["=".join(map(str, item)) for item in new_params])
+
+ def files_parameters(self, files: Dict[str, Union[str, bytes]]):
+ """Builds form parameters.
+
+ :param files: File parameters.
+ :return: Form parameters with files.
+ """
+ params = []
+ for k, v in files.items():
+ if isinstance(v, str):
+ with open(v, 'rb') as f:
+ filename = os.path.basename(f.name)
+ filedata = f.read()
+ elif isinstance(v, bytes):
+ filename = k
+ filedata = v
+ else:
+ raise ValueError("Unsupported file value")
+ mimetype = (
+ mimetypes.guess_type(filename)[0]
+ or 'application/octet-stream'
+ )
+ params.append(
+ tuple([k, tuple([filename, filedata, mimetype])])
+ )
+ return params
+
+ def select_header_accept(self, accepts: List[str]) -> Optional[str]:
+ """Returns `Accept` based on an array of accepts provided.
+
+ :param accepts: List of headers.
+ :return: Accept (e.g. application/json).
+ """
+ if not accepts:
+ return None
+
+ for accept in accepts:
+ if re.search('json', accept, re.IGNORECASE):
+ return accept
+
+ return accepts[0]
+
+ def select_header_content_type(self, content_types):
+ """Returns `Content-Type` based on an array of content_types provided.
+
+ :param content_types: List of content-types.
+ :return: Content-Type (e.g. application/json).
+ """
+ if not content_types:
+ return None
+
+ for content_type in content_types:
+ if re.search('json', content_type, re.IGNORECASE):
+ return content_type
+
+ return content_types[0]
+
+ def __deserialize_file(self, response):
+ """Deserializes body to file
+
+ Saves response body into a file in a temporary folder,
+ using the filename from the `Content-Disposition` header if provided.
+
+ handle file downloading
+ save response body into a tmp file and return the instance
+
+ :param response: RESTResponse.
+ :return: file path.
+ """
+ fd, path = tempfile.mkstemp(dir=self.configuration.temp_folder_path)
+ os.close(fd)
+ os.remove(path)
+
+ content_disposition = response.getheader("Content-Disposition")
+ if content_disposition:
+ m = re.search(
+ r'filename=[\'"]?([^\'"\s]+)[\'"]?',
+ content_disposition
+ )
+ if m is None:
+ raise ValueError("Unexpected 'content-disposition' header value")
+ filename = m.group(1)
+ path = os.path.join(os.path.dirname(path), filename)
+
+ with open(path, "wb") as f:
+ f.write(response.data)
+
+ return path
+
+ def __deserialize_primitive(self, data, klass):
+ """Deserializes string to primitive type.
+
+ :param data: str.
+ :param klass: class literal.
+
+ :return: int, long, float, str, bool.
+ """
+ try:
+ return klass(data)
+ except UnicodeEncodeError:
+ return str(data)
+ except TypeError:
+ return data
+
+ def __deserialize_object(self, value):
+ """Return an original value.
+
+ :return: object.
+ """
+ return value
+
+ def __deserialize_date(self, string):
+ """Deserializes string to date.
+
+ :param string: str.
+ :return: date.
+ """
+ try:
+ return parse(string).date()
+ except ImportError:
+ return string
+ except ValueError:
+ raise rest.ApiException(
+ status=0,
+ reason="Failed to parse `{0}` as date object".format(string)
+ )
+
+ def __deserialize_datetime(self, string):
+ """Deserializes string to datetime.
+
+ The string should be in iso8601 datetime format.
+
+ :param string: str.
+ :return: datetime.
+ """
+ try:
+ return parse(string)
+ except ImportError:
+ return string
+ except ValueError:
+ raise rest.ApiException(
+ status=0,
+ reason=(
+ "Failed to parse `{0}` as datetime object"
+ .format(string)
+ )
+ )
+
+ def __deserialize_enum(self, data, klass):
+ """Deserializes primitive type to enum.
+
+ :param data: primitive type.
+ :param klass: class literal.
+ :return: enum value.
+ """
+ try:
+ return klass(data)
+ except ValueError:
+ raise rest.ApiException(
+ status=0,
+ reason=(
+ "Failed to parse `{0}` as `{1}`"
+ .format(data, klass)
+ )
+ )
+
+ def __deserialize_model(self, data, klass):
+ """Deserializes list or dict to model.
+
+ :param data: dict, list.
+ :param klass: class literal.
+ :return: model object.
+ """
+
+ return klass.from_dict(data)
diff --git a/templates/python/api_doc.mustache b/templates/python/api_doc.mustache
new file mode 100644
index 0000000..6a58fc9
--- /dev/null
+++ b/templates/python/api_doc.mustache
@@ -0,0 +1,76 @@
+# {{packageName}}.{{classname}}{{#description}}
+{{.}}{{/description}}
+
+All URIs are relative to *{{basePath}}*
+
+Method | HTTP request | Description
+------------- | ------------- | -------------
+{{#operations}}{{#operation}}[**{{operationId}}**]({{classname}}.md#{{operationId}}) | **{{httpMethod}}** {{path}} | {{summary}}
+{{/operation}}{{/operations}}
+
+{{#operations}}
+{{#operation}}
+# **{{{operationId}}}**
+> {{#returnType}}{{{.}}} {{/returnType}}{{{operationId}}}({{#allParams}}{{#required}}{{{paramName}}}{{/required}}{{^required}}{{{paramName}}}={{{paramName}}}{{/required}}{{^-last}}, {{/-last}}{{/allParams}})
+
+{{{summary}}}{{#notes}}
+
+{{{.}}}{{/notes}}
+
+### Example
+
+{{#hasAuthMethods}}
+{{#authMethods}}
+{{#isBasic}}
+{{#isBasicBasic}}
+* Basic Authentication ({{name}}):
+{{/isBasicBasic}}
+{{#isBasicBearer}}
+* Bearer{{#bearerFormat}} ({{{.}}}){{/bearerFormat}} Authentication ({{name}}):
+{{/isBasicBearer}}
+{{/isBasic}}
+{{#isApiKey}}
+* Api Key Authentication ({{name}}):
+{{/isApiKey }}
+{{#isOAuth}}
+* OAuth Authentication ({{name}}):
+{{/isOAuth }}
+{{/authMethods}}
+{{/hasAuthMethods}}
+{{> api_doc_example }}
+
+### Parameters
+
+{{^allParams}}This endpoint does not need any parameter.{{/allParams}}{{#allParams}}{{#-last}}
+Name | Type | Description | Notes
+------------- | ------------- | ------------- | -------------{{/-last}}{{/allParams}}
+{{#allParams}} **{{paramName}}** | {{#isFile}}**{{dataType}}**{{/isFile}}{{^isFile}}{{#isPrimitiveType}}**{{dataType}}**{{/isPrimitiveType}}{{^isPrimitiveType}}[**{{dataType}}**]({{baseType}}.md){{/isPrimitiveType}}{{/isFile}}| {{description}} | {{^required}}[optional] {{/required}}{{#defaultValue}}[default to {{.}}]{{/defaultValue}}
+{{/allParams}}
+
+### Return type
+
+{{#returnType}}{{#returnTypeIsPrimitive}}**{{{returnType}}}**{{/returnTypeIsPrimitive}}{{^returnTypeIsPrimitive}}[**{{{returnType}}}**]({{returnBaseType}}.md){{/returnTypeIsPrimitive}}{{/returnType}}{{^returnType}}void (empty response body){{/returnType}}
+
+### Authorization
+
+{{^authMethods}}No authorization required{{/authMethods}}{{#authMethods}}[{{{name}}}](../README.md#{{{name}}}){{^-last}}, {{/-last}}{{/authMethods}}
+
+### HTTP request headers
+
+ - **Content-Type**: {{#consumes}}{{{mediaType}}}{{^-last}}, {{/-last}}{{/consumes}}{{^consumes}}Not defined{{/consumes}}
+ - **Accept**: {{#produces}}{{{mediaType}}}{{^-last}}, {{/-last}}{{/produces}}{{^produces}}Not defined{{/produces}}
+
+{{#responses.0}}
+### HTTP response details
+
+| Status code | Description | Response headers |
+|-------------|-------------|------------------|
+{{#responses}}
+**{{code}}** | {{message}} | {{#headers}} * {{baseName}} - {{description}}
{{/headers}}{{^headers.0}} - {{/headers.0}} |
+{{/responses}}
+{{/responses.0}}
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
+{{/operation}}
+{{/operations}}
diff --git a/templates/python/api_doc_example.mustache b/templates/python/api_doc_example.mustache
new file mode 100644
index 0000000..f5d7eef
--- /dev/null
+++ b/templates/python/api_doc_example.mustache
@@ -0,0 +1,37 @@
+
+```python
+import {{{packageName}}}
+{{#vendorExtensions.x-py-example-import}}
+{{{.}}}
+{{/vendorExtensions.x-py-example-import}}
+from {{{packageName}}}.rest import ApiException
+from pprint import pprint
+
+{{> python_doc_auth_partial}}
+# Enter a context with an instance of the API client
+{{#asyncio}}async {{/asyncio}}with {{{packageName}}}.ApiClient(configuration) as api_client:
+ # Create an instance of the API class
+ api_instance = {{{packageName}}}.{{{classname}}}(api_client)
+ {{#allParams}}
+ {{paramName}} = {{{example}}} # {{{dataType}}} | {{{description}}}{{^required}} (optional){{/required}}{{#defaultValue}} (default to {{{.}}}){{/defaultValue}}
+ {{/allParams}}
+
+ try:
+ {{#summary}}
+ # {{{.}}}
+ {{/summary}}
+ {{#returnType}}api_response = {{/returnType}}{{#asyncio}}await {{/asyncio}}api_instance.{{{operationId}}}({{#allParams}}{{#required}}{{paramName}}{{/required}}{{^required}}{{paramName}}={{paramName}}{{/required}}{{^-last}}, {{/-last}}{{/allParams}})
+ {{#returnType}}
+ print("The response of {{classname}}->{{operationId}}:\n")
+ pprint(api_response)
+ {{/returnType}}
+ except Exception as e:
+ print("Exception when calling {{classname}}->{{operationId}}: %s\n" % e)
+```
+
+{{#vendorExtensions.x-py-postponed-example-imports.size}}
+{{#vendorExtensions.x-py-postponed-example-imports}}
+{{{.}}}
+{{/vendorExtensions.x-py-postponed-example-imports}}
+{{classname}}.update_forward_refs()
+{{/vendorExtensions.x-py-postponed-example-imports.size}}
diff --git a/templates/python/api_response.mustache b/templates/python/api_response.mustache
new file mode 100644
index 0000000..9bc7c11
--- /dev/null
+++ b/templates/python/api_response.mustache
@@ -0,0 +1,21 @@
+"""API response object."""
+
+from __future__ import annotations
+from typing import Optional, Generic, Mapping, TypeVar
+from pydantic import Field, StrictInt, StrictBytes, BaseModel
+
+T = TypeVar("T")
+
+class ApiResponse(BaseModel, Generic[T]):
+ """
+ API response object
+ """
+
+ status_code: StrictInt = Field(description="HTTP status code")
+ headers: Optional[Mapping[str, str]] = Field(None, description="HTTP headers")
+ data: T = Field(description="Deserialized data given the data type")
+ raw_data: StrictBytes = Field(description="Raw data (HTTP response body)")
+
+ model_config = {
+ "arbitrary_types_allowed": True
+ }
diff --git a/templates/python/api_test.mustache b/templates/python/api_test.mustache
new file mode 100644
index 0000000..5354d3c
--- /dev/null
+++ b/templates/python/api_test.mustache
@@ -0,0 +1,33 @@
+# coding: utf-8
+
+{{>partial_header}}
+
+import unittest
+
+from {{apiPackage}}.{{classFilename}} import {{classname}}
+
+
+class {{#operations}}Test{{classname}}(unittest.TestCase):
+ """{{classname}} unit test stubs"""
+
+ def setUp(self) -> None:
+ self.api = {{classname}}()
+
+ def tearDown(self) -> None:
+ pass
+
+ {{#operation}}
+ def test_{{operationId}}(self) -> None:
+ """Test case for {{{operationId}}}
+
+{{#summary}}
+ {{{.}}}
+{{/summary}}
+ """
+ pass
+
+ {{/operation}}
+{{/operations}}
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/templates/python/asyncio/rest.mustache b/templates/python/asyncio/rest.mustache
new file mode 100644
index 0000000..edfcc66
--- /dev/null
+++ b/templates/python/asyncio/rest.mustache
@@ -0,0 +1,205 @@
+# coding: utf-8
+
+{{>partial_header}}
+
+import io
+import json
+import re
+import ssl
+from typing import Optional, Union
+
+import aiohttp
+import aiohttp_retry
+
+from {{packageName}}.exceptions import ApiException, ApiValueError
+
+RESTResponseType = aiohttp.ClientResponse
+
+ALLOW_RETRY_METHODS = frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'TRACE'})
+
+class RESTResponse(io.IOBase):
+
+ def __init__(self, resp) -> None:
+ self.response = resp
+ self.status = resp.status
+ self.reason = resp.reason
+ self.data = None
+
+ async def read(self):
+ if self.data is None:
+ self.data = await self.response.read()
+ return self.data
+
+ def getheaders(self):
+ """Returns a CIMultiDictProxy of the response headers."""
+ return self.response.headers
+
+ def getheader(self, name, default=None):
+ """Returns a given response header."""
+ return self.response.headers.get(name, default)
+
+
+class RESTClientObject:
+
+ def __init__(self, configuration) -> None:
+
+ # maxsize is number of requests to host that are allowed in parallel
+ maxsize = configuration.connection_pool_maxsize
+
+ ssl_context = ssl.create_default_context(
+ cafile=configuration.ssl_ca_cert
+ )
+ if configuration.cert_file:
+ ssl_context.load_cert_chain(
+ configuration.cert_file, keyfile=configuration.key_file
+ )
+
+ if not configuration.verify_ssl:
+ ssl_context.check_hostname = False
+ ssl_context.verify_mode = ssl.CERT_NONE
+
+ connector = aiohttp.TCPConnector(
+ limit=maxsize,
+ ssl=ssl_context
+ )
+
+ self.proxy = configuration.proxy
+ self.proxy_headers = configuration.proxy_headers
+
+ # https pool manager
+ self.pool_manager = aiohttp.ClientSession(
+ connector=connector,
+ trust_env=True
+ )
+
+ retries = configuration.retries
+ self.retry_client: Optional[aiohttp_retry.RetryClient]
+ if retries is not None:
+ self.retry_client = aiohttp_retry.RetryClient(
+ client_session=self.pool_manager,
+ retry_options=aiohttp_retry.ExponentialRetry(
+ attempts=retries,
+ factor=0.0,
+ start_timeout=0.0,
+ max_timeout=120.0
+ )
+ )
+ else:
+ self.retry_client = None
+
+ async def close(self):
+ await self.pool_manager.close()
+ if self.retry_client is not None:
+ await self.retry_client.close()
+
+ async def request(
+ self,
+ method,
+ url,
+ headers=None,
+ body=None,
+ post_params=None,
+ _request_timeout=None
+ ):
+ """Execute request
+
+ :param method: http request method
+ :param url: http request url
+ :param headers: http request headers
+ :param body: request json body, for `application/json`
+ :param post_params: request post parameters,
+ `application/x-www-form-urlencoded`
+ and `multipart/form-data`
+ :param _request_timeout: timeout setting for this request. If one
+ number provided, it will be total request
+ timeout. It can also be a pair (tuple) of
+ (connection, read) timeouts.
+ """
+ method = method.upper()
+ assert method in [
+ 'GET',
+ 'HEAD',
+ 'DELETE',
+ 'POST',
+ 'PUT',
+ 'PATCH',
+ 'OPTIONS'
+ ]
+
+ if post_params and body:
+ raise ApiValueError(
+ "body parameter cannot be used with post_params parameter."
+ )
+
+ post_params = post_params or {}
+ headers = headers or {}
+ # url already contains the URL query string
+ timeout = _request_timeout or 5 * 60
+
+ if 'Content-Type' not in headers:
+ headers['Content-Type'] = 'application/json'
+
+ args = {
+ "method": method,
+ "url": url,
+ "timeout": timeout,
+ "headers": headers
+ }
+
+ if self.proxy:
+ args["proxy"] = self.proxy
+ if self.proxy_headers:
+ args["proxy_headers"] = self.proxy_headers
+
+ # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE`
+ if method in ['POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']:
+ if re.search('json', headers['Content-Type'], re.IGNORECASE):
+ if body is not None:
+ body = json.dumps(body)
+ args["data"] = body
+ elif headers['Content-Type'] == 'application/x-www-form-urlencoded':
+ args["data"] = aiohttp.FormData(post_params)
+ elif headers['Content-Type'] == 'multipart/form-data':
+ # must del headers['Content-Type'], or the correct
+ # Content-Type which generated by aiohttp
+ del headers['Content-Type']
+ data = aiohttp.FormData()
+ for param in post_params:
+ k, v = param
+ if isinstance(v, tuple) and len(v) == 3:
+ data.add_field(
+ k,
+ value=v[1],
+ filename=v[0],
+ content_type=v[2]
+ )
+ else:
+ data.add_field(k, v)
+ args["data"] = data
+
+ # Pass a `bytes` or `str` parameter directly in the body to support
+ # other content types than Json when `body` argument is provided
+ # in serialized form
+ elif isinstance(body, str) or isinstance(body, bytes):
+ args["data"] = body
+ else:
+ # Cannot generate the request from given parameters
+ msg = """Cannot prepare a request message for provided
+ arguments. Please check that your arguments match
+ declared content type."""
+ raise ApiException(status=0, reason=msg)
+
+ pool_manager: Union[aiohttp.ClientSession, aiohttp_retry.RetryClient]
+ if self.retry_client is not None and method in ALLOW_RETRY_METHODS:
+ pool_manager = self.retry_client
+ else:
+ pool_manager = self.pool_manager
+
+ r = await pool_manager.request(**args)
+
+ return RESTResponse(r)
+
+
+
+
+
diff --git a/templates/python/common_README.mustache b/templates/python/common_README.mustache
new file mode 100644
index 0000000..b7ce461
--- /dev/null
+++ b/templates/python/common_README.mustache
@@ -0,0 +1,84 @@
+```python
+{{#apiInfo}}{{#apis}}{{#-last}}{{#hasHttpSignatureMethods}}import datetime{{/hasHttpSignatureMethods}}{{/-last}}{{/apis}}{{/apiInfo}}
+import {{{packageName}}}
+from {{{packageName}}}.rest import ApiException
+from pprint import pprint
+{{#apiInfo}}{{#apis}}{{#-first}}{{#operations}}{{#operation}}{{#-first}}
+{{> python_doc_auth_partial}}
+
+# Enter a context with an instance of the API client
+{{#asyncio}}async {{/asyncio}}with {{{packageName}}}.ApiClient(configuration) as api_client:
+ # Create an instance of the API class
+ api_instance = {{{packageName}}}.{{{classname}}}(api_client)
+ {{#allParams}}
+ {{paramName}} = {{{example}}} # {{{dataType}}} | {{{description}}}{{^required}} (optional){{/required}}{{#defaultValue}} (default to {{{.}}}){{/defaultValue}}
+ {{/allParams}}
+
+ try:
+ {{#summary}}
+ # {{{.}}}
+ {{/summary}}
+ {{#returnType}}api_response = {{/returnType}}{{#asyncio}}await {{/asyncio}}api_instance.{{{operationId}}}({{#allParams}}{{#required}}{{paramName}}{{/required}}{{^required}}{{paramName}}={{paramName}}{{/required}}{{^-last}}, {{/-last}}{{/allParams}})
+ {{#returnType}}
+ print("The response of {{classname}}->{{operationId}}:\n")
+ pprint(api_response)
+ {{/returnType}}
+ except ApiException as e:
+ print("Exception when calling {{classname}}->{{operationId}}: %s\n" % e)
+{{/-first}}{{/operation}}{{/operations}}{{/-first}}{{/apis}}{{/apiInfo}}
+```
+
+## Documentation for API Endpoints
+
+All URIs are relative to *{{{basePath}}}*
+
+Class | Method | HTTP request | Description
+------------ | ------------- | ------------- | -------------
+{{#apiInfo}}{{#apis}}{{#operations}}{{#operation}}*{{classname}}* | [**{{operationId}}**]({{apiDocPath}}{{classname}}.md#{{operationIdLowerCase}}) | **{{httpMethod}}** {{path}} | {{summary}}
+{{/operation}}{{/operations}}{{/apis}}{{/apiInfo}}
+
+## Documentation For Models
+
+{{#models}}{{#model}} - [{{{classname}}}]({{modelDocPath}}{{{classname}}}.md)
+{{/model}}{{/models}}
+
+
+## Documentation For Authorization
+
+{{^authMethods}}Endpoints do not require authorization.{{/authMethods}}
+{{#hasAuthMethods}}Authentication schemes defined for the API:{{/hasAuthMethods}}
+{{#authMethods}}
+
+### {{{name}}}
+
+{{#isApiKey}}
+- **Type**: API key
+- **API key parameter name**: {{{keyParamName}}}
+- **Location**: {{#isKeyInQuery}}URL query string{{/isKeyInQuery}}{{#isKeyInHeader}}HTTP header{{/isKeyInHeader}}
+{{/isApiKey}}
+{{#isBasic}}
+{{#isBasicBasic}}
+- **Type**: HTTP basic authentication
+{{/isBasicBasic}}
+{{#isBasicBearer}}
+- **Type**: Bearer authentication{{#bearerFormat}} ({{{.}}}){{/bearerFormat}}
+{{/isBasicBearer}}
+{{#isHttpSignature}}
+- **Type**: HTTP signature authentication
+{{/isHttpSignature}}
+{{/isBasic}}
+{{#isOAuth}}
+- **Type**: OAuth
+- **Flow**: {{{flow}}}
+- **Authorization URL**: {{{authorizationUrl}}}
+- **Scopes**: {{^scopes}}N/A{{/scopes}}
+{{#scopes}} - **{{{scope}}}**: {{{description}}}
+{{/scopes}}
+{{/isOAuth}}
+
+{{/authMethods}}
+
+## Author
+
+{{#apiInfo}}{{#apis}}{{#-last}}{{infoEmail}}
+{{/-last}}{{/apis}}{{/apiInfo}}
diff --git a/templates/python/configuration.mustache b/templates/python/configuration.mustache
new file mode 100644
index 0000000..a5d8c63
--- /dev/null
+++ b/templates/python/configuration.mustache
@@ -0,0 +1,116 @@
+# coding: utf-8
+
+{{>partial_header}}
+class HostConfiguration:
+ def __init__(self,
+ region=None,
+ server_index=None, server_variables=None,
+ server_operation_index=None, server_operation_variables=None,
+ ignore_operation_servers=False,
+ ) -> None:
+ """Constructor
+ """
+ self._base_path = "{{{basePath}}}"
+ """Default Base url
+ """
+ self.server_index = 0 if server_index is None else server_index
+ self.server_operation_index = server_operation_index or {}
+ """Default server index
+ """
+ self.server_variables = server_variables or {}
+ if region:
+ self.server_variables['region'] = "{}.".format(region)
+ self.server_operation_variables = server_operation_variables or {}
+ """Default server variables
+ """
+ self.ignore_operation_servers = ignore_operation_servers
+ """Ignore operation servers
+ """
+
+ def get_host_settings(self):
+ """Gets an array of host settings
+
+ :return: An array of host settings
+ """
+ return [
+ {{#servers}}
+ {
+ 'url': "{{{url}}}",
+ 'description': "{{{description}}}{{^description}}No description provided{{/description}}",
+ {{#variables}}
+ {{#-first}}
+ 'variables': {
+ {{/-first}}
+ '{{{name}}}': {
+ 'description': "{{{description}}}{{^description}}No description provided{{/description}}",
+ 'default_value': "{{{defaultValue}}}",
+ {{#enumValues}}
+ {{#-first}}
+ 'enum_values': [
+ {{/-first}}
+ "{{{.}}}"{{^-last}},{{/-last}}
+ {{#-last}}
+ ]
+ {{/-last}}
+ {{/enumValues}}
+ }{{^-last}},{{/-last}}
+ {{#-last}}
+ }
+ {{/-last}}
+ {{/variables}}
+ }{{^-last}},{{/-last}}
+ {{/servers}}
+ ]
+
+ def get_host_from_settings(self, index, variables=None, servers=None):
+ """Gets host URL based on the index and variables
+ :param index: array index of the host settings
+ :param variables: hash of variable and the corresponding value
+ :param servers: an array of host settings or None
+ :return: URL based on host settings
+ """
+ if index is None:
+ return self._base_path
+
+ variables = {} if variables is None else variables
+ servers = self.get_host_settings() if servers is None else servers
+
+ try:
+ server = servers[index]
+ except IndexError:
+ raise ValueError(
+ "Invalid index {0} when selecting the host settings. "
+ "Must be less than {1}".format(index, len(servers)))
+
+ url = server['url']
+
+ # go through variables and replace placeholders
+ for variable_name, variable in server.get('variables', {}).items():
+ used_value = variables.get(
+ variable_name, variable['default_value'])
+
+ if 'enum_values' in variable and used_value not in variable['enum_values']:
+ given_value = variables[variable_name].replace(".", "")
+ valid_values = [v.replace('.', '') for v in variable['enum_values']]
+ raise ValueError(
+ "The variable `{0}` in the host URL has invalid value '{1}'. Must be '{2}'.".format(
+ variable_name,
+ given_value,
+ valid_values
+ )
+ )
+
+ url = url.replace("{" + variable_name + "}", used_value)
+
+ return url
+
+ @property
+ def host(self):
+ """Return generated host."""
+ return self.get_host_from_settings(self.server_index, variables=self.server_variables)
+
+ @host.setter
+ def host(self, value):
+ """Fix base path."""
+ self._base_path = value
+ self.server_index = None
diff --git a/templates/python/exceptions.mustache b/templates/python/exceptions.mustache
new file mode 100644
index 0000000..1c9fee3
--- /dev/null
+++ b/templates/python/exceptions.mustache
@@ -0,0 +1,189 @@
+# coding: utf-8
+
+{{>partial_header}}
+from typing import Any, Optional
+from typing_extensions import Self
+
+class OpenApiException(Exception):
+ """The base exception class for all OpenAPIExceptions"""
+
+
+class ApiTypeError(OpenApiException, TypeError):
+ def __init__(self, msg, path_to_item=None, valid_classes=None,
+ key_type=None) -> None:
+ """ Raises an exception for TypeErrors
+
+ Args:
+ msg (str): the exception message
+
+ Keyword Args:
+ path_to_item (list): a list of keys an indices to get to the
+ current_item
+ None if unset
+ valid_classes (tuple): the primitive classes that current item
+ should be an instance of
+ None if unset
+ key_type (bool): False if our value is a value in a dict
+ True if it is a key in a dict
+ False if our item is an item in a list
+ None if unset
+ """
+ self.path_to_item = path_to_item
+ self.valid_classes = valid_classes
+ self.key_type = key_type
+ full_msg = msg
+ if path_to_item:
+ full_msg = "{0} at {1}".format(msg, render_path(path_to_item))
+ super(ApiTypeError, self).__init__(full_msg)
+
+
+class ApiValueError(OpenApiException, ValueError):
+ def __init__(self, msg, path_to_item=None) -> None:
+ """
+ Args:
+ msg (str): the exception message
+
+ Keyword Args:
+ path_to_item (list) the path to the exception in the
+ received_data dict. None if unset
+ """
+
+ self.path_to_item = path_to_item
+ full_msg = msg
+ if path_to_item:
+ full_msg = "{0} at {1}".format(msg, render_path(path_to_item))
+ super(ApiValueError, self).__init__(full_msg)
+
+
+class ApiAttributeError(OpenApiException, AttributeError):
+ def __init__(self, msg, path_to_item=None) -> None:
+ """
+ Raised when an attribute reference or assignment fails.
+
+ Args:
+ msg (str): the exception message
+
+ Keyword Args:
+ path_to_item (None/list) the path to the exception in the
+ received_data dict
+ """
+ self.path_to_item = path_to_item
+ full_msg = msg
+ if path_to_item:
+ full_msg = "{0} at {1}".format(msg, render_path(path_to_item))
+ super(ApiAttributeError, self).__init__(full_msg)
+
+
+class ApiKeyError(OpenApiException, KeyError):
+ def __init__(self, msg, path_to_item=None) -> None:
+ """
+ Args:
+ msg (str): the exception message
+
+ Keyword Args:
+ path_to_item (None/list) the path to the exception in the
+ received_data dict
+ """
+ self.path_to_item = path_to_item
+ full_msg = msg
+ if path_to_item:
+ full_msg = "{0} at {1}".format(msg, render_path(path_to_item))
+ super(ApiKeyError, self).__init__(full_msg)
+
+
+class ApiException(OpenApiException):
+
+ def __init__(
+ self,
+ status=None,
+ reason=None,
+ http_resp=None,
+ *,
+ body: Optional[str] = None,
+ data: Optional[Any] = None,
+ ) -> None:
+ self.status = status
+ self.reason = reason
+ self.body = body
+ self.data = data
+ self.headers = None
+
+ if http_resp:
+ if self.status is None:
+ self.status = http_resp.status
+ if self.reason is None:
+ self.reason = http_resp.reason
+ if self.body is None:
+ try:
+ self.body = http_resp.data.decode('utf-8')
+ except Exception: # noqa: S110
+ pass
+ self.headers = http_resp.getheaders()
+
+ @classmethod
+ def from_response(
+ cls,
+ *,
+ http_resp,
+ body: Optional[str],
+ data: Optional[Any],
+ ) -> Self:
+ if http_resp.status == 400:
+ raise BadRequestException(http_resp=http_resp, body=body, data=data)
+
+ if http_resp.status == 401:
+ raise UnauthorizedException(http_resp=http_resp, body=body, data=data)
+
+ if http_resp.status == 403:
+ raise ForbiddenException(http_resp=http_resp, body=body, data=data)
+
+ if http_resp.status == 404:
+ raise NotFoundException(http_resp=http_resp, body=body, data=data)
+
+ if 500 <= http_resp.status <= 599:
+ raise ServiceException(http_resp=http_resp, body=body, data=data)
+ raise ApiException(http_resp=http_resp, body=body, data=data)
+
+ def __str__(self):
+ """Custom error messages for exception"""
+ error_message = "({0})\n"\
+ "Reason: {1}\n".format(self.status, self.reason)
+ if self.headers:
+ error_message += "HTTP response headers: {0}\n".format(
+ self.headers)
+
+ if self.data or self.body:
+ error_message += "HTTP response body: {0}\n".format(self.data or self.body)
+
+ return error_message
+
+
+class BadRequestException(ApiException):
+ pass
+
+
+class NotFoundException(ApiException):
+ pass
+
+
+class UnauthorizedException(ApiException):
+ pass
+
+
+class ForbiddenException(ApiException):
+ pass
+
+
+class ServiceException(ApiException):
+ pass
+
+
+def render_path(path_to_item):
+ """Returns a string representation of a path"""
+ result = ""
+ for pth in path_to_item:
+ if isinstance(pth, int):
+ result += "[{0}]".format(pth)
+ else:
+ result += "['{0}']".format(pth)
+ return result
diff --git a/templates/python/git_push.sh.mustache b/templates/python/git_push.sh.mustache
new file mode 100755
index 0000000..0e3776a
--- /dev/null
+++ b/templates/python/git_push.sh.mustache
@@ -0,0 +1,57 @@
+#!/bin/sh
+# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
+#
+# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com"
+
+git_user_id=$1
+git_repo_id=$2
+release_note=$3
+git_host=$4
+
+if [ "$git_host" = "" ]; then
+ git_host="{{{gitHost}}}"
+ echo "[INFO] No command line input provided. Set \$git_host to $git_host"
+fi
+
+if [ "$git_user_id" = "" ]; then
+ git_user_id="{{{gitUserId}}}"
+ echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
+fi
+
+if [ "$git_repo_id" = "" ]; then
+ git_repo_id="{{{gitRepoId}}}"
+ echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
+fi
+
+if [ "$release_note" = "" ]; then
+ release_note="{{{releaseNote}}}"
+ echo "[INFO] No command line input provided. Set \$release_note to $release_note"
+fi
+
+# Initialize the local directory as a Git repository
+git init
+
+# Adds the files in the local repository and stages them for commit.
+git add .
+
+# Commits the tracked changes and prepares them to be pushed to a remote repository.
+git commit -m "$release_note"
+
+# Sets the new remote
+git_remote=$(git remote)
+if [ "$git_remote" = "" ]; then # git remote not defined
+
+ if [ "$GIT_TOKEN" = "" ]; then
+ echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
+ git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
+ else
+ git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
+ fi
+
+fi
+
+git pull origin master
+
+# Pushes (Forces) the changes in the local repository up to the remote repository
+echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git"
+git push origin master 2>&1 | grep -v 'To https'
diff --git a/templates/python/github-workflow.mustache b/templates/python/github-workflow.mustache
new file mode 100644
index 0000000..868124c
--- /dev/null
+++ b/templates/python/github-workflow.mustache
@@ -0,0 +1,39 @@
+# NOTE: This file is auto generated by OpenAPI Generator.
+# URL: https://openapi-generator.tech
+#
+# ref: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
+
+name: {{packageName}} Python package
+{{=<% %>=}}
+
+on: [push, pull_request]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install flake8 pytest
+ if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+ if [ -f test-requirements.txt ]; then pip install -r test-requirements.txt; fi
+ - name: Lint with flake8
+ run: |
+ # stop the build if there are Python syntax errors or undefined names
+ flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+ # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+ flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+ - name: Test with pytest
+ run: |
+ pytest
diff --git a/templates/python/gitignore.mustache b/templates/python/gitignore.mustache
new file mode 100644
index 0000000..43995bd
--- /dev/null
+++ b/templates/python/gitignore.mustache
@@ -0,0 +1,66 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+.hypothesis/
+venv/
+.venv/
+.python-version
+.pytest_cache
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+#Ipython Notebook
+.ipynb_checkpoints
diff --git a/templates/python/gitlab-ci.mustache b/templates/python/gitlab-ci.mustache
new file mode 100644
index 0000000..8a6130a
--- /dev/null
+++ b/templates/python/gitlab-ci.mustache
@@ -0,0 +1,31 @@
+# NOTE: This file is auto generated by OpenAPI Generator.
+# URL: https://openapi-generator.tech
+#
+# ref: https://docs.gitlab.com/ee/ci/README.html
+# ref: https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Python.gitlab-ci.yml
+
+stages:
+ - test
+
+.pytest:
+ stage: test
+ script:
+ - pip install -r requirements.txt
+ - pip install -r test-requirements.txt
+ - pytest --cov={{{packageName}}}
+
+pytest-3.7:
+ extends: .pytest
+ image: python:3.7-alpine
+pytest-3.8:
+ extends: .pytest
+ image: python:3.8-alpine
+pytest-3.9:
+ extends: .pytest
+ image: python:3.9-alpine
+pytest-3.10:
+ extends: .pytest
+ image: python:3.10-alpine
+pytest-3.11:
+ extends: .pytest
+ image: python:3.11-alpine
diff --git a/templates/python/model.mustache b/templates/python/model.mustache
new file mode 100644
index 0000000..84792dd
--- /dev/null
+++ b/templates/python/model.mustache
@@ -0,0 +1,14 @@
+# coding: utf-8
+
+{{>partial_header}}
+
+{{#models}}
+{{#model}}
+{{#isEnum}}
+{{>model_enum}}
+{{/isEnum}}
+{{^isEnum}}
+{{#oneOf}}{{#-first}}{{>model_oneof}}{{/-first}}{{/oneOf}}{{^oneOf}}{{#anyOf}}{{#-first}}{{>model_anyof}}{{/-first}}{{/anyOf}}{{^anyOf}}{{>model_generic}}{{/anyOf}}{{/oneOf}}
+{{/isEnum}}
+{{/model}}
+{{/models}}
\ No newline at end of file
diff --git a/templates/python/model_anyof.mustache b/templates/python/model_anyof.mustache
new file mode 100644
index 0000000..3e2e6fc
--- /dev/null
+++ b/templates/python/model_anyof.mustache
@@ -0,0 +1,182 @@
+from __future__ import annotations
+from inspect import getfullargspec
+import json
+import pprint
+import re
+{{#vendorExtensions.x-py-other-imports}}
+{{{.}}}
+{{/vendorExtensions.x-py-other-imports}}
+{{#vendorExtensions.x-py-model-imports}}
+{{{.}}}
+{{/vendorExtensions.x-py-model-imports}}
+from typing import Union, Any, List, Set, TYPE_CHECKING, Optional, Dict
+from typing_extensions import Literal, Self
+from pydantic import Field
+
+{{#lambda.uppercase}}{{{classname}}}{{/lambda.uppercase}}_ANY_OF_SCHEMAS = [{{#anyOf}}"{{.}}"{{^-last}}, {{/-last}}{{/anyOf}}]
+
+class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}}):
+ """
+ {{{description}}}{{^description}}{{{classname}}}{{/description}}
+ """
+
+{{#composedSchemas.anyOf}}
+ # data type: {{{dataType}}}
+ {{vendorExtensions.x-py-name}}: {{{vendorExtensions.x-py-typing}}}
+{{/composedSchemas.anyOf}}
+ if TYPE_CHECKING:
+ actual_instance: Optional[Union[{{#anyOf}}{{{.}}}{{^-last}}, {{/-last}}{{/anyOf}}]] = None
+ else:
+ actual_instance: Any = None
+ any_of_schemas: Set[str] = { {{#anyOf}}"{{.}}"{{^-last}}, {{/-last}}{{/anyOf}} }
+
+ model_config = {
+ "validate_assignment": True,
+ "protected_namespaces": (),
+ }
+{{#discriminator}}
+
+ discriminator_value_class_map: Dict[str, str] = {
+{{#children}}
+ '{{^vendorExtensions.x-discriminator-value}}{{name}}{{/vendorExtensions.x-discriminator-value}}{{#vendorExtensions.x-discriminator-value}}{{{vendorExtensions.x-discriminator-value}}}{{/vendorExtensions.x-discriminator-value}}': '{{{classname}}}'{{^-last}},{{/-last}}
+{{/children}}
+ }
+{{/discriminator}}
+
+ def __init__(self, *args, **kwargs) -> None:
+ if args:
+ if len(args) > 1:
+ raise ValueError("If a position argument is used, only 1 is allowed to set `actual_instance`")
+ if kwargs:
+ raise ValueError("If a position argument is used, keyword arguments cannot be used.")
+ super().__init__(actual_instance=args[0])
+ else:
+ super().__init__(**kwargs)
+
+ @field_validator('actual_instance')
+ def actual_instance_must_validate_anyof(cls, v):
+ {{#isNullable}}
+ if v is None:
+ return v
+
+ {{/isNullable}}
+ instance = {{{classname}}}.model_construct()
+ error_messages = []
+ {{#composedSchemas.anyOf}}
+ # validate data type: {{{dataType}}}
+ {{#isContainer}}
+ try:
+ instance.{{vendorExtensions.x-py-name}} = v
+ return v
+ except (ValidationError, ValueError) as e:
+ error_messages.append(str(e))
+ {{/isContainer}}
+ {{^isContainer}}
+ {{#isPrimitiveType}}
+ try:
+ instance.{{vendorExtensions.x-py-name}} = v
+ return v
+ except (ValidationError, ValueError) as e:
+ error_messages.append(str(e))
+ {{/isPrimitiveType}}
+ {{^isPrimitiveType}}
+ if not isinstance(v, {{{dataType}}}):
+ error_messages.append(f"Error! Input type `{type(v)}` is not `{{{dataType}}}`")
+ else:
+ return v
+
+ {{/isPrimitiveType}}
+ {{/isContainer}}
+ {{/composedSchemas.anyOf}}
+ if error_messages:
+ # no match
+ raise ValueError("No match found when setting the actual_instance in {{{classname}}} with anyOf schemas: {{#anyOf}}{{{.}}}{{^-last}}, {{/-last}}{{/anyOf}}. Details: " + ", ".join(error_messages))
+ else:
+ return v
+
+ @classmethod
+ def from_dict(cls, obj: Dict[str, Any]) -> Self:
+ return cls.from_json(json.dumps(obj))
+
+ @classmethod
+ def from_json(cls, json_str: str) -> Self:
+ """Returns the object represented by the json string"""
+ instance = cls.model_construct()
+ {{#isNullable}}
+ if json_str is None:
+ return instance
+
+ {{/isNullable}}
+ error_messages = []
+ {{#composedSchemas.anyOf}}
+ {{#isContainer}}
+ # deserialize data into {{{dataType}}}
+ try:
+ # validation
+ instance.{{vendorExtensions.x-py-name}} = json.loads(json_str)
+ # assign value to actual_instance
+ instance.actual_instance = instance.{{vendorExtensions.x-py-name}}
+ return instance
+ except (ValidationError, ValueError) as e:
+ error_messages.append(str(e))
+ {{/isContainer}}
+ {{^isContainer}}
+ {{#isPrimitiveType}}
+ # deserialize data into {{{dataType}}}
+ try:
+ # validation
+ instance.{{vendorExtensions.x-py-name}} = json.loads(json_str)
+ # assign value to actual_instance
+ instance.actual_instance = instance.{{vendorExtensions.x-py-name}}
+ return instance
+ except (ValidationError, ValueError) as e:
+ error_messages.append(str(e))
+ {{/isPrimitiveType}}
+ {{^isPrimitiveType}}
+ # {{vendorExtensions.x-py-name}}: {{{vendorExtensions.x-py-typing}}}
+ try:
+ instance.actual_instance = {{{dataType}}}.from_json(json_str)
+ return instance
+ except (ValidationError, ValueError) as e:
+ error_messages.append(str(e))
+ {{/isPrimitiveType}}
+ {{/isContainer}}
+ {{/composedSchemas.anyOf}}
+
+ if error_messages:
+ # no match
+ raise ValueError("No match found when deserializing the JSON string into {{{classname}}} with anyOf schemas: {{#anyOf}}{{{.}}}{{^-last}}, {{/-last}}{{/anyOf}}. Details: " + ", ".join(error_messages))
+ else:
+ return instance
+
+ def to_json(self) -> str:
+ """Returns the JSON representation of the actual instance"""
+ if self.actual_instance is None:
+ return "null"
+
+ if hasattr(self.actual_instance, "to_json") and callable(self.actual_instance.to_json):
+ return self.actual_instance.to_json()
+ else:
+ return json.dumps(self.actual_instance)
+
+ def to_dict(self) -> Optional[Union[Dict[str, Any], {{#anyOf}}{{.}}{{^-last}}, {{/-last}}{{/anyOf}}]]:
+ """Returns the dict representation of the actual instance"""
+ if self.actual_instance is None:
+ return None
+
+ if hasattr(self.actual_instance, "to_dict") and callable(self.actual_instance.to_dict):
+ return self.actual_instance.to_dict()
+ else:
+ return self.actual_instance
+
+ def to_str(self) -> str:
+ """Returns the string representation of the actual instance"""
+ return pprint.pformat(self.model_dump())
+
+{{#vendorExtensions.x-py-postponed-model-imports.size}}
+{{#vendorExtensions.x-py-postponed-model-imports}}
+{{{.}}}
+{{/vendorExtensions.x-py-postponed-model-imports}}
+# TODO: Rewrite to not use raise_errors
+{{classname}}.model_rebuild(raise_errors=False)
+{{/vendorExtensions.x-py-postponed-model-imports.size}}
diff --git a/templates/python/model_doc.mustache b/templates/python/model_doc.mustache
new file mode 100644
index 0000000..98d50cf
--- /dev/null
+++ b/templates/python/model_doc.mustache
@@ -0,0 +1,40 @@
+{{#models}}{{#model}}# {{classname}}
+
+{{#description}}{{&description}}
+{{/description}}
+
+{{^isEnum}}
+## Properties
+
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+{{#vars}}**{{name}}** | {{#isPrimitiveType}}**{{dataType}}**{{/isPrimitiveType}}{{^isPrimitiveType}}[**{{dataType}}**]({{complexType}}.md){{/isPrimitiveType}} | {{description}} | {{^required}}[optional] {{/required}}{{#isReadOnly}}[readonly] {{/isReadOnly}}{{#defaultValue}}[default to {{{.}}}]{{/defaultValue}}
+{{/vars}}
+
+## Example
+
+```python
+from {{modelPackage}}.{{#lambda.snakecase}}{{classname}}{{/lambda.snakecase}} import {{classname}}
+
+# TODO update the JSON string below
+json = "{}"
+# create an instance of {{classname}} from a JSON string
+{{#lambda.snakecase}}{{classname}}{{/lambda.snakecase}}_instance = {{classname}}.from_json(json)
+# print the JSON string representation of the object
+print({{classname}}.to_json())
+
+# convert the object into a dict
+{{#lambda.snakecase}}{{classname}}{{/lambda.snakecase}}_dict = {{#lambda.snakecase}}{{classname}}{{/lambda.snakecase}}_instance.to_dict()
+# create an instance of {{classname}} from a dict
+{{#lambda.snakecase}}{{classname}}{{/lambda.snakecase}}_from_dict = {{classname}}.from_dict({{#lambda.snakecase}}{{classname}}{{/lambda.snakecase}}_dict)
+```
+{{/isEnum}}
+{{#isEnum}}
+## Enum
+{{#allowableValues}}{{#enumVars}}
+* `{{name}}` (value: `{{{value}}}`)
+{{/enumVars}}{{/allowableValues}}
+{{/isEnum}}
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+{{/model}}{{/models}}
diff --git a/templates/python/model_enum.mustache b/templates/python/model_enum.mustache
new file mode 100644
index 0000000..3f449b1
--- /dev/null
+++ b/templates/python/model_enum.mustache
@@ -0,0 +1,36 @@
+from __future__ import annotations
+import json
+from enum import Enum
+{{#vendorExtensions.x-py-other-imports}}
+{{{.}}}
+{{/vendorExtensions.x-py-other-imports}}
+from typing_extensions import Self
+
+
+class {{classname}}({{vendorExtensions.x-py-enum-type}}, Enum):
+ """
+ {{{description}}}{{^description}}{{{classname}}}{{/description}}
+ """
+
+ """
+ allowed enum values
+ """
+{{#allowableValues}}
+ {{#enumVars}}
+ {{{name}}} = {{{value}}}
+ {{/enumVars}}
+
+ @classmethod
+ def from_json(cls, json_str: str) -> Self:
+ """Create an instance of {{classname}} from a JSON string"""
+ return cls(json.loads(json_str))
+
+ {{#defaultValue}}
+
+ #
+ @classmethod
+ def _missing_value_(cls, value):
+ if value is no_arg:
+ return cls.{{{.}}}
+ {{/defaultValue}}
+{{/allowableValues}}
diff --git a/templates/python/model_generic.mustache b/templates/python/model_generic.mustache
new file mode 100644
index 0000000..e6624b8
--- /dev/null
+++ b/templates/python/model_generic.mustache
@@ -0,0 +1,396 @@
+from __future__ import annotations
+import pprint
+import re
+import json
+
+{{#vendorExtensions.x-py-other-imports}}
+{{{.}}}
+{{/vendorExtensions.x-py-other-imports}}
+{{#vendorExtensions.x-py-model-imports}}
+{{{.}}}
+{{/vendorExtensions.x-py-model-imports}}
+from typing import Set, Optional
+from typing_extensions import Self
+
+{{#hasChildren}}
+{{#discriminator}}
+{{! If this model is a super class, importlib is used. So import the necessary modules for the type here. }}
+from typing import TYPE_CHECKING
+if TYPE_CHECKING:
+{{#mappedModels}}
+ from {{packageName}}.models.{{model.classVarName}} import {{modelName}}
+{{/mappedModels}}
+
+{{/discriminator}}
+{{/hasChildren}}
+class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}}):
+ """
+ {{#description}}{{{description}}}{{/description}}{{^description}}{{{classname}}}{{/description}}
+ """
+{{#vars}}
+ {{name}}: {{{vendorExtensions.x-py-typing}}}
+{{/vars}}
+{{#isAdditionalPropertiesTrue}}
+ additional_properties: Dict[str, Any] = {}
+{{/isAdditionalPropertiesTrue}}
+ __properties: ClassVar[List[str]] = [{{#allVars}}"{{baseName}}"{{^-last}}, {{/-last}}{{/allVars}}]
+{{#vars}}
+ {{#vendorExtensions.x-regex}}
+
+ @field_validator('{{{name}}}')
+ def {{{name}}}_validate_regular_expression(cls, value):
+ """Validates the regular expression"""
+ {{^required}}
+ if value is None:
+ return value
+
+ {{/required}}
+ {{#required}}
+ {{#isNullable}}
+ if value is None:
+ return value
+
+ {{/isNullable}}
+ {{/required}}
+ if not re.match(r"{{{.}}}", value{{#vendorExtensions.x-modifiers}} ,re.{{{.}}}{{/vendorExtensions.x-modifiers}}):
+ raise ValueError(r"must validate the regular expression {{{vendorExtensions.x-pattern}}}")
+ return value
+ {{/vendorExtensions.x-regex}}
+ {{#isEnum}}
+
+ @field_validator('{{{name}}}')
+ def {{{name}}}_validate_enum(cls, value):
+ """Validates the enum"""
+ {{^required}}
+ if value is None:
+ return value
+
+ {{/required}}
+ {{#required}}
+ {{#isNullable}}
+ if value is None:
+ return value
+
+ {{/isNullable}}
+ {{/required}}
+ {{#isArray}}
+ for i in value:
+ if i not in set([{{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}}, {{/-last}}{{/enumVars}}{{/allowableValues}}]):
+ raise ValueError("each list item must be one of ({{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}}, {{/-last}}{{/enumVars}}{{/allowableValues}})")
+ {{/isArray}}
+ {{^isArray}}
+ if value not in set([{{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}}, {{/-last}}{{/enumVars}}{{/allowableValues}}]):
+ raise ValueError("must be one of enum values ({{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}}, {{/-last}}{{/enumVars}}{{/allowableValues}})")
+ {{/isArray}}
+ return value
+ {{/isEnum}}
+{{/vars}}
+
+ model_config = ConfigDict(
+ populate_by_name=True,
+ validate_assignment=True,
+ protected_namespaces=(),
+ )
+
+
+{{#hasChildren}}
+{{#discriminator}}
+ # JSON field name that stores the object type
+ __discriminator_property_name: ClassVar[str] = '{{discriminator.propertyBaseName}}'
+
+ # discriminator mappings
+ __discriminator_value_class_map: ClassVar[Dict[str, str]] = {
+ {{#mappedModels}}'{{{mappingName}}}': '{{{modelName}}}'{{^-last}},{{/-last}}{{/mappedModels}}
+ }
+
+ @classmethod
+ def get_discriminator_value(cls, obj: Dict[str, Any]) -> Optional[str]:
+ """Returns the discriminator value (object type) of the data"""
+ discriminator_value = obj[cls.__discriminator_property_name]
+ if discriminator_value:
+ return cls.__discriminator_value_class_map.get(discriminator_value)
+ else:
+ return None
+
+{{/discriminator}}
+{{/hasChildren}}
+ def to_str(self) -> str:
+ """Returns the string representation of the model using alias"""
+ return pprint.pformat(self.model_dump(by_alias=True))
+
+ def to_json(self) -> str:
+ """Returns the JSON representation of the model using alias"""
+ # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
+ return json.dumps(self.to_dict())
+
+ @classmethod
+ def from_json(cls, json_str: str) -> Optional[{{^hasChildren}}Self{{/hasChildren}}{{#hasChildren}}{{#discriminator}}Union[{{#mappedModels}}{{{modelName}}}{{^-last}}, {{/-last}}{{/mappedModels}}]{{/discriminator}}{{^discriminator}}Self{{/discriminator}}{{/hasChildren}}]:
+ """Create an instance of {{{classname}}} from a JSON string"""
+ return cls.from_dict(json.loads(json_str))
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Return the dictionary representation of the model using alias.
+
+ This has the following differences from calling pydantic's
+ `self.model_dump(by_alias=True)`:
+
+ * `None` is only added to the output dict for nullable fields that
+ were set at model initialization. Other fields with value `None`
+ are ignored.
+ {{#vendorExtensions.x-py-readonly}}
+ * OpenAPI `readOnly` fields are excluded.
+ {{/vendorExtensions.x-py-readonly}}
+ {{#isAdditionalPropertiesTrue}}
+ * Fields in `self.additional_properties` are added to the output dict.
+ {{/isAdditionalPropertiesTrue}}
+ """
+ excluded_fields: Set[str] = set([
+ {{#vendorExtensions.x-py-readonly}}
+ "{{{.}}}",
+ {{/vendorExtensions.x-py-readonly}}
+ {{#isAdditionalPropertiesTrue}}
+ "additional_properties",
+ {{/isAdditionalPropertiesTrue}}
+ ])
+
+ _dict = self.model_dump(
+ by_alias=True,
+ exclude=excluded_fields,
+ exclude_none=True,
+ )
+ {{#allVars}}
+ {{#isContainer}}
+ {{#isArray}}
+ {{#items.isArray}}
+ {{^items.items.isPrimitiveType}}
+ # override the default output from pydantic by calling `to_dict()` of each item in {{{name}}} (list of list)
+ _items = []
+ if self.{{{name}}}:
+ for _item in self.{{{name}}}:
+ if _item:
+ _items.append(
+ [_inner_item.to_dict() for _inner_item in _item if _inner_item is not None]
+ )
+ _dict['{{{baseName}}}'] = _items
+ {{/items.items.isPrimitiveType}}
+ {{/items.isArray}}
+ {{^items.isArray}}
+ {{^items.isPrimitiveType}}
+ {{^items.isEnumOrRef}}
+ # override the default output from pydantic by calling `to_dict()` of each item in {{{name}}} (list)
+ _items = []
+ if self.{{{name}}}:
+ for _item in self.{{{name}}}:
+ if _item:
+ _items.append(_item.to_dict())
+ _dict['{{{baseName}}}'] = _items
+ {{/items.isEnumOrRef}}
+ {{/items.isPrimitiveType}}
+ {{/items.isArray}}
+ {{/isArray}}
+ {{#isMap}}
+ {{#items.isArray}}
+ {{^items.items.isPrimitiveType}}
+ # override the default output from pydantic by calling `to_dict()` of each value in {{{name}}} (dict of array)
+ _field_dict_of_array = {}
+ if self.{{{name}}}:
+ for _key in self.{{{name}}}:
+ if self.{{{name}}}[_key] is not None:
+ _field_dict_of_array[_key] = [
+ _item.to_dict() for _item in self.{{{name}}}[_key]
+ ]
+ _dict['{{{baseName}}}'] = _field_dict_of_array
+ {{/items.items.isPrimitiveType}}
+ {{/items.isArray}}
+ {{^items.isArray}}
+ {{^items.isPrimitiveType}}
+ {{^items.isEnumOrRef}}
+ # override the default output from pydantic by calling `to_dict()` of each value in {{{name}}} (dict)
+ _field_dict = {}
+ if self.{{{name}}}:
+ for _key in self.{{{name}}}:
+ if self.{{{name}}}[_key]:
+ _field_dict[_key] = self.{{{name}}}[_key].to_dict()
+ _dict['{{{baseName}}}'] = _field_dict
+ {{/items.isEnumOrRef}}
+ {{/items.isPrimitiveType}}
+ {{/items.isArray}}
+ {{/isMap}}
+ {{/isContainer}}
+ {{^isContainer}}
+ {{^isPrimitiveType}}
+ {{^isEnumOrRef}}
+ # override the default output from pydantic by calling `to_dict()` of {{{name}}}
+ if self.{{{name}}}:
+ _dict['{{{baseName}}}'] = self.{{{name}}}.to_dict()
+ {{/isEnumOrRef}}
+ {{/isPrimitiveType}}
+ {{/isContainer}}
+ {{/allVars}}
+ {{#isAdditionalPropertiesTrue}}
+ # puts key-value pairs in additional_properties in the top level
+ if self.additional_properties is not None:
+ for _key, _value in self.additional_properties.items():
+ _dict[_key] = _value
+
+ {{/isAdditionalPropertiesTrue}}
+ {{#allVars}}
+ {{#isNullable}}
+ # set to None if {{{name}}} (nullable) is None
+ # and model_fields_set contains the field
+ if self.{{name}} is None and "{{{name}}}" in self.model_fields_set:
+ _dict['{{{baseName}}}'] = None
+
+ {{/isNullable}}
+ {{/allVars}}
+ return _dict
+
+ {{#hasChildren}}
+ @classmethod
+ def from_dict(cls, obj: Dict[str, Any]) -> Optional[{{#discriminator}}Union[{{#mappedModels}}{{{modelName}}}{{^-last}}, {{/-last}}{{/mappedModels}}]{{/discriminator}}{{^discriminator}}Self{{/discriminator}}]:
+ """Create an instance of {{{classname}}} from a dict"""
+ {{#discriminator}}
+ # look up the object type based on discriminator mapping
+ object_type = cls.get_discriminator_value(obj)
+ {{#mappedModels}}
+ if object_type == '{{{modelName}}}':
+ return import_module("{{packageName}}.models.{{model.classVarName}}").{{modelName}}.from_dict(obj)
+ {{/mappedModels}}
+
+ raise ValueError("{{{classname}}} failed to lookup discriminator value from " +
+ json.dumps(obj) + ". Discriminator property name: " + cls.__discriminator_property_name +
+ ", mapping: " + json.dumps(cls.__discriminator_value_class_map))
+ {{/discriminator}}
+ {{/hasChildren}}
+ {{^hasChildren}}
+ @classmethod
+ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
+ """Create an instance of {{{classname}}} from a dict"""
+ if obj is None:
+ return None
+
+ if not isinstance(obj, dict):
+ return cls.model_validate(obj)
+
+ {{#disallowAdditionalPropertiesIfNotPresent}}
+ {{^isAdditionalPropertiesTrue}}
+ # raise errors for additional fields in the input
+ for _key in obj.keys():
+ if _key not in cls.__properties:
+ raise ValueError("Error due to additional fields (not defined in {{classname}}) in the input: " + _key)
+
+ {{/isAdditionalPropertiesTrue}}
+ {{/disallowAdditionalPropertiesIfNotPresent}}
+ _obj = cls.model_validate({
+ {{#allVars}}
+ {{#isContainer}}
+ {{#isArray}}
+ {{#items.isArray}}
+ {{#items.items.isPrimitiveType}}
+ "{{{baseName}}}": obj.get("{{{baseName}}}"){{^-last}},{{/-last}}
+ {{/items.items.isPrimitiveType}}
+ {{^items.items.isPrimitiveType}}
+ "{{{baseName}}}": [
+ [{{{items.items.dataType}}}.from_dict(_inner_item) for _inner_item in _item]
+ for _item in obj["{{{baseName}}}"]
+ ] if obj.get("{{{baseName}}}") is not None else None{{^-last}},{{/-last}}
+ {{/items.items.isPrimitiveType}}
+ {{/items.isArray}}
+ {{^items.isArray}}
+ {{^items.isPrimitiveType}}
+ {{#items.isEnumOrRef}}
+ "{{{baseName}}}": obj.get("{{{baseName}}}"){{^-last}},{{/-last}}
+ {{/items.isEnumOrRef}}
+ {{^items.isEnumOrRef}}
+ "{{{baseName}}}": [{{{items.dataType}}}.from_dict(_item) for _item in obj["{{{baseName}}}"]] if obj.get("{{{baseName}}}") is not None else None{{^-last}},{{/-last}}
+ {{/items.isEnumOrRef}}
+ {{/items.isPrimitiveType}}
+ {{#items.isPrimitiveType}}
+ "{{{baseName}}}": obj.get("{{{baseName}}}"){{^-last}},{{/-last}}
+ {{/items.isPrimitiveType}}
+ {{/items.isArray}}
+ {{/isArray}}
+ {{#isMap}}
+ {{^items.isPrimitiveType}}
+ {{^items.isEnumOrRef}}
+ {{#items.isContainer}}
+ {{#items.isMap}}
+ "{{{baseName}}}": dict(
+ (_k, dict(
+ (_ik, {{{items.items.dataType}}}.from_dict(_iv))
+ for _ik, _iv in _v.items()
+ )
+ if _v is not None
+ else None
+ )
+ for _k, _v in obj.get("{{{baseName}}}").items()
+ )
+ if obj.get("{{{baseName}}}") is not None
+ else None{{^-last}},{{/-last}}
+ {{/items.isMap}}
+ {{#items.isArray}}
+ "{{{baseName}}}": dict(
+ (_k,
+ [{{{items.items.dataType}}}.from_dict(_item) for _item in _v]
+ if _v is not None
+ else None
+ )
+ for _k, _v in obj.get("{{{baseName}}}", {}).items()
+ ){{^-last}},{{/-last}}
+ {{/items.isArray}}
+ {{/items.isContainer}}
+ {{^items.isContainer}}
+ "{{{baseName}}}": dict(
+ (_k, {{{items.dataType}}}.from_dict(_v))
+ for _k, _v in obj["{{{baseName}}}"].items()
+ )
+ if obj.get("{{{baseName}}}") is not None
+ else None{{^-last}},{{/-last}}
+ {{/items.isContainer}}
+ {{/items.isEnumOrRef}}
+ {{#items.isEnumOrRef}}
+ "{{{baseName}}}": dict((_k, _v) for _k, _v in obj.get("{{{baseName}}}").items()){{^-last}},{{/-last}}
+ {{/items.isEnumOrRef}}
+ {{/items.isPrimitiveType}}
+ {{#items.isPrimitiveType}}
+ "{{{baseName}}}": obj.get("{{{baseName}}}"){{^-last}},{{/-last}}
+ {{/items.isPrimitiveType}}
+ {{/isMap}}
+ {{/isContainer}}
+ {{^isContainer}}
+ {{^isPrimitiveType}}
+ {{^isEnumOrRef}}
+ "{{{baseName}}}": {{{dataType}}}.from_dict(obj["{{{baseName}}}"]) if obj.get("{{{baseName}}}") is not None else None{{^-last}},{{/-last}}
+ {{/isEnumOrRef}}
+ {{#isEnumOrRef}}
+ "{{{baseName}}}": obj.get("{{{baseName}}}"){{#defaultValue}} if obj.get("{{baseName}}") is not None else {{defaultValue}}{{/defaultValue}}{{^-last}},{{/-last}}
+ {{/isEnumOrRef}}
+ {{/isPrimitiveType}}
+ {{#isPrimitiveType}}
+ {{#defaultValue}}
+ "{{{baseName}}}": obj.get("{{{baseName}}}") if obj.get("{{{baseName}}}") is not None else {{{defaultValue}}}{{^-last}},{{/-last}}
+ {{/defaultValue}}
+ {{^defaultValue}}
+ "{{{baseName}}}": obj.get("{{{baseName}}}"){{^-last}},{{/-last}}
+ {{/defaultValue}}
+ {{/isPrimitiveType}}
+ {{/isContainer}}
+ {{/allVars}}
+ })
+ {{#isAdditionalPropertiesTrue}}
+ # store additional fields in additional_properties
+ for _key in obj.keys():
+ if _key not in cls.__properties:
+ _obj.additional_properties[_key] = obj.get(_key)
+
+ {{/isAdditionalPropertiesTrue}}
+ return _obj
+ {{/hasChildren}}
+
+{{#vendorExtensions.x-py-postponed-model-imports.size}}
+{{#vendorExtensions.x-py-postponed-model-imports}}
+{{{.}}}
+{{/vendorExtensions.x-py-postponed-model-imports}}
+# TODO: Rewrite to not use raise_errors
+{{classname}}.model_rebuild(raise_errors=False)
+{{/vendorExtensions.x-py-postponed-model-imports.size}}
diff --git a/templates/python/model_oneof.mustache b/templates/python/model_oneof.mustache
new file mode 100644
index 0000000..07a4d93
--- /dev/null
+++ b/templates/python/model_oneof.mustache
@@ -0,0 +1,209 @@
+from __future__ import annotations
+import json
+import pprint
+{{#vendorExtensions.x-py-other-imports}}
+{{{.}}}
+{{/vendorExtensions.x-py-other-imports}}
+{{#vendorExtensions.x-py-model-imports}}
+{{{.}}}
+{{/vendorExtensions.x-py-model-imports}}
+from pydantic import StrictStr, Field
+from typing import Union, List, Set, Optional, Dict
+from typing_extensions import Literal, Self
+
+{{#lambda.uppercase}}{{{classname}}}{{/lambda.uppercase}}_ONE_OF_SCHEMAS = [{{#oneOf}}"{{.}}"{{^-last}}, {{/-last}}{{/oneOf}}]
+
+class {{classname}}({{#parent}}{{{.}}}{{/parent}}{{^parent}}BaseModel{{/parent}}):
+ """
+ {{{description}}}{{^description}}{{{classname}}}{{/description}}
+ """
+{{#composedSchemas.oneOf}}
+ # data type: {{{dataType}}}
+ {{vendorExtensions.x-py-name}}: {{{vendorExtensions.x-py-typing}}}
+{{/composedSchemas.oneOf}}
+ actual_instance: Optional[Union[{{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}}]] = None
+ one_of_schemas: Set[str] = { {{#oneOf}}"{{.}}"{{^-last}}, {{/-last}}{{/oneOf}} }
+
+ model_config = ConfigDict(
+ validate_assignment=True,
+ protected_namespaces=(),
+ )
+
+{{#discriminator}}
+
+ discriminator_value_class_map: Dict[str, str] = {
+{{#children}}
+ '{{^vendorExtensions.x-discriminator-value}}{{name}}{{/vendorExtensions.x-discriminator-value}}{{#vendorExtensions.x-discriminator-value}}{{{vendorExtensions.x-discriminator-value}}}{{/vendorExtensions.x-discriminator-value}}': '{{{classname}}}'{{^-last}},{{/-last}}
+{{/children}}
+ }
+{{/discriminator}}
+
+ def __init__(self, *args, **kwargs) -> None:
+ if args:
+ if len(args) > 1:
+ raise ValueError("If a position argument is used, only 1 is allowed to set `actual_instance`")
+ if kwargs:
+ raise ValueError("If a position argument is used, keyword arguments cannot be used.")
+ super().__init__(actual_instance=args[0])
+ else:
+ super().__init__(**kwargs)
+
+ @field_validator('actual_instance')
+ def actual_instance_must_validate_oneof(cls, v):
+ {{#isNullable}}
+ if v is None:
+ return v
+
+ {{/isNullable}}
+ instance = {{{classname}}}.model_construct()
+ error_messages = []
+ match = 0
+ {{#composedSchemas.oneOf}}
+ # validate data type: {{{dataType}}}
+ {{#isContainer}}
+ try:
+ instance.{{vendorExtensions.x-py-name}} = v
+ match += 1
+ except (ValidationError, ValueError) as e:
+ error_messages.append(str(e))
+ {{/isContainer}}
+ {{^isContainer}}
+ {{#isPrimitiveType}}
+ try:
+ instance.{{vendorExtensions.x-py-name}} = v
+ match += 1
+ except (ValidationError, ValueError) as e:
+ error_messages.append(str(e))
+ {{/isPrimitiveType}}
+ {{^isPrimitiveType}}
+ if not isinstance(v, {{{dataType}}}):
+ error_messages.append(f"Error! Input type `{type(v)}` is not `{{{dataType}}}`")
+ else:
+ match += 1
+ {{/isPrimitiveType}}
+ {{/isContainer}}
+ {{/composedSchemas.oneOf}}
+ if match > 1:
+ # more than 1 match
+ raise ValueError("Multiple matches found when setting `actual_instance` in {{{classname}}} with oneOf schemas: {{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}}. Details: " + ", ".join(error_messages))
+ elif match == 0:
+ # no match
+ raise ValueError("No match found when setting `actual_instance` in {{{classname}}} with oneOf schemas: {{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}}. Details: " + ", ".join(error_messages))
+ else:
+ return v
+
+ @classmethod
+ def from_dict(cls, obj: Union[str, Dict[str, Any]]) -> Self:
+ return cls.from_json(json.dumps(obj))
+
+ @classmethod
+ {{#isNullable}}
+ def from_json(cls, json_str: Optional[str]) -> Self:
+ {{/isNullable}}
+ {{^isNullable}}
+ def from_json(cls, json_str: str) -> Self:
+ {{/isNullable}}
+ """Returns the object represented by the json string"""
+ instance = cls.model_construct()
+ {{#isNullable}}
+ if json_str is None:
+ return instance
+
+ {{/isNullable}}
+ error_messages = []
+ match = 0
+
+ {{#useOneOfDiscriminatorLookup}}
+ {{#discriminator}}
+ {{#mappedModels}}
+ {{#-first}}
+ # use oneOf discriminator to lookup the data type
+ _data_type = json.loads(json_str).get("{{{propertyBaseName}}}")
+ if not _data_type:
+ raise ValueError("Failed to lookup data type from the field `{{{propertyBaseName}}}` in the input.")
+
+ {{/-first}}
+ # check if data type is `{{{modelName}}}`
+ if _data_type == "{{{mappingName}}}":
+ instance.actual_instance = {{{modelName}}}.from_json(json_str)
+ return instance
+
+ {{/mappedModels}}
+ {{/discriminator}}
+ {{/useOneOfDiscriminatorLookup}}
+ {{#composedSchemas.oneOf}}
+ {{#isContainer}}
+ # deserialize data into {{{dataType}}}
+ try:
+ # validation
+ instance.{{vendorExtensions.x-py-name}} = json.loads(json_str)
+ # assign value to actual_instance
+ instance.actual_instance = instance.{{vendorExtensions.x-py-name}}
+ match += 1
+ except (ValidationError, ValueError) as e:
+ error_messages.append(str(e))
+ {{/isContainer}}
+ {{^isContainer}}
+ {{#isPrimitiveType}}
+ # deserialize data into {{{dataType}}}
+ try:
+ # validation
+ instance.{{vendorExtensions.x-py-name}} = json.loads(json_str)
+ # assign value to actual_instance
+ instance.actual_instance = instance.{{vendorExtensions.x-py-name}}
+ match += 1
+ except (ValidationError, ValueError) as e:
+ error_messages.append(str(e))
+ {{/isPrimitiveType}}
+ {{^isPrimitiveType}}
+ # deserialize data into {{{dataType}}}
+ try:
+ instance.actual_instance = {{{dataType}}}.from_json(json_str)
+ match += 1
+ except (ValidationError, ValueError) as e:
+ error_messages.append(str(e))
+ {{/isPrimitiveType}}
+ {{/isContainer}}
+ {{/composedSchemas.oneOf}}
+
+ if match > 1:
+ # more than 1 match
+ raise ValueError("Multiple matches found when deserializing the JSON string into {{{classname}}} with oneOf schemas: {{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}}. Details: " + ", ".join(error_messages))
+ elif match == 0:
+ # no match
+ raise ValueError("No match found when deserializing the JSON string into {{{classname}}} with oneOf schemas: {{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}}. Details: " + ", ".join(error_messages))
+ else:
+ return instance
+
+ def to_json(self) -> str:
+ """Returns the JSON representation of the actual instance"""
+ if self.actual_instance is None:
+ return "null"
+
+ if hasattr(self.actual_instance, "to_json") and callable(self.actual_instance.to_json):
+ return self.actual_instance.to_json()
+ else:
+ return json.dumps(self.actual_instance)
+
+ def to_dict(self) -> Optional[Union[Dict[str, Any], {{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}}]]:
+ """Returns the dict representation of the actual instance"""
+ if self.actual_instance is None:
+ return None
+
+ if hasattr(self.actual_instance, "to_dict") and callable(self.actual_instance.to_dict):
+ return self.actual_instance.to_dict()
+ else:
+ # primitive type
+ return self.actual_instance
+
+ def to_str(self) -> str:
+ """Returns the string representation of the actual instance"""
+ return pprint.pformat(self.model_dump())
+
+{{#vendorExtensions.x-py-postponed-model-imports.size}}
+{{#vendorExtensions.x-py-postponed-model-imports}}
+{{{.}}}
+{{/vendorExtensions.x-py-postponed-model-imports}}
+# TODO: Rewrite to not use raise_errors
+{{classname}}.model_rebuild(raise_errors=False)
+{{/vendorExtensions.x-py-postponed-model-imports.size}}
diff --git a/templates/python/model_test.mustache b/templates/python/model_test.mustache
new file mode 100644
index 0000000..0808855
--- /dev/null
+++ b/templates/python/model_test.mustache
@@ -0,0 +1,59 @@
+# coding: utf-8
+
+{{>partial_header}}
+
+import unittest
+
+{{#models}}
+{{#model}}
+from {{modelPackage}}.{{classFilename}} import {{classname}}
+
+class Test{{classname}}(unittest.TestCase):
+ """{{classname}} unit test stubs"""
+
+ def setUp(self):
+ pass
+
+ def tearDown(self):
+ pass
+{{^isEnum}}
+
+ def make_instance(self, include_optional) -> {{classname}}:
+ """Test {{classname}}
+ include_optional is a boolean, when False only required
+ params are included, when True both required and
+ optional params are included """
+ # uncomment below to create an instance of `{{{classname}}}`
+ """
+ model = {{classname}}()
+ if include_optional:
+ return {{classname}}(
+ {{#vars}}
+ {{name}} = {{{example}}}{{^example}}None{{/example}}{{^-last}},{{/-last}}
+ {{/vars}}
+ )
+ else:
+ return {{classname}}(
+ {{#vars}}
+ {{#required}}
+ {{name}} = {{{example}}}{{^example}}None{{/example}},
+ {{/required}}
+ {{/vars}}
+ )
+ """
+{{/isEnum}}
+
+ def test{{classname}}(self):
+ """Test {{classname}}"""
+{{^isEnum}}
+ # inst_req_only = self.make_instance(include_optional=False)
+ # inst_req_and_optional = self.make_instance(include_optional=True)
+{{/isEnum}}
+{{#isEnum}}
+ # inst = {{{classname}}}()
+{{/isEnum}}
+{{/model}}
+{{/models}}
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/templates/python/partial_api.mustache b/templates/python/partial_api.mustache
new file mode 100644
index 0000000..cf2fd0a
--- /dev/null
+++ b/templates/python/partial_api.mustache
@@ -0,0 +1,52 @@
+ """{{#isDeprecated}}(Deprecated) {{/isDeprecated}}{{{summary}}}{{^summary}}{{operationId}}{{/summary}}
+
+ {{#notes}}
+ {{{.}}}
+ {{/notes}}
+
+ {{#allParams}}
+ :param {{paramName}}:{{#description}} {{{.}}}{{/description}}{{#required}} (required){{/required}}{{#optional}}(optional){{/optional}}
+ :type {{paramName}}: {{dataType}}{{#optional}}, optional{{/optional}}
+ {{/allParams}}
+ :param _request_timeout: timeout setting for this request. If one
+ number provided, it will be total request
+ timeout. It can also be a pair (tuple) of
+ (connection, read) timeouts.
+ :type _request_timeout: int, tuple(int, int), optional
+ :param _request_auth: set to override the auth_settings for an a single
+ request; this effectively ignores the
+ authentication in the spec for a single request.
+ :type _request_auth: dict, optional
+ :param _content_type: force content-type for the request.
+ :type _content_type: str, Optional
+ :param _headers: set to override the headers for a single
+ request; this effectively ignores the headers
+ in the spec for a single request.
+ :type _headers: dict, optional
+ :param _host_index: set to override the host_index for a single
+ request; this effectively ignores the host_index
+ in the spec for a single request.
+ :type _host_index: int, optional
+ :return: Returns the result object.
+ """ # noqa: E501 docstring might be too long
+ {{#isDeprecated}}
+ warnings.warn("{{{httpMethod}}} {{{path}}} is deprecated.", DeprecationWarning)
+ {{/isDeprecated}}
+
+ _param = self._{{operationId}}_serialize(
+ {{#allParams}}
+ {{paramName}}={{paramName}},
+ {{/allParams}}
+ _request_auth=_request_auth,
+ _content_type=_content_type,
+ _headers=_headers,
+ _host_index=_host_index
+ )
+
+ _response_types_map: Dict[str, Optional[str]] = {
+ {{#responses}}
+ {{^isWildcard}}
+ '{{code}}': {{#dataType}}"{{.}}"{{/dataType}}{{^dataType}}None{{/dataType}},
+ {{/isWildcard}}
+ {{/responses}}
+ }
\ No newline at end of file
diff --git a/templates/python/partial_api_args.mustache b/templates/python/partial_api_args.mustache
new file mode 100644
index 0000000..379b67d
--- /dev/null
+++ b/templates/python/partial_api_args.mustache
@@ -0,0 +1,18 @@
+(
+ self,
+ {{#allParams}}
+ {{paramName}}: {{{vendorExtensions.x-py-typing}}}{{^required}} = None{{/required}},
+ {{/allParams}}
+ _request_timeout: Union[
+ None,
+ Annotated[StrictFloat, Field(gt=0)],
+ Tuple[
+ Annotated[StrictFloat, Field(gt=0)],
+ Annotated[StrictFloat, Field(gt=0)]
+ ]
+ ] = None,
+ _request_auth: Optional[Dict[StrictStr, Any]] = None,
+ _content_type: Optional[StrictStr] = None,
+ _headers: Optional[Dict[StrictStr, Any]] = None,
+ _host_index: Annotated[StrictInt, Field(ge=0, le={{#servers.size}}{{servers.size}}{{/servers.size}}{{^servers.size}}1{{/servers.size}})] = 0,
+ )
\ No newline at end of file
diff --git a/templates/python/partial_header.mustache b/templates/python/partial_header.mustache
new file mode 100644
index 0000000..092aad7
--- /dev/null
+++ b/templates/python/partial_header.mustache
@@ -0,0 +1,19 @@
+"""
+{{#appName}}
+ {{{.}}}
+
+{{/appName}}
+{{#appDescription}}
+ {{{.}}}
+
+{{/appDescription}}
+ {{#version}}
+ The version of the OpenAPI document: {{{.}}}
+ {{/version}}
+ {{#infoEmail}}
+ Contact: {{{.}}}
+ {{/infoEmail}}
+ Generated by OpenAPI Generator (https://openapi-generator.tech)
+
+ Do not edit the class manually.
+""" # noqa: E501 docstring might be too long
diff --git a/templates/python/py.typed.mustache b/templates/python/py.typed.mustache
new file mode 100644
index 0000000..e69de29
diff --git a/templates/python/pyproject.mustache b/templates/python/pyproject.mustache
new file mode 100644
index 0000000..6cb8712
--- /dev/null
+++ b/templates/python/pyproject.mustache
@@ -0,0 +1,93 @@
+[project]
+name = "{{{pythonPackageName}}}"
+version = "{{{packageVersion}}}"
+authors = [
+ { name="{{infoName}}{{^infoName}}OpenAPI Generator Community{{/infoName}}", email="{{infoEmail}}{{^infoEmail}}team@openapitools.org{{/infoEmail}}" },
+]
+description = "{{{appName}}}"
+#readme = "README.md"
+#license = "{{{licenseInfo}}}{{^licenseInfo}}NoLicense{{/licenseInfo}}"
+requires-python = ">=3.8"
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "License :: OSI Approved :: Apache Software License",
+ "Operating System :: OS Independent",
+]
+dependencies = [
+ "requests ~= 2.32.3",
+ "python_dateutil ~= 2.5.3",
+ "pydantic ~= 2.9.2",
+ "stackit-core ~= 0.0.1",
+]
+
+[project.optional-dependencies]
+dev = [
+ "black >= 24.8.0",
+ "pytest ~= 8.3.2",
+ "flake8 ~= 7.1.0",
+ "flake8-black ~= 0.3.6",
+ "flake8-pyproject ~= 1.2.3",
+ "flake8-quotes ~= 3.4.0",
+ "flake8-bandit ~= 4.1.1",
+ "flake8-bugbear ~= 24.8.19",
+ "flake8-eradicate ~= 1.5.0",
+ "flake8-eol ~= 0.0.8",
+ "autoimport ~= 1.6.1",
+ "isort ~= 5.13.2",
+]
+
+[project.urls]
+Homepage = "https://github.com/{{{gitUserId}}}/{{{gitRepoId}}}"
+Issues = "https://github.com/{{{gitUserId}}}/{{{gitRepoId}}}/issues"
+
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[tool.black]
+line-length = 120
+exclude = """
+/(
+ .eggs
+ | .git
+ | .hg
+ | .mypy_cache
+ | .nox
+ | .pants.d
+ | .tox
+ | .venv
+ | _build
+ | buck-out
+ | build
+ | dist
+ | node_modules
+ | venv
+)/
+"""
+
+[tool.isort]
+profile = 'black'
+
+[tool.flake8]
+exclude= [".eggs", ".git", ".hg", ".mypy_cache", ".tox", ".venv", ".devcontainer", "venv", "_build", "buck-out", "build", "dist"]
+statistics = true
+show-source = false
+max-line-length = 120
+# E203,W503 and E704 are incompatible with the formatter black
+# W291 needs to be disabled because some doc-strings get generated with trailing whitespace but black won't re-format comments
+ignore = ["E203", "W503", "E704", "W291"]
+inline-quotes = '"'
+docstring-quotes = '"""'
+multiline-quotes = '"""'
+ban-relative-imports = true
+per-file-ignores = """
+ # asserts are fine in tests, tests shouldn't be build optimized
+ ./tests/*: S101,
+ # F841: some variables get generated but may not be used, depending on the api-spec
+ # E501: long descriptions/string values might lead to lines that are too long
+ ./stackit/*/models/*: F841,E501
+ # F841: some variables get generated but may not be used, depending on the api-spec
+ # E501: long descriptions/string values might lead to lines that are too long
+ # B028: stacklevel for deprecation warning is irrelevant
+ ./stackit/*/api/default_api.py: F841,B028,E501
+"""
\ No newline at end of file
diff --git a/templates/python/python_doc_auth_partial.mustache b/templates/python/python_doc_auth_partial.mustache
new file mode 100644
index 0000000..f478fe0
--- /dev/null
+++ b/templates/python/python_doc_auth_partial.mustache
@@ -0,0 +1,108 @@
+# Defining the host is optional and defaults to {{{basePath}}}
+# See configuration.py for a list of all supported configuration parameters.
+configuration = {{{packageName}}}.Configuration(
+ host = "{{{basePath}}}"
+)
+
+{{#hasAuthMethods}}
+# The client must configure the authentication and authorization parameters
+# in accordance with the API server security policy.
+# Examples for each auth method are provided below, use the example that
+# satisfies your auth use case.
+{{#authMethods}}
+{{#isBasic}}
+{{#isBasicBasic}}
+
+# Configure HTTP basic authorization: {{{name}}}
+configuration = {{{packageName}}}.Configuration(
+ username = os.environ["USERNAME"],
+ password = os.environ["PASSWORD"]
+)
+{{/isBasicBasic}}
+{{#isBasicBearer}}
+
+# Configure Bearer authorization{{#bearerFormat}} ({{{.}}}){{/bearerFormat}}: {{{name}}}
+configuration = {{{packageName}}}.Configuration(
+ access_token = os.environ["BEARER_TOKEN"]
+)
+{{/isBasicBearer}}
+{{#isHttpSignature}}
+
+# Configure HTTP message signature: {{{name}}}
+# The HTTP Signature Header mechanism that can be used by a client to
+# authenticate the sender of a message and ensure that particular headers
+# have not been modified in transit.
+#
+# You can specify the signing key-id, private key path, signing scheme,
+# signing algorithm, list of signed headers and signature max validity.
+# The 'key_id' parameter is an opaque string that the API server can use
+# to lookup the client and validate the signature.
+# The 'private_key_path' parameter should be the path to a file that
+# contains a DER or base-64 encoded private key.
+# The 'private_key_passphrase' parameter is optional. Set the passphrase
+# if the private key is encrypted.
+# The 'signed_headers' parameter is used to specify the list of
+# HTTP headers included when generating the signature for the message.
+# You can specify HTTP headers that you want to protect with a cryptographic
+# signature. Note that proxies may add, modify or remove HTTP headers
+# for legitimate reasons, so you should only add headers that you know
+# will not be modified. For example, if you want to protect the HTTP request
+# body, you can specify the Digest header. In that case, the client calculates
+# the digest of the HTTP request body and includes the digest in the message
+# signature.
+# The 'signature_max_validity' parameter is optional. It is configured as a
+# duration to express when the signature ceases to be valid. The client calculates
+# the expiration date every time it generates the cryptographic signature
+# of an HTTP request. The API server may have its own security policy
+# that controls the maximum validity of the signature. The client max validity
+# must be lower than the server max validity.
+# The time on the client and server must be synchronized, otherwise the
+# server may reject the client signature.
+#
+# The client must use a combination of private key, signing scheme,
+# signing algorithm and hash algorithm that matches the security policy of
+# the API server.
+#
+# See {{{packageName}}}.signing for a list of all supported parameters.
+from {{{packageName}}} import signing
+import datetime
+
+configuration = {{{packageName}}}.Configuration(
+ host = "{{{basePath}}}",
+ signing_info = {{{packageName}}}.HttpSigningConfiguration(
+ key_id = 'my-key-id',
+ private_key_path = 'private_key.pem',
+ private_key_passphrase = 'YOUR_PASSPHRASE',
+ signing_scheme = {{{packageName}}}.signing.SCHEME_HS2019,
+ signing_algorithm = {{{packageName}}}.signing.ALGORITHM_ECDSA_MODE_FIPS_186_3,
+ hash_algorithm = {{{packageName}}}.signing.SCHEME_RSA_SHA256,
+ signed_headers = [
+ {{{packageName}}}.signing.HEADER_REQUEST_TARGET,
+ {{{packageName}}}.signing.HEADER_CREATED,
+ {{{packageName}}}.signing.HEADER_EXPIRES,
+ {{{packageName}}}.signing.HEADER_HOST,
+ {{{packageName}}}.signing.HEADER_DATE,
+ {{{packageName}}}.signing.HEADER_DIGEST,
+ 'Content-Type',
+ 'Content-Length',
+ 'User-Agent'
+ ],
+ signature_max_validity = datetime.timedelta(minutes=5)
+ )
+)
+{{/isHttpSignature}}
+{{/isBasic}}
+{{#isApiKey}}
+
+# Configure API key authorization: {{{name}}}
+configuration.api_key['{{{name}}}'] = os.environ["API_KEY"]
+
+# Uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+# configuration.api_key_prefix['{{name}}'] = 'Bearer'
+{{/isApiKey}}
+{{#isOAuth}}
+
+configuration.access_token = os.environ["ACCESS_TOKEN"]
+{{/isOAuth}}
+{{/authMethods}}
+{{/hasAuthMethods}}
diff --git a/templates/python/requirements.mustache b/templates/python/requirements.mustache
new file mode 100644
index 0000000..5412515
--- /dev/null
+++ b/templates/python/requirements.mustache
@@ -0,0 +1,12 @@
+python_dateutil >= 2.5.3
+setuptools >= 21.0.0
+urllib3 >= 1.25.3, < 2.1.0
+pydantic >= 2
+typing-extensions >= 4.7.1
+{{#asyncio}}
+aiohttp >= 3.0.0
+aiohttp-retry >= 2.8.3
+{{/asyncio}}
+{{#hasHttpSignatureMethods}}
+pycryptodome >= 3.9.0
+{{/hasHttpSignatureMethods}}
diff --git a/templates/python/rest.mustache b/templates/python/rest.mustache
new file mode 100644
index 0000000..5d6305f
--- /dev/null
+++ b/templates/python/rest.mustache
@@ -0,0 +1,163 @@
+# coding: utf-8
+
+{{>partial_header}}
+
+import io
+import json
+import re
+
+import requests
+
+from stackit.core.configuration import Configuration
+from stackit.core.authorization import Authorization
+from {{packageName}}.exceptions import ApiException, ApiValueError
+
+RESTResponseType = requests.Response
+
+
+class RESTResponse(io.IOBase):
+
+ def __init__(self, resp) -> None:
+ self.response = resp
+ self.status = resp.status_code
+ self.reason = resp.reason
+ self.data = None
+
+ def read(self):
+ if self.data is None:
+ self.data = self.response.content
+ return self.data
+
+ def getheaders(self):
+ """Returns a dictionary of the response headers."""
+ return self.response.headers
+
+ def getheader(self, name, default=None):
+ """Returns a given response header."""
+ return self.response.headers.get(name, default)
+
+
+class RESTClientObject:
+ def __init__(self, config: Configuration) -> None:
+ self.session = config.custom_http_session if config.custom_http_session else requests.Session()
+ authorization = Authorization(config)
+ self.session.auth = authorization.auth_method
+
+ def request(
+ self,
+ method,
+ url,
+ headers=None,
+ body=None,
+ post_params=None,
+ _request_timeout=None
+ ):
+ """Perform requests.
+
+ :param method: http request method
+ :param url: http request url
+ :param headers: http request headers
+ :param body: request json body, for `application/json`
+ :param post_params: request post parameters,
+ `application/x-www-form-urlencoded`
+ and `multipart/form-data`
+ :param _request_timeout: timeout setting for this request. If one
+ number provided, it will be total request
+ timeout. It can also be a pair (tuple) of
+ (connection, read) timeouts.
+ """
+ method = method.upper()
+ if method not in [
+ 'GET',
+ 'HEAD',
+ 'DELETE',
+ 'POST',
+ 'PUT',
+ 'PATCH',
+ 'OPTIONS'
+ ]:
+ raise ValueError("Method %s not allowed", method)
+
+ if post_params and body:
+ raise ApiValueError(
+ "body parameter cannot be used with post_params parameter."
+ )
+
+ post_params = post_params or {}
+ headers = headers or {}
+
+ try:
+ # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE`
+ if method in ['POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']:
+
+ # no content type provided or payload is json
+ content_type = headers.get('Content-Type')
+ if (
+ not content_type
+ or re.search('json', content_type, re.IGNORECASE)
+ ):
+ request_body = None
+ if body is not None:
+ request_body = json.dumps(body{{#setEnsureAsciiToFalse}}, ensure_ascii=False{{/setEnsureAsciiToFalse}})
+ r = self.session.request(
+ method,
+ url,
+ data=request_body,
+ headers=headers,
+ )
+ elif content_type == 'application/x-www-form-urlencoded':
+ r = self.session.request(
+ method,
+ url,
+ params=post_params,
+ headers=headers,
+ )
+ elif content_type == 'multipart/form-data':
+ # must del headers['Content-Type'], or the correct
+ # Content-Type which generated by urllib3 will be
+ # overwritten.
+ del headers['Content-Type']
+ # Ensures that dict objects are serialized
+ post_params = [(a, json.dumps(b)) if isinstance(b, dict) else (a,b) for a, b in post_params]
+ r = self.session.request(
+ method,
+ url,
+ files=post_params,
+ headers=headers,
+ )
+ # Pass a `string` parameter directly in the body to support
+ # other content types than JSON when `body` argument is
+ # provided in serialized form.
+ elif isinstance(body, str) or isinstance(body, bytes):
+ r = self.session.request(
+ method,
+ url,
+ data=body,
+ headers=headers,
+ )
+ elif headers['Content-Type'] == 'text/plain' and isinstance(body, bool):
+ request_body = "true" if body else "false"
+ r = self.session.request(
+ method,
+ url,
+ data=request_body,
+ headers=headers)
+ else:
+ # Cannot generate the request from given parameters
+ msg = """Cannot prepare a request message for provided
+ arguments. Please check that your arguments match
+ declared content type."""
+ raise ApiException(status=0, reason=msg)
+ # For `GET`, `HEAD`
+ else:
+ r = self.session.request(
+ method,
+ url,
+ params={},
+ headers=headers,
+ )
+ except requests.exceptions.SSLError as e:
+ msg = "\n".join([type(e).__name__, str(e)])
+ raise ApiException(status=0, reason=msg)
+
+ return RESTResponse(r)
diff --git a/templates/python/setup.mustache b/templates/python/setup.mustache
new file mode 100644
index 0000000..9c68cf3
--- /dev/null
+++ b/templates/python/setup.mustache
@@ -0,0 +1,57 @@
+# coding: utf-8
+
+{{>partial_header}}
+
+from setuptools import setup, find_packages
+
+# To install the library, run the following
+#
+# python setup.py install
+#
+# prerequisite: setuptools
+# http://pypi.python.org/pypi/setuptools
+NAME = "{{{projectName}}}"
+VERSION = "{{packageVersion}}"
+PYTHON_REQUIRES = ">=3.7"
+{{#apiInfo}}
+{{#apis}}
+{{#-last}}
+REQUIRES = [
+ "urllib3 >= 1.25.3, < 2.1.0",
+ "python-dateutil",
+{{#asyncio}}
+ "aiohttp >= 3.0.0",
+ "aiohttp-retry >= 2.8.3",
+{{/asyncio}}
+{{#tornado}}
+ "tornado>=4.2,<5",
+{{/tornado}}
+{{#hasHttpSignatureMethods}}
+ "pem>=19.3.0",
+ "pycryptodome>=3.9.0",
+{{/hasHttpSignatureMethods}}
+ "pydantic >= 2",
+ "typing-extensions >= 4.7.1",
+]
+
+setup(
+ name=NAME,
+ version=VERSION,
+ description="{{appName}}",
+ author="{{infoName}}{{^infoName}}OpenAPI Generator community{{/infoName}}",
+ author_email="{{infoEmail}}{{^infoEmail}}team@openapitools.org{{/infoEmail}}",
+ url="{{packageUrl}}",
+ keywords=["OpenAPI", "OpenAPI-Generator", "{{{appName}}}"],
+ install_requires=REQUIRES,
+ packages=find_packages(exclude=["test", "tests"]),
+ include_package_data=True,
+ {{#licenseInfo}}license="{{.}}",
+ {{/licenseInfo}}long_description_content_type='text/markdown',
+ long_description="""\
+ {{appDescription}}
+ """, # noqa: E501 docstring might be too long
+ package_data={"{{{packageName}}}": ["py.typed"]},
+)
+{{/-last}}
+{{/apis}}
+{{/apiInfo}}
diff --git a/templates/python/setup_cfg.mustache b/templates/python/setup_cfg.mustache
new file mode 100644
index 0000000..11433ee
--- /dev/null
+++ b/templates/python/setup_cfg.mustache
@@ -0,0 +1,2 @@
+[flake8]
+max-line-length=99
diff --git a/templates/python/signing.mustache b/templates/python/signing.mustache
new file mode 100644
index 0000000..4d00424
--- /dev/null
+++ b/templates/python/signing.mustache
@@ -0,0 +1,422 @@
+{{>partial_header}}
+
+from base64 import b64encode
+from Crypto.IO import PEM, PKCS8
+from Crypto.Hash import SHA256, SHA512
+from Crypto.Hash.SHA512 import SHA512Hash
+from Crypto.Hash.SHA256 import SHA256Hash
+from Crypto.PublicKey import RSA, ECC
+from Crypto.Signature import PKCS1_v1_5, pss, DSS
+from datetime import timedelta
+from email.utils import formatdate
+import os
+import re
+from time import time
+from typing import List, Optional, Union
+from urllib.parse import urlencode, urlparse
+
+# The constants below define a subset of HTTP headers that can be included in the
+# HTTP signature scheme. Additional headers may be included in the signature.
+
+# The '(request-target)' header is a calculated field that includes the HTTP verb,
+# the URL path and the URL query.
+HEADER_REQUEST_TARGET = '(request-target)'
+# The time when the HTTP signature was generated.
+HEADER_CREATED = '(created)'
+# The time when the HTTP signature expires. The API server should reject HTTP requests
+# that have expired.
+HEADER_EXPIRES = '(expires)'
+# The 'Host' header.
+HEADER_HOST = 'Host'
+# The 'Date' header.
+HEADER_DATE = 'Date'
+# When the 'Digest' header is included in the HTTP signature, the client automatically
+# computes the digest of the HTTP request body, per RFC 3230.
+HEADER_DIGEST = 'Digest'
+# The 'Authorization' header is automatically generated by the client. It includes
+# the list of signed headers and a base64-encoded signature.
+HEADER_AUTHORIZATION = 'Authorization'
+
+# The constants below define the cryptographic schemes for the HTTP signature scheme.
+SCHEME_HS2019 = 'hs2019'
+SCHEME_RSA_SHA256 = 'rsa-sha256'
+SCHEME_RSA_SHA512 = 'rsa-sha512'
+
+# The constants below define the signature algorithms that can be used for the HTTP
+# signature scheme.
+ALGORITHM_RSASSA_PSS = 'RSASSA-PSS'
+ALGORITHM_RSASSA_PKCS1v15 = 'RSASSA-PKCS1-v1_5'
+
+ALGORITHM_ECDSA_MODE_FIPS_186_3 = 'fips-186-3'
+ALGORITHM_ECDSA_MODE_DETERMINISTIC_RFC6979 = 'deterministic-rfc6979'
+ALGORITHM_ECDSA_KEY_SIGNING_ALGORITHMS = {
+ ALGORITHM_ECDSA_MODE_FIPS_186_3,
+ ALGORITHM_ECDSA_MODE_DETERMINISTIC_RFC6979
+}
+
+# The cryptographic hash algorithm for the message signature.
+HASH_SHA256 = 'sha256'
+HASH_SHA512 = 'sha512'
+
+
+class HttpSigningConfiguration:
+ """The configuration parameters for the HTTP signature security scheme.
+
+ The HTTP signature security scheme is used to sign HTTP requests with a private key
+ which is in possession of the API client.
+
+ An ``Authorization`` header is calculated by creating a hash of select headers,
+ and optionally the body of the HTTP request, then signing the hash value using
+ a private key. The ``Authorization`` header is added to outbound HTTP requests.
+
+ :param key_id: A string value specifying the identifier of the cryptographic key,
+ when signing HTTP requests.
+ :param signing_scheme: A string value specifying the signature scheme, when
+ signing HTTP requests.
+ Supported value are: ``hs2019``, ``rsa-sha256``, ``rsa-sha512``.
+ Avoid using ``rsa-sha256``, ``rsa-sha512`` as they are deprecated. These values are
+ available for server-side applications that only support the older
+ HTTP signature algorithms.
+ :param private_key_path: A string value specifying the path of the file containing
+ a private key. The private key is used to sign HTTP requests.
+ :param private_key_passphrase: A string value specifying the passphrase to decrypt
+ the private key.
+ :param signed_headers: A list of strings. Each value is the name of a HTTP header
+ that must be included in the HTTP signature calculation.
+ The two special signature headers ``(request-target)`` and ``(created)`` SHOULD be
+ included in SignedHeaders.
+ The ``(created)`` header expresses when the signature was created.
+ The ``(request-target)`` header is a concatenation of the lowercased :method, an
+ ASCII space, and the :path pseudo-headers.
+ When signed_headers is not specified, the client defaults to a single value,
+ ``(created)``, in the list of HTTP headers.
+ When SignedHeaders contains the 'Digest' value, the client performs the
+ following operations:
+ 1. Calculate a digest of request body, as specified in `RFC3230,
+ section 4.3.2`_.
+ 2. Set the ``Digest`` header in the request body.
+ 3. Include the ``Digest`` header and value in the HTTP signature.
+ :param signing_algorithm: A string value specifying the signature algorithm, when
+ signing HTTP requests.
+ Supported values are:
+ 1. For RSA keys: RSASSA-PSS, RSASSA-PKCS1-v1_5.
+ 2. For ECDSA keys: fips-186-3, deterministic-rfc6979.
+ If None, the signing algorithm is inferred from the private key.
+ The default signing algorithm for RSA keys is RSASSA-PSS.
+ The default signing algorithm for ECDSA keys is fips-186-3.
+ :param hash_algorithm: The hash algorithm for the signature. Supported values are
+ sha256 and sha512.
+ If the signing_scheme is rsa-sha256, the hash algorithm must be set
+ to None or sha256.
+ If the signing_scheme is rsa-sha512, the hash algorithm must be set
+ to None or sha512.
+ :param signature_max_validity: The signature max validity, expressed as
+ a datetime.timedelta value. It must be a positive value.
+ """
+ def __init__(self,
+ key_id: str,
+ signing_scheme: str,
+ private_key_path: str,
+ private_key_passphrase: Union[None, str]=None,
+ signed_headers: Optional[List[str]]=None,
+ signing_algorithm: Optional[str]=None,
+ hash_algorithm: Optional[str]=None,
+ signature_max_validity: Optional[timedelta]=None,
+ ) -> None:
+ self.key_id = key_id
+ if signing_scheme not in {SCHEME_HS2019, SCHEME_RSA_SHA256, SCHEME_RSA_SHA512}:
+ raise Exception("Unsupported security scheme: {0}".format(signing_scheme))
+ self.signing_scheme = signing_scheme
+ if not os.path.exists(private_key_path):
+ raise Exception("Private key file does not exist")
+ self.private_key_path = private_key_path
+ self.private_key_passphrase = private_key_passphrase
+ self.signing_algorithm = signing_algorithm
+ self.hash_algorithm = hash_algorithm
+ if signing_scheme == SCHEME_RSA_SHA256:
+ if self.hash_algorithm is None:
+ self.hash_algorithm = HASH_SHA256
+ elif self.hash_algorithm != HASH_SHA256:
+ raise Exception("Hash algorithm must be sha256 when security scheme is %s" %
+ SCHEME_RSA_SHA256)
+ elif signing_scheme == SCHEME_RSA_SHA512:
+ if self.hash_algorithm is None:
+ self.hash_algorithm = HASH_SHA512
+ elif self.hash_algorithm != HASH_SHA512:
+ raise Exception("Hash algorithm must be sha512 when security scheme is %s" %
+ SCHEME_RSA_SHA512)
+ elif signing_scheme == SCHEME_HS2019:
+ if self.hash_algorithm is None:
+ self.hash_algorithm = HASH_SHA256
+ elif self.hash_algorithm not in {HASH_SHA256, HASH_SHA512}:
+ raise Exception("Invalid hash algorithm")
+ if signature_max_validity is not None and signature_max_validity.total_seconds() < 0:
+ raise Exception("The signature max validity must be a positive value")
+ self.signature_max_validity = signature_max_validity
+ # If the user has not provided any signed_headers, the default must be set to '(created)',
+ # as specified in the 'HTTP signature' standard.
+ if signed_headers is None or len(signed_headers) == 0:
+ signed_headers = [HEADER_CREATED]
+ if self.signature_max_validity is None and HEADER_EXPIRES in signed_headers:
+ raise Exception(
+ "Signature max validity must be set when "
+ "'(expires)' signature parameter is specified")
+ if len(signed_headers) != len(set(signed_headers)):
+ raise Exception("Cannot have duplicates in the signed_headers parameter")
+ if HEADER_AUTHORIZATION in signed_headers:
+ raise Exception("'Authorization' header cannot be included in signed headers")
+ self.signed_headers = signed_headers
+ self.private_key: Optional[Union[ECC.EccKey, RSA.RsaKey]] = None
+ """The private key used to sign HTTP requests.
+ Initialized when the PEM-encoded private key is loaded from a file.
+ """
+ self.host: Optional[str] = None
+ """The host name, optionally followed by a colon and TCP port number.
+ """
+ self._load_private_key()
+
+ def get_http_signature_headers(self, resource_path, method, headers, body, query_params):
+ """Create a cryptographic message signature for the HTTP request and add the signed headers.
+
+ :param resource_path : A string representation of the HTTP request resource path.
+ :param method: A string representation of the HTTP request method, e.g. GET, POST.
+ :param headers: A dict containing the HTTP request headers.
+ :param body: The object representing the HTTP request body.
+ :param query_params: A string representing the HTTP request query parameters.
+ :return: A dict of HTTP headers that must be added to the outbound HTTP request.
+ """
+ if method is None:
+ raise Exception("HTTP method must be set")
+ if resource_path is None:
+ raise Exception("Resource path must be set")
+
+ signed_headers_list, request_headers_dict = self._get_signed_header_info(
+ resource_path, method, headers, body, query_params)
+
+ header_items = [
+ "{0}: {1}".format(key.lower(), value) for key, value in signed_headers_list]
+ string_to_sign = "\n".join(header_items)
+
+ digest, digest_prefix = self._get_message_digest(string_to_sign.encode())
+ b64_signed_msg = self._sign_digest(digest)
+
+ request_headers_dict[HEADER_AUTHORIZATION] = self._get_authorization_header(
+ signed_headers_list, b64_signed_msg)
+
+ return request_headers_dict
+
+ def get_public_key(self):
+ """Returns the public key object associated with the private key.
+ """
+ pubkey: Optional[Union[ECC.EccKey, RSA.RsaKey]] = None
+ if isinstance(self.private_key, RSA.RsaKey):
+ pubkey = self.private_key.publickey()
+ elif isinstance(self.private_key, ECC.EccKey):
+ pubkey = self.private_key.public_key()
+ return pubkey
+
+ def _load_private_key(self):
+ """Load the private key used to sign HTTP requests.
+ The private key is used to sign HTTP requests as defined in
+ https://datatracker.ietf.org/doc/draft-cavage-http-signatures/.
+ """
+ if self.private_key is not None:
+ return
+ with open(self.private_key_path, 'r') as f:
+ pem_data = f.read()
+ # Verify PEM Pre-Encapsulation Boundary
+ r = re.compile(r"\s*-----BEGIN (.*)-----\s+")
+ m = r.match(pem_data)
+ if not m:
+ raise ValueError("Not a valid PEM pre boundary")
+ pem_header = m.group(1)
+ if pem_header == 'RSA PRIVATE KEY':
+ self.private_key = RSA.importKey(pem_data, self.private_key_passphrase)
+ elif pem_header == 'EC PRIVATE KEY':
+ self.private_key = ECC.import_key(pem_data, self.private_key_passphrase)
+ elif pem_header in {'PRIVATE KEY', 'ENCRYPTED PRIVATE KEY'}:
+ # Key is in PKCS8 format, which is capable of holding many different
+ # types of private keys, not just EC keys.
+ if self.private_key_passphrase is not None:
+ passphrase = self.private_key_passphrase.encode("utf-8")
+ else:
+ passphrase = None
+ (key_binary, pem_header, is_encrypted) = PEM.decode(pem_data, passphrase)
+ (oid, privkey, params) = \
+ PKCS8.unwrap(key_binary, passphrase=self.private_key_passphrase)
+ if oid == '1.2.840.10045.2.1':
+ self.private_key = ECC.import_key(pem_data, self.private_key_passphrase)
+ else:
+ raise Exception("Unsupported key: {0}. OID: {1}".format(pem_header, oid))
+ else:
+ raise Exception("Unsupported key: {0}".format(pem_header))
+ # Validate the specified signature algorithm is compatible with the private key.
+ if self.signing_algorithm is not None:
+ supported_algs = None
+ if isinstance(self.private_key, RSA.RsaKey):
+ supported_algs = {ALGORITHM_RSASSA_PSS, ALGORITHM_RSASSA_PKCS1v15}
+ elif isinstance(self.private_key, ECC.EccKey):
+ supported_algs = ALGORITHM_ECDSA_KEY_SIGNING_ALGORITHMS
+ if supported_algs is not None and self.signing_algorithm not in supported_algs:
+ raise Exception(
+ "Signing algorithm {0} is not compatible with private key".format(
+ self.signing_algorithm))
+
+ def _get_signed_header_info(self, resource_path, method, headers, body, query_params):
+ """Build the HTTP headers (name, value) that need to be included in
+ the HTTP signature scheme.
+
+ :param resource_path : A string representation of the HTTP request resource path.
+ :param method: A string representation of the HTTP request method, e.g. GET, POST.
+ :param headers: A dict containing the HTTP request headers.
+ :param body: The object (e.g. a dict) representing the HTTP request body.
+ :param query_params: A string representing the HTTP request query parameters.
+ :return: A tuple containing two dict objects:
+ The first dict contains the HTTP headers that are used to calculate
+ the HTTP signature.
+ The second dict contains the HTTP headers that must be added to
+ the outbound HTTP request.
+ """
+
+ if body is None:
+ body = ''
+ else:
+ body = body.to_json()
+
+ # Build the '(request-target)' HTTP signature parameter.
+ target_host = urlparse(self.host).netloc
+ target_path = urlparse(self.host).path
+ request_target = method.lower() + " " + target_path + resource_path
+ if query_params:
+ request_target += "?" + urlencode(query_params)
+
+ # Get UNIX time, e.g. seconds since epoch, not including leap seconds.
+ now = time()
+ # Format date per RFC 7231 section-7.1.1.2. An example is:
+ # Date: Wed, 21 Oct 2015 07:28:00 GMT
+ cdate = formatdate(timeval=now, localtime=False, usegmt=True)
+ # The '(created)' value MUST be a Unix timestamp integer value.
+ # Subsecond precision is not supported.
+ created = int(now)
+ if self.signature_max_validity is not None:
+ expires = now + self.signature_max_validity.total_seconds()
+
+ signed_headers_list = []
+ request_headers_dict = {}
+ for hdr_key in self.signed_headers:
+ hdr_key = hdr_key.lower()
+ if hdr_key == HEADER_REQUEST_TARGET:
+ value = request_target
+ elif hdr_key == HEADER_CREATED:
+ value = '{0}'.format(created)
+ elif hdr_key == HEADER_EXPIRES:
+ value = '{0}'.format(expires)
+ elif hdr_key == HEADER_DATE.lower():
+ value = cdate
+ request_headers_dict[HEADER_DATE] = '{0}'.format(cdate)
+ elif hdr_key == HEADER_DIGEST.lower():
+ request_body = body.encode()
+ body_digest, digest_prefix = self._get_message_digest(request_body)
+ b64_body_digest = b64encode(body_digest.digest())
+ value = digest_prefix + b64_body_digest.decode('ascii')
+ request_headers_dict[HEADER_DIGEST] = '{0}{1}'.format(
+ digest_prefix, b64_body_digest.decode('ascii'))
+ elif hdr_key == HEADER_HOST.lower():
+ if isinstance(target_host, bytes):
+ value = target_host.decode('ascii')
+ else:
+ value = target_host
+ request_headers_dict[HEADER_HOST] = value
+ else:
+ value = next((v for k, v in headers.items() if k.lower() == hdr_key), None)
+ if value is None:
+ raise Exception(
+ "Cannot sign HTTP request. "
+ "Request does not contain the '{0}' header".format(hdr_key))
+ signed_headers_list.append((hdr_key, value))
+
+ return signed_headers_list, request_headers_dict
+
+ def _get_message_digest(self, data):
+ """Calculates and returns a cryptographic digest of a specified HTTP request.
+
+ :param data: The string representation of the date to be hashed with a cryptographic hash.
+ :return: A tuple of (digest, prefix).
+ The digest is a hashing object that contains the cryptographic digest of
+ the HTTP request.
+ The prefix is a string that identifies the cryptographic hash. It is used
+ to generate the 'Digest' header as specified in RFC 3230.
+ """
+
+ digest: Union[SHA256Hash, SHA512Hash]
+
+ if self.hash_algorithm == HASH_SHA512:
+ digest = SHA512.new()
+ prefix = 'SHA-512='
+ elif self.hash_algorithm == HASH_SHA256:
+ digest = SHA256.new()
+ prefix = 'SHA-256='
+ else:
+ raise Exception("Unsupported hash algorithm: {0}".format(self.hash_algorithm))
+ digest.update(data)
+ return digest, prefix
+
+ def _sign_digest(self, digest):
+ """Signs a message digest with a private key specified in the signing_info.
+
+ :param digest: A hashing object that contains the cryptographic digest of the HTTP request.
+ :return: A base-64 string representing the cryptographic signature of the input digest.
+ """
+ sig_alg = self.signing_algorithm
+ if isinstance(self.private_key, RSA.RsaKey):
+ if sig_alg is None or sig_alg == ALGORITHM_RSASSA_PSS:
+ # RSASSA-PSS in Section 8.1 of RFC8017.
+ signature = pss.new(self.private_key).sign(digest)
+ elif sig_alg == ALGORITHM_RSASSA_PKCS1v15:
+ # RSASSA-PKCS1-v1_5 in Section 8.2 of RFC8017.
+ signature = PKCS1_v1_5.new(self.private_key).sign(digest)
+ else:
+ raise Exception("Unsupported signature algorithm: {0}".format(sig_alg))
+ elif isinstance(self.private_key, ECC.EccKey):
+ if sig_alg is None:
+ sig_alg = ALGORITHM_ECDSA_MODE_FIPS_186_3
+ if sig_alg in ALGORITHM_ECDSA_KEY_SIGNING_ALGORITHMS:
+ # draft-ietf-httpbis-message-signatures-00 does not specify the ECDSA encoding.
+ # Issue: https://github.com/w3c-ccg/http-signatures/issues/107
+ signature = DSS.new(key=self.private_key, mode=sig_alg,
+ encoding='der').sign(digest)
+ else:
+ raise Exception("Unsupported signature algorithm: {0}".format(sig_alg))
+ else:
+ raise Exception("Unsupported private key: {0}".format(type(self.private_key)))
+ return b64encode(signature)
+
+ def _get_authorization_header(self, signed_headers, signed_msg):
+ """Calculates and returns the value of the 'Authorization' header when signing HTTP requests.
+
+ :param signed_headers : A list of tuples. Each value is the name of a HTTP header that
+ must be included in the HTTP signature calculation.
+ :param signed_msg: A base-64 encoded string representation of the signature.
+ :return: The string value of the 'Authorization' header, representing the signature
+ of the HTTP request.
+ """
+ created_ts = None
+ expires_ts = None
+ for k, v in signed_headers:
+ if k == HEADER_CREATED:
+ created_ts = v
+ elif k == HEADER_EXPIRES:
+ expires_ts = v
+ lower_keys = [k.lower() for k, v in signed_headers]
+ headers_value = " ".join(lower_keys)
+
+ auth_str = "Signature keyId=\"{0}\",algorithm=\"{1}\",".format(
+ self.key_id, self.signing_scheme)
+ if created_ts is not None:
+ auth_str = auth_str + "created={0},".format(created_ts)
+ if expires_ts is not None:
+ auth_str = auth_str + "expires={0},".format(expires_ts)
+ auth_str = auth_str + "headers=\"{0}\",signature=\"{1}\"".format(
+ headers_value, signed_msg.decode('ascii'))
+
+ return auth_str
diff --git a/templates/python/test-requirements.mustache b/templates/python/test-requirements.mustache
new file mode 100644
index 0000000..8e6d8cb
--- /dev/null
+++ b/templates/python/test-requirements.mustache
@@ -0,0 +1,5 @@
+pytest~=7.1.3
+pytest-cov>=2.8.1
+pytest-randomly>=3.12.0
+mypy>=1.4.1
+types-python-dateutil>=2.8.19
diff --git a/templates/python/tornado/rest.mustache b/templates/python/tornado/rest.mustache
new file mode 100644
index 0000000..f4bfbfb
--- /dev/null
+++ b/templates/python/tornado/rest.mustache
@@ -0,0 +1,142 @@
+# coding: utf-8
+
+{{>partial_header}}
+
+import io
+import json
+import re
+
+from urllib.parse import urlencode
+import tornado
+import tornado.gen
+from tornado import httpclient
+from urllib3.filepost import encode_multipart_formdata
+
+from {{packageName}}.exceptions import ApiException, ApiValueError
+
+RESTResponseType = httpclient.HTTPResponse
+
+class RESTResponse(io.IOBase):
+
+ def __init__(self, resp) -> None:
+ self.response = resp
+ self.status = resp.code
+ self.reason = resp.reason
+ self.data = None
+
+ def read(self):
+ if self.data is None:
+ self.data = self.response.body
+ return self.data
+
+ def getheaders(self):
+ """Returns a CIMultiDictProxy of the response headers."""
+ return self.response.headers
+
+ def getheader(self, name, default=None):
+ """Returns a given response header."""
+ return self.response.headers.get(name, default)
+
+
+class RESTClientObject:
+
+ def __init__(self, configuration) -> None:
+
+ self.ca_certs = configuration.ssl_ca_cert
+ self.client_key = configuration.key_file
+ self.client_cert = configuration.cert_file
+
+ self.proxy_port = self.proxy_host = None
+
+ # https pool manager
+ if configuration.proxy:
+ self.proxy_port = 80
+ self.proxy_host = configuration.proxy
+
+ self.pool_manager = httpclient.AsyncHTTPClient()
+
+ @tornado.gen.coroutine
+ def request(
+ self,
+ method,
+ url,
+ headers=None,
+ body=None,
+ post_params=None,
+ _request_timeout=None
+ ):
+ """Execute Request
+
+ :param method: http request method
+ :param url: http request url
+ :param headers: http request headers
+ :param body: request json body, for `application/json`
+ :param post_params: request post parameters,
+ `application/x-www-form-urlencoded`
+ and `multipart/form-data`
+ :param _request_timeout: timeout setting for this request. If one
+ number provided, it will be total request
+ timeout. It can also be a pair (tuple) of
+ (connection, read) timeouts.
+ """
+ method = method.upper()
+ assert method in [
+ 'GET',
+ 'HEAD',
+ 'DELETE',
+ 'POST',
+ 'PUT',
+ 'PATCH',
+ 'OPTIONS'
+ ]
+
+ if post_params and body:
+ raise ApiValueError(
+ "body parameter cannot be used with post_params parameter."
+ )
+
+ request = httpclient.HTTPRequest(url)
+ request.allow_nonstandard_methods = True
+ request.ca_certs = self.ca_certs
+ request.client_key = self.client_key
+ request.client_cert = self.client_cert
+ request.proxy_host = self.proxy_host
+ request.proxy_port = self.proxy_port
+ request.method = method
+ if headers:
+ request.headers = headers
+ if 'Content-Type' not in headers:
+ request.headers['Content-Type'] = 'application/json'
+ request.request_timeout = _request_timeout or 5 * 60
+
+ post_params = post_params or {}
+
+ # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE`
+ if method in ['POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']:
+ if re.search('json', headers['Content-Type'], re.IGNORECASE):
+ if body:
+ body = json.dumps(body)
+ request.body = body
+ elif headers['Content-Type'] == 'application/x-www-form-urlencoded':
+ request.body = urlencode(post_params)
+ elif headers['Content-Type'] == 'multipart/form-data':
+ multipart = encode_multipart_formdata(post_params)
+ request.body, headers['Content-Type'] = multipart
+ # Pass a `bytes` parameter directly in the body to support
+ # other content types than Json when `body` argument is provided
+ # in serialized form
+ elif isinstance(body, bytes):
+ request.body = body
+ else:
+ # Cannot generate the request from given parameters
+ msg = """Cannot prepare a request message for provided
+ arguments. Please check that your arguments match
+ declared content type."""
+ raise ApiException(status=0, reason=msg)
+
+ r = yield self.pool_manager.fetch(request, raise_error=False)
+
+
+ r = RESTResponse(r)
+
+ raise tornado.gen.Return(r)
diff --git a/templates/python/tox.mustache b/templates/python/tox.mustache
new file mode 100644
index 0000000..9d717c3
--- /dev/null
+++ b/templates/python/tox.mustache
@@ -0,0 +1,9 @@
+[tox]
+envlist = py3
+
+[testenv]
+deps=-r{toxinidir}/requirements.txt
+ -r{toxinidir}/test-requirements.txt
+
+commands=
+ pytest --cov={{{packageName}}}
diff --git a/templates/python/travis.mustache b/templates/python/travis.mustache
new file mode 100644
index 0000000..53cb57e
--- /dev/null
+++ b/templates/python/travis.mustache
@@ -0,0 +1,17 @@
+# ref: https://docs.travis-ci.com/user/languages/python
+language: python
+python:
+ - "3.7"
+ - "3.8"
+ - "3.9"
+ - "3.10"
+ - "3.11"
+ # uncomment the following if needed
+ #- "3.11-dev" # 3.11 development branch
+ #- "nightly" # nightly build
+# command to install dependencies
+install:
+ - "pip install -r requirements.txt"
+ - "pip install -r test-requirements.txt"
+# command to run tests
+script: pytest --cov={{{packageName}}}