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}}}