From 9e58d2a54572076b045a716724220b125cb133d6 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 27 Sep 2026 14:43:15 +0100 Subject: [PATCH] feat(python): Create sdkgen Go and JS generate a client package for each plugin from its spec, but Python had no such tool, so a plugin had no Python client short of copying its generated plumbing into the SDK. sdkgen renders unikraft-cloud-plugin--api from each spec. It takes a Transport shaped like ApiClient rather than the SDK, so the two depend one way, and publish refuses changed sources under an unchanged version. Signed-off-by: Daniel Vallance --- python/tools/sdkgen/.gitignore | 1 + python/tools/sdkgen/Makefile | 181 ++++++++ python/tools/sdkgen/README.md | 201 +++++++++ python/tools/sdkgen/publish-check.sh | 162 +++++++ python/tools/sdkgen/static/LICENSE.md | 28 ++ python/tools/sdkgen/templates/README.md.tmpl | 84 ++++ .../tools/sdkgen/templates/__init__.py.tmpl | 96 ++++ python/tools/sdkgen/templates/meta.py.tmpl | 27 ++ python/tools/sdkgen/templates/models.py.tmpl | 262 +++++++++++ .../sdkgen/templates/pyproject.toml.tmpl | 40 ++ python/tools/sdkgen/templates/resources.tmpl | 418 ++++++++++++++++++ .../tools/sdkgen/templates/transport.py.tmpl | 127 ++++++ 12 files changed, 1627 insertions(+) create mode 100644 python/tools/sdkgen/.gitignore create mode 100644 python/tools/sdkgen/Makefile create mode 100644 python/tools/sdkgen/README.md create mode 100755 python/tools/sdkgen/publish-check.sh create mode 100644 python/tools/sdkgen/static/LICENSE.md create mode 100644 python/tools/sdkgen/templates/README.md.tmpl create mode 100644 python/tools/sdkgen/templates/__init__.py.tmpl create mode 100644 python/tools/sdkgen/templates/meta.py.tmpl create mode 100644 python/tools/sdkgen/templates/models.py.tmpl create mode 100644 python/tools/sdkgen/templates/pyproject.toml.tmpl create mode 100644 python/tools/sdkgen/templates/resources.tmpl create mode 100644 python/tools/sdkgen/templates/transport.py.tmpl diff --git a/python/tools/sdkgen/.gitignore b/python/tools/sdkgen/.gitignore new file mode 100644 index 0000000..30bcfa4 --- /dev/null +++ b/python/tools/sdkgen/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/python/tools/sdkgen/Makefile b/python/tools/sdkgen/Makefile new file mode 100644 index 0000000..852e0b0 --- /dev/null +++ b/python/tools/sdkgen/Makefile @@ -0,0 +1,181 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Build one PyPI package per plugin, unikraft-cloud-plugin--api, from the +# plugin's OpenAPI spec. See README.md. + +# The plugins repository holds /api.tsp, not /openapi.yaml, so +# compile the spec there first. +SPEC_ROOT ?= ../../../../plugins +BUILD_ROOT ?= .build +TEMPLATES ?= ./templates +STATIC ?= ./static + +GO ?= go +UV ?= uv +UVX ?= uvx +# Pinned, so two builds of one version format alike. +RUFF ?= ruff@0.16.2 +CURL ?= curl +SHA256 ?= $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo shasum -a 256) + +# publish-check.sh runs curl and python too. The build needs uv already, so +# uv finds the python, and no system python3 is assumed. +PYTHON ?= $(UV) run --no-project python +export CURL PYTHON + +# One knob for two things: the OpenAPI branch and the generator branch. PyPI +# has no dist-tags; a staging build is told apart by its version instead. +CHANNEL ?= prod-staging + +# The generator follows the channel. A prod-stable build must not render its +# plugins with staging's generator. +OPENAPI_GEN_MOD = unikraft.com/x/tools/openapi-gen +OPENAPI_GEN_REVISION = $(eval OPENAPI_GEN_REVISION := $(shell $(GO) list -m -f '{{.Version}}' $(OPENAPI_GEN_MOD)@$(CHANNEL) 2>/dev/null))$(OPENAPI_GEN_REVISION) +OPENAPI_GEN ?= $(GO) run $(OPENAPI_GEN_MOD)@$(OPENAPI_GEN_REVISION) + +# The oldest unikraft-cloud whose ApiClient is a transport for the generated +# classes. Recorded in the package and its README, not depended on: the +# package takes any transport of that shape and pulls no SDK in. +SDK_MIN ?= 0.2.0 + +# Extra `uv publish` flags. CI passes --trusted-publishing always. +PUBLISH_FLAGS ?= + +# Every plugin under SPEC_ROOT that has a compiled spec. +PLUGINS := $(notdir $(patsubst %/,%,$(dir $(wildcard $(SPEC_ROOT)/*/openapi.yaml)))) + +SPEC = $(SPEC_ROOT)/$(PLUGIN)/openapi.yaml +BUILD = $(BUILD_ROOT)/$(PLUGIN) +# The distribution name on PyPI and the module it installs. +DIST = unikraft-cloud-plugin-$(PLUGIN)-api +MODULE = unikraft_cloud_plugin_$(subst -,_,$(PLUGIN))_api + +# info.version, with YAML quotes and any leading "v" stripped. The value is +# interpolated into shell below, so only a well-formed version leaves awk. +SPEC_VERSION = $(shell awk '/^info:/{f=1;next} /^[^[:space:]#]/{f=0} f&&/^[[:space:]]+version:/{v=$$2; gsub(/["\047]/,"",v); sub(/^v/,"",v); if (v ~ /^[0-9]+\.[0-9]+\.[0-9]+(-next\.[0-9]+)?$$/) print v; exit}' "$(SPEC)" 2>/dev/null) + +# A version given from outside wins; CI derives one from the release tags. +# A semver prerelease "X.Y.Z-next.N" is spelled "X.Y.Z.devN" on PyPI: PEP 440 +# has no "next", and a dev release sorts below X.Y.Z as the prerelease does. +VERSION ?= $(SPEC_VERSION) +PY_VERSION = $(shell printf '%s' "$(VERSION)" | sed -E 's/^v//; s/^([0-9]+\.[0-9]+\.[0-9]+)-next\.([0-9]+)$$/\1.dev\2/') +PY_VERSION_OK = $(shell printf '%s' "$(PY_VERSION)" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(\.dev[0-9]+)?$$' && echo yes) + +# The package version is CI's or the spec's info.version, and nothing forces +# either to move when the sources do. publish compares these against the +# published version, so a changed source under an unchanged version fails +# instead of silently reporting success. +SPEC_HASH = $(shell $(SHA256) "$(SPEC)" 2>/dev/null | cut -c1-64) +# A tree is hashed from inside it, every file at any depth, so the spelling +# of TEMPLATES or STATIC leaves no mark; each tree is labelled, so a file +# moved from one to the other does. An unreadable tree yields no hash. +hash_tree = (cd "$(1)" && find . -type f | LC_ALL=C sort | xargs -r $(SHA256)) +TEMPLATES_HASH = $(shell out=$$({ echo templates && $(call hash_tree,$(TEMPLATES)) && echo static && $(call hash_tree,$(STATIC)); } 2>/dev/null) && printf '%s\n' "$$out" | $(SHA256) | cut -c1-64) + +# The spec and the templates are not the only inputs. The SDK floor and the +# generator decide the output as well, and neither leaves a mark in any file +# the two hashes above cover. +# The generator is named by what pins its code: the module at its revision, +# or the command when one is given from outside. +OPENAPI_GEN_ID = $(if $(filter file,$(origin OPENAPI_GEN)),$(OPENAPI_GEN_MOD)@$(OPENAPI_GEN_REVISION),$(OPENAPI_GEN)) +CONFIG_HASH = $(shell printf '%s\n%s\n' "$(SDK_MIN)" "$(OPENAPI_GEN_ID)" | $(SHA256) | cut -c1-64) + +.PHONY: all list clean generate build check publish publish-all config check-plugins + +check-plugins: + @test -n "$(PLUGINS)" || { \ + echo "error: no plugin specs under SPEC_ROOT=$(SPEC_ROOT)"; \ + echo " The plugins repository holds /api.tsp, not /openapi.yaml."; \ + echo " Compile it there ('npx tsp compile /api.tsp'), or set SPEC_ROOT."; \ + exit 1; } + +## Build every plugin. +all: check-plugins + @for p in $(PLUGINS); do $(MAKE) --no-print-directory build PLUGIN=$$p || exit 1; done + +## Build and publish every plugin whose version is not on PyPI yet. +publish-all: check-plugins + @for p in $(PLUGINS); do $(MAKE) --no-print-directory publish PLUGIN=$$p || exit 1; done + +## List the plugins that have a compiled spec. +list: + @echo $(PLUGINS) + +## Print the resolved channel configuration. +config: + @echo "channel: $(CHANNEL)" + @echo "spec root: $(SPEC_ROOT)" + @echo "plugins: $(PLUGINS)" + @echo "sdk floor: $(SDK_MIN)" + @echo "generator: $(OPENAPI_GEN)" + @echo "templates: $(TEMPLATES_HASH)" + @echo "config: $(CONFIG_HASH)" + +## Build one plugin, e.g. `make plugin-sandbox`. +plugin-%: + @$(MAKE) --no-print-directory build PLUGIN=$* + +## Generate one plugin's Python, without building a wheel. +generate: + @test -n "$(PLUGIN)" || { echo "error: set PLUGIN="; exit 1; } + @printf '%s' "$(PLUGIN)" | grep -Eq '^[a-z0-9]+(-[a-z0-9]+)*$$' || { echo "error: PLUGIN=$(PLUGIN) is not a plugin name: lowercase letters and digits, with a single - between them"; exit 1; } + @test -f "$(SPEC)" || { echo "error: no spec at $(SPEC) (compile it with 'npx tsp compile $(PLUGIN)/api.tsp')"; exit 1; } + @test -n "$(VERSION)" || { echo "error: no info.version in $(SPEC)"; exit 1; } + @test "$(PY_VERSION_OK)" = yes || { echo "error: version '$(VERSION)' is not X.Y.Z or X.Y.Z-next.N, so it cannot be spelled for PyPI"; exit 1; } + @$(if $(filter file,$(origin OPENAPI_GEN)),test -n "$(OPENAPI_GEN_REVISION)" || { echo "error: could not resolve $(OPENAPI_GEN_MOD)@$(CHANNEL) to a revision with '$(GO) list -m'"; exit 1; }) + @test -n "$(SPEC_HASH)" || { echo "error: could not hash $(SPEC) with '$(SHA256)'"; exit 1; } + @test -n "$(TEMPLATES_HASH)" || { echo "error: could not hash $(TEMPLATES)/ and $(STATIC)/ with '$(SHA256)'"; exit 1; } + @test -n "$(CONFIG_HASH)" || { echo "error: could not hash the generation config with '$(SHA256)'"; exit 1; } + rm -rf "$(BUILD)" + mkdir -p "$(BUILD)" + $(OPENAPI_GEN) \ + -i "$(SPEC)" \ + -o "$(BUILD)" \ + -t "$(TEMPLATES)" \ + -v "pluginName=$(PLUGIN)" \ + -v "dist=$(DIST)" \ + -v "module=$(MODULE)" \ + -v "version=$(PY_VERSION)" \ + -v "sdkMin=$(SDK_MIN)" \ + -v "specHash=$(SPEC_HASH)" \ + -v "templatesHash=$(TEMPLATES_HASH)" \ + -v "configHash=$(CONFIG_HASH)" + cp -R $(STATIC)/. "$(BUILD)/" + touch "$(BUILD)/src/$(MODULE)/py.typed" + @echo "generated $(DIST)@$(PY_VERSION) (needs unikraft-cloud >= $(SDK_MIN) as its transport) -> $(BUILD)" + +## Generate one plugin and build its wheel and sdist. +build: generate + cd "$(BUILD)" && $(UVX) $(RUFF) format src + cd "$(BUILD)" && $(UV) build + @echo "built $(BUILD)/dist" + +## Build one plugin, then import and type-check it in a scratch environment. +check: build + $(UV) run --isolated --no-project --with "$(BUILD)"/dist/*.whl \ + python -c "import $(MODULE) as m; print(m.VERSION, m.PLUGIN_NAME, sorted(n for n in m.__all__ if n.endswith('Api')))" + $(UVX) --with "$(BUILD)"/dist/*.whl --with mypy mypy --strict -p $(MODULE) + +## Check one plugin and publish it, unless PyPI already carries that version +## built from the same sources. Needs UV_PUBLISH_TOKEN, or trusted publishing. +## +## publish-check.sh decides: 0 publish it, 10 skip it, 11 publish the sdist +## only, 12 publish the wheel only, anything else is a failure it has already +## explained. +publish: check + @rc=0; \ + "$(CURDIR)/publish-check.sh" "$(DIST)" "$(PY_VERSION)" "$(MODULE)" \ + "$(SPEC_HASH)" "$(TEMPLATES_HASH)" "$(CONFIG_HASH)" || rc=$$?; \ + case $$rc in \ + 0) echo "publishing $(DIST)@$(PY_VERSION)"; \ + $(UV) publish $(PUBLISH_FLAGS) "$(BUILD)"/dist/* ;; \ + 10) : ;; \ + 11) $(UV) publish $(PUBLISH_FLAGS) "$(BUILD)"/dist/*.tar.gz ;; \ + 12) $(UV) publish $(PUBLISH_FLAGS) "$(BUILD)"/dist/*.whl ;; \ + *) exit $$rc ;; \ + esac + +## Remove all build output. +clean: + rm -rf "$(BUILD_ROOT)" diff --git a/python/tools/sdkgen/README.md b/python/tools/sdkgen/README.md new file mode 100644 index 0000000..9bdc58b --- /dev/null +++ b/python/tools/sdkgen/README.md @@ -0,0 +1,201 @@ +# sdkgen (Python) + +`sdkgen` builds a Python client for each Unikraft Cloud plugin. It publishes +each client to PyPI as `unikraft-cloud-plugin--api`. + +It is the Python counterpart of [`tsplugingen`](../../../js/tools/tsplugingen), +and the two are laid out the same way on purpose: PyPI, like npm, has no +mechanism that generates a package at install time, so this tool builds and +publishes in advance. CI renders each plugin's specification to a finished +package, then pushes it to the index. The Go counterpart, +[`sdkgen`](../../../go/tools/sdkgen), instead renders a module proxy tree that +`go get` fetches directly. + +- **Distribution:** `unikraft-cloud-plugin--api` on PyPI; module + `unikraft_cloud_plugin__api`, with each `-` in `` spelled + `_`. +- **Version:** the plugin's OpenAPI `info.version` without its leading `v`, or + the version CI passes, spelled for PEP 440 (see below). +- **Input:** `/openapi.yaml` in the plugins repository. +- **Generator:** [`openapi-gen`](https://github.com/unikraft-cloud/x/tree/prod-staging/tools/openapi-gen) + with the templates in [`templates/`](templates). The models and resources + templates are adapted from the Python SDK's, changed to take an external + transport; the rest are this tool's own. +- **Dependencies:** pydantic and typing_extensions. Not the SDK: see below. + +## Generated code only + +The package holds only generated code: one class per OpenAPI tag, methods that +carry the name of their `operationId`, and responses that keep the raw +envelope. + +The layer with the ergonomic API is **not** here. It lives in +[`python-sdk`](https://github.com/unikraft-cloud/python-sdk), which depends on +this package and wraps it. For the same reason, this package does not derive +the base URL from the specification. `servers` in `api.tsp` hardcodes the +`sandbox` segment, but a plugin answers under the name that you attach it with. +The SDK builds the URL in `unikraft_cloud.plugins` instead, and this package +takes a finished `base_url`. + +## No dependency on the SDK + +Python has no peer dependencies, so a package that imported the SDK's +`ApiClient` and was in turn imported by the SDK would close a dependency cycle. +Instead the generated classes take a **transport**: any object with the four +methods that `transport.py` declares as a `Protocol`. The SDK's `ApiClient` +satisfies it by shape, and so does anything else with those methods, so the +package pulls no SDK in and the SDK's type check confirms the fit wherever it +hands an `ApiClient` over. + +`SDK_MIN` names the oldest SDK whose `ApiClient` carries that public surface. +It is written into the package's `_meta.py` and README as guidance, and it is +part of the configuration fingerprint. It is not a dependency. + +Every generated method ends in `**options: Unpack[CallOptions]`, a +`TypedDict` of `headers`, `base_url` and `timeout` that it hands to the +transport untouched. A key that is absent leaves the transport's default in +place, so the package needs no "unset" sentinel of the SDK's to tell "no +timeout" from "the client's timeout". + +One consequence of taking no SDK: a query parameter that the API reads as one +comma-separated value is refused at generation, because the marker type that +tells the SDK to encode it that way is the SDK's own. No plugin declares one +today. + +## Request models + +A request body is the caller's to fill in, so a field the specification +requires is required in the generated model too, and a request missing one +fails at construction rather than at the server. A response is the server's, +and one response shape serves several answers, so there every field is +optional and a partial answer always parses. + +The templates tell the two apart by name, following the TypeSpec convention: +a schema whose name contains `Request` and not `Response` is a request model. +Two things follow for a plugin's `api.tsp`: + +- Name a request body, and every model only a request body reaches, with + `Request` in it (`RunCommandRequest`, `RunCommandRequestEnv`). +- Never carry a `Request`-named schema in a response. Its required fields stay + required, so a response that leaves one out fails to parse. + +A schema that breaks the first rule is caught one hop out, as a workaround +until the specification is fixed: a schema that a `Request`-named schema or an +operation's request body names directly is a request model too, unless a +response or a schema outside the convention also names it. The exception is +what keeps every response parsing, whatever the names: a schema the server +fills in anywhere is never made strict. The hop is not repeated, so a schema +two references away from a request is a response model with every field +optional, and a caller can build and send a body the server will reject. The +sandbox specification has one schema the hop catches, `CommandLogsRange` +under `CommandLogsRequest`, and none beyond it. The fix belongs in the +plugin's `api.tsp`: name the schema with `Request`, and the hop has nothing to +do. + +A full walk over every reference, repeated until it settles, would find every +such schema at any depth. It is not done: a single pass over the models and +the operations is cheap and covers the specifications there are, and the +convention is the fix. + +## Versions + +PyPI reads PEP 440, not semver. A stable version `X.Y.Z` passes through. The +staging prerelease that CI derives, `X.Y.Z-next.N`, is spelled `X.Y.Z.devN`: a +dev release sorts below `X.Y.Z` as the semver prerelease does, and there is no +`next` in PEP 440. Any other prerelease form is refused rather than guessed at. + +PyPI has no dist-tags, so a staging build is told apart by its version alone. + +## The specifications are not in the repository + +The plugins repository holds `/api.tsp`, not `/openapi.yaml`. +Compile the specification there before you generate a client: + +```sh +cd ../../../../plugins +npm ci +npx tsp compile sandbox/api.tsp --warn-as-error +``` + +## Usage + +```sh +make config # show the resolved channel, plugins and SDK floor +make list # list the plugins that have a compiled specification +make plugin-sandbox # generate one plugin and build its wheel +make check PLUGIN=sandbox # build, then import and type-check the wheel +make all # every plugin +make publish-all # publish everything that is not on PyPI yet +``` + +The build writes its output to `.build/`, which git ignores, with the +wheel and sdist under `.build//dist`. + +## A publish checks the sources that it came from + +In CI the package version follows the conventional commits that touch the +plugin's directory in the plugins repository; elsewhere it is the OpenAPI +specification's `info.version`. Nothing forces either to move when the sources +do: the generator, its templates and the SDK floor live outside that directory +altogether. A changed source under an unchanged version therefore generates new +code. The publish step finds the version on PyPI, skips it, and reports +success. The SDK keeps the stale client, and no step fails. + +To stop that, `generate` hashes every input that decides the output and writes +the hashes into the package as `_meta.py`: + +| Constant | Covers | +| ---------------- | ----------------------------------------------- | +| `SPEC_HASH` | `/openapi.yaml` | +| `TEMPLATES_HASH` | `templates/` and `static/` | +| `CONFIG_HASH` | `SDK_MIN`, and the generator's module and revision | + +`publish-check.sh` fetches the published wheel and compares all three: + +| On PyPI | Result | +| ------------------------- | ----------------------------------------------- | +| Version absent | Publish it. | +| Present, all hashes match | Skip. A re-run of an unchanged channel is safe. | +| Present, any hash differs | **Fail**, and name the input that changed. | + +A failed check needs a new version that ships the change. In CI that is a +`fix():` commit in the plugins repository that changes a file under +`/`. svu and the prerelease counter read only the commits that touch +that directory, so an empty commit moves nothing. Outside CI, pass `VERSION=` +to `make`, or bump `info.version` in the plugin's `api.tsp`. + +An index that cannot be reached is not an answer either way, so a failed +lookup fails the step. + +## Configuration + +| Variable | Default | Description | +| --------------- | ---------------------------------------------------- | --------------------------------------------------------------------- | +| `SPEC_ROOT` | `../../../../plugins` | Root that holds `/openapi.yaml`. | +| `CHANNEL` | `prod-staging` | Release channel. Sets the generator branch. | +| `OPENAPI_GEN` | `go run unikraft.com/x/tools/openapi-gen@` | The generator command. `` is the commit that `CHANNEL` resolves to. | +| `SDK_MIN` | `0.2.0` | The oldest `unikraft-cloud` whose `ApiClient` is a transport for the package. | +| `VERSION` | `info.version` of the spec | The version to publish as. CI derives one from the release tags. | +| `BUILD_ROOT` | `.build` | Directory that `generate` writes each plugin into. | +| `PUBLISH_FLAGS` | _(empty)_ | Extra `uv publish` flags. CI passes `--trusted-publishing always`. | +| `GO` | `go` | Runs the generator. | +| `UV` | `uv` | Builds the wheel and the sdist, runs the import check, and publishes. | +| `UVX` | `uvx` | Runs ruff and mypy without installing them. | +| `RUFF` | `ruff@0.16.2` | Formats the generated sources. Pinned so two builds of one version format alike. | +| `PYTHON` | `uv run --no-project python` | Reads the published wheel in `publish-check.sh`. | +| `CURL` | `curl` | Asks PyPI in `publish-check.sh`. | + +## Templates + +| File | Output | Contents | +| --------------------- | ----------------------------------------- | -------------------------------------------- | +| `models.py.tmpl` | `src//models_gen.py` | request and response models, enums, aliases | +| `resources.tmpl` | `src//_gen.py` | one `…Api` class per tag | +| `transport.py.tmpl` | `src//transport.py` | the `Transport` and `RawResponse` protocols | +| `__init__.py.tmpl` | `src//__init__.py` | the container class, and the exports | +| `meta.py.tmpl` | `src//_meta.py` | the version, the SDK floor and the hashes | +| `pyproject.toml.tmpl` | `pyproject.toml` | the distribution manifest | +| `README.md.tmpl` | `README.md` | the per-package readme | + +`static/` holds the files copied into every package as they are: the licence. +`generate` also writes an empty `py.typed`, so the package's types are read. diff --git a/python/tools/sdkgen/publish-check.sh b/python/tools/sdkgen/publish-check.sh new file mode 100755 index 0000000..d20e1d8 --- /dev/null +++ b/python/tools/sdkgen/publish-check.sh @@ -0,0 +1,162 @@ +#!/bin/sh +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Decide whether a generated package should be published. +# +# The package version follows the plugin's commits in CI and is the +# specification's info.version elsewhere, and nothing forces either to move +# when the sources do. A changed source under an unchanged version would +# otherwise regenerate the code, find the version on PyPI, skip it, and report +# success. So compare the fingerprints that `generate` wrote into the +# package's _meta.py against the ones the published files carry. +# +# A version is two files, a wheel and an sdist, that PyPI takes one by one, so +# an upload can stop between them. A version that is on PyPI with one file +# only, built from these sources, is finished by publishing the other. +# +# Usage: publish-check.sh +# +# Exit status: +# 0 not on PyPI, so publish it +# 10 on PyPI and built from these sources, so skip it +# 11 on PyPI from these sources, but without its sdist: publish the sdist +# 12 on PyPI from these sources, but without its wheel: publish the wheel +# 1 on PyPI from different sources, or the index could not be reached +set -eu + +# The Makefile exports these so `make CURL=... PYTHON=...` reaches here too. +CURL=${CURL:-curl} +PYTHON=${PYTHON:-python3} +# The JSON API of the index to ask. Overridable for a mirror or a test. +PYPI_JSON=${PYPI_JSON:-https://pypi.org/pypi} + +dist=${1:?distribution name} +ver=${2:?version} +module=${3:?module name} +spec_hash=${4:?spec hash} +tmpl_hash=${5:?templates hash} +cfg_hash=${6:?config hash} + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +# PyPI answers 404 for a version it does not hold. Any other failure is not +# a reason to publish: a network or index blip must not slip past the +# comparison below. curl prints 000 itself when no response came. +code=$($CURL -sS -o "$tmp/release.json" -w '%{http_code}' "$PYPI_JSON/$dist/$ver/json") || code=000 +case "$code" in +200) ;; +404) + echo "$dist@$ver is not on PyPI yet" + exit 0 + ;; +*) + echo "error: could not ask PyPI whether $dist@$ver exists (HTTP $code)" >&2 + exit 1 + ;; +esac + +# The URL of the file of one package type in the release, or nothing. +file_url() { + $PYTHON -c ' +import json, sys +release = json.load(open(sys.argv[1])) +for file in release.get("urls", []): + if file.get("packagetype") == sys.argv[2]: + print(file["url"]) + break +' "$tmp/release.json" "$1" +} + +wheel=$(file_url bdist_wheel) +sdist=$(file_url sdist) +if [ -z "$wheel" ] && [ -z "$sdist" ]; then + echo "error: $dist@$ver is on PyPI but has no file to read the fingerprints from" >&2 + exit 1 +fi + +# The fingerprints live in both files, so read them from whichever is there, +# the wheel first. An unreachable file host reads as a failure, not as "no +# fingerprints". +if [ -n "$wheel" ]; then + url=$wheel + pkg=$tmp/pkg.whl +else + url=$sdist + pkg=$tmp/pkg.tar.gz +fi +if ! $CURL -fsSL -o "$pkg" "$url"; then + echo "error: could not fetch $dist@$ver from $url" >&2 + exit 1 +fi + +# _meta.py is a few constant assignments; read them without importing. A +# wheel holds the module at its root, an sdist under -/src/. +meta() { + $PYTHON -c ' +import re, sys, tarfile, zipfile +path, module, name = sys.argv[1:4] +text = "" +if path.endswith(".whl"): + with zipfile.ZipFile(path) as whl: + try: + text = whl.read(f"{module}/_meta.py").decode() + except KeyError: + pass +else: + with tarfile.open(path) as tar: + for member in tar.getmembers(): + if member.name.endswith(f"/src/{module}/_meta.py"): + text = tar.extractfile(member).read().decode() + break +match = re.search(rf"^{name} = \"([^\"]*)\"", text, re.M) +print(match.group(1) if match else "") +' "$pkg" "$module" "$1" +} + +was_spec=$(meta SPEC_HASH) +was_tmpl=$(meta TEMPLATES_HASH) +was_cfg=$(meta CONFIG_HASH) + +# Every version of these packages carries its fingerprints, so one without +# them cannot be shown to match. Refuse, and have the version bumped. +if [ -z "$was_spec" ]; then + echo "error: $dist@$ver carries no source fingerprints, so its sources cannot be verified; bump the version" >&2 + exit 1 +fi + +if [ "$was_spec" = "$spec_hash" ] && + [ "$was_tmpl" = "$tmpl_hash" ] && + [ "$was_cfg" = "$cfg_hash" ]; then + if [ -z "$sdist" ]; then + echo "$dist@$ver is on PyPI from these sources, but without its sdist; publishing the sdist" + exit 11 + fi + if [ -z "$wheel" ]; then + echo "$dist@$ver is on PyPI from these sources, but without its wheel; publishing the wheel" + exit 12 + fi + echo "$dist@$ver is on PyPI and was built from these sources; skipping" + exit 10 +fi + +# The plugin's name, for the remedy: unikraft-cloud-plugin--api. +plugin=${dist#unikraft-cloud-plugin-} +plugin=${plugin%-api} + +echo "error: $dist@$ver is on PyPI, but it came from different sources." >&2 +[ "$was_spec" = "$spec_hash" ] || + echo " The specification changed." >&2 +[ "$was_tmpl" = "$tmpl_hash" ] || + echo " The generator templates or the static files changed." >&2 +[ "$was_cfg" = "$cfg_hash" ] || + echo " The SDK floor or the generator changed." >&2 +echo " A new version must ship the change. In CI the version follows the" >&2 +echo " conventional commits that touch $plugin/ in the plugins repository, so" >&2 +echo " land a 'fix($plugin):' commit that changes a file there; an empty commit" >&2 +echo " touches no directory and moves nothing. Elsewhere pass VERSION=" >&2 +echo " to make, or bump info.version in $plugin/api.tsp." >&2 +echo " on PyPI: spec=$was_spec templates=$was_tmpl config=$was_cfg" >&2 +echo " current: spec=$spec_hash templates=$tmpl_hash config=$cfg_hash" >&2 +exit 1 diff --git a/python/tools/sdkgen/static/LICENSE.md b/python/tools/sdkgen/static/LICENSE.md new file mode 100644 index 0000000..1b761f4 --- /dev/null +++ b/python/tools/sdkgen/static/LICENSE.md @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Unikraft GmbH. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/python/tools/sdkgen/templates/README.md.tmpl b/python/tools/sdkgen/templates/README.md.tmpl new file mode 100644 index 0000000..963d2fc --- /dev/null +++ b/python/tools/sdkgen/templates/README.md.tmpl @@ -0,0 +1,84 @@ +{{- $plugin := .Var "pluginName" "plugin" -}} +{{- $dist := .Var "dist" "unikraft-cloud-plugin-api" -}} +{{- $module := .Var "module" "unikraft_cloud_plugin_api" -}} +{{- $sdkMin := .Var "sdkMin" "" -}} +{{- $tags := uniqueTags .Operations -}} +{{- $class := printf "%sPluginApi" (pascalcase $plugin) -}} +--- README.md +# {{ $dist }} + +The generated client for the Unikraft Cloud `{{ $plugin }}` plugin API. +openapi-gen generates it from the plugin's OpenAPI specification, so **do not +edit it by hand**. + +This package is the raw ("plumbing") layer. Each method carries the name of its +`operationId` and returns the response envelope exactly as the specification +describes it. If you want the idiomatic layer, use +[`unikraft-cloud`](https://pypi.org/project/unikraft-cloud/), which wraps this +package and resolves the instance for you. + +## Install + +```sh +pip install {{ $dist }} +``` + +The package depends on pydantic and typing_extensions only. Each generated +class takes a `Transport`, +an object with `request()`, `request_no_content()`, `request_bytes()` and +`stream()`. The `ApiClient` of `unikraft-cloud` +{{ $sdkMin }} or later is one. If you want to use it as the transport, install +the SDK. + +## Usage + +The plugin runs inside a single instance and answers on that instance's plugin +route, so `base_url` is the full plugin endpoint rather than a metro root. + +```python +import os + +from unikraft_cloud.core.http import ApiClient, ApiClientConfig +from {{ $module }} import {{ $class }} + +uuid = "..." # The instance the plugin is attached to. +api = {{ $class }}( + ApiClient( + ApiClientConfig( + base_url=f"https://api.fra.unikraft.cloud/v1/instances/{uuid}/plugins/{{ $plugin }}", + token=os.environ["UKC_TOKEN"], + ) + ) +) +``` + +The last path segment is whatever name the plugin was attached under in the +instance's `plugins` array, so it is not necessarily `{{ $plugin }}`. To let the +SDK build that URL for you, use `unikraft-cloud` instead. + +## Exports + +Everything below is importable from `{{ $module }}`. + +| Name | Carries | +| --- | --- | +| `{{ $class }}` | Every resource of the plugin behind one transport | +{{- range $tags }} +| `{{ pascalcase . }}Api` | The `{{ . }}` resource | +{{- end }} +| `models` | The wire models, an alias of `{{ $module }}.models_gen` | +| `PLUGIN_NAME` | `"{{ $plugin }}"`, the name the plugin is attached under unless the instance's creator chose another | +| `Transport`, `RawResponse` | The protocols a transport and a raw-bytes response satisfy | +| `CallOptions` | The options every method takes as `**options`: `headers`, `base_url` and `timeout` | +| `QueryValue` | What a query parameter may hold | +| `VERSION`, `SDK_MIN` | This package's version, and the oldest `unikraft-cloud` whose `ApiClient` is a transport for it | +| `SPEC_HASH`, `TEMPLATES_HASH`, `CONFIG_HASH` | SHA-256 fingerprints of the specification, the templates and the configuration this package was built from | + +A `CallOptions` key that is absent leaves the transport's default in place, so +`timeout=None` lifts the timeout for one call and no `timeout` at all keeps the +client's. + +`{{ $class }}` groups one client per resource: +{{ range $tags }} +- `api.{{ snakecase . }}`: the `{{ . }}` resource +{{- end }} diff --git a/python/tools/sdkgen/templates/__init__.py.tmpl b/python/tools/sdkgen/templates/__init__.py.tmpl new file mode 100644 index 0000000..a779d94 --- /dev/null +++ b/python/tools/sdkgen/templates/__init__.py.tmpl @@ -0,0 +1,96 @@ +{{- $ops := .Operations -}} +{{- $tags := uniqueTags $ops -}} +{{- $plugin := .Var "pluginName" "plugin" -}} +{{- $module := .Var "module" "unikraft_cloud_plugin_api" -}} +{{- $class := printf "%sPluginApi" (pascalcase $plugin) -}} +{{- /* pascalcase keeps a leading digit, which Python rejects in a class name. */ -}} +{{- if not (regexMatch "^[A-Za-z_][A-Za-z0-9_]*$" $class) -}} +{{- fail (printf "plugin name %q cannot name a Python class" $plugin) -}} +{{- end -}} +{{- /* The tag names are checked by "tagNames" in resources.tmpl, once for + both templates. */ -}} +{{- template "tagNames" (dict "tags" $tags "plugin" $plugin) -}} +--- src/{{ $module }}/__init__.py +# Code generated by openapi-gen; DO NOT EDIT. +# +# The `{{ $plugin }}` plugin API's "plumbing" layer: the generated clients, plus +# a container that groups them behind one transport. Everything here returns +# the response envelope exactly as the OpenAPI specification describes it, or, +# for an operation that answers with raw bytes, the payload untouched. + +from __future__ import annotations + +from . import models_gen as models +from ._meta import CONFIG_HASH, SDK_MIN, SPEC_HASH, TEMPLATES_HASH, VERSION +{{- range $tag := $tags }} +from .{{ snakecase $tag }}_gen import {{ pascalcase $tag }}Api +{{- end }} +from .transport import CallOptions, QueryValue, RawResponse, Transport + +__all__ = [ + "CONFIG_HASH", + "PLUGIN_NAME", + "SDK_MIN", + "SPEC_HASH", + "TEMPLATES_HASH", + "VERSION", +{{- range $tag := $tags }} + "{{ pascalcase $tag }}Api", +{{- end }} + "CallOptions", + "QueryValue", + "RawResponse", + "{{ $class }}", + "Transport", + "models", +] + +__version__ = VERSION + +#: The name the plugin is attached to an instance under, unless the instance's +#: creator chose another, and hence the ```` in its route. +PLUGIN_NAME = "{{ $plugin }}" + +#: What a transport must offer, for the error that names a missing one. +_TRANSPORT_METHODS = ("request", "request_no_content", "request_bytes", "stream") + + +class {{ $class }}: + """Every `{{ $plugin }}` plugin API resource, raw, behind one transport. + + The idiomatic layer in ``unikraft_cloud`` derives the plugin's route from an + instance. Use this class only when you want the raw envelopes against a + route you already have. + + .. code-block:: python + + import os + + from unikraft_cloud.core.http import ApiClient, ApiClientConfig + from {{ $module }} import {{ $class }} + + uuid = "..." # The instance the plugin is attached to. + api = {{ $class }}( + ApiClient( + ApiClientConfig( + base_url=f"https://api.fra.unikraft.cloud/v1/instances/{uuid}/plugins/{{ $plugin }}", + token=os.environ["UKC_TOKEN"], + ) + ) + ) + """ + + def __init__(self, transport: Transport) -> None: + missing = [name for name in _TRANSPORT_METHODS if not callable(getattr(transport, name, None))] + if missing: + raise TypeError( + f"The transport has no {missing[0]}() method. Pass an ApiClient from " + "unikraft_cloud.core.http, or an object with " + f"{', '.join(_TRANSPORT_METHODS)}." + ) + #: The transport every request goes through. + self.transport = transport +{{- range $tag := $tags }} + #: The `{{ $tag }}` resource of this plugin. + self.{{ snakecase $tag }} = {{ pascalcase $tag }}Api(transport) +{{- end }} diff --git a/python/tools/sdkgen/templates/meta.py.tmpl b/python/tools/sdkgen/templates/meta.py.tmpl new file mode 100644 index 0000000..d635ec4 --- /dev/null +++ b/python/tools/sdkgen/templates/meta.py.tmpl @@ -0,0 +1,27 @@ +{{- $module := .Var "module" "unikraft_cloud_plugin_api" -}} +{{- $version := .Var "version" "0.0.0" -}} +{{- $sdkMin := .Var "sdkMin" "" -}} +{{- $specHash := .Var "specHash" "" -}} +{{- $templatesHash := .Var "templatesHash" "" -}} +{{- $configHash := .Var "configHash" "" -}} +--- src/{{ $module }}/_meta.py +# Code generated by openapi-gen; DO NOT EDIT. +# +# What this package was built from. The version is what it was published as; +# the hashes fingerprint the sources that decided its contents, so a publish +# can tell a rebuild of the same sources from a change under the same version. + +#: The version this package was published as. +VERSION = "{{ $version }}" + +#: The oldest unikraft-cloud SDK whose ApiClient is a transport for this package. +SDK_MIN = "{{ $sdkMin }}" + +#: SHA-256 of the OpenAPI specification. +SPEC_HASH = "{{ $specHash }}" + +#: SHA-256 over the generator's templates and static files. +TEMPLATES_HASH = "{{ $templatesHash }}" + +#: SHA-256 over the generation configuration: the SDK floor and the generator. +CONFIG_HASH = "{{ $configHash }}" diff --git a/python/tools/sdkgen/templates/models.py.tmpl b/python/tools/sdkgen/templates/models.py.tmpl new file mode 100644 index 0000000..a639014 --- /dev/null +++ b/python/tools/sdkgen/templates/models.py.tmpl @@ -0,0 +1,262 @@ +{{- /* Models for one API surface: enum aliases, the pydantic models, then the + union aliases. + + Python evaluates a module top to bottom, where TypeScript's declarations are + order-free, so this is laid out to survive the parser's alphabetical order: + self-contained enum aliases first, then the models with deferred annotations, + then the aliases for schemas that are only a union of models -- an assignment + is evaluated on the spot, unlike an annotation, so its branches must already + exist -- and a rebuild pass once every name does. */ -}} +{{- $module := .Var "module" "unikraft_cloud_plugin_api" -}} +{{- /* A schema name is rendered as it is, in its declaration and in every + reference, so it must be an identifier that nothing in the module takes: an + import, a type the annotations name, or the loop variable of the rebuild. */ -}} +{{- $moduleNames := list "Any" "BaseModel" "ConfigDict" "Field" "Literal" "annotations" "bool" "datetime" "dict" "float" "int" "list" "str" }} +{{- $schemaNames := list }} +{{- range $m := .Models }}{{ $schemaNames = append $schemaNames $m.SchemaName }}{{ end }} +{{- range $m := .Models }} +{{- $name := $m.SchemaName }} +{{- if or (not (regexMatch "^[A-Za-z_][A-Za-z0-9_]*$" $name)) (ne (pySafeName $name) $name) }} +{{- fail (printf "schema %q cannot name a Python class; rename it in the plugin's api.tsp" $name) }} +{{- end }} +{{- if or (has $name $moduleNames) (eq $name "_model") }} +{{- fail (printf "schema %q has a name that the models module takes; rename it in the plugin's api.tsp" $name) }} +{{- end }} +{{- end }} +{{- /* A request model is named `Request`, or is one hop from one and in no + response. A workaround: name the schema in api.tsp (README, "Request models"). */ -}} +{{- $named := list }} +{{- range $m := .Models }} +{{- if and (contains "Request" $m.SchemaName) (not (contains "Response" $m.SchemaName)) }}{{ $named = append $named $m.SchemaName }}{{ end }} +{{- end }} +{{- /* Every type a request renders, and every type a non-request renders. */ -}} +{{- $fromRequests := "" }} +{{- $fromOthers := "" }} +{{- /* A field name is derived here, once, and looked up where the field is + rendered, so the imports the fields need are known before them. */ -}} +{{- $fieldNames := dict }} +{{- $needField := false }} +{{- $needDatetime := false }} +{{- $needLiteral := false }} +{{- range $m := .Models }} +{{- $name := $m.SchemaName }} +{{- if $m.Schema.Enum }}{{ $needLiteral = true }}{{ end }} +{{- /* Only an alias renders its own type; a class renders its fields. */ -}} +{{- $schema := $m.Schema }} +{{- $union := and (or $schema.OneOf $schema.AnyOf) (not $schema.Properties) }} +{{- $scalar := and (not $schema.Properties) (not $schema.OneOf) (not $schema.AnyOf) (not $schema.AllOf) $schema.Type (not ($schema.Type.Is "object")) }} +{{- $map := and (not $schema.Properties) (not $schema.OneOf) (not $schema.AnyOf) (not $schema.AllOf) (or (not $schema.Type) ($schema.Type.Is "object")) (or $schema.AdditionalProperties.Schema (eq (toJson $schema.AdditionalProperties.Has) "true")) }} +{{- $types := "" }} +{{- if and (not $schema.Enum) (or $union $scalar $map) }}{{ $types = schemaToPyType $schema }}{{ end }} +{{- range $prop := propertyNamesOrdered $name $m.Schema }} +{{- $p := getProperty $m.Schema $prop }} +{{- $types = printf "%s %s" $types (schemaToPyType $p) }} +{{- if eq $p.Format "date-time" }}{{ $needDatetime = true }}{{ end }} +{{- /* A field is an attribute, and one with a leading underscore is private to + pydantic, so a name that is no public identifier is spelled in snake case + and kept as the alias; one that still is none is refused. */ -}} +{{- $safe := pySafeName $prop }} +{{- if not (regexMatch "^[A-Za-z][A-Za-z0-9_]*$" $safe) }}{{ $safe = pySafeName (snakecase $prop) }}{{ end }} +{{- if not (regexMatch "^[A-Za-z][A-Za-z0-9_]*$" $safe) }} +{{- fail (printf "property %q of %s cannot name a Python field, even in snake case; rename it in the specification" $prop $name) }} +{{- end }} +{{- /* pydantic keeps `model_` for its own members, and the methods of its + first version shadow a field with a warning, so both are spelled apart. */ -}} +{{- if hasPrefix "model_" $safe }}{{ $safe = printf "field_%s" $safe }}{{ end }} +{{- if has $safe (list "construct" "copy" "dict" "from_orm" "json" "parse_file" "parse_obj" "parse_raw" "schema" "schema_json" "update_forward_refs" "validate") }}{{ $safe = printf "%s_" $safe }}{{ end }} +{{- /* A field with a default is an attribute of the class body, where the + annotations and defaults of later fields are resolved, so it may not take a + name that they use. */ -}} +{{- if or (has $safe (list "Any" "Field" "Literal" "bool" "datetime" "dict" "float" "int" "list" "str")) (has $safe $schemaNames) }}{{ $safe = printf "%s_" $safe }}{{ end }} +{{- if ne $safe $prop }}{{ $needField = true }}{{ end }} +{{- $_ := set $fieldNames (printf "%s.%s" $name $prop) $safe }} +{{- end }} +{{- if has $name $named }}{{ $fromRequests = printf "%s %s" $fromRequests $types }}{{ else }}{{ $fromOthers = printf "%s %s" $fromOthers $types }}{{ end }} +{{- end }} +{{- $needAny := regexMatch "\\bAny\\b" (printf "%s %s" $fromRequests $fromOthers) }} +{{- if regexMatch "\\bLiteral\\b" (printf "%s %s" $fromRequests $fromOthers) }}{{ $needLiteral = true }}{{ end }} +{{- range $op := .Operations }} +{{- with requestJSONSchema $op.Operation }}{{ $fromRequests = printf "%s %s" $fromRequests (pyTypeRef .) }}{{ end }} +{{- range $entry := sortedResponseCodes $op.Operation.Responses }}{{ with $entry.Ref }}{{ with .Value }} +{{- range $media, $content := .Content }}{{ with $content.Schema }}{{ $fromOthers = printf "%s %s" $fromOthers (pyTypeRef .) }}{{ end }}{{ end }} +{{- end }}{{ end }}{{ end }} +{{- end }} +{{- $requests := $named }} +{{- range $m := .Models }} +{{- $name := $m.SchemaName }} +{{- if and (not (has $name $named)) (regexMatch (printf "\\b%s\\b" $name) $fromRequests) (not (regexMatch (printf "\\b%s\\b" $name) $fromOthers)) }} +{{- $requests = append $requests $name }} +{{- end }} +{{- end }} +--- src/{{ $module }}/models_gen.py +# Code generated by openapi-gen; DO NOT EDIT. +# +# Type definitions for a Unikraft Cloud plugin API, generated from its OpenAPI +# specification. These mirror the wire format exactly. + +from __future__ import annotations +{{- if or $needDatetime $needAny $needLiteral }} +{{ end }} +{{- if $needDatetime }} +from datetime import datetime +{{- end }} +{{- if or $needAny $needLiteral }} +from typing import {{ if $needAny }}Any{{ end }}{{ if and $needAny $needLiteral }}, {{ end }}{{ if $needLiteral }}Literal{{ end }} +{{- end }} + +from pydantic import BaseModel, ConfigDict{{ if $needField }}, Field{{ end }} + +# Enumerations, as Literal aliases rather than enum classes: the values stay +# plain strings, so `state == "running"` needs no import, and a value the server +# adds is one line to widen. +{{ range $m := .Models }} +{{- $schema := $m.Schema }} +{{- if $schema.Enum }} + +{{ $m.SchemaName }} = {{ enumPyLiteral $schema }}{{ if $schema.Nullable }} | None{{ end }} +{{- with $schema.Description }} +{{ pyDoc . "" }} +{{- end }} +{{- end }} +{{- end }} + +{{ range $m := .Models }} +{{- $schema := $m.Schema }} +{{- $name := $m.SchemaName }} +{{- /* A request body is the caller's to fill in, so a field the specification + requires is required here too. A response is the server's, and one shape + serves several answers, so there every field stays optional. */ -}} +{{- $request := has $name $requests }} +{{- /* A model flattens no union: its fields would accept every branch at once + and none, which is not the wire schema. */ -}} +{{- if and $schema.Properties (or $schema.OneOf $schema.AnyOf) }} +{{- fail (printf "schema %s combines properties with oneOf or anyOf, which the templates cannot render; split it in the plugin's api.tsp" $name) }} +{{- end }} +{{- /* A union alias renders one union keyword and nothing else. */ -}} +{{- if and (or $schema.OneOf $schema.AnyOf) (or $schema.AllOf (and $schema.OneOf $schema.AnyOf)) }} +{{- fail (printf "schema %s combines oneOf or anyOf with allOf or with each other, which the templates cannot render; split it in the plugin's api.tsp" $name) }} +{{- end }} +{{- range $part := $schema.AllOf }}{{ with $part.Value }} +{{- if or .OneOf .AnyOf }} +{{- fail (printf "schema %s has a oneOf or anyOf inside its allOf, which the templates cannot render; split it in the plugin's api.tsp" $name) }} +{{- end }} +{{- range $inner := .AllOf }}{{ with $inner.Value }}{{ if or .OneOf .AnyOf }} +{{- fail (printf "schema %s has a oneOf or anyOf inside its allOf, which the templates cannot render; split it in the plugin's api.tsp" $name) }} +{{- end }}{{ end }}{{ end }} +{{- end }}{{ end }} +{{- $union := and (or $schema.OneOf $schema.AnyOf) (not $schema.Properties) }} +{{- $scalar := and (not $schema.Properties) (not $schema.OneOf) (not $schema.AnyOf) (not $schema.AllOf) $schema.Type (not ($schema.Type.Is "object")) }} +{{- /* A map is an object with no properties of its own and a schema, or a + `true`, for the additional ones. Has is a *bool, read through toJson. */ -}} +{{- $map := and (not $schema.Properties) (not $schema.OneOf) (not $schema.AnyOf) (not $schema.AllOf) (or (not $schema.Type) ($schema.Type.Is "object")) (or $schema.AdditionalProperties.Schema (eq (toJson $schema.AdditionalProperties.Has) "true")) }} +{{- if and (not $schema.Enum) (not $union) (not $scalar) (not $map) }} + + +class {{ $name }}(BaseModel): +{{- with $schema.Description }} + {{ pyDoc . " " }} +{{ end }} + # Fields the specification does not describe are kept rather than dropped, + # so a newly added one survives a round trip and does not break this client. +{{- if $request }} + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. +{{- else }} + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. +{{- end }} + model_config = ConfigDict(extra="allow", populate_by_name=True) +{{- $fields := list }} +{{ range $prop := propertyNamesOrdered $name $schema }} +{{- $p := getProperty $schema $prop }} +{{- $safe := get $fieldNames (printf "%s.%s" $name $prop) }} +{{- if has $safe $fields }} +{{- fail (printf "property %q of %s renders the field %s, which another property renders too; rename one of them in the specification" $prop $name $safe) }} +{{- end }} +{{- $fields = append $fields $safe }} +{{- /* A date-time is an ISO-8601 string on the wire; pydantic parses it into a + `datetime` and writes it back out in the same form. */ -}} +{{- $type := schemaToPyType $p }} +{{- if eq $p.Format "date-time" }}{{ $type = "datetime" }}{{ if $p.Nullable }}{{ $type = "datetime | None" }}{{ end }}{{ end }} +{{- /* A nullable type already ends in `| None`; an optional field adds it once. */ -}} +{{- $optional := $type }} +{{- if not (hasSuffix "| None" $type) }}{{ $optional = printf "%s | None" $type }}{{ end }} +{{- if and $request (getPropertyRequired $schema $prop) }} +{{- if eq $safe $prop }} + {{ $safe }}: {{ $type }} +{{- else }} + {{ $safe }}: {{ $type }} = Field(alias={{ printf "%q" $prop }}) +{{- end }} +{{- else if eq $safe $prop }} + {{ $safe }}: {{ $optional }} = None +{{- else }} + {{ $safe }}: {{ $optional }} = Field(default=None, alias={{ printf "%q" $prop }}) +{{- end }} +{{- with $p.Description }} + {{ pyDoc . " " }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} +{{- /* A schema that is only a union of branches, or a plain scalar, array or map, + is an alias, not a model: `Line = Shell | Args`, `Args = list[str]`, + `Env = dict[str, str]`. It comes after the models because the assignment + evaluates the names it uses right here, where a field annotation would wait + for the rebuild below. An alias that names another alias comes after it, so + the aliases go out in passes, each taking those whose aliases are all out; + one left at the end names itself. */ -}} +{{- $aliases := list }} +{{- range $m := .Models }} +{{- $schema := $m.Schema }} +{{- $union := and (or $schema.OneOf $schema.AnyOf) (not $schema.Properties) }} +{{- $scalar := and (not $schema.Properties) (not $schema.OneOf) (not $schema.AnyOf) (not $schema.AllOf) $schema.Type (not ($schema.Type.Is "object")) }} +{{- /* A map is an object with no properties of its own and a schema, or a + `true`, for the additional ones. Has is a *bool, read through toJson. */ -}} +{{- $map := and (not $schema.Properties) (not $schema.OneOf) (not $schema.AnyOf) (not $schema.AllOf) (or (not $schema.Type) ($schema.Type.Is "object")) (or $schema.AdditionalProperties.Schema (eq (toJson $schema.AdditionalProperties.Has) "true")) }} +{{- if and (not $schema.Enum) (or $union $scalar $map) }}{{ $aliases = append $aliases $m.SchemaName }}{{ end }} +{{- end }} +{{- $emitted := list }} +{{- range $pass := $aliases }} +{{- range $m := $.Models }} +{{- $name := $m.SchemaName }} +{{- if and (has $name $aliases) (not (has $name $emitted)) }} +{{- $code := schemaToPyType $m.Schema }} +{{- $ready := true }} +{{- range $other := $aliases }} +{{- if and (not (has $other $emitted)) (regexMatch (printf "\\b%s\\b" $other) $code) }}{{ $ready = false }}{{ end }} +{{- end }} +{{- if $ready }} + +{{ $name }} = {{ $code }} +{{- with $m.Schema.Description }} +{{ pyDoc . "" }} +{{- end }} +{{- $emitted = append $emitted $name }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} +{{- range $name := $aliases }} +{{- if not (has $name $emitted) }} +{{- fail (printf "schema %s is an alias that names itself, directly or through another alias, so it cannot be rendered" $name) }} +{{- end }} +{{- end }} + + +# Annotations are deferred, so a model may name one defined further down. This +# resolves them all now: a reference that cannot be resolved becomes an error on +# import rather than a surprise at the first request. +for _model in ( +{{- range $m := .Models }} +{{- $schema := $m.Schema }} +{{- $union := and (or $schema.OneOf $schema.AnyOf) (not $schema.Properties) }} +{{- $scalar := and (not $schema.Properties) (not $schema.OneOf) (not $schema.AnyOf) (not $schema.AllOf) $schema.Type (not ($schema.Type.Is "object")) }} +{{- /* A map is an object with no properties of its own and a schema, or a + `true`, for the additional ones. Has is a *bool, read through toJson. */ -}} +{{- $map := and (not $schema.Properties) (not $schema.OneOf) (not $schema.AnyOf) (not $schema.AllOf) (or (not $schema.Type) ($schema.Type.Is "object")) (or $schema.AdditionalProperties.Schema (eq (toJson $schema.AdditionalProperties.Has) "true")) }} +{{- if and (not $schema.Enum) (not $union) (not $scalar) (not $map) }} + {{ $m.SchemaName }}, +{{- end }} +{{- end }} +): + _model.model_rebuild() diff --git a/python/tools/sdkgen/templates/pyproject.toml.tmpl b/python/tools/sdkgen/templates/pyproject.toml.tmpl new file mode 100644 index 0000000..ecfc39f --- /dev/null +++ b/python/tools/sdkgen/templates/pyproject.toml.tmpl @@ -0,0 +1,40 @@ +{{- $plugin := .Var "pluginName" "plugin" -}} +{{- $dist := .Var "dist" "unikraft-cloud-plugin-api" -}} +{{- $version := .Var "version" "0.0.0" -}} +{{- /* PyPI folds runs of `-` into one and the module name does not, so a + doubled or outer `-` could collide with another plugin's project. */ -}} +{{- if not (regexMatch "^[a-z0-9]+(-[a-z0-9]+)*$" $plugin) -}} +{{- fail (printf "plugin name %q cannot be part of a PyPI distribution name; use lowercase letters and digits, with a single - between them" $plugin) -}} +{{- end -}} +--- pyproject.toml +[project] +name = "{{ $dist }}" +version = "{{ $version }}" +description = "Generated Python client for the Unikraft Cloud `{{ $plugin }}` plugin API" +readme = "README.md" +license = "BSD-3-Clause" +license-files = ["LICENSE.md"] +authors = [{ name = "Unikraft GmbH" }] +requires-python = ">=3.10" +keywords = ["unikraft", "unikraft-cloud", "plugin", "{{ $plugin }}", "sdk"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +# The wire models are pydantic; the transport is whatever the caller passes. +# Unpack on **options needs typing_extensions below Python 3.12. +dependencies = [ + "pydantic>=2.7", + "typing_extensions>=4.4", +] + +[project.urls] +Homepage = "https://unikraft.com" +Repository = "https://github.com/unikraft-cloud/plugins" + +[build-system] +requires = ["uv_build>=0.8.0,<0.9.0"] +build-backend = "uv_build" diff --git a/python/tools/sdkgen/templates/resources.tmpl b/python/tools/sdkgen/templates/resources.tmpl new file mode 100644 index 0000000..7fa4968 --- /dev/null +++ b/python/tools/sdkgen/templates/resources.tmpl @@ -0,0 +1,418 @@ +{{- $ops := .Operations -}} +{{- /* A class is rendered per tag, so an operation without one would be + dropped from every class in silence. */ -}} +{{- range $op := $ops }}{{ if not $op.Operation.Tags }} +{{- fail (printf "%s %s has no tag, so no generated class can expose it" $op.Method $op.Path) }} +{{- end }}{{ end -}} +{{- /* A tag names a module and a class here, and an attribute in __init__.py; + one check serves both. Takes `tags`, and `plugin` for the container. */ -}} +{{- define "tagNames" -}} +{{- $container := printf "%sPluginApi" (pascalcase .plugin) -}} +{{- $names := list -}} +{{- range $tag := .tags -}} +{{- /* The tag is quoted in docstrings and comments as it is, where a control + character would end the line. */ -}} +{{- if not (regexMatch "^[A-Za-z0-9][A-Za-z0-9 ._-]*$" $tag) -}} +{{- fail (printf "tag %q has a character the templates cannot render; use letters, digits, spaces, dots, dashes and underscores in the plugin's api.tsp" $tag) -}} +{{- end -}} +{{- /* The attribute must be an identifier that is not a keyword, `transport` + is the container's own, `models` is the models module, and two tags + that normalise alike would leave one. */ -}} +{{- $attr := snakecase $tag -}} +{{- if or (not (regexMatch "^[A-Za-z_][A-Za-z0-9_]*$" $attr)) (ne (pySafeName $attr) $attr) -}} +{{- fail (printf "tag %q cannot name a Python module or attribute; rename it in the plugin's api.tsp" $tag) -}} +{{- end -}} +{{- if has $attr (list "transport" "models") -}} +{{- fail (printf "tag %q gives the module %s_gen and the attribute %s, which the package reserves; rename it in the plugin's api.tsp" $tag $attr $attr) -}} +{{- end -}} +{{- $apiClass := printf "%sApi" (pascalcase $tag) -}} +{{- if eq $apiClass $container -}} +{{- fail (printf "tag %q gives the class %s, which is the package's container; rename it in the plugin's api.tsp" $tag $apiClass) -}} +{{- end -}} +{{- range $key := list (printf "class %s" $apiClass) (printf "attribute %s" $attr) -}} +{{- if has $key $names -}} +{{- fail (printf "tag %q gives the same %s as another tag; rename one of them in the plugin's api.tsp" $tag $key) -}} +{{- end -}} +{{- $names = append $names $key -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- template "tagNames" (dict "tags" (uniqueTags $ops) "plugin" (.Var "pluginName" "plugin")) -}} +{{- /* Every operation of a tag is a method of its class, so two that render + the same name would leave one, and `transport` is the class's own + attribute. */ -}} +{{- range $tag := uniqueTags $ops -}} +{{- $methods := list -}} +{{- range $op := $ops -}} +{{- if has $tag $op.Operation.Tags -}} +{{- $method := pySafeName (snakecase $op.Operation.OperationID) -}} +{{- if not (regexMatch "^[A-Za-z_][A-Za-z0-9_]*$" $method) -}} +{{- fail (printf "operation %q cannot name a Python method; rename it in the plugin's api.tsp" $op.Operation.OperationID) -}} +{{- end -}} +{{- if eq $method "transport" -}} +{{- fail (printf "operation %q renders the method %s, which is the name of the class's transport attribute; rename it in the plugin's api.tsp" $op.Operation.OperationID $method) -}} +{{- end -}} +{{- if has $method $methods -}} +{{- fail (printf "operation %q renders the method %s under tag %q, and another operation renders it too; rename one of them in the plugin's api.tsp" $op.Operation.OperationID $method $tag) -}} +{{- end -}} +{{- $methods = append $methods $method -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- $module := .Var "module" "unikraft_cloud_plugin_api" -}} +{{- /* One file per tag: a "plumbing" client class exposing every operation for + that resource, returning the raw response envelope. The class holds a + transport of the shape transport.py describes rather than extending the + SDK's, so the package depends on no SDK. An operation with several tags + is a method of each class. */ -}} +{{- range $tag := uniqueTags $ops }} +--- src/{{ $module }}/{{ snakecase $tag }}_gen.py +# Code generated by openapi-gen; DO NOT EDIT. +{{- /* quote, RawResponse, AsyncIterator, Any and Literal serve path parameters, + raw bytes, event streams, untyped values and inline enumerations; each is + imported only when used. */ -}} +{{- $quoted := false }} +{{- $raw := false }} +{{- $sse := false }} +{{- /* A path or header parameter that is not a string is spelled by a helper + rendered once per file, and only where one is needed. */ -}} +{{- $text := false }} +{{- /* Every type the methods render, to find Any in. */ -}} +{{- $types := "" }} +{{- range $op := $ops }} +{{- if has $tag $op.Operation.Tags }} +{{- if pathParameters $op }}{{ $quoted = true }}{{ end }} +{{- range pathParameters $op }}{{ if and .Schema (not (.Schema.Value.Type.Is "string")) }}{{ $text = true }}{{ end }}{{ $types = printf "%s %s" $types (paramToPyType .) }}{{ end }} +{{- range queryParameters $op }}{{ $types = printf "%s %s" $types (paramToPyType .) }}{{ end }} +{{- range $op.Operation.Parameters }}{{ with .Value }}{{ if eq .In "header" }}{{ if and .Schema (not (.Schema.Value.Type.Is "string")) }}{{ $text = true }}{{ end }}{{ $types = printf "%s %s" $types (paramToPyType .) }}{{ end }}{{ end }}{{ end }} +{{- with $op.PathItem }}{{ range .Parameters }}{{ with .Value }}{{ if eq .In "header" }}{{ if and .Schema (not (.Schema.Value.Type.Is "string")) }}{{ $text = true }}{{ end }}{{ $types = printf "%s %s" $types (paramToPyType .) }}{{ end }}{{ end }}{{ end }}{{ end }} +{{- with requestJSONSchema $op.Operation }}{{ $types = printf "%s %s" $types (pyTypeRef .) }}{{ end }} +{{- $success := list }} +{{- range $entry := sortedResponseCodes $op.Operation.Responses }}{{ if hasPrefix "2" $entry.Code }}{{ $success = append $success $entry }}{{ end }}{{ end }} +{{- if not $success }}{{ range $entry := sortedResponseCodes $op.Operation.Responses }}{{ if eq $entry.Code "default" }}{{ $success = append $success $entry }}{{ end }}{{ end }}{{ end }} +{{- range $entry := $success }}{{ with $entry.Ref }}{{ with .Value }} +{{- if index .Content "application/octet-stream" }}{{ $raw = true }}{{ end }} +{{- if index .Content "text/event-stream" }}{{ $sse = true }}{{ end }} +{{- range $media, $content := .Content }}{{ if or (eq $media "application/json") (eq $media "text/event-stream") }}{{ with $content.Schema }}{{ $types = printf "%s %s" $types (pyTypeRef .) }}{{ end }}{{ end }}{{ end }} +{{- end }}{{ end }}{{ end }} +{{- end }} +{{- end }} +{{- $any := regexMatch "\\bAny\\b" $types }} +{{- $literal := regexMatch "\\bLiteral\\b" $types }} + +from __future__ import annotations +{{- if or $sse $text $any $literal }} +{{ end }} +{{- if or $sse $text }} +from collections.abc import {{ if $sse }}AsyncIterator{{ end }}{{ if and $sse $text }}, {{ end }}{{ if $text }}Sequence{{ end }} +{{- end }} +{{- if or $any $literal }} +from typing import {{ if $any }}Any{{ end }}{{ if and $any $literal }}, {{ end }}{{ if $literal }}Literal{{ end }} +{{- end }} + +from typing_extensions import Unpack +{{- if $quoted }} +from urllib.parse import quote +{{- end }} +{{- /* A list sent as one comma-separated parameter is marked with a type the + SDK's transport recognises, which a package without the SDK cannot + name. No plugin declares one yet; refuse rather than render a query the + transport would encode wrongly. */ -}} +{{- range $op := $ops }} +{{- if has $tag $op.Operation.Tags }} +{{- range queryParameters $op }} +{{- if not .Schema }} +{{- fail (printf "%s %s: query parameter %q has no schema, so the templates cannot serialize it" $op.Method $op.Path .Name) }} +{{- end }} +{{- /* A query string has no null: the transport omits a None value and + spells a None list item as "None". */ -}} +{{- if .Schema.Value.Nullable }} +{{- fail (printf "%s %s: query parameter %q is nullable, but a query string has no null; drop nullable in the plugin's api.tsp" $op.Method $op.Path .Name) }} +{{- end }} +{{- /* QueryValue is a scalar or a list of scalars; an object has no style the + transport knows. */ -}} +{{- $qt := .Schema.Value.Type }} +{{- if $qt.Is "array" }} +{{- if not .Schema.Value.Items }} +{{- fail (printf "%s %s: query parameter %q is an array without items, so the templates cannot serialize it" $op.Method $op.Path .Name) }} +{{- end }} +{{- if .Schema.Value.Items.Value.Nullable }} +{{- fail (printf "%s %s: query parameter %q has nullable items, but a query string has no null; drop nullable in the plugin's api.tsp" $op.Method $op.Path .Name) }} +{{- end }} +{{- $qi := .Schema.Value.Items.Value.Type }} +{{- if not (or ($qi.Is "string") ($qi.Is "integer") ($qi.Is "number") ($qi.Is "boolean")) }} +{{- fail (printf "%s %s: query parameter %q is not an array of scalars, so the templates cannot serialize it" $op.Method $op.Path .Name) }} +{{- end }} +{{- else if not (or ($qt.Is "string") ($qt.Is "integer") ($qt.Is "number") ($qt.Is "boolean")) }} +{{- fail (printf "%s %s: query parameter %q is not a scalar or an array of scalars, so the templates cannot serialize it" $op.Method $op.Path .Name) }} +{{- end }} +{{- /* The transport sends form style with each list item repeated, and + percent-encodes every value. */ -}} +{{- if ne (.SerializationMethod).Style "form" }} +{{- fail (printf "%s %s: query parameter %q uses the %s style, which a plugin client cannot send; use form style in the plugin's api.tsp" $op.Method $op.Path .Name (.SerializationMethod).Style) }} +{{- end }} +{{- if .AllowReserved }} +{{- fail (printf "%s %s: query parameter %q allows reserved characters, which a plugin client percent-encodes; drop allowReserved in the plugin's api.tsp" $op.Method $op.Path .Name) }} +{{- end }} +{{- if and (.Schema.Value.Type.Is "array") (not (.SerializationMethod).Explode) }} +{{- fail (printf "parameter %q of %s is a comma-separated list, which a plugin client cannot send yet; explode it in the plugin's api.tsp" .Name $op.Operation.OperationID) }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} + +from . import models_gen as models +from .transport import CallOptions, {{ if $raw }}RawResponse, {{ end }}Transport +{{- if $text }} + + +def _text(value: str | int | float | bool | Sequence[str | int | float | bool]) -> str: + """Spell a parameter as the ``simple`` style of OpenAPI does.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, str | int | float): + return str(value) + return ",".join(_text(item) for item in value) +{{- end }} + + +class {{ pascalcase $tag }}Api: + """Low-level "plumbing" client for the `{{ $tag }}` resource. + + Methods mirror the OpenAPI specification and return the raw response + envelope. The idiomatic clients are built on top of this. + """ + + def __init__(self, transport: Transport) -> None: + #: The transport every request of this resource goes through. + self.transport = transport +{{- range $op := $ops }} +{{- if has $tag $op.Operation.Tags }} +{{ template "method" $op }} +{{- end }} +{{- end }} +{{ end }} +{{- define "method" -}} +{{- $op := .Operation -}} +{{- $path := .Path -}} +{{- /* A path parameter is one segment, whatever it contains: a name with a + `/`, `?` or `#` in it must not change the path or start a query, so each + is percent-encoded with nothing left unescaped. */ -}} +{{- range pathParameters . -}} +{{- $arg := pySafeName .Name -}} +{{- if not .Schema -}} +{{- fail (printf "%s %s: path parameter %q has no schema, so the templates cannot serialize it" $.Method $.Path .Name) -}} +{{- end -}} +{{- if .Schema.Value.Nullable -}} +{{- fail (printf "%s %s: path parameter %q is nullable, but a path segment has no null; drop nullable in the plugin's api.tsp" $.Method $.Path .Name) -}} +{{- end -}} +{{- $type := .Schema.Value.Type -}} +{{- if $type.Is "string" -}} +{{- else if or ($type.Is "integer") ($type.Is "number") ($type.Is "boolean") -}} +{{- $arg = printf "_text(%s)" $arg -}} +{{- else -}} +{{- fail (printf "%s %s: path parameter %q is not a scalar, so the templates cannot serialize it" $.Method $.Path .Name) -}} +{{- end -}} +{{- if ne (.SerializationMethod).Style "simple" -}} +{{- fail (printf "%s %s: path parameter %q uses the %s style, which the templates render as simple; use simple style in the plugin's api.tsp" $.Method $.Path .Name (.SerializationMethod).Style) -}} +{{- end -}} +{{- $path = replace (printf "{%s}" .Name) (printf "{quote(%s, safe='')}" $arg) $path -}} +{{- end -}} +{{- /* Next to a 2xx response, `default` describes the errors, so it counts as + the success only when no 2xx exists. */ -}} +{{- $success := list -}} +{{- range $entry := sortedResponseCodes $op.Responses -}} +{{- if hasPrefix "2" $entry.Code -}}{{- $success = append $success $entry -}}{{- end -}} +{{- end -}} +{{- if not $success -}} +{{- range $entry := sortedResponseCodes $op.Responses -}} +{{- if eq $entry.Code "default" -}}{{- $success = append $success $entry -}}{{- end -}} +{{- end -}} +{{- end -}} +{{- /* A method makes one transport call, which decodes one media type into + one model. A success response the templates cannot return, one without + a schema, several media types, several models, or a payload next to no + payload are refused rather than discarded. */ -}} +{{- $kinds := list -}} +{{- $rets := list -}} +{{- $none := false -}} +{{- range $entry := $success -}} +{{- with $entry.Ref -}}{{- with .Value -}} +{{- if not .Content -}}{{- $none = true -}}{{- end -}} +{{- range $media, $content := .Content -}} +{{- if or (eq $media "application/json") (eq $media "text/event-stream") -}} +{{- if not $content.Schema -}} +{{- fail (printf "%s %s: the %s response is %s without a schema, so the templates have no model to parse it into" $.Method $.Path $entry.Code $media) -}} +{{- end -}} +{{- with $content.Schema.Value -}} +{{- if or .Enum .Nullable (and (or .OneOf .AnyOf) (not .Properties)) -}} +{{- fail (printf "%s %s: the %s response is nullable, a union or an enumeration, which is no type the transport decodes into; wrap it in an object that is never null" $.Method $.Path $entry.Code) -}} +{{- end -}} +{{- end -}} +{{- $kinds = append $kinds $media -}} +{{- $rets = append $rets (qualifyModels "models" (pyTypeRef $content.Schema)) -}} +{{- else if eq $media "application/octet-stream" -}} +{{- $kinds = append $kinds $media -}} +{{- else -}} +{{- fail (printf "%s %s: the %s response is %s, which the templates cannot return" $.Method $.Path $entry.Code $media) -}} +{{- end -}} +{{- end -}} +{{- end -}}{{- end -}} +{{- end -}} +{{- $kinds = uniq $kinds -}} +{{- $rets = uniq $rets -}} +{{- if gt (len $kinds) 1 -}} +{{- fail (printf "%s %s: the success responses offer %s, but a generated method decodes one media type only" $.Method $.Path (join " and " $kinds)) -}} +{{- end -}} +{{- if gt (len $rets) 1 -}} +{{- fail (printf "%s %s: the success responses are %s, but a generated method parses one model only" $.Method $.Path (join " and " $rets)) -}} +{{- end -}} +{{- if and $none $kinds -}} +{{- fail (printf "%s %s: the success responses carry a payload and none, but a generated method decodes one shape only" $.Method $.Path) -}} +{{- end -}} +{{- /* A server-sent event stream yields each event parsed as its model, + through the transport's stream(). An `application/octet-stream` success + is returned verbatim and unenveloped as a RawResponse, because its + status and headers (a 206 and its Content-Range) carry meaning. */ -}} +{{- $sse := has "text/event-stream" $kinds -}} +{{- $raw := has "application/octet-stream" $kinds -}} +{{- $ret := "" -}} +{{- with $rets -}}{{- $ret = first . -}}{{- end -}} +{{- /* Header parameters become keyword arguments and are merged over the + caller's own headers, so `Range` is as easy to pass as a query filter. + A path item's header applies to each of its operations, unless the + operation declares one of the same name. */ -}} +{{- $hdrs := list -}} +{{- $hdrNames := list -}} +{{- range $op.Parameters -}}{{- with .Value -}}{{- if eq .In "header" -}}{{- $hdrs = append $hdrs . -}}{{- $hdrNames = append $hdrNames .Name -}}{{- end -}}{{- end -}}{{- end -}} +{{- with .PathItem -}}{{- range .Parameters -}}{{- with .Value -}}{{- if and (eq .In "header") (not (has .Name $hdrNames)) -}}{{- $hdrs = append $hdrs . -}}{{- end -}}{{- end -}}{{- end -}}{{- end -}} +{{- /* CallOptions has no cookie option, so a cookie parameter has no way to + travel. */ -}} +{{- range $op.Parameters -}}{{- with .Value -}}{{- if eq .In "cookie" -}} +{{- fail (printf "%s %s: parameter %q is a cookie, which the templates cannot send" $.Method $.Path .Name) -}} +{{- end -}}{{- end -}}{{- end -}} +{{- with .PathItem -}}{{- range .Parameters -}}{{- with .Value -}}{{- if eq .In "cookie" -}} +{{- fail (printf "%s %s: parameter %q is a cookie, which the templates cannot send" $.Method $.Path .Name) -}} +{{- end -}}{{- end -}}{{- end -}}{{- end -}} +{{- $bodyRef := requestJSONSchema $op -}} +{{- if and $op.RequestBody (not $bodyRef) -}} +{{- fail (printf "%s %s: the request body is not application/json, and the templates send JSON only" $.Method $.Path) -}} +{{- end -}} +{{- $bodyReq := requestBodyRequired $op -}} +{{- $query := queryParameters . -}} +{{- /* A body is rendered as the specification declares it, on any method: + the plumbing mirrors the plugin's API and drops nothing. */ -}} +{{- /* Every parameter is an argument of one signature, so each must render a + valid identifier that no other parameter, the body, self, options, a key + of CallOptions or a name the method body uses takes. */ -}} +{{- $idents := list "self" "options" "headers" "base_url" "timeout" "dict" "models" "quote" "request_headers" "str" "_text" -}} +{{- if $bodyRef -}}{{- $idents = append $idents "body" -}}{{- end -}} +{{- range pathParameters . -}} +{{- $ident := pySafeName .Name -}} +{{- if not (regexMatch "^[A-Za-z_][A-Za-z0-9_]*$" $ident) -}} +{{- fail (printf "%s %s: parameter %q cannot name a Python argument; rename it in the plugin's api.tsp" $.Method $.Path .Name) -}} +{{- end -}} +{{- if has $ident $idents -}} +{{- fail (printf "%s %s: parameter %q renders the argument %s, which another parameter or the method itself takes; rename it in the plugin's api.tsp" $.Method $.Path .Name $ident) -}} +{{- end -}} +{{- $idents = append $idents $ident -}} +{{- end -}} +{{- range $query -}} +{{- $ident := pySafeName .Name -}} +{{- if not (regexMatch "^[A-Za-z_][A-Za-z0-9_]*$" $ident) -}} +{{- fail (printf "%s %s: parameter %q cannot name a Python argument; rename it in the plugin's api.tsp" $.Method $.Path .Name) -}} +{{- end -}} +{{- if has $ident $idents -}} +{{- fail (printf "%s %s: parameter %q renders the argument %s, which another parameter or the method itself takes; rename it in the plugin's api.tsp" $.Method $.Path .Name $ident) -}} +{{- end -}} +{{- $idents = append $idents $ident -}} +{{- end -}} +{{- range $hdrs -}} +{{- $ident := pySafeName (snakecase .Name) -}} +{{- if not (regexMatch "^[A-Za-z_][A-Za-z0-9_]*$" $ident) -}} +{{- fail (printf "%s %s: parameter %q cannot name a Python argument; rename it in the plugin's api.tsp" $.Method $.Path .Name) -}} +{{- end -}} +{{- if has $ident $idents -}} +{{- fail (printf "%s %s: parameter %q renders the argument %s, which another parameter or the method itself takes; rename it in the plugin's api.tsp" $.Method $.Path .Name $ident) -}} +{{- end -}} +{{- $idents = append $idents $ident -}} +{{- end }} + {{ if $sse }}def{{ else }}async def{{ end }} {{ pySafeName (snakecase $op.OperationID) }}( + self, + {{- range pathParameters . }} + {{ pySafeName .Name }}: {{ qualifyModels "models" (paramToPyType .) }}, + {{- end }} + {{- /* A bare `*` may not stand right before `**options`. */ -}} + {{- if or $query $hdrs $bodyRef }} + *, + {{- end }} + {{- range $query }} + {{ pySafeName .Name }}: {{ qualifyModels "models" (paramToPyType .) }}{{ if not .Required }} | None = None{{ end }}, + {{- end }} + {{- range $hdrs }} + {{ pySafeName (snakecase .Name) }}: {{ qualifyModels "models" (paramToPyType .) }}{{ if not .Required }} | None = None{{ end }}, + {{- end }} + {{- if $bodyRef }} + body: {{ qualifyModels "models" (pyTypeRef $bodyRef) }}{{ if not $bodyReq }} | None = None{{ end }}, + {{- end }} + **options: Unpack[CallOptions], + ) -> {{ if $sse }}AsyncIterator[{{ $ret }}]{{ else if $raw }}RawResponse{{ else if eq $ret "" }}None{{ else }}{{ $ret }}{{ end }}: +{{- with $op.Summary }} + {{ pyDoc . " " }} +{{- else }} +{{- with $op.Description }} + {{ pyDoc . " " }} +{{- end }} +{{- end }} +{{- if $hdrs }} + request_headers: dict[str, str] = dict(options.get("headers") or {}) + {{- range $hdrs }} + {{- $value := pySafeName (snakecase .Name) }} + {{- if not .Schema }} + {{- fail (printf "%s %s: header parameter %q has no schema, so the templates cannot serialize it" $.Method $.Path .Name) }} + {{- end }} + {{- /* A header has no null: a None value is left out, and a None item + has no spelling. */ -}} + {{- if .Schema.Value.Nullable }} + {{- fail (printf "%s %s: header parameter %q is nullable, but a header has no null; drop nullable in the plugin's api.tsp" $.Method $.Path .Name) }} + {{- end }} + {{- $type := .Schema.Value.Type }} + {{- if $type.Is "string" }} + {{- else if or ($type.Is "integer") ($type.Is "number") ($type.Is "boolean") }} + {{- $value = printf "_text(%s)" $value }} + {{- else if and ($type.Is "array") .Schema.Value.Items }} + {{- if .Schema.Value.Items.Value.Nullable }} + {{- fail (printf "%s %s: header parameter %q has nullable items, but a header has no null; drop nullable in the plugin's api.tsp" $.Method $.Path .Name) }} + {{- end }} + {{- $items := .Schema.Value.Items.Value.Type }} + {{- if not (or ($items.Is "string") ($items.Is "integer") ($items.Is "number") ($items.Is "boolean")) }} + {{- fail (printf "%s %s: header parameter %q is not an array of scalars, so the templates cannot serialize it" $.Method $.Path .Name) }} + {{- end }} + {{- $value = printf "_text(%s)" $value }} + {{- else }} + {{- fail (printf "%s %s: header parameter %q is not a scalar or an array of scalars, so the templates cannot serialize it" $.Method $.Path .Name) }} + {{- end }} + if {{ pySafeName (snakecase .Name) }} is not None: + request_headers[{{ printf "%q" .Name }}] = {{ $value }} + {{- end }} + options["headers"] = request_headers +{{- end }} + return {{ if not $sse }}await {{ end }}self.transport.{{ if $sse }}stream{{ else if $raw }}request_bytes{{ else if eq $ret "" }}request_no_content{{ else }}request{{ end }}( + {{ if or $sse (ne $ret "") }}{{ $ret }}, + {{ end }}method="{{ .Method }}", + {{ if pathParameters . }}path=f"{{ $path }}"{{ else }}path="{{ $path }}"{{ end }}, + {{- if $query }} + query={ + {{- /* A parameter name is free-form in OpenAPI, so it is + rendered as an escaped literal. */ -}} + {{- range $query }} + {{ printf "%q" .Name }}: {{ pySafeName .Name }}, + {{- end }} + }, + {{- end }} + {{- if $bodyRef }} + body=body, + {{- end }} + **options, + ) +{{ end -}} diff --git a/python/tools/sdkgen/templates/transport.py.tmpl b/python/tools/sdkgen/templates/transport.py.tmpl new file mode 100644 index 0000000..289c196 --- /dev/null +++ b/python/tools/sdkgen/templates/transport.py.tmpl @@ -0,0 +1,127 @@ +{{- $module := .Var "module" "unikraft_cloud_plugin_api" -}} +--- src/{{ $module }}/transport.py +# Code generated by openapi-gen; DO NOT EDIT. +# +# The contract between this package and the transport that carries its +# requests. `ApiClient` in `unikraft_cloud.core.http` satisfies it, and so does +# any object with these four methods, because the types are protocols. This +# module imports no SDK, so the package depends on none. +# +# This is the only copy of the contract. The SDK's `ApiClient` satisfies it by +# shape, which the SDK's own type check confirms wherever it hands one over. +# Widening is safe: a new optional parameter, a wider input type. A rename, a +# new required parameter, or a changed return type must reach `ApiClient` in +# the SDK as well, and every plugin package must be republished. + +from __future__ import annotations + +from collections.abc import AsyncIterator, Mapping, Sequence +from typing import Any, Protocol, TypedDict, TypeVar + +from typing_extensions import Unpack + +__all__ = ["CallOptions", "QueryValue", "RawResponse", "Transport"] + +T = TypeVar("T") + +#: A value that can be serialised into a query string. +QueryValue = str | int | float | bool | Sequence[str | int | float | bool] | None + + +class CallOptions(TypedDict, total=False): + """What a caller may set on one call, over the transport's own defaults. + + A key that is absent leaves the transport's default in place, so + ``timeout=None`` lifts the timeout for one call and no ``timeout`` at all + keeps the client's. A generated method passes these through untouched. + """ + + #: Extra headers, merged over the transport's defaults for this call. + headers: Mapping[str, str] | None + #: The base URL to send this call to, e.g. another metro. + base_url: str | None + #: Seconds to wait for this call, or ``None`` to wait without limit. + timeout: float | None + + +class RawResponse(Protocol): + """The payload of an operation that answers with raw bytes, unenveloped. + + The status and headers travel with the bytes because they carry meaning + of their own there: a ``206`` answers a ``Range`` request, and its + ``Content-Range`` header says which bytes these are. + """ + + @property + def content(self) -> bytes: + """The response body, untouched.""" + ... + + @property + def status(self) -> int: + """The HTTP status code.""" + ... + + @property + def headers(self) -> Mapping[str, str]: + """The response headers, case-insensitive.""" + ... + + +class Transport(Protocol): + """What a generated class needs from the SDK: one method per response shape. + + Every method takes the request as keyword arguments, applies the + :class:`CallOptions` it is given over its own defaults, and raises the + SDK's error on a failure. + """ + + async def request( + self, + model: type[T], + *, + method: str, + path: str, + query: Mapping[str, QueryValue] | None = None, + body: Any = None, + **options: Unpack[CallOptions], + ) -> T: + """Perform a request and return its response envelope parsed as ``model``.""" + ... + + async def request_no_content( + self, + *, + method: str, + path: str, + query: Mapping[str, QueryValue] | None = None, + body: Any = None, + **options: Unpack[CallOptions], + ) -> None: + """Perform a request whose response carries no payload worth parsing.""" + ... + + async def request_bytes( + self, + *, + method: str, + path: str, + query: Mapping[str, QueryValue] | None = None, + body: Any = None, + **options: Unpack[CallOptions], + ) -> RawResponse: + """Perform a request whose success response is a raw byte payload.""" + ... + + def stream( + self, + model: type[T], + *, + method: str, + path: str, + query: Mapping[str, QueryValue] | None = None, + body: Any = None, + **options: Unpack[CallOptions], + ) -> AsyncIterator[T]: + """Yield each event of a ``text/event-stream`` response, parsed as ``model``.""" + ...