From 4b2d9811c46e2bda55618398d6029dc4f040a2ce Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Tue, 15 Sep 2026 18:11:40 +0100 Subject: [PATCH 01/36] feat(core): Return a raw response unenveloped The transport decoded every answer as the JSON envelope, but a few operations, such as a file download or a stream of output, answer with the bytes themselves, and a 206 means nothing without the Content-Range that came with it. _request_bytes returns the payload as a RawResponse with its status and headers, which parse the Content-Range into byte_range and total_size, and _stream_bytes yields it as it arrives. A failure is raised from the envelope as for any other operation. Signed-off-by: Daniel Vallance --- src/unikraft_cloud/__init__.py | 2 + src/unikraft_cloud/core/http.py | 141 ++++++++++++++++++++++++++++++++ tests/test_transport.py | 104 +++++++++++++++++++++++ 3 files changed, 247 insertions(+) diff --git a/src/unikraft_cloud/__init__.py b/src/unikraft_cloud/__init__.py index a17be9a..bad5a53 100644 --- a/src/unikraft_cloud/__init__.py +++ b/src/unikraft_cloud/__init__.py @@ -39,6 +39,7 @@ ApiClient, ApiClientConfig, CallOptions, + RawResponse, TimeoutOption, Unset, ) @@ -160,6 +161,7 @@ "PlatformApi", "Quotas", "RateLimitError", + "RawResponse", "Ref", "RefLike", "Remove", diff --git a/src/unikraft_cloud/core/http.py b/src/unikraft_cloud/core/http.py index b15170c..a16a723 100644 --- a/src/unikraft_cloud/core/http.py +++ b/src/unikraft_cloud/core/http.py @@ -33,6 +33,7 @@ "CallOptions", "CommaSeparated", "QueryValue", + "RawResponse", "TimeoutOption", "Unset", "comma_separated", @@ -124,6 +125,67 @@ def normalise_base_url(base_url: str) -> str: return _API_VERSION.sub("", base_url.rstrip("/")) +@dataclass(frozen=True) +class RawResponse: + """The payload of an operation that answers with raw bytes, unenveloped. + + A few operations (a file download, a command's output stream) return their + payload verbatim rather than inside the JSON envelope. 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 and how many there are in total. + """ + + #: The response body, untouched. + content: bytes + #: The HTTP status code: ``200`` for a whole payload, ``206`` for a part. + status: int + #: The response headers, case-insensitive. + headers: httpx.Headers + + @property + def content_range(self) -> str | None: + """The ``Content-Range`` header of a partial response, verbatim, if any.""" + value: str | None = self.headers.get("content-range") + return value + + @property + def byte_range(self) -> tuple[int, int] | None: + """The first and last byte this payload covers, when ``Content-Range`` names them. + + A ``206`` to a ``Range`` request says ``bytes 0-99/500``; this is + ``(0, 99)``. A whole payload, or an unsatisfiable range, has none. + """ + parsed = _parse_content_range(self.content_range) + return parsed[0] if parsed else None + + @property + def total_size(self) -> int | None: + """How many bytes the whole payload has, when ``Content-Range`` says. + + ``bytes 0-99/500`` and ``bytes */500`` both say ``500``; a ``*`` in its + place says the server does not know. + """ + parsed = _parse_content_range(self.content_range) + return parsed[1] if parsed else None + + +#: ``bytes -/``, with ``*`` for a range or total not known. +_CONTENT_RANGE = re.compile(r"^\s*bytes\s+(?:(\d+)-(\d+)|\*)/(\d+|\*)\s*$", re.IGNORECASE) + + +def _parse_content_range(value: str | None) -> tuple[tuple[int, int] | None, int | None] | None: + """Split a ``Content-Range`` header into its range and its total, if it parses.""" + if value is None: + return None + match = _CONTENT_RANGE.match(value) + if match is None: + return None + first, last, total = match.groups() + byte_range = (int(first), int(last)) if first is not None and last is not None else None + return byte_range, None if total == "*" else int(total) + + #: Separator between two server-sent events (``\n\n``, ``\r\n\r\n`` or ``\r\r``). _SSE_EVENT_BOUNDARY = re.compile(r"\r\n\r\n|\n\n|\r\r") @@ -443,6 +505,85 @@ async def _request_no_content( response = await self._send(request, stream=False) self._raise_for_status(response, self._parse_json(response, request.url)) + async def _request_bytes( + self, + *, + method: str, + path: str, + query: Mapping[str, QueryValue] | None = None, + body: Any = None, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> RawResponse: + """Perform a request whose success response is a raw byte payload. + + Only a 2xx body comes back untouched. A failure still arrives as the JSON + envelope (or an empty body), so it is parsed and raised exactly as it is + for an enveloped operation. + """ + request = self._build_request( + method=method, + path=path, + accept="application/octet-stream", + query=query, + body=body, + headers=headers, + base_url=base_url, + timeout=timeout, + ) + response = await self._send(request, stream=False) + if not response.is_success: + self._raise_for_status(response, self._parse_json(response, request.url)) + return RawResponse( + content=response.content, status=response.status_code, headers=response.headers + ) + + async def _stream_bytes( + self, + *, + method: str, + path: str, + query: Mapping[str, QueryValue] | None = None, + body: Any = None, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + chunk_size: int | None = None, + ) -> AsyncIterator[bytes]: + """Yield a raw byte payload as it arrives, without holding it all in memory. + + The iterator ends when the server closes the body. Close it to stop early + and release the connection at once, with ``contextlib.aclosing`` or its + ``aclose()``; a ``break`` alone leaves that to garbage collection. A failure + is raised before the first chunk, exactly as :meth:`_request_bytes` raises it. + """ + request = self._build_request( + method=method, + path=path, + accept="application/octet-stream", + query=query, + body=body, + headers=headers, + base_url=base_url, + timeout=timeout, + ) + response = await self._send(request, stream=True) + try: + if not response.is_success: + await response.aread() + self._raise_for_status(response, self._parse_json(response, request.url)) + async for chunk in response.aiter_bytes(chunk_size): + yield chunk + except httpx.HTTPError as cause: + # The headers arrived, so `_send` let this through; the body can still + # fail mid-stream, and that is a network error like any other. + raise self._network_error(request, cause) from cause + finally: + # Close on early return or raise so the connection is not left + # dangling mid-stream. + await response.aclose() + async def _stream( self, model: type[T], diff --git a/tests/test_transport.py b/tests/test_transport.py index 7f3e35e..3dc9b18 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -7,6 +7,7 @@ import json import warnings +from collections.abc import AsyncIterator from typing import Any import httpx @@ -20,6 +21,7 @@ AuthenticationError, NotFoundError, RateLimitError, + RawResponse, ServerError, UnikraftCloudError, UnikraftCloudWarning, @@ -62,6 +64,12 @@ async def discard(self) -> None: def events(self) -> Any: return self._stream(dict, method="GET", path="/v1/events") + async def download(self, **kwargs: Any) -> RawResponse: + return await self._request_bytes(method="GET", path="/v1/blob", **kwargs) + + def chunks(self, **kwargs: Any) -> Any: + return self._stream_bytes(method="GET", path="/v1/blob", **kwargs) + def client(recorder: Recorder, **config: Any) -> Client: return Client( @@ -342,6 +350,102 @@ async def test_still_raises_on_a_redirect(self) -> None: assert caught.value.status == 302 +class TestRawBytes: + def _blob(self, status: int = 200, body: bytes = b"hello", **headers: str) -> Recorder: + return Recorder( + lambda request: httpx.Response( + status, + content=body, + headers={"content-type": "application/octet-stream", **headers}, + ) + ) + + async def test_returns_the_payload_with_its_status_and_headers(self) -> None: + recorder = self._blob() + async with client(recorder) as api: + blob = await api.download() + assert isinstance(blob, RawResponse) + assert (blob.content, blob.status) == (b"hello", 200) + assert blob.headers["content-type"] == "application/octet-stream" + assert blob.content_range is None + + async def test_asks_for_bytes_not_json(self) -> None: + recorder = self._blob() + async with client(recorder) as api: + await api.download() + assert recorder.calls[0].headers["accept"] == "application/octet-stream" + + async def test_a_partial_response_carries_its_content_range(self) -> None: + recorder = self._blob(206, b"ell", **{"content-range": "bytes 1-3/5"}) + async with client(recorder) as api: + blob = await api.download() + assert (blob.status, blob.content, blob.content_range) == (206, b"ell", "bytes 1-3/5") + + async def test_a_failure_raises_the_enveloped_error(self) -> None: + recorder = queued( + [(404, {"status": "error", "message": "no such file", "errors": [{"status": 404}]})] + ) + async with client(recorder) as api: + with pytest.raises(NotFoundError) as caught: + await api.download() + assert "no such file" in str(caught.value) + + async def test_a_failure_with_an_empty_body_still_reports_its_status(self) -> None: + recorder = Recorder(lambda request: httpx.Response(410)) + async with client(recorder) as api: + with pytest.raises(UnikraftCloudError) as caught: + await api.download() + assert caught.value.status == 410 + + async def test_streams_the_payload_in_chunks(self) -> None: + recorder = self._blob(body=b"abcdef") + async with client(recorder) as api: + chunks = [chunk async for chunk in api.chunks(chunk_size=4)] + assert chunks == [b"abcd", b"ef"] + assert recorder.calls[0].headers["accept"] == "application/octet-stream" + + async def test_a_streamed_failure_raises_before_the_first_chunk(self) -> None: + recorder = queued([(403, {"status": "error", "message": "denied"})]) + async with client(recorder) as api: + with pytest.raises(AuthenticationError): + [chunk async for chunk in api.chunks()] + + async def test_a_stream_that_breaks_mid_body_is_a_network_error(self) -> None: + class Breaks(httpx.AsyncByteStream): + async def __aiter__(self) -> AsyncIterator[bytes]: + yield b"he" + raise httpx.ReadError("connection reset mid-body") + + recorder = Recorder( + lambda request: httpx.Response( + 200, stream=Breaks(), headers={"content-type": "application/octet-stream"} + ) + ) + async with client(recorder) as api: + got: list[bytes] = [] + with pytest.raises(UnikraftCloudError) as caught: + async for chunk in api.chunks(): + got.append(chunk) + assert got == [b"he"] + assert caught.value.kind == "network" + assert isinstance(caught.value.__cause__, httpx.ReadError) + + def test_a_content_range_is_parsed_as_well_as_kept(self) -> None: + def raw(**headers: str) -> RawResponse: + return RawResponse(content=b"", status=206, headers=httpx.Headers(headers)) + + part = raw(**{"content-range": "bytes 1-4/5"}) + assert (part.content_range, part.byte_range, part.total_size) == ("bytes 1-4/5", (1, 4), 5) + unsatisfiable = raw(**{"content-range": "bytes */5"}) + assert (unsatisfiable.byte_range, unsatisfiable.total_size) == (None, 5) + unknown_total = raw(**{"content-range": "bytes 0-1/*"}) + assert (unknown_total.byte_range, unknown_total.total_size) == ((0, 1), None) + odd = raw(**{"content-range": "items 1-4/5"}) + assert (odd.content_range, odd.byte_range, odd.total_size) == ("items 1-4/5", None, None) + whole = raw() + assert (whole.content_range, whole.byte_range, whole.total_size) == (None, None, None) + + class TestEventStreams: def _stream(self, body: bytes) -> Recorder: return Recorder( From b054a7e185760a31ffca44a6a5f6cbbc4f6f4e55 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Tue, 15 Sep 2026 18:11:50 +0100 Subject: [PATCH 02/36] feat(templates): Render raw payloads and headers An operation whose success response was octet-stream was typed as answering nothing, so its payload was lost, and a header parameter such as Range had no argument of its own. Such an operation returns a RawResponse through the raw transport, and a header parameter is a keyword argument merged over the caller's headers. Each spec has a Makefile target of its own, and the templates take the transport's package from a variable. Signed-off-by: Daniel Vallance --- Makefile | 21 +++++++++----- templates/resources.tmpl | 60 +++++++++++++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index 84ae3ad..3432e2f 100644 --- a/Makefile +++ b/Makefile @@ -26,26 +26,33 @@ OUTPUT ?= ./src/unikraft_cloud/api .PHONY: all all: generate test +# Each per-spec target clears its generated modules first, so a tag the spec +# drops leaves no stale client behind. The hand-written `__init__.py` files stay. .PHONY: generate -generate: ## Regenerate both plumbing clients from the OpenAPI specs. - # Generated modules are cleared first, so a tag the spec drops leaves no - # stale client behind. The hand-written `__init__.py` files stay. - rm -f $(OUTPUT)/platform/*_gen.py $(OUTPUT)/controlplane/*_gen.py - # Namespaced schema names (`Instances.Instance`) are not Python identifiers, - # so the namespace is stripped from them. +generate: generate-platform generate-controlplane ## Regenerate every plumbing client from the OpenAPI specs. + $(MAKE) fmt + +# Namespaced schema names (`Instances.Instance`) are not Python identifiers, so +# `--namespace-flatten strip` removes the namespace. +.PHONY: generate-platform +generate-platform: ## Regenerate the platform API plumbing. + rm -f $(OUTPUT)/platform/*_gen.py $(OPENAPI_GEN) \ -i $(PLATFORM_SPEC) \ -o $(OUTPUT)/platform \ -t $(TEMPLATES) \ --namespace-flatten strip \ -v package=api + +.PHONY: generate-controlplane +generate-controlplane: ## Regenerate the control-plane API plumbing. + rm -f $(OUTPUT)/controlplane/*_gen.py $(OPENAPI_GEN) \ -i $(CONTROLPLANE_SPEC) \ -o $(OUTPUT)/controlplane \ -t $(TEMPLATES) \ --namespace-flatten strip \ -v package=api - $(MAKE) fmt .PHONY: fmt fmt: ## Format the generated (and all) sources. diff --git a/templates/resources.tmpl b/templates/resources.tmpl index 9f1e7a8..ee147c5 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -1,4 +1,8 @@ {{- $ops := .Operations -}} +{{- /* Where the shared transport lives, relative to the generated package. The + platform and control-plane packages sit at api//, which the default + fits; a package elsewhere passes `-v core=`. */ -}} +{{- $core := .Var "core" "...core" -}} {{- /* One file per tag: a "plumbing" client class exposing every operation for that resource, returning the raw response envelope. */ -}} {{- range $tag := uniqueTags $ops }} @@ -9,11 +13,24 @@ from __future__ import annotations from collections.abc import AsyncIterator, Mapping from typing import Any -{{- /* Path parameters are percent-encoded on the way out, so only the clients - that have any need the import. */ -}} +{{- /* Path parameters are percent-encoded on the way out, and a raw-bytes + response has a payload type of its own, so only the clients that have + either need the import. */ -}} {{- $quoted := false }} +{{- $raw := false }} {{- range $op := $ops }} -{{- if and (eq (first $op.Operation.Tags) $tag) (pathParameters $op) }}{{ $quoted = true }}{{ end }} +{{- if eq (first $op.Operation.Tags) $tag }} +{{- if pathParameters $op }}{{ $quoted = true }}{{ end }} +{{- $seen := false }} +{{- range $entry := sortedResponseCodes $op.Operation.Responses }} +{{- if and (not $seen) (or (hasPrefix "2" $entry.Code) (eq $entry.Code "default")) }} +{{- $seen = true }} +{{- with $entry.Ref }}{{ with .Value }} +{{- if and (index .Content "application/octet-stream") (not (index .Content "application/json")) }}{{ $raw = true }}{{ end }} +{{- end }}{{ end }} +{{- end }} +{{- end }} +{{- end }} {{- end }} {{- if $quoted }} from urllib.parse import quote @@ -28,7 +45,7 @@ from urllib.parse import quote {{- end }} {{- end }} -from ...core.http import UNSET, ApiClient, TimeoutOption{{ if $csv }}, comma_separated{{ end }} +from {{ $core }}.http import UNSET, ApiClient, {{ if $raw }}RawResponse, {{ end }}TimeoutOption{{ if $csv }}, comma_separated{{ end }} from . import models_gen as models @@ -68,6 +85,25 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- else -}} {{- $sse = false -}} {{- end -}} +{{- /* An operation whose success response is `application/octet-stream` returns + its payload verbatim, unenveloped: a file download, a command's output + stream. It is fetched via ApiClient._request_bytes() and returned as a + RawResponse, because the status and headers (a 206 and its + Content-Range) carry meaning there. */ -}} +{{- $raw := false -}} +{{- $seen := false -}} +{{- range $entry := sortedResponseCodes $op.Responses -}} +{{- if and (not $seen) (or (hasPrefix "2" $entry.Code) (eq $entry.Code "default")) -}} +{{- $seen = true -}} +{{- with $entry.Ref -}}{{- with .Value -}} +{{- if and (index .Content "application/octet-stream") (not (index .Content "application/json")) -}}{{- $raw = true -}}{{- end -}} +{{- end -}}{{- end -}} +{{- end -}} +{{- 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. */ -}} +{{- $hdrs := list -}} +{{- range $op.Parameters -}}{{- with .Value -}}{{- if eq .In "header" -}}{{- $hdrs = append $hdrs . -}}{{- end -}}{{- end -}}{{- end -}} {{- $bodyRef := requestJSONSchema $op -}} {{- $bodyReq := requestBodyRequired $op -}} {{- $query := queryParameters . -}} @@ -123,13 +159,16 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- range $query }} {{ pySafeName .Name }}: {{ qualifyModels "models" (paramToPyType .) }} | None = None, {{- end }} + {{- range $hdrs }} + {{ pySafeName (snakecase .Name) }}: {{ qualifyModels "models" (paramToPyType .) }} | None = None, + {{- end }} {{- if $bodyRef }} body: {{ qualifyModels "models" (pyTypeRef $bodyRef) }}{{ if not $bodyReq }} | None = None{{ end }}, {{- end }} headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, - ) -> {{ if $sse }}AsyncIterator[{{ $ret }}]{{ else if eq $ret "" }}None{{ else }}{{ $ret }}{{ end }}: + ) -> {{ if $sse }}AsyncIterator[{{ $ret }}]{{ else if $raw }}RawResponse{{ else if eq $ret "" }}None{{ else }}{{ $ret }}{{ end }}: {{- with $op.Summary }} {{ pyDoc . " " }} {{- else }} @@ -137,7 +176,14 @@ class {{ pascalcase $tag }}Api(ApiClient): {{ pyDoc . " " }} {{- end }} {{- end }} - return {{ if not $sse }}await {{ end }}self.{{ if $sse }}_stream{{ else if eq $ret "" }}_request_no_content{{ else }}_request{{ end }}( +{{- if $hdrs }} + request_headers: dict[str, str] = dict(headers or {}) + {{- range $hdrs }} + if {{ pySafeName (snakecase .Name) }} is not None: + request_headers["{{ .Name }}"] = {{ pySafeName (snakecase .Name) }} + {{- end }} +{{- end }} + return {{ if not $sse }}await {{ end }}self.{{ 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 }}, @@ -157,7 +203,7 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- if $bodyRef }} body=body, {{- end }} - headers=headers, + headers={{ if $hdrs }}request_headers{{ else }}headers{{ end }}, base_url=base_url, timeout=timeout, ) From 27357b7c04b3e2a1d7da3083136394b83d25aae7 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:30:55 +0100 Subject: [PATCH 03/36] feat(core): Expose the transport surface The transport methods of ApiClient were private, so a client outside this package, such as a generated plugin client that takes a transport rather than extending one, had nothing public to call and would have leaned on names it does not own. request, request_no_content, request_bytes, stream_bytes and stream are the public names, and config the configuration a client sends with. The template emits them, and the generated plumbing is regenerated, which changes only the names it calls. Signed-off-by: Daniel Vallance --- .../api/controlplane/auth_gen.py | 6 +- .../api/controlplane/images_gen.py | 2 +- .../api/controlplane/metros_gen.py | 2 +- .../node_activation_service_gen.py | 2 +- .../api/controlplane/node_service_gen.py | 22 +++--- .../api/platform/autoscale_gen.py | 22 +++--- .../api/platform/certificates_gen.py | 14 ++-- src/unikraft_cloud/api/platform/images_gen.py | 4 +- .../api/platform/instances_gen.py | 74 +++++++++---------- src/unikraft_cloud/api/platform/node_gen.py | 2 +- .../api/platform/service_groups_gen.py | 14 ++-- src/unikraft_cloud/api/platform/users_gen.py | 6 +- .../api/platform/volumes_gen.py | 40 +++++----- src/unikraft_cloud/core/http.py | 33 ++++++--- templates/resources.tmpl | 2 +- tests/test_transport.py | 12 +-- 16 files changed, 134 insertions(+), 123 deletions(-) diff --git a/src/unikraft_cloud/api/controlplane/auth_gen.py b/src/unikraft_cloud/api/controlplane/auth_gen.py index 14eaaba..000650f 100644 --- a/src/unikraft_cloud/api/controlplane/auth_gen.py +++ b/src/unikraft_cloud/api/controlplane/auth_gen.py @@ -25,7 +25,7 @@ def check_authorization( timeout: TimeoutOption = UNSET, ) -> AsyncIterator[models.CheckAuthorizationResponse]: """Check Authorization of Sign-In Request""" - return self._stream( + return self.stream( models.CheckAuthorizationResponse, method="POST", path="/v1/auth/check", @@ -43,7 +43,7 @@ async def get_authorization( timeout: TimeoutOption = UNSET, ) -> models.GetAuthorizationResponse: """Get Authorization Status""" - return await self._request( + return await self.request( models.GetAuthorizationResponse, method="GET", path="/v1/auth", @@ -61,7 +61,7 @@ async def request_signin( timeout: TimeoutOption = UNSET, ) -> models.RequestSigninResponse: """Request Sign-In""" - return await self._request( + return await self.request( models.RequestSigninResponse, method="POST", path="/v1/auth/signin", diff --git a/src/unikraft_cloud/api/controlplane/images_gen.py b/src/unikraft_cloud/api/controlplane/images_gen.py index 8fdfb8c..5461815 100644 --- a/src/unikraft_cloud/api/controlplane/images_gen.py +++ b/src/unikraft_cloud/api/controlplane/images_gen.py @@ -26,7 +26,7 @@ async def list_images( timeout: TimeoutOption = UNSET, ) -> models.ListImagesResponse: """List Images""" - return await self._request( + return await self.request( models.ListImagesResponse, method="GET", path="/v1/images", diff --git a/src/unikraft_cloud/api/controlplane/metros_gen.py b/src/unikraft_cloud/api/controlplane/metros_gen.py index 3858c8c..f56a7ce 100644 --- a/src/unikraft_cloud/api/controlplane/metros_gen.py +++ b/src/unikraft_cloud/api/controlplane/metros_gen.py @@ -24,7 +24,7 @@ async def list_metros( timeout: TimeoutOption = UNSET, ) -> models.ListMetroResponse: """Get Metros""" - return await self._request( + return await self.request( models.ListMetroResponse, method="GET", path="/v1/metros", diff --git a/src/unikraft_cloud/api/controlplane/node_activation_service_gen.py b/src/unikraft_cloud/api/controlplane/node_activation_service_gen.py index 49bf88f..d3f9ca0 100644 --- a/src/unikraft_cloud/api/controlplane/node_activation_service_gen.py +++ b/src/unikraft_cloud/api/controlplane/node_activation_service_gen.py @@ -29,7 +29,7 @@ async def node_activate( activation, a secret must be provided. For renewal, the CSR self-signature is used as proof of key possession and the secret is omitted. """ - return await self._request( + return await self.request( models.NodeActivateResponse, method="POST", path="/v1/nodes/activate", diff --git a/src/unikraft_cloud/api/controlplane/node_service_gen.py b/src/unikraft_cloud/api/controlplane/node_service_gen.py index ae46c3e..6650391 100644 --- a/src/unikraft_cloud/api/controlplane/node_service_gen.py +++ b/src/unikraft_cloud/api/controlplane/node_service_gen.py @@ -27,7 +27,7 @@ async def destroy_node( timeout: TimeoutOption = UNSET, ) -> models.DestroyNodeResponse: """Delete Node Nodes""" - return await self._request( + return await self.request( models.DestroyNodeResponse, method="DELETE", path="/v1/nodes", @@ -50,7 +50,7 @@ async def destroy_node_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.DestroyNodeResponse: """Delete Node Node by UUID""" - return await self._request( + return await self.request( models.DestroyNodeResponse, method="DELETE", path=f"/v1/nodes/{quote(uuid, safe='')}", @@ -71,7 +71,7 @@ async def get_node_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.ListNodesResponse: """Get Node Node by UUID""" - return await self._request( + return await self.request( models.ListNodesResponse, method="GET", path=f"/v1/nodes/{quote(uuid, safe='')}", @@ -90,7 +90,7 @@ async def list_machine_types( timeout: TimeoutOption = UNSET, ) -> models.ListMachineTypesResponse: """List Machine Types""" - return await self._request( + return await self.request( models.ListMachineTypesResponse, method="GET", path=f"/v1/nodes/providers/{quote(cloudprovider, safe='')}/types", @@ -117,7 +117,7 @@ async def list_nodes( timeout: TimeoutOption = UNSET, ) -> models.ListNodesResponse: """List Node Nodes""" - return await self._request( + return await self.request( models.ListNodesResponse, method="GET", path="/v1/nodes", @@ -144,7 +144,7 @@ async def list_regions( timeout: TimeoutOption = UNSET, ) -> models.ListRegionsResponse: """List Regions""" - return await self._request( + return await self.request( models.ListRegionsResponse, method="GET", path=f"/v1/nodes/providers/{quote(cloudprovider, safe='')}/regions", @@ -162,7 +162,7 @@ async def provision_node( timeout: TimeoutOption = UNSET, ) -> models.ProvisionNodeResponse: """Create Node Node""" - return await self._request( + return await self.request( models.ProvisionNodeResponse, method="POST", path="/v1/nodes", @@ -182,7 +182,7 @@ async def update_node_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.UpdateNodesResponse: """Update Node Node by UUID""" - return await self._request( + return await self.request( models.UpdateNodesResponse, method="PATCH", path=f"/v1/nodes/{quote(uuid, safe='')}", @@ -204,7 +204,7 @@ async def update_nodes( timeout: TimeoutOption = UNSET, ) -> models.UpdateNodesResponse: """Update Node Nodes""" - return await self._request( + return await self.request( models.UpdateNodesResponse, method="PATCH", path="/v1/nodes", @@ -230,7 +230,7 @@ async def wait_node_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.WaitNodesResponse: """Wait for Node Node State by UUID""" - return await self._request( + return await self.request( models.WaitNodesResponse, method="GET", path=f"/v1/nodes/{quote(uuid, safe='')}/wait", @@ -255,7 +255,7 @@ async def wait_nodes( timeout: TimeoutOption = UNSET, ) -> models.WaitNodesResponse: """Wait for Node Nodes States""" - return await self._request( + return await self.request( models.WaitNodesResponse, method="GET", path="/v1/nodes/wait", diff --git a/src/unikraft_cloud/api/platform/autoscale_gen.py b/src/unikraft_cloud/api/platform/autoscale_gen.py index f731aaa..c1fda0c 100644 --- a/src/unikraft_cloud/api/platform/autoscale_gen.py +++ b/src/unikraft_cloud/api/platform/autoscale_gen.py @@ -27,7 +27,7 @@ async def create_autoscale_configuration_by_service_group_uuid( timeout: TimeoutOption = UNSET, ) -> models.CreateAutoscaleConfigurationsResponse: """Create Autoscale Configuration by Service Group UUID""" - return await self._request( + return await self.request( models.CreateAutoscaleConfigurationsResponse, method="POST", path=f"/v1/services/{quote(uuid, safe='')}/autoscale", @@ -47,7 +47,7 @@ async def create_autoscale_configuration_policy( timeout: TimeoutOption = UNSET, ) -> models.CreateAutoscaleConfigurationPolicyResponse: """Create Autoscale Configuration Policy""" - return await self._request( + return await self.request( models.CreateAutoscaleConfigurationPolicyResponse, method="POST", path=f"/v1/services/{quote(uuid, safe='')}/autoscale/policies", @@ -66,7 +66,7 @@ async def create_autoscale_configurations( timeout: TimeoutOption = UNSET, ) -> models.CreateAutoscaleConfigurationsResponse: """Create Autoscale Configurations""" - return await self._request( + return await self.request( models.CreateAutoscaleConfigurationsResponse, method="POST", path="/v1/services/autoscale", @@ -86,7 +86,7 @@ async def delete_autoscale_configuration_policies( timeout: TimeoutOption = UNSET, ) -> models.DeleteAutoscaleConfigurationPolicyResponse: """Delete Autoscale Configuration Policies""" - return await self._request( + return await self.request( models.DeleteAutoscaleConfigurationPolicyResponse, method="DELETE", path=f"/v1/services/{quote(uuid, safe='')}/autoscale/policies", @@ -106,7 +106,7 @@ async def delete_autoscale_configuration_policy_by_name( timeout: TimeoutOption = UNSET, ) -> models.DeleteAutoscaleConfigurationPolicyResponse: """Delete Autoscale Configuration Policy by Name""" - return await self._request( + return await self.request( models.DeleteAutoscaleConfigurationPolicyResponse, method="DELETE", path=f"/v1/services/{quote(uuid, safe='')}/autoscale/policies/{quote(name, safe='')}", @@ -124,7 +124,7 @@ async def delete_autoscale_configurations( timeout: TimeoutOption = UNSET, ) -> models.DeleteAutoscaleConfigurationsResponse: """Delete Autoscale Configurations""" - return await self._request( + return await self.request( models.DeleteAutoscaleConfigurationsResponse, method="DELETE", path="/v1/services/autoscale", @@ -143,7 +143,7 @@ async def delete_autoscale_configurations_by_service_group_uuid( timeout: TimeoutOption = UNSET, ) -> models.DeleteAutoscaleConfigurationsResponse: """Delete Autoscale Configurations by Service Group UUID""" - return await self._request( + return await self.request( models.DeleteAutoscaleConfigurationsResponse, method="DELETE", path=f"/v1/services/{quote(uuid, safe='')}/autoscale", @@ -162,7 +162,7 @@ async def get_autoscale_configuration_policies( timeout: TimeoutOption = UNSET, ) -> models.GetAutoscaleConfigurationPolicyResponse: """List Autoscale Configuration Policies""" - return await self._request( + return await self.request( models.GetAutoscaleConfigurationPolicyResponse, method="GET", path=f"/v1/services/{quote(uuid, safe='')}/autoscale/policies", @@ -182,7 +182,7 @@ async def get_autoscale_configuration_policy_by_name( timeout: TimeoutOption = UNSET, ) -> models.GetAutoscaleConfigurationPolicyResponse: """Get Autoscale Configuration Policy by Name""" - return await self._request( + return await self.request( models.GetAutoscaleConfigurationPolicyResponse, method="GET", path=f"/v1/services/{quote(uuid, safe='')}/autoscale/policies/{quote(name, safe='')}", @@ -201,7 +201,7 @@ async def get_autoscale_configurations( timeout: TimeoutOption = UNSET, ) -> models.GetAutoscaleConfigurationsResponse: """List Autoscale Configurations""" - return await self._request( + return await self.request( models.GetAutoscaleConfigurationsResponse, method="GET", path="/v1/services/autoscale", @@ -223,7 +223,7 @@ async def get_autoscale_configurations_by_service_group_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetAutoscaleConfigurationsResponse: """Get Autoscale Configurations by Service Group UUID""" - return await self._request( + return await self.request( models.GetAutoscaleConfigurationsResponse, method="GET", path=f"/v1/services/{quote(uuid, safe='')}/autoscale", diff --git a/src/unikraft_cloud/api/platform/certificates_gen.py b/src/unikraft_cloud/api/platform/certificates_gen.py index 9d4f900..b829415 100644 --- a/src/unikraft_cloud/api/platform/certificates_gen.py +++ b/src/unikraft_cloud/api/platform/certificates_gen.py @@ -26,7 +26,7 @@ async def create_certificate( timeout: TimeoutOption = UNSET, ) -> models.CreateCertificateResponse: """Create Certificate""" - return await self._request( + return await self.request( models.CreateCertificateResponse, method="POST", path="/v1/certificates", @@ -45,7 +45,7 @@ async def delete_certificate_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.DeleteCertificatesResponse: """Delete Certificate by UUID""" - return await self._request( + return await self.request( models.DeleteCertificatesResponse, method="DELETE", path=f"/v1/certificates/{quote(uuid, safe='')}", @@ -63,7 +63,7 @@ async def delete_certificates( timeout: TimeoutOption = UNSET, ) -> models.DeleteCertificatesResponse: """Delete Certificates""" - return await self._request( + return await self.request( models.DeleteCertificatesResponse, method="DELETE", path="/v1/certificates", @@ -82,7 +82,7 @@ async def get_certificate_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetCertificatesResponse: """Get Certificate by UUID""" - return await self._request( + return await self.request( models.GetCertificatesResponse, method="GET", path=f"/v1/certificates/{quote(uuid, safe='')}", @@ -106,7 +106,7 @@ async def get_certificates( timeout: TimeoutOption = UNSET, ) -> models.GetCertificatesResponse: """List Certificates""" - return await self._request( + return await self.request( models.GetCertificatesResponse, method="GET", path="/v1/certificates", @@ -134,7 +134,7 @@ async def update_certificate_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.UpdateCertificatesResponse: """Update Certificate by UUID""" - return await self._request( + return await self.request( models.UpdateCertificatesResponse, method="PUT", path=f"/v1/certificates/{quote(uuid, safe='')}", @@ -153,7 +153,7 @@ async def update_certificates( timeout: TimeoutOption = UNSET, ) -> models.UpdateCertificatesResponse: """Update Certificates""" - return await self._request( + return await self.request( models.UpdateCertificatesResponse, method="PUT", path="/v1/certificates", diff --git a/src/unikraft_cloud/api/platform/images_gen.py b/src/unikraft_cloud/api/platform/images_gen.py index 65772d2..68e175c 100644 --- a/src/unikraft_cloud/api/platform/images_gen.py +++ b/src/unikraft_cloud/api/platform/images_gen.py @@ -27,7 +27,7 @@ async def get_image_store( timeout: TimeoutOption = UNSET, ) -> models.GetImagesResponse: """Get Images in Store""" - return await self._request( + return await self.request( models.GetImagesResponse, method="GET", path="/v1/image-store", @@ -52,7 +52,7 @@ async def get_images( timeout: TimeoutOption = UNSET, ) -> models.GetImagesResponse: """Get Images""" - return await self._request( + return await self.request( models.GetImagesResponse, method="GET", path="/v1/images", diff --git a/src/unikraft_cloud/api/platform/instances_gen.py b/src/unikraft_cloud/api/platform/instances_gen.py index 17adc28..be9fd5e 100644 --- a/src/unikraft_cloud/api/platform/instances_gen.py +++ b/src/unikraft_cloud/api/platform/instances_gen.py @@ -26,7 +26,7 @@ async def create_checkpoint_instances( timeout: TimeoutOption = UNSET, ) -> models.CreateCheckpointInstancesResponse: """Create checkpoint instances""" - return await self._request( + return await self.request( models.CreateCheckpointInstancesResponse, method="POST", path="/v1/instances/checkpoints", @@ -45,7 +45,7 @@ async def create_instance( timeout: TimeoutOption = UNSET, ) -> models.CreateInstanceResponse: """Create Instance""" - return await self._request( + return await self.request( models.CreateInstanceResponse, method="POST", path="/v1/instances", @@ -64,7 +64,7 @@ async def create_template_instances( timeout: TimeoutOption = UNSET, ) -> models.CreateTemplateInstancesResponse: """Create template instances from Instances""" - return await self._request( + return await self.request( models.CreateTemplateInstancesResponse, method="POST", path="/v1/instances/templates", @@ -83,7 +83,7 @@ async def delete_checkpoint_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.DeleteCheckpointInstancesResponse: """Delete checkpoint instance by UUID""" - return await self._request( + return await self.request( models.DeleteCheckpointInstancesResponse, method="DELETE", path=f"/v1/instances/checkpoints/{quote(uuid, safe='')}", @@ -101,7 +101,7 @@ async def delete_checkpoint_instances( timeout: TimeoutOption = UNSET, ) -> models.DeleteCheckpointInstancesResponse: """Delete checkpoint instances""" - return await self._request( + return await self.request( models.DeleteCheckpointInstancesResponse, method="DELETE", path="/v1/instances/checkpoints", @@ -121,7 +121,7 @@ async def delete_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.DeleteInstancesResponse: """Delete Instance by UUID""" - return await self._request( + return await self.request( models.DeleteInstancesResponse, method="DELETE", path=f"/v1/instances/{quote(uuid, safe='')}", @@ -140,7 +140,7 @@ async def delete_instances( timeout: TimeoutOption = UNSET, ) -> models.DeleteInstancesResponse: """Delete Instances""" - return await self._request( + return await self.request( models.DeleteInstancesResponse, method="DELETE", path="/v1/instances", @@ -159,7 +159,7 @@ async def delete_template_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.DeleteTemplateInstancesResponse: """Delete template instance by UUID""" - return await self._request( + return await self.request( models.DeleteTemplateInstancesResponse, method="DELETE", path=f"/v1/instances/templates/{quote(uuid, safe='')}", @@ -177,7 +177,7 @@ async def delete_template_instances( timeout: TimeoutOption = UNSET, ) -> models.DeleteTemplateInstancesResponse: """Delete template instances""" - return await self._request( + return await self.request( models.DeleteTemplateInstancesResponse, method="DELETE", path="/v1/instances/templates", @@ -197,7 +197,7 @@ async def get_checkpoint_history( timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointHistoryResponse: """Get checkpoint history""" - return await self._request( + return await self.request( models.GetCheckpointHistoryResponse, method="GET", path="/v1/instances/checkpoints/history", @@ -219,7 +219,7 @@ async def get_checkpoint_history_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointHistoryResponse: """Get checkpoint history by UUID""" - return await self._request( + return await self.request( models.GetCheckpointHistoryResponse, method="GET", path=f"/v1/instances/checkpoints/{quote(uuid, safe='')}/history", @@ -238,7 +238,7 @@ async def get_checkpoint_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointInstancesResponse: """Get checkpoint instance by UUID""" - return await self._request( + return await self.request( models.GetCheckpointInstancesResponse, method="GET", path=f"/v1/instances/checkpoints/{quote(uuid, safe='')}", @@ -266,7 +266,7 @@ async def get_checkpoint_instances( timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointInstancesResponse: """List checkpoint instances""" - return await self._request( + return await self.request( models.GetCheckpointInstancesResponse, method="GET", path="/v1/instances/checkpoints", @@ -295,7 +295,7 @@ async def get_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetInstancesResponse: """Get Instance by UUID""" - return await self._request( + return await self.request( models.GetInstancesResponse, method="GET", path=f"/v1/instances/{quote(uuid, safe='')}", @@ -317,7 +317,7 @@ async def get_instance_history( timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointHistoryResponse: """Get instances checkpoint history""" - return await self._request( + return await self.request( models.GetCheckpointHistoryResponse, method="GET", path="/v1/instances/history", @@ -339,7 +339,7 @@ async def get_instance_history_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointHistoryResponse: """Get instance checkpoint history by UUID""" - return await self._request( + return await self.request( models.GetCheckpointHistoryResponse, method="GET", path=f"/v1/instances/{quote(uuid, safe='')}/history", @@ -361,7 +361,7 @@ async def get_instance_logs( timeout: TimeoutOption = UNSET, ) -> models.GetInstancesLogsResponse: """Get Instances Logs""" - return await self._request( + return await self.request( models.GetInstancesLogsResponse, method="GET", path="/v1/instances/log", @@ -387,7 +387,7 @@ async def get_instance_logs_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetInstancesLogsResponse: """Get Instance Logs by UUID""" - return await self._request( + return await self.request( models.GetInstancesLogsResponse, method="GET", path=f"/v1/instances/{quote(uuid, safe='')}/log", @@ -407,7 +407,7 @@ async def get_instance_metrics( timeout: TimeoutOption = UNSET, ) -> models.GetInstancesMetricsResponse: """Get Instances Metrics""" - return await self._request( + return await self.request( models.GetInstancesMetricsResponse, method="GET", path="/v1/instances/metrics", @@ -429,7 +429,7 @@ async def get_instance_metrics_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetInstancesMetricsResponse: """Get Instance Metrics by UUID""" - return await self._request( + return await self.request( models.GetInstancesMetricsResponse, method="GET", path=f"/v1/instances/{quote(uuid, safe='')}/metrics", @@ -454,7 +454,7 @@ async def get_instances( timeout: TimeoutOption = UNSET, ) -> models.GetInstancesResponse: """List Instances""" - return await self._request( + return await self.request( models.GetInstancesResponse, method="GET", path="/v1/instances", @@ -483,7 +483,7 @@ async def get_template_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetTemplateInstancesResponse: """Get template instance by UUID""" - return await self._request( + return await self.request( models.GetTemplateInstancesResponse, method="GET", path=f"/v1/instances/templates/{quote(uuid, safe='')}", @@ -511,7 +511,7 @@ async def get_template_instances( timeout: TimeoutOption = UNSET, ) -> models.GetTemplateInstancesResponse: """List template instances""" - return await self._request( + return await self.request( models.GetTemplateInstancesResponse, method="GET", path="/v1/instances/templates", @@ -540,7 +540,7 @@ async def start_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.StartInstancesResponse: """Start Instance by UUID""" - return await self._request( + return await self.request( models.StartInstancesResponse, method="PUT", path=f"/v1/instances/{quote(uuid, safe='')}/start", @@ -559,7 +559,7 @@ async def start_instances( timeout: TimeoutOption = UNSET, ) -> models.StartInstancesResponse: """Start Instances""" - return await self._request( + return await self.request( models.StartInstancesResponse, method="PUT", path="/v1/instances/start", @@ -579,7 +579,7 @@ async def stop_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.StopInstancesResponse: """Stop Instance by UUID""" - return await self._request( + return await self.request( models.StopInstancesResponse, method="PUT", path=f"/v1/instances/{quote(uuid, safe='')}/stop", @@ -598,7 +598,7 @@ async def stop_instances( timeout: TimeoutOption = UNSET, ) -> models.StopInstancesResponse: """Stop Instances""" - return await self._request( + return await self.request( models.StopInstancesResponse, method="PUT", path="/v1/instances/stop", @@ -618,7 +618,7 @@ async def suspend_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.SuspendInstancesResponse: """Suspend Instance by UUID""" - return await self._request( + return await self.request( models.SuspendInstancesResponse, method="PUT", path=f"/v1/instances/{quote(uuid, safe='')}/suspend", @@ -637,7 +637,7 @@ async def suspend_instances( timeout: TimeoutOption = UNSET, ) -> models.SuspendInstancesResponse: """Suspend Instances""" - return await self._request( + return await self.request( models.SuspendInstancesResponse, method="PUT", path="/v1/instances/suspend", @@ -657,7 +657,7 @@ async def update_checkpoint_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.UpdateCheckpointInstancesResponse: """Update checkpoint instance by UUID""" - return await self._request( + return await self.request( models.UpdateCheckpointInstancesResponse, method="PATCH", path=f"/v1/instances/checkpoints/{quote(uuid, safe='')}", @@ -676,7 +676,7 @@ async def update_checkpoint_instances( timeout: TimeoutOption = UNSET, ) -> models.UpdateCheckpointInstancesResponse: """Update checkpoint instances""" - return await self._request( + return await self.request( models.UpdateCheckpointInstancesResponse, method="PATCH", path="/v1/instances/checkpoints", @@ -696,7 +696,7 @@ async def update_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.UpdateInstancesResponse: """Update Instance by UUID""" - return await self._request( + return await self.request( models.UpdateInstancesResponse, method="PATCH", path=f"/v1/instances/{quote(uuid, safe='')}", @@ -715,7 +715,7 @@ async def update_instances( timeout: TimeoutOption = UNSET, ) -> models.UpdateInstancesResponse: """Update Instances""" - return await self._request( + return await self.request( models.UpdateInstancesResponse, method="PATCH", path="/v1/instances", @@ -735,7 +735,7 @@ async def update_template_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.UpdateTemplateInstancesResponse: """Update template instance by UUID""" - return await self._request( + return await self.request( models.UpdateTemplateInstancesResponse, method="PATCH", path=f"/v1/instances/templates/{quote(uuid, safe='')}", @@ -754,7 +754,7 @@ async def update_template_instances( timeout: TimeoutOption = UNSET, ) -> models.UpdateTemplateInstancesResponse: """Update template instances""" - return await self._request( + return await self.request( models.UpdateTemplateInstancesResponse, method="PATCH", path="/v1/instances/templates", @@ -774,7 +774,7 @@ async def wait_instance_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.WaitInstancesResponse: """Wait for Instance State by UUID""" - return await self._request( + return await self.request( models.WaitInstancesResponse, method="GET", path=f"/v1/instances/{quote(uuid, safe='')}/wait", @@ -798,7 +798,7 @@ async def wait_instances( timeout: TimeoutOption = UNSET, ) -> models.WaitInstancesResponse: """Wait for Instances States""" - return await self._request( + return await self.request( models.WaitInstancesResponse, method="GET", path="/v1/instances/wait", diff --git a/src/unikraft_cloud/api/platform/node_gen.py b/src/unikraft_cloud/api/platform/node_gen.py index 4b727f5..e0224e8 100644 --- a/src/unikraft_cloud/api/platform/node_gen.py +++ b/src/unikraft_cloud/api/platform/node_gen.py @@ -24,7 +24,7 @@ async def healthz( timeout: TimeoutOption = UNSET, ) -> models.HealthzResponse: """Health Check""" - return await self._request( + return await self.request( models.HealthzResponse, method="GET", path="/v1/healthz", diff --git a/src/unikraft_cloud/api/platform/service_groups_gen.py b/src/unikraft_cloud/api/platform/service_groups_gen.py index 55b7014..e655327 100644 --- a/src/unikraft_cloud/api/platform/service_groups_gen.py +++ b/src/unikraft_cloud/api/platform/service_groups_gen.py @@ -26,7 +26,7 @@ async def create_service_group( timeout: TimeoutOption = UNSET, ) -> models.CreateServiceGroupResponse: """Create Service Group""" - return await self._request( + return await self.request( models.CreateServiceGroupResponse, method="POST", path="/v1/services", @@ -45,7 +45,7 @@ async def delete_service_group_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.DeleteServiceGroupsResponse: """Delete Service Group by UUID""" - return await self._request( + return await self.request( models.DeleteServiceGroupsResponse, method="DELETE", path=f"/v1/services/{quote(uuid, safe='')}", @@ -63,7 +63,7 @@ async def delete_service_groups( timeout: TimeoutOption = UNSET, ) -> models.DeleteServiceGroupsResponse: """Delete Service Groups""" - return await self._request( + return await self.request( models.DeleteServiceGroupsResponse, method="DELETE", path="/v1/services", @@ -83,7 +83,7 @@ async def get_service_group_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetServiceGroupsResponse: """Get Service Group by UUID""" - return await self._request( + return await self.request( models.GetServiceGroupsResponse, method="GET", path=f"/v1/services/{quote(uuid, safe='')}", @@ -110,7 +110,7 @@ async def get_service_groups( timeout: TimeoutOption = UNSET, ) -> models.GetServiceGroupsResponse: """List Service Groups""" - return await self._request( + return await self.request( models.GetServiceGroupsResponse, method="GET", path="/v1/services", @@ -138,7 +138,7 @@ async def update_service_group_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.UpdateServiceGroupsResponse: """Update Service Group by UUID""" - return await self._request( + return await self.request( models.UpdateServiceGroupsResponse, method="PATCH", path=f"/v1/services/{quote(uuid, safe='')}", @@ -157,7 +157,7 @@ async def update_service_groups( timeout: TimeoutOption = UNSET, ) -> models.UpdateServiceGroupsResponse: """Update Service Groups""" - return await self._request( + return await self.request( models.UpdateServiceGroupsResponse, method="PATCH", path="/v1/services", diff --git a/src/unikraft_cloud/api/platform/users_gen.py b/src/unikraft_cloud/api/platform/users_gen.py index 2f162c1..e3333cf 100644 --- a/src/unikraft_cloud/api/platform/users_gen.py +++ b/src/unikraft_cloud/api/platform/users_gen.py @@ -26,7 +26,7 @@ async def add_users( timeout: TimeoutOption = UNSET, ) -> models.AddUsersResponse: """Add Users""" - return await self._request( + return await self.request( models.AddUsersResponse, method="POST", path="/v1/users", @@ -44,7 +44,7 @@ async def get_user( timeout: TimeoutOption = UNSET, ) -> models.QuotasResponse: """Get Current User Quotas""" - return await self._request( + return await self.request( models.QuotasResponse, method="GET", path="/v1/users/quotas", @@ -62,7 +62,7 @@ async def get_user_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.QuotasResponse: """Get User Quotas by UUID""" - return await self._request( + return await self.request( models.QuotasResponse, method="GET", path=f"/v1/users/{quote(uuid, safe='')}/quotas", diff --git a/src/unikraft_cloud/api/platform/volumes_gen.py b/src/unikraft_cloud/api/platform/volumes_gen.py index 50f2e57..bcbf513 100644 --- a/src/unikraft_cloud/api/platform/volumes_gen.py +++ b/src/unikraft_cloud/api/platform/volumes_gen.py @@ -27,7 +27,7 @@ async def attach_volume_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.AttachVolumesResponse: """Attach Volume by UUID""" - return await self._request( + return await self.request( models.AttachVolumesResponse, method="PUT", path=f"/v1/volumes/{quote(uuid, safe='')}/attach", @@ -46,7 +46,7 @@ async def attach_volumes( timeout: TimeoutOption = UNSET, ) -> models.AttachVolumesResponse: """Attach Volumes""" - return await self._request( + return await self.request( models.AttachVolumesResponse, method="PUT", path="/v1/volumes/attach", @@ -66,7 +66,7 @@ async def clone_volume_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.CloneVolumesResponse: """Clone Volume by UUID""" - return await self._request( + return await self.request( models.CloneVolumesResponse, method="POST", path=f"/v1/volumes/{quote(uuid, safe='')}/clone", @@ -85,7 +85,7 @@ async def clone_volumes( timeout: TimeoutOption = UNSET, ) -> models.CloneVolumesResponse: """Clone Volumes""" - return await self._request( + return await self.request( models.CloneVolumesResponse, method="POST", path="/v1/volumes/clone", @@ -104,7 +104,7 @@ async def create_template_volume( timeout: TimeoutOption = UNSET, ) -> models.CreateTemplateVolumesResponse: """Create template volume""" - return await self._request( + return await self.request( models.CreateTemplateVolumesResponse, method="POST", path="/v1/volumes/templates", @@ -123,7 +123,7 @@ async def create_volume( timeout: TimeoutOption = UNSET, ) -> models.CreateVolumeResponse: """Create Volume""" - return await self._request( + return await self.request( models.CreateVolumeResponse, method="POST", path="/v1/volumes", @@ -142,7 +142,7 @@ async def delete_template_volume_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.DeleteTemplateVolumesResponse: """Delete template volume by UUID""" - return await self._request( + return await self.request( models.DeleteTemplateVolumesResponse, method="DELETE", path=f"/v1/volumes/templates/{quote(uuid, safe='')}", @@ -160,7 +160,7 @@ async def delete_template_volumes( timeout: TimeoutOption = UNSET, ) -> models.DeleteTemplateVolumesResponse: """Delete template volumes""" - return await self._request( + return await self.request( models.DeleteTemplateVolumesResponse, method="DELETE", path="/v1/volumes/templates", @@ -179,7 +179,7 @@ async def delete_volume_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.DeleteVolumesResponse: """Delete Volume by UUID""" - return await self._request( + return await self.request( models.DeleteVolumesResponse, method="DELETE", path=f"/v1/volumes/{quote(uuid, safe='')}", @@ -197,7 +197,7 @@ async def delete_volumes( timeout: TimeoutOption = UNSET, ) -> models.DeleteVolumesResponse: """Delete Volumes""" - return await self._request( + return await self.request( models.DeleteVolumesResponse, method="DELETE", path="/v1/volumes", @@ -217,7 +217,7 @@ async def detach_volume_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.DetachVolumesResponse: """Detach Volume by UUID""" - return await self._request( + return await self.request( models.DetachVolumesResponse, method="PUT", path=f"/v1/volumes/{quote(uuid, safe='')}/detach", @@ -236,7 +236,7 @@ async def detach_volumes( timeout: TimeoutOption = UNSET, ) -> models.DetachVolumesResponse: """Detach Volumes""" - return await self._request( + return await self.request( models.DetachVolumesResponse, method="PUT", path="/v1/volumes/detach", @@ -256,7 +256,7 @@ async def get_template_volume_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetTemplateVolumesResponse: """Get template volume by UUID""" - return await self._request( + return await self.request( models.GetTemplateVolumesResponse, method="GET", path=f"/v1/volumes/templates/{quote(uuid, safe='')}", @@ -284,7 +284,7 @@ async def get_template_volumes( timeout: TimeoutOption = UNSET, ) -> models.GetTemplateVolumesResponse: """List template volumes""" - return await self._request( + return await self.request( models.GetTemplateVolumesResponse, method="GET", path="/v1/volumes/templates", @@ -313,7 +313,7 @@ async def get_volume_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.GetVolumesResponse: """Get Volume by UUID""" - return await self._request( + return await self.request( models.GetVolumesResponse, method="GET", path=f"/v1/volumes/{quote(uuid, safe='')}", @@ -341,7 +341,7 @@ async def get_volumes( timeout: TimeoutOption = UNSET, ) -> models.GetVolumesResponse: """List Volumes""" - return await self._request( + return await self.request( models.GetVolumesResponse, method="GET", path="/v1/volumes", @@ -370,7 +370,7 @@ async def update_template_volume_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.UpdateTemplateVolumesResponse: """Update template volume by UUID""" - return await self._request( + return await self.request( models.UpdateTemplateVolumesResponse, method="PATCH", path=f"/v1/volumes/templates/{quote(uuid, safe='')}", @@ -389,7 +389,7 @@ async def update_template_volumes( timeout: TimeoutOption = UNSET, ) -> models.UpdateTemplateVolumesResponse: """Update template volumes""" - return await self._request( + return await self.request( models.UpdateTemplateVolumesResponse, method="PATCH", path="/v1/volumes/templates", @@ -409,7 +409,7 @@ async def update_volume_by_uuid( timeout: TimeoutOption = UNSET, ) -> models.UpdateVolumesResponse: """Update Volume by UUID""" - return await self._request( + return await self.request( models.UpdateVolumesResponse, method="PATCH", path=f"/v1/volumes/{quote(uuid, safe='')}", @@ -428,7 +428,7 @@ async def update_volumes( timeout: TimeoutOption = UNSET, ) -> models.UpdateVolumesResponse: """Update Volumes""" - return await self._request( + return await self.request( models.UpdateVolumesResponse, method="PATCH", path="/v1/volumes", diff --git a/src/unikraft_cloud/core/http.py b/src/unikraft_cloud/core/http.py index a16a723..679b65e 100644 --- a/src/unikraft_cloud/core/http.py +++ b/src/unikraft_cloud/core/http.py @@ -7,7 +7,7 @@ import json import re -from collections.abc import AsyncIterator, Mapping, Sequence +from collections.abc import AsyncGenerator, Mapping, Sequence from dataclasses import dataclass, field, fields, is_dataclass, replace from types import TracebackType from typing import Any, TypeVar @@ -374,6 +374,11 @@ class ApiClient(_PoolOwner): Performs authenticated requests and returns the parsed response envelope. Raises :class:`UnikraftCloudError` on network failures and non-2xx HTTP responses. + + The transport is public: :meth:`request`, :meth:`request_no_content`, + :meth:`request_bytes`, :meth:`stream_bytes` and :meth:`stream` carry a + request whose shape a generated client describes, so a client generated + outside this package can send through it too. """ def __init__(self, config: ApiClientConfig) -> None: @@ -384,6 +389,11 @@ def __init__(self, config: ApiClientConfig) -> None: if config.user_agent: self._default_headers["user-agent"] = config.user_agent + @property + def config(self) -> ApiClientConfig: + """The configuration this client sends with: endpoint, token, timeout, pool.""" + return self._config + def _build_request( self, *, @@ -444,7 +454,7 @@ def _network_error(self, request: httpx.Request, cause: httpx.HTTPError) -> Unik kind="network", ) - async def _request( + async def request( self, model: type[T], *, @@ -480,7 +490,7 @@ async def _request( body=parsed, ) from cause - async def _request_no_content( + async def request_no_content( self, *, method: str, @@ -505,7 +515,7 @@ async def _request_no_content( response = await self._send(request, stream=False) self._raise_for_status(response, self._parse_json(response, request.url)) - async def _request_bytes( + async def request_bytes( self, *, method: str, @@ -539,7 +549,7 @@ async def _request_bytes( content=response.content, status=response.status_code, headers=response.headers ) - async def _stream_bytes( + async def stream_bytes( self, *, method: str, @@ -550,13 +560,13 @@ async def _stream_bytes( base_url: str | None = None, timeout: TimeoutOption = UNSET, chunk_size: int | None = None, - ) -> AsyncIterator[bytes]: + ) -> AsyncGenerator[bytes, None]: """Yield a raw byte payload as it arrives, without holding it all in memory. The iterator ends when the server closes the body. Close it to stop early and release the connection at once, with ``contextlib.aclosing`` or its ``aclose()``; a ``break`` alone leaves that to garbage collection. A failure - is raised before the first chunk, exactly as :meth:`_request_bytes` raises it. + is raised before the first chunk, exactly as :meth:`request_bytes` raises it. """ request = self._build_request( method=method, @@ -584,7 +594,7 @@ async def _stream_bytes( # dangling mid-stream. await response.aclose() - async def _stream( + async def stream( self, model: type[T], *, @@ -595,11 +605,12 @@ async def _stream( headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, - ) -> AsyncIterator[T]: + ) -> AsyncGenerator[T, None]: """Yield each event of a ``text/event-stream`` response, parsed as `model`. - The iterator ends when the server closes the stream; ``break`` out of the - loop to stop early and release the connection. + The iterator ends when the server closes the stream. Close it to stop + early and release the connection at once, with ``contextlib.aclosing`` + or its ``aclose()``; a ``break`` alone leaves that to garbage collection. """ request = self._build_request( method=method, diff --git a/templates/resources.tmpl b/templates/resources.tmpl index ee147c5..a2be9f3 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -183,7 +183,7 @@ class {{ pascalcase $tag }}Api(ApiClient): request_headers["{{ .Name }}"] = {{ pySafeName (snakecase .Name) }} {{- end }} {{- end }} - return {{ if not $sse }}await {{ end }}self.{{ if $sse }}_stream{{ else if $raw }}_request_bytes{{ else if eq $ret "" }}_request_no_content{{ else }}_request{{ end }}( + return {{ if not $sse }}await {{ end }}self.{{ 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 }}, diff --git a/tests/test_transport.py b/tests/test_transport.py index 3dc9b18..4bbe359 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -53,22 +53,22 @@ class Client(ApiClient): """A stand-in for a generated client, exercising the transport directly.""" async def read(self, **kwargs: Any) -> Env: - return await self._request(Env, method="GET", path="/v1/things", **kwargs) + return await self.request(Env, method="GET", path="/v1/things", **kwargs) async def write(self, body: Any) -> Env: - return await self._request(Env, method="POST", path="/v1/things", body=body) + return await self.request(Env, method="POST", path="/v1/things", body=body) async def discard(self) -> None: - return await self._request_no_content(method="DELETE", path="/v1/things") + return await self.request_no_content(method="DELETE", path="/v1/things") def events(self) -> Any: - return self._stream(dict, method="GET", path="/v1/events") + return self.stream(dict, method="GET", path="/v1/events") async def download(self, **kwargs: Any) -> RawResponse: - return await self._request_bytes(method="GET", path="/v1/blob", **kwargs) + return await self.request_bytes(method="GET", path="/v1/blob", **kwargs) def chunks(self, **kwargs: Any) -> Any: - return self._stream_bytes(method="GET", path="/v1/blob", **kwargs) + return self.stream_bytes(method="GET", path="/v1/blob", **kwargs) def client(recorder: Recorder, **config: Any) -> Client: From 16e536d8201981f84159dca3d9113f03679eca21 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 27 Sep 2026 14:30:35 +0100 Subject: [PATCH 04/36] feat(templates): Alias a scalar or array schema A schema that is only a scalar or an array rendered as an empty model, so a union that named it had no type to carry and a value of it parsed to nothing. The template renders such a schema as a type alias, such as `Names = list[str]`, beside the union aliases. Signed-off-by: Daniel Vallance --- templates/models.py.tmpl | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index 61737be..ce8b589 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -1,11 +1,11 @@ {{- /* Models for one API surface: enum aliases, the pydantic models, then the - union aliases. + other 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 + then the aliases for the schemas that are no model -- an assignment is + evaluated on the spot, unlike an annotation, so what it names must already exist -- and a rebuild pass once every name does. */ -}} --- models_gen.py # Code generated by openapi-gen; DO NOT EDIT. @@ -37,7 +37,9 @@ from pydantic import BaseModel, ConfigDict, Field {{ range $m := .Models }} {{- $schema := $m.Schema }} {{- $name := $m.SchemaName }} -{{- if and (not $schema.Enum) (not (and (or $schema.OneOf $schema.AnyOf) (not $schema.Properties))) }} +{{- $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")) }} +{{- if and (not $schema.Enum) (not $union) (not $scalar) }} class {{ $name }}(BaseModel): @@ -67,12 +69,15 @@ class {{ $name }}(BaseModel): {{- end }} {{- end }} {{- end }} -{{- /* A schema that is only a union of branches is an alias, not a model. It - comes after the models because `X = A | B` evaluates A and B right here, where - a field annotation would wait for the rebuild below. */ -}} +{{- /* A schema that is only a union of branches, or a plain scalar or array, + is an alias, not a model: `Line = Shell | Args`, `Args = list[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. */ -}} {{- range $m := .Models }} {{- $schema := $m.Schema }} -{{- if and (not $schema.Enum) (or $schema.OneOf $schema.AnyOf) (not $schema.Properties) }} +{{- $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")) }} +{{- if and (not $schema.Enum) (or $union $scalar) }} {{ $m.SchemaName }} = {{ schemaToPyType $schema }} {{- with $schema.Description }} @@ -88,7 +93,9 @@ class {{ $name }}(BaseModel): for _model in ( {{- range $m := .Models }} {{- $schema := $m.Schema }} -{{- if and (not $schema.Enum) (not (and (or $schema.OneOf $schema.AnyOf) (not $schema.Properties))) }} +{{- $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")) }} +{{- if and (not $schema.Enum) (not $union) (not $scalar) }} {{ $m.SchemaName }}, {{- end }} {{- end }} From a86b27d74ba46fb071b058ccf810d23d9b1bd2ef Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:30:56 +0100 Subject: [PATCH 05/36] feat(templates): Honour required request fields Every generated model marked every field optional, request bodies included, though the specification requires many of theirs. A service group request without its services built without complaint and failed at the server, where the error named no field. A schema named as a request now keeps the specification's required fields, so the omission fails at construction. A response stays optional throughout: one shape serves several answers, and a summary or a failed item carries only some fields. Signed-off-by: Daniel Vallance --- templates/models.py.tmpl | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index ce8b589..4f4e880 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -37,6 +37,11 @@ from pydantic import BaseModel, ConfigDict, Field {{ 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. The TypeSpec + convention names request schemas `Request*`. */ -}} +{{- $request := and (contains "Request" $name) (not (contains "Response" $name)) }} {{- $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")) }} {{- if and (not $schema.Enum) (not $union) (not $scalar) }} @@ -48,8 +53,13 @@ class {{ $name }}(BaseModel): {{ 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) {{ range $prop := propertyNamesOrdered $name $schema }} {{- $p := getProperty $schema $prop }} @@ -58,7 +68,13 @@ class {{ $name }}(BaseModel): `datetime` and writes it back out in the same form. */ -}} {{- $type := schemaToPyType $p }} {{- if eq $p.Format "date-time" }}{{ $type = "datetime" }}{{ end }} +{{- if and $request (getPropertyRequired $schema $prop) }} {{- if eq $safe $prop }} + {{ $safe }}: {{ $type }} +{{- else }} + {{ $safe }}: {{ $type }} = Field(alias="{{ $prop }}") +{{- end }} +{{- else if eq $safe $prop }} {{ $safe }}: {{ $type }} | None = None {{- else }} {{ $safe }}: {{ $type }} | None = Field(default=None, alias="{{ $prop }}") From 4edfa9aace773e6aa0b89847fdd6a51affaa1027 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Fri, 2 Oct 2026 13:37:36 +0100 Subject: [PATCH 06/36] fix(templates): Alias maps and unsafe field names A named map schema rendered as an empty model, a property name that is no identifier rendered as the field name itself, and an operation with several tags was rendered under its first only. A map is now an alias of dict[str, T], such a property is spelled in snake case with the wire name as its alias, or refused when no spelling is an identifier or two render alike, an operation is a method of each tagged class, and literals are rendered escaped. Signed-off-by: Daniel Vallance --- templates/models.py.tmpl | 35 ++++++++++++++++++++++++++++------- templates/resources.tmpl | 21 ++++++++++++--------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index 4f4e880..3dba205 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -44,7 +44,10 @@ from pydantic import BaseModel, ConfigDict, Field {{- $request := and (contains "Request" $name) (not (contains "Response" $name)) }} {{- $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")) }} -{{- if and (not $schema.Enum) (not $union) (not $scalar) }} +{{- /* 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): @@ -61,9 +64,20 @@ class {{ $name }}(BaseModel): # 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 }} +{{- /* A field is an attribute, so a name that is no 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 }} +{{- 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 }} @@ -72,12 +86,12 @@ class {{ $name }}(BaseModel): {{- if eq $safe $prop }} {{ $safe }}: {{ $type }} {{- else }} - {{ $safe }}: {{ $type }} = Field(alias="{{ $prop }}") + {{ $safe }}: {{ $type }} = Field(alias={{ printf "%q" $prop }}) {{- end }} {{- else if eq $safe $prop }} {{ $safe }}: {{ $type }} | None = None {{- else }} - {{ $safe }}: {{ $type }} | None = Field(default=None, alias="{{ $prop }}") + {{ $safe }}: {{ $type }} | None = Field(default=None, alias={{ printf "%q" $prop }}) {{- end }} {{- with $p.Description }} {{ pyDoc . " " }} @@ -85,15 +99,19 @@ class {{ $name }}(BaseModel): {{- end }} {{- end }} {{- end }} -{{- /* A schema that is only a union of branches, or a plain scalar or array, - is an alias, not a model: `Line = Shell | Args`, `Args = list[str]`. It comes +{{- /* 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. */ -}} {{- 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")) }} -{{- if and (not $schema.Enum) (or $union $scalar) }} +{{- /* 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) }} {{ $m.SchemaName }} = {{ schemaToPyType $schema }} {{- with $schema.Description }} @@ -111,7 +129,10 @@ for _model in ( {{- $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")) }} -{{- if and (not $schema.Enum) (not $union) (not $scalar) }} +{{- /* 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 }} diff --git a/templates/resources.tmpl b/templates/resources.tmpl index a2be9f3..b33f4d5 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -4,7 +4,8 @@ fits; a package elsewhere passes `-v core=`. */ -}} {{- $core := .Var "core" "...core" -}} {{- /* One file per tag: a "plumbing" client class exposing every operation for - that resource, returning the raw response envelope. */ -}} + that resource, returning the raw response envelope. An operation with + several tags is a method of each class. */ -}} {{- range $tag := uniqueTags $ops }} --- {{ snakecase $tag }}_gen.py # Code generated by openapi-gen; DO NOT EDIT. @@ -19,7 +20,7 @@ from typing import Any {{- $quoted := false }} {{- $raw := false }} {{- range $op := $ops }} -{{- if eq (first $op.Operation.Tags) $tag }} +{{- if has $tag $op.Operation.Tags }} {{- if pathParameters $op }}{{ $quoted = true }}{{ end }} {{- $seen := false }} {{- range $entry := sortedResponseCodes $op.Operation.Responses }} @@ -38,7 +39,7 @@ from urllib.parse import quote {{- /* Likewise the marker for lists sent as one comma-separated parameter. */ -}} {{- $csv := false }} {{- range $op := $ops }} -{{- if eq (first $op.Operation.Tags) $tag }} +{{- if has $tag $op.Operation.Tags }} {{- range queryParameters $op }} {{- if and (.Schema.Value.Type.Is "array") (not (.SerializationMethod).Explode) }}{{ $csv = true }}{{ end }} {{- end }} @@ -56,7 +57,7 @@ class {{ pascalcase $tag }}Api(ApiClient): envelope. The idiomatic clients are built on top of this. """ {{- range $op := $ops }} -{{- if eq (first $op.Operation.Tags) $tag }} +{{- if has $tag $op.Operation.Tags }} {{ template "method" $op }} {{- end }} {{- end }} @@ -180,7 +181,7 @@ class {{ pascalcase $tag }}Api(ApiClient): request_headers: dict[str, str] = dict(headers or {}) {{- range $hdrs }} if {{ pySafeName (snakecase .Name) }} is not None: - request_headers["{{ .Name }}"] = {{ pySafeName (snakecase .Name) }} + request_headers[{{ printf "%q" .Name }}] = {{ pySafeName (snakecase .Name) }} {{- end }} {{- end }} return {{ if not $sse }}await {{ end }}self.{{ if $sse }}stream{{ else if $raw }}request_bytes{{ else if eq $ret "" }}request_no_content{{ else }}request{{ end }}( @@ -190,12 +191,14 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- if $query }} query={ {{- range $query }} - {{- /* A list the specification does not explode travels as one - comma-separated value (`?tags=a,b`), not as a repeated one. */ -}} + {{- /* A parameter name is free-form in OpenAPI, so it is rendered as + an escaped literal. A list the specification does not explode + travels as one comma-separated value (`?tags=a,b`), not as a + repeated one. */ -}} {{- if and (.Schema.Value.Type.Is "array") (not (.SerializationMethod).Explode) }} - "{{ .Name }}": comma_separated({{ pySafeName .Name }}), + {{ printf "%q" .Name }}: comma_separated({{ pySafeName .Name }}), {{- else }} - "{{ .Name }}": {{ pySafeName .Name }}, + {{ printf "%q" .Name }}: {{ pySafeName .Name }}, {{- end }} {{- end }} }, From fa31a428cd7b2b30dbc6321e118b3ab3fb4d24f4 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Fri, 2 Oct 2026 13:42:49 +0100 Subject: [PATCH 07/36] feat(templates): Refuse what cannot be rendered A specification shape the templates could not render came out as a method that was wrong in silence: an operation without a tag was dropped, a non-JSON body or payload was discarded, and a parameter that was no identifier or clashed with another broke the module. Generation now stops with a message naming the operation or tag for each such shape, a cookie or a tag clash included; a required query or header parameter is a required argument, a path item's headers reach its operations, and _text() spells a non-string one. Signed-off-by: Daniel Vallance --- templates/resources.tmpl | 257 +++++++++++++++++++++++++++++++-------- 1 file changed, 207 insertions(+), 50 deletions(-) diff --git a/templates/resources.tmpl b/templates/resources.tmpl index b33f4d5..4b0e3a3 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -3,39 +3,74 @@ platform and control-plane packages sit at api//, which the default fits; a package elsewhere passes `-v core=`. */ -}} {{- $core := .Var "core" "...core" -}} +{{- /* 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, so two tags that normalise alike would + leave one, and a tag normalising to `models` would take the models + module's name. Every operation of a tag is a method of its class, so two + that render the same name would leave one, and the public methods of + ApiClient are the class's own. */ -}} +{{- $names := list -}} +{{- $reserved := list "aclose" "http" "request" "request_bytes" "request_no_content" "stream" "stream_bytes" -}} +{{- range $tag := uniqueTags $ops -}} +{{- if not (regexMatch "^[A-Za-z_][A-Za-z0-9_]*$" (snakecase $tag)) -}} +{{- fail (printf "tag %q cannot name a Python module; rename it in the specification" $tag) -}} +{{- end -}} +{{- if eq (snakecase $tag) "models" -}} +{{- fail (printf "tag %q gives the module models_gen, which holds the models; rename it in the specification" $tag) -}} +{{- end -}} +{{- range $key := list (printf "class %sApi" (pascalcase $tag)) (printf "module %s_gen" (snakecase $tag)) -}} +{{- if has $key $names -}} +{{- fail (printf "tag %q gives the same %s as another tag; rename one of them in the specification" $tag $key) -}} +{{- end -}} +{{- $names = append $names $key -}} +{{- end -}} +{{- $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 specification" $op.Operation.OperationID) -}} +{{- end -}} +{{- if has $method $reserved -}} +{{- fail (printf "operation %q renders the method %s, which ApiClient defines; rename it in the specification" $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 specification" $op.Operation.OperationID $method $tag) -}} +{{- end -}} +{{- $methods = append $methods $method -}} +{{- end -}} +{{- end -}} +{{- end -}} {{- /* One file per tag: a "plumbing" client class exposing every operation for that resource, returning the raw response envelope. An operation with several tags is a method of each class. */ -}} {{- range $tag := uniqueTags $ops }} --- {{ snakecase $tag }}_gen.py # Code generated by openapi-gen; DO NOT EDIT. - -from __future__ import annotations - -from collections.abc import AsyncIterator, Mapping -from typing import Any {{- /* Path parameters are percent-encoded on the way out, and a raw-bytes response has a payload type of its own, so only the clients that have either need the import. */ -}} {{- $quoted := false }} {{- $raw := 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 }} {{- range $op := $ops }} {{- if has $tag $op.Operation.Tags }} {{- if pathParameters $op }}{{ $quoted = true }}{{ end }} -{{- $seen := false }} -{{- range $entry := sortedResponseCodes $op.Operation.Responses }} -{{- if and (not $seen) (or (hasPrefix "2" $entry.Code) (eq $entry.Code "default")) }} -{{- $seen = true }} -{{- with $entry.Ref }}{{ with .Value }} -{{- if and (index .Content "application/octet-stream") (not (index .Content "application/json")) }}{{ $raw = true }}{{ end }} -{{- end }}{{ end }} -{{- end }} +{{- range pathParameters $op }}{{ if and .Schema (not (.Schema.Value.Type.Is "string")) }}{{ $text = true }}{{ end }}{{ end }} +{{- range $op.Operation.Parameters }}{{ with .Value }}{{ if and (eq .In "header") .Schema (not (.Schema.Value.Type.Is "string")) }}{{ $text = true }}{{ end }}{{ end }}{{ end }} +{{- with $op.PathItem }}{{ range .Parameters }}{{ with .Value }}{{ if and (eq .In "header") .Schema (not (.Schema.Value.Type.Is "string")) }}{{ $text = true }}{{ end }}{{ end }}{{ end }}{{ 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 }}{{ end }}{{ end }}{{ end }} {{- end }} {{- end }} -{{- end }} -{{- if $quoted }} -from urllib.parse import quote -{{- end }} {{- /* Likewise the marker for lists sent as one comma-separated parameter. */ -}} {{- $csv := false }} {{- range $op := $ops }} @@ -46,8 +81,27 @@ from urllib.parse import quote {{- end }} {{- end }} +from __future__ import annotations + +from collections.abc import AsyncIterator, Mapping{{ if $text }}, Sequence{{ end }} +from typing import Any +{{- if $quoted }} +from urllib.parse import quote +{{- end }} + from {{ $core }}.http import UNSET, ApiClient, {{ if $raw }}RawResponse, {{ end }}TimeoutOption{{ if $csv }}, comma_separated{{ end }} from . import models_gen as models +{{- 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(ApiClient): @@ -68,44 +122,95 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- /* 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 . }}{{ $path = replace (printf "{%s}" .Name) (printf "{quote(%s, safe='')}" (pySafeName .Name)) $path }}{{ end -}} -{{- $ret := qualifyModels "models" (pyTypeRef (responseJSONSchema $op)) -}} -{{- /* An operation whose response is a server-sent event stream has no JSON - response body, so it would otherwise be typed as returning nothing. - Yield the event payload instead, streamed via ApiClient._stream(). */ -}} -{{- $sse := false -}} -{{- range $entry := sortedResponseCodes $op.Responses -}} -{{- if and (not $sse) (or (hasPrefix "2" $entry.Code) (eq $entry.Code "default")) -}} -{{- with $entry.Ref -}}{{- with .Value -}} -{{- with index .Content "text/event-stream" -}}{{- $sse = .Schema -}}{{- end -}} -{{- end -}}{{- end -}} +{{- 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 -}} -{{- end -}} -{{- if and $sse (eq $ret "") -}} -{{- $ret = qualifyModels "models" (pyTypeRef $sse) -}} +{{- $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 -}} -{{- $sse = false -}} -{{- end -}} -{{- /* An operation whose success response is `application/octet-stream` returns - its payload verbatim, unenveloped: a file download, a command's output - stream. It is fetched via ApiClient._request_bytes() and returned as a - RawResponse, because the status and headers (a 206 and its - Content-Range) carry meaning there. */ -}} -{{- $raw := false -}} -{{- $seen := false -}} +{{- fail (printf "%s %s: path parameter %q is not a scalar, so the templates cannot serialize it" $.Method $.Path .Name) -}} +{{- 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 and (not $seen) (or (hasPrefix "2" $entry.Code) (eq $entry.Code "default")) -}} -{{- $seen = true -}} +{{- 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 and (index .Content "application/octet-stream") (not (index .Content "application/json")) -}}{{- $raw = true -}}{{- end -}} +{{- 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 -}} +{{- $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 ApiClient.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. */ -}} + 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 -}} -{{- range $op.Parameters -}}{{- with .Value -}}{{- if eq .In "header" -}}{{- $hdrs = append $hdrs . -}}{{- end -}}{{- end -}}{{- end -}} +{{- $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 -}} +{{- /* The transport 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 . -}} {{- /* The specification models the filters of most GET and HEAD operations @@ -146,10 +251,45 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- $beyondIds = true -}} {{- end -}} {{- end -}} -{{- if and (not $beyondIds) $idsInQuery -}} +{{- if and $props (not $beyondIds) $idsInQuery -}} {{- $bodyRef = false -}} {{- $bodyReq = false -}} {{- end -}} +{{- end -}} +{{- /* Every parameter is an argument of one signature, so each must render a + valid identifier that no other parameter, the body, self or a call + option takes. */ -}} +{{- $idents := list "self" "headers" "base_url" "timeout" -}} +{{- 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 specification" $.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 specification" $.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 specification" $.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 specification" $.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 specification" $.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 specification" $.Method $.Path .Name $ident) -}} +{{- end -}} +{{- $idents = append $idents $ident -}} {{- end }} {{ if $sse }}def{{ else }}async def{{ end }} {{ pySafeName (snakecase $op.OperationID) }}( self, @@ -158,10 +298,10 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- end }} *, {{- range $query }} - {{ pySafeName .Name }}: {{ qualifyModels "models" (paramToPyType .) }} | None = None, + {{ pySafeName .Name }}: {{ qualifyModels "models" (paramToPyType .) }}{{ if not .Required }} | None = None{{ end }}, {{- end }} {{- range $hdrs }} - {{ pySafeName (snakecase .Name) }}: {{ qualifyModels "models" (paramToPyType .) }} | None = None, + {{ 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 }}, @@ -180,8 +320,25 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- if $hdrs }} request_headers: dict[str, str] = dict(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 }} + {{- $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 }} + {{- $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 }}] = {{ pySafeName (snakecase .Name) }} + request_headers[{{ printf "%q" .Name }}] = {{ $value }} {{- end }} {{- end }} return {{ if not $sse }}await {{ end }}self.{{ if $sse }}stream{{ else if $raw }}request_bytes{{ else if eq $ret "" }}request_no_content{{ else }}request{{ end }}( @@ -190,11 +347,11 @@ class {{ pascalcase $tag }}Api(ApiClient): {{ if pathParameters . }}path=f"{{ $path }}"{{ else }}path="{{ $path }}"{{ end }}, {{- if $query }} query={ - {{- range $query }} {{- /* A parameter name is free-form in OpenAPI, so it is rendered as an escaped literal. A list the specification does not explode travels as one comma-separated value (`?tags=a,b`), not as a repeated one. */ -}} + {{- range $query }} {{- if and (.Schema.Value.Type.Is "array") (not (.SerializationMethod).Explode) }} {{ printf "%q" .Name }}: comma_separated({{ pySafeName .Name }}), {{- else }} From d7ece10b4b3809f67040b7eecdec1e880971ae7f Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Fri, 2 Oct 2026 14:55:01 +0100 Subject: [PATCH 08/36] fix(templates): Order aliases by what they name An alias that named another alias came out in alphabetical order, so one that sorted before the alias it named raised a NameError on import. The aliases are now written in passes, each taking those whose named aliases are already written; one left over names itself and is refused. Signed-off-by: Daniel Vallance --- templates/models.py.tmpl | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index 3dba205..c871d9e 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -101,9 +101,12 @@ class {{ $name }}(BaseModel): {{- 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. */ -}} + `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) }} @@ -111,12 +114,32 @@ class {{ $name }}(BaseModel): {{- /* 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) }} +{{- 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 }} -{{ $m.SchemaName }} = {{ schemaToPyType $schema }} -{{- with $schema.Description }} +{{ $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 }} From 531f2f8264dcba95554ee862746a35eef61ff24c Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Fri, 2 Oct 2026 15:08:53 +0100 Subject: [PATCH 09/36] fix(templates): Fix content queries and _ fields A query parameter declared with content rather than a schema made generation stop on a nil pointer instead of a message, and a property with a leading underscore became a pydantic private attribute, so its value never reached the field. Such a query parameter is refused with a message naming it, and such a property is spelled without the underscore and keeps the wire name as its alias, as other unsafe names already are. Signed-off-by: Daniel Vallance --- templates/models.py.tmpl | 9 +++++---- templates/resources.tmpl | 3 +++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index c871d9e..6b50feb 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -67,11 +67,12 @@ class {{ $name }}(BaseModel): {{- $fields := list }} {{ range $prop := propertyNamesOrdered $name $schema }} {{- $p := getProperty $schema $prop }} -{{- /* A field is an attribute, so a name that is no identifier is spelled in - snake case and kept as the alias; one that still is none is refused. */ -}} +{{- /* 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) }} +{{- 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 }} {{- if has $safe $fields }} diff --git a/templates/resources.tmpl b/templates/resources.tmpl index 4b0e3a3..8edd670 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -76,6 +76,9 @@ {{- 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 }} {{- if and (.Schema.Value.Type.Is "array") (not (.SerializationMethod).Explode) }}{{ $csv = true }}{{ end }} {{- end }} {{- end }} From 65269767bee4ab010893f96a43109167cb57181b Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Fri, 2 Oct 2026 19:20:21 +0100 Subject: [PATCH 10/36] fix(templates): Fix pydantic name clashes A property named like a pydantic member broke its model: a model_ member failed at import, and a version 1 method such as json was shadowed with a warning. A query parameter holding objects and a union or enum response rendered what the transport cannot handle. A model_ name gets a field_ prefix and a version 1 method name a trailing underscore, both with the wire name as alias. Such a parameter or response is refused with a message that names the operation. Signed-off-by: Daniel Vallance --- templates/models.py.tmpl | 4 ++++ templates/resources.tmpl | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index 6b50feb..968a148 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -75,6 +75,10 @@ class {{ $name }}(BaseModel): {{- 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 }} {{- 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 }} diff --git a/templates/resources.tmpl b/templates/resources.tmpl index 8edd670..36218fa 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -79,6 +79,20 @@ {{- 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 }} +{{- /* 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 }} +{{- $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 }} {{- if and (.Schema.Value.Type.Is "array") (not (.SerializationMethod).Explode) }}{{ $csv = true }}{{ end }} {{- end }} {{- end }} @@ -165,6 +179,11 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- 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 (and (or .OneOf .AnyOf) (not .Properties)) -}} +{{- fail (printf "%s %s: the %s response is a union or an enumeration, which is no type the transport decodes into; wrap it in an object" $.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" -}} From 45b6ddeae9beef0231447ea15206309213cb0119 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Fri, 2 Oct 2026 19:36:38 +0100 Subject: [PATCH 11/36] fix(templates): Check schema names and nullables A schema named like a keyword or an import of the models module rendered an invalid or shadowing class, and a required date-time that the specification allowed to be null was typed as datetime alone, so pydantic refused the null. A schema name is now checked before anything is rendered, and a nullable date-time keeps its None. Signed-off-by: Daniel Vallance --- templates/models.py.tmpl | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index 968a148..8cfc8d5 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -7,6 +7,17 @@ then the aliases for the schemas that are no model -- an assignment is evaluated on the spot, unlike an annotation, so what it names must already exist -- and a rebuild pass once every name does. */ -}} +{{- /* 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. */ -}} +{{- 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 specification" $name) }} +{{- end }} +{{- if has $name (list "Any" "BaseModel" "ConfigDict" "Field" "Literal" "annotations" "datetime") }} +{{- fail (printf "schema %q has the name of an import of the models module; rename it in the specification" $name) }} +{{- end }} +{{- end }} --- models_gen.py # Code generated by openapi-gen; DO NOT EDIT. # @@ -86,7 +97,7 @@ class {{ $name }}(BaseModel): {{- /* 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" }}{{ end }} +{{- if eq $p.Format "date-time" }}{{ $type = "datetime" }}{{ if $p.Nullable }}{{ $type = "datetime | None" }}{{ end }}{{ end }} {{- if and $request (getPropertyRequired $schema $prop) }} {{- if eq $safe $prop }} {{ $safe }}: {{ $type }} From 5ebe68a5632a8119afa34a3ffabf9d59bc68ab4d Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:31:01 +0100 Subject: [PATCH 12/36] fix(templates): Keep generated names distinct A schema named like a builtin the annotations use took its place in every model defined after it, a field named like one broke the annotations of the fields after it, and a parameter named like a helper the method body calls took the place of that helper. Such a schema or parameter is refused with a message naming it, and such a field is spelled with a trailing underscore and keeps the wire name as its alias. Signed-off-by: Daniel Vallance --- templates/models.py.tmpl | 14 +++++++++++--- templates/resources.tmpl | 6 +++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index 8cfc8d5..9052502 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -8,14 +8,18 @@ evaluated on the spot, unlike an annotation, so what it names must already exist -- and a rebuild pass once every name does. */ -}} {{- /* 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. */ -}} + 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 specification" $name) }} {{- end }} -{{- if has $name (list "Any" "BaseModel" "ConfigDict" "Field" "Literal" "annotations" "datetime") }} -{{- fail (printf "schema %q has the name of an import of the models module; rename it in the specification" $name) }} +{{- if or (has $name $moduleNames) (eq $name "_model") }} +{{- fail (printf "schema %q has a name that the models module takes; rename it in the specification" $name) }} {{- end }} {{- end }} --- models_gen.py @@ -90,6 +94,10 @@ class {{ $name }}(BaseModel): 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 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 }} diff --git a/templates/resources.tmpl b/templates/resources.tmpl index 36218fa..28645a9 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -279,9 +279,9 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- end -}} {{- end -}} {{- /* Every parameter is an argument of one signature, so each must render a - valid identifier that no other parameter, the body, self or a call - option takes. */ -}} -{{- $idents := list "self" "headers" "base_url" "timeout" -}} + valid identifier that no other parameter, the body, self, a call option + or a name the method body uses takes. */ -}} +{{- $idents := list "self" "headers" "base_url" "timeout" "comma_separated" "dict" "models" "quote" "request_headers" "str" "_text" -}} {{- if $bodyRef -}}{{- $idents = append $idents "body" -}}{{- end -}} {{- range pathParameters . -}} {{- $ident := pySafeName .Name -}} From bd70305907afea6fec501d988d79394fc8d021e1 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Fri, 2 Oct 2026 20:33:07 +0100 Subject: [PATCH 13/36] docs(templates): Explain request model detection Which generated models keep their required fields was decided by the schema's name, and nothing told whoever names the schemas, or what follows when a name breaks the convention. The models template and the README now say that a schema whose name contains Request keeps its required fields, every other one has each field optional, and what a request or response named otherwise does. Signed-off-by: Daniel Vallance --- README.md | 8 ++++++++ templates/models.py.tmpl | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1831586..b700648 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,14 @@ specification by [`openapi-gen`](https://github.com/unikraft-cloud) using the te [`templates/`](templates). Everything else is hand-written. Files ending in `_gen.py` are never edited by hand. +The templates tell request models from response models by name, as the TypeSpec +convention `Request*` has it: a schema whose name contains `Request` and +not `Response` keeps the fields the specification requires, so a request missing one fails +at construction, and every other schema has every field optional, so a partial response +always parses. A request body named otherwise still works but accepts a missing field, +and a `Request`-named schema carried in a response fails to parse when the server leaves a +required field out. + ```sh make generate # regenerate both plumbing clients from the specs make lint # ruff check + format --check diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index 9052502..deb5bbc 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -54,8 +54,11 @@ from pydantic import BaseModel, ConfigDict, Field {{- $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. The TypeSpec - convention names request schemas `Request*`. */ -}} + serves several answers, so there every field stays optional. Which is which + is told by name, as the TypeSpec convention `Request*` has + it: a request schema named otherwise has every field optional, and a + response that carries a schema named `Request` fails to parse when the + server leaves a required field out. */ -}} {{- $request := and (contains "Request" $name) (not (contains "Response" $name)) }} {{- $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")) }} From 97c0b3216137dad4115c9a261926d07a310b4f13 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Fri, 2 Oct 2026 20:45:39 +0100 Subject: [PATCH 14/36] fix(templates): Keep a nullable enum nullable A named enumeration the specification marked nullable rendered as its Literal alone, so a required request field of that type refused None although the specification allowed it. The other alias forms already kept their None. The enum alias now carries | None when the schema is nullable, as the array, map and union aliases already do. Signed-off-by: Daniel Vallance --- templates/models.py.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index deb5bbc..d629b72 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -42,7 +42,7 @@ from pydantic import BaseModel, ConfigDict, Field {{- $schema := $m.Schema }} {{- if $schema.Enum }} -{{ $m.SchemaName }} = {{ enumPyLiteral $schema }} +{{ $m.SchemaName }} = {{ enumPyLiteral $schema }}{{ if $schema.Nullable }} | None{{ end }} {{- with $schema.Description }} {{ pyDoc . "" }} {{- end }} From 2de461982d1b6d209fd71f58ecba97463e4e9253 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sat, 3 Oct 2026 00:10:46 +0100 Subject: [PATCH 15/36] fix(templates): Refuse styles the transport lacks A query parameter in a style other than form, or one that allows reserved characters, and a path parameter in the label or matrix style rendered as if they were form and simple, so the request went out with a value or a route the specification did not mean. Such a parameter is refused with a message that names it. Signed-off-by: Daniel Vallance --- templates/resources.tmpl | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/templates/resources.tmpl b/templates/resources.tmpl index 28645a9..fb75100 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -93,6 +93,14 @@ {{- 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, exploded or comma-separated, and + percent-encodes every value. */ -}} +{{- if ne (.SerializationMethod).Style "form" }} +{{- fail (printf "%s %s: query parameter %q uses the %s style, which the transport cannot send" $op.Method $op.Path .Name (.SerializationMethod).Style) }} +{{- end }} +{{- if .AllowReserved }} +{{- fail (printf "%s %s: query parameter %q allows reserved characters, which the transport percent-encodes" $op.Method $op.Path .Name) }} +{{- end }} {{- if and (.Schema.Value.Type.Is "array") (not (.SerializationMethod).Explode) }}{{ $csv = true }}{{ end }} {{- end }} {{- end }} @@ -151,6 +159,9 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- 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 specification" $.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 From 39ad27697b695bd065b6f32972a7d0686da45c59 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sat, 3 Oct 2026 00:54:53 +0100 Subject: [PATCH 16/36] fix(templates): Refuse combined union keywords A schema that put oneOf or anyOf beside allOf, or beside each other, reached the alias path, where one union keyword is rendered and the rest is dropped, so the type was wider than the wire. Such a schema is refused with a message that names it. Signed-off-by: Daniel Vallance --- templates/models.py.tmpl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index d629b72..6434d07 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -60,6 +60,11 @@ from pydantic import BaseModel, ConfigDict, Field response that carries a schema named `Request` fails to parse when the server leaves a required field out. */ -}} {{- $request := and (contains "Request" $name) (not (contains "Response" $name)) }} +{{- /* A union alias renders one union keyword and nothing else, so a schema + that adds allOf or the other union keyword to it is not what it renders. */ -}} +{{- 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 specification" $name) }} +{{- 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 From 794db24c4cdb0f4b1442d4f35736d3fb801ee594 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sat, 3 Oct 2026 01:08:02 +0100 Subject: [PATCH 17/36] fix(templates): Check a tag's characters A tag is rendered verbatim in the docstring of its class, and the name checks only saw its snake case, so a tag holding a quote or a backslash escape passed them and left a module that does not parse. The tag itself is now checked before anything is rendered: letters, digits, spaces, dots, dashes and underscores only. Signed-off-by: Daniel Vallance --- templates/resources.tmpl | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/resources.tmpl b/templates/resources.tmpl index fb75100..57d3263 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -16,6 +16,11 @@ {{- $names := list -}} {{- $reserved := list "aclose" "http" "request" "request_bytes" "request_no_content" "stream" "stream_bytes" -}} {{- range $tag := uniqueTags $ops -}} +{{- /* The tag goes verbatim into a class docstring, where a quote or a + backslash can break the module. */ -}} +{{- 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 specification" $tag) -}} +{{- end -}} {{- if not (regexMatch "^[A-Za-z_][A-Za-z0-9_]*$" (snakecase $tag)) -}} {{- fail (printf "tag %q cannot name a Python module; rename it in the specification" $tag) -}} {{- end -}} From df3568fc72de15d0ebc8cb2c5df79aec170aaf4c Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:31:05 +0100 Subject: [PATCH 18/36] fix(templates): Import only what a module uses A generated module imported datetime, Any, Literal, Field and AsyncIterator whether it used them or not, so the unused-import check had to be switched off for the whole generated tree, where it would also have caught a stale import of ours. The models template derives each field's name and type first and imports only what they need, adding | None to an optional field once; the resources template imports AsyncIterator, Any and Literal only where a module streams, takes Any or takes an enum. Signed-off-by: Daniel Vallance --- templates/models.py.tmpl | 78 +++++++++++++++++++++++++++++----------- templates/resources.tmpl | 31 +++++++++++----- 2 files changed, 79 insertions(+), 30 deletions(-) diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index 6434d07..064637f 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -22,6 +22,49 @@ {{- fail (printf "schema %q has a name that the models module takes; rename it in the specification" $name) }} {{- end }} {{- end }} +{{- /* 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 }} +{{- /* Every type the module renders, to find Any and Literal in. */ -}} +{{- $types := "" }} +{{- 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")) }} +{{- if and (not $schema.Enum) (or $union $scalar $map) }}{{ $types = printf "%s %s" $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 its version 1 methods + shadow a field: such a name gets a `field_` prefix or a trailing `_`. */ -}} +{{- 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 }} +{{- end }} +{{- $needAny := regexMatch "\\bAny\\b" $types }} +{{- if regexMatch "\\bLiteral\\b" $types }}{{ $needLiteral = true }}{{ end }} --- models_gen.py # Code generated by openapi-gen; DO NOT EDIT. # @@ -29,11 +72,16 @@ # specification. These mirror the wire format exactly. from __future__ import annotations - +{{- if or $needDatetime $needAny $needLiteral }} +{{ end }} +{{- if $needDatetime }} from datetime import datetime -from typing import Any, Literal +{{- 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, Field +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 @@ -90,22 +138,7 @@ class {{ $name }}(BaseModel): {{- $fields := list }} {{ range $prop := propertyNamesOrdered $name $schema }} {{- $p := getProperty $schema $prop }} -{{- /* 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 }} +{{- $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 }} @@ -114,6 +147,9 @@ class {{ $name }}(BaseModel): `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 }} @@ -121,9 +157,9 @@ class {{ $name }}(BaseModel): {{ $safe }}: {{ $type }} = Field(alias={{ printf "%q" $prop }}) {{- end }} {{- else if eq $safe $prop }} - {{ $safe }}: {{ $type }} | None = None + {{ $safe }}: {{ $optional }} = None {{- else }} - {{ $safe }}: {{ $type }} | None = Field(default=None, alias={{ printf "%q" $prop }}) + {{ $safe }}: {{ $optional }} = Field(default=None, alias={{ printf "%q" $prop }}) {{- end }} {{- with $p.Description }} {{ pyDoc . " " }} diff --git a/templates/resources.tmpl b/templates/resources.tmpl index 57d3263..15c8bbb 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -56,26 +56,37 @@ {{- range $tag := uniqueTags $ops }} --- {{ snakecase $tag }}_gen.py # Code generated by openapi-gen; DO NOT EDIT. -{{- /* Path parameters are percent-encoded on the way out, and a raw-bytes - response has a payload type of its own, so only the clients that have - either need the import. */ -}} +{{- /* 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 and Literal 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 }}{{ end }} -{{- range $op.Operation.Parameters }}{{ with .Value }}{{ if and (eq .In "header") .Schema (not (.Schema.Value.Type.Is "string")) }}{{ $text = true }}{{ end }}{{ end }}{{ end }} -{{- with $op.PathItem }}{{ range .Parameters }}{{ with .Value }}{{ if and (eq .In "header") .Schema (not (.Schema.Value.Type.Is "string")) }}{{ $text = true }}{{ end }}{{ end }}{{ end }}{{ 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 }}{{ 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 }} {{- /* Likewise the marker for lists sent as one comma-separated parameter. */ -}} {{- $csv := false }} {{- range $op := $ops }} @@ -113,8 +124,10 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping{{ if $text }}, Sequence{{ end }} -from typing import Any +from collections.abc import {{ if $sse }}AsyncIterator, {{ end }}Mapping{{ if $text }}, Sequence{{ end }} +{{- if or $any $literal }} +from typing import {{ if $any }}Any{{ end }}{{ if and $any $literal }}, {{ end }}{{ if $literal }}Literal{{ end }} +{{- end }} {{- if $quoted }} from urllib.parse import quote {{- end }} From 5bc4fb4e5bd2ad437a3673f401af8255b6af17ab Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:31:06 +0100 Subject: [PATCH 19/36] feat(templates): Detect request models by use A request model was detected by its name alone, so a schema that only requests use, such as the image spec a plugin takes, had every field optional when its name lacked Request, and a request missing a field the specification requires failed at the server. A schema that only request bodies reach, directly or through the models they name, is a request model too, as the plugin generator has it; one a response also reaches stays optional. Signed-off-by: Daniel Vallance --- README.md | 15 ++++---- templates/models.py.tmpl | 76 ++++++++++++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b700648..c80aef8 100644 --- a/README.md +++ b/README.md @@ -317,13 +317,14 @@ specification by [`openapi-gen`](https://github.com/unikraft-cloud) using the te [`templates/`](templates). Everything else is hand-written. Files ending in `_gen.py` are never edited by hand. -The templates tell request models from response models by name, as the TypeSpec -convention `Request*` has it: a schema whose name contains `Request` and -not `Response` keeps the fields the specification requires, so a request missing one fails -at construction, and every other schema has every field optional, so a partial response -always parses. A request body named otherwise still works but accepts a missing field, -and a `Request`-named schema carried in a response fails to parse when the server leaves a -required field out. +The templates tell request models from response models by name, after the TypeSpec +convention `Request*`, and by use: a schema whose name contains +`Request` and not `Response` keeps the fields the specification requires, and so does a +schema that only requests reach -- through request bodies and the models they name, +however deep -- so a request missing a required field fails at construction. A schema +that any response reaches has every field optional, so a partial response always parses, +and a `Request`-named schema that a response also carries fails to parse when the server +leaves a required field out. ```sh make generate # regenerate both plumbing clients from the specs diff --git a/templates/models.py.tmpl b/templates/models.py.tmpl index 064637f..d6f5bfd 100644 --- a/templates/models.py.tmpl +++ b/templates/models.py.tmpl @@ -22,14 +22,22 @@ {{- fail (printf "schema %q has a name that the models module takes; rename it in the specification" $name) }} {{- end }} {{- end }} +{{- /* A request model has `Request` and not `Response` in its name, after the + TypeSpec convention `Request*`, or only requests reach it: + see the README, "Development". */ -}} +{{- $named := list }} +{{- range $m := .Models }} +{{- if and (contains "Request" $m.SchemaName) (not (contains "Response" $m.SchemaName)) }}{{ $named = append $named $m.SchemaName }}{{ end }} +{{- end }} +{{- /* The types each model renders, by name, and all of them together. */ -}} +{{- $modelTypes := dict }} +{{- $allTypes := "" }} {{- /* 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 }} -{{- /* Every type the module renders, to find Any and Literal in. */ -}} -{{- $types := "" }} {{- range $m := .Models }} {{- $name := $m.SchemaName }} {{- if $m.Schema.Enum }}{{ $needLiteral = true }}{{ end }} @@ -38,7 +46,8 @@ {{- $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")) }} -{{- if and (not $schema.Enum) (or $union $scalar $map) }}{{ $types = printf "%s %s" $types (schemaToPyType $schema) }}{{ end }} +{{- $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) }} @@ -62,9 +71,57 @@ {{- if ne $safe $prop }}{{ $needField = true }}{{ end }} {{- $_ := set $fieldNames (printf "%s.%s" $name $prop) $safe }} {{- end }} +{{- $_ := set $modelTypes $name $types }} +{{- $allTypes = printf "%s %s" $allTypes $types }} +{{- end }} +{{- $needAny := regexMatch "\\bAny\\b" $allTypes }} +{{- if regexMatch "\\bLiteral\\b" $allTypes }}{{ $needLiteral = true }}{{ end }} +{{- /* What a request body names is the caller's, and what a response names is + the server's. Each side is followed through the models it names, so a schema + reached from requests alone, however deep, is a request model, and one a + response reaches anywhere is not. The named request models seed the request + side, and stay request models wherever else they are reached. */ -}} +{{- $requestSeed := "" }} +{{- $responseSeed := "" }} +{{- range $op := .Operations }} +{{- with requestJSONSchema $op.Operation }}{{ $requestSeed = printf "%s %s" $requestSeed (pyTypeRef .) }}{{ end }} +{{- range $entry := sortedResponseCodes $op.Operation.Responses }}{{ with $entry.Ref }}{{ with .Value }} +{{- range $media, $content := .Content }}{{ with $content.Schema }}{{ $responseSeed = printf "%s %s" $responseSeed (pyTypeRef .) }}{{ end }}{{ end }} +{{- end }}{{ end }}{{ end }} +{{- end }} +{{- $requestSide := dict }} +{{- $responseSide := dict }} +{{- range $m := .Models }} +{{- $name := $m.SchemaName }} +{{- if or (has $name $named) (regexMatch (printf "\\b%s\\b" $name) $requestSeed) }}{{ $_ := set $requestSide $name true }}{{ end }} +{{- if regexMatch (printf "\\b%s\\b" $name) $responseSeed }}{{ $_ := set $responseSide $name true }}{{ end }} +{{- end }} +{{- /* Each model reached is followed once; a pass per model is as many as the + longest chain of references can need. */ -}} +{{- $followedRequest := dict }} +{{- $followedResponse := dict }} +{{- range $pass := .Models }} +{{- range $m := $.Models }} +{{- $name := $m.SchemaName }} +{{- if and (hasKey $requestSide $name) (not (hasKey $followedRequest $name)) }} +{{- $_ := set $followedRequest $name true }} +{{- $types := get $modelTypes $name }} +{{- range $other := $.Models }}{{ if regexMatch (printf "\\b%s\\b" $other.SchemaName) $types }}{{ $_ := set $requestSide $other.SchemaName true }}{{ end }}{{ end }} +{{- end }} +{{- if and (hasKey $responseSide $name) (not (hasKey $followedResponse $name)) }} +{{- $_ := set $followedResponse $name true }} +{{- $types := get $modelTypes $name }} +{{- range $other := $.Models }}{{ if regexMatch (printf "\\b%s\\b" $other.SchemaName) $types }}{{ $_ := set $responseSide $other.SchemaName true }}{{ end }}{{ end }} +{{- end }} +{{- end }} +{{- end }} +{{- $requests := $named }} +{{- range $m := .Models }} +{{- $name := $m.SchemaName }} +{{- if and (not (has $name $named)) (hasKey $requestSide $name) (not (hasKey $responseSide $name)) }} +{{- $requests = append $requests $name }} +{{- end }} {{- end }} -{{- $needAny := regexMatch "\\bAny\\b" $types }} -{{- if regexMatch "\\bLiteral\\b" $types }}{{ $needLiteral = true }}{{ end }} --- models_gen.py # Code generated by openapi-gen; DO NOT EDIT. # @@ -103,11 +160,10 @@ from pydantic import BaseModel, ConfigDict{{ if $needField }}, Field{{ end }} {{- /* 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. Which is which - is told by name, as the TypeSpec convention `Request*` has - it: a request schema named otherwise has every field optional, and a - response that carries a schema named `Request` fails to parse when the - server leaves a required field out. */ -}} -{{- $request := and (contains "Request" $name) (not (contains "Response" $name)) }} + is told by name and by use, as the pass above has it: a schema named + otherwise that requests alone reach is a request too, and one a response + reaches stays optional. */ -}} +{{- $request := has $name $requests }} {{- /* A union alias renders one union keyword and nothing else, so a schema that adds allOf or the other union keyword to it is not what it renders. */ -}} {{- if and (or $schema.OneOf $schema.AnyOf) (or $schema.AllOf (and $schema.OneOf $schema.AnyOf)) }} From 093823dd4a1be2e83c36f355c3b3d42df89d8f4e Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:31:07 +0100 Subject: [PATCH 20/36] fix(templates): Refuse a null the wire lacks A nullable query, path or header parameter rendered an argument the transport cannot send as null: it leaves a None value out and spells a None list item as "None". A nullable response schema was typed as a model the transport cannot decode a null into. Such a parameter or response is refused with a message naming it and what to change in the specification, as the plugin generator does. Signed-off-by: Daniel Vallance --- templates/resources.tmpl | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/templates/resources.tmpl b/templates/resources.tmpl index 15c8bbb..c663f34 100644 --- a/templates/resources.tmpl +++ b/templates/resources.tmpl @@ -95,6 +95,11 @@ {{- 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 specification" $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 }} @@ -102,6 +107,9 @@ {{- 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 specification" $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) }} @@ -170,6 +178,9 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- 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 specification" $.Method $.Path .Name) -}} +{{- end -}} {{- $type := .Schema.Value.Type -}} {{- if $type.Is "string" -}} {{- else if or ($type.Is "integer") ($type.Is "number") ($type.Is "boolean") -}} @@ -209,8 +220,8 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- 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 (and (or .OneOf .AnyOf) (not .Properties)) -}} -{{- fail (printf "%s %s: the %s response is a union or an enumeration, which is no type the transport decodes into; wrap it in an object" $.Method $.Path $entry.Code) -}} +{{- 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 -}} @@ -375,11 +386,19 @@ class {{ pascalcase $tag }}Api(ApiClient): {{- 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 specification" $.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 specification" $.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) }} From 83ed1dd6dce8b78a5e24691a2bd74e372048f105 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:31:07 +0100 Subject: [PATCH 21/36] feat(api): Regenerate the plumbing from the specs The platform and control-plane clients were rendered from an older specification, so a create could not be given annotations, gpus or type: check_spec refused each as unknown, though the API has accepted all three for some time. Both are regenerated from the published specification: ServiceGroupsApi is ServicesApi, PlatformApi gains the audit API, UsersApi loses add_users, request models keep their required fields, as the README now says, and F401 is on again. Signed-off-by: Daniel Vallance --- README.md | 5 + pyproject.toml | 2 +- .../api/controlplane/auth_gen.py | 1 - .../api/controlplane/images_gen.py | 3 +- .../api/controlplane/metros_gen.py | 3 +- .../api/controlplane/models_gen.py | 85 +- .../node_activation_service_gen.py | 36 +- .../api/controlplane/node_service_gen.py | 3 +- src/unikraft_cloud/api/platform/__init__.py | 10 +- src/unikraft_cloud/api/platform/audit_gen.py | 41 + .../api/platform/autoscale_gen.py | 7 +- .../api/platform/certificates_gen.py | 5 +- src/unikraft_cloud/api/platform/images_gen.py | 43 +- .../api/platform/instances_gen.py | 45 +- src/unikraft_cloud/api/platform/models_gen.py | 7890 ++++++++--------- src/unikraft_cloud/api/platform/node_gen.py | 5 +- ...{service_groups_gen.py => services_gen.py} | 7 +- src/unikraft_cloud/api/platform/users_gen.py | 24 +- .../api/platform/volumes_gen.py | 29 +- .../resources/service_groups.py | 6 +- 20 files changed, 4117 insertions(+), 4133 deletions(-) create mode 100644 src/unikraft_cloud/api/platform/audit_gen.py rename src/unikraft_cloud/api/platform/{service_groups_gen.py => services_gen.py} (96%) diff --git a/README.md b/README.md index c80aef8..a333fc1 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,11 @@ all subclass `UnikraftCloudError`. The API reports some failures inside an other envelope, per item; those carry the API's own code on `err.errors[n].code` and are raised with the status that says the same thing. +Two failures are caught before anything is sent, and are not `UnikraftCloudError`: a +property the resource does not have is a `TypeError`, and a request missing a field the +specification requires -- a service group without its `services`, an instance's `volumes` +entry without its `at` -- is pydantic's `ValidationError`. + A `wait()` that runs out of time raises `WaitTimeoutError`, which is also a builtin `TimeoutError`, and carries the state the API last saw: diff --git a/pyproject.toml b/pyproject.toml index 063042c..2f71fce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ select = ["B", "E", "F", "I", "N", "RUF", "SIM", "UP", "W"] # layout is the generator's, and the docstrings are the specification's prose # verbatim -- including the odd non-ASCII character, which is not ours to edit. [tool.ruff.lint.per-file-ignores] -"src/unikraft_cloud/api/**" = ["E501", "F401", "N803", "N815", "RUF001", "RUF012"] +"src/unikraft_cloud/api/**" = ["E501", "N803", "N815", "RUF001", "RUF012"] [tool.mypy] # The plugin understands populate_by_name, so a generated model can be built diff --git a/src/unikraft_cloud/api/controlplane/auth_gen.py b/src/unikraft_cloud/api/controlplane/auth_gen.py index 000650f..af6c1f0 100644 --- a/src/unikraft_cloud/api/controlplane/auth_gen.py +++ b/src/unikraft_cloud/api/controlplane/auth_gen.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import AsyncIterator, Mapping -from typing import Any from ...core.http import UNSET, ApiClient, TimeoutOption from . import models_gen as models diff --git a/src/unikraft_cloud/api/controlplane/images_gen.py b/src/unikraft_cloud/api/controlplane/images_gen.py index 5461815..2aa660e 100644 --- a/src/unikraft_cloud/api/controlplane/images_gen.py +++ b/src/unikraft_cloud/api/controlplane/images_gen.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from ...core.http import UNSET, ApiClient, TimeoutOption from . import models_gen as models diff --git a/src/unikraft_cloud/api/controlplane/metros_gen.py b/src/unikraft_cloud/api/controlplane/metros_gen.py index f56a7ce..4cd6cd9 100644 --- a/src/unikraft_cloud/api/controlplane/metros_gen.py +++ b/src/unikraft_cloud/api/controlplane/metros_gen.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from ...core.http import UNSET, ApiClient, TimeoutOption from . import models_gen as models diff --git a/src/unikraft_cloud/api/controlplane/models_gen.py b/src/unikraft_cloud/api/controlplane/models_gen.py index 7c664c2..f2736c9 100644 --- a/src/unikraft_cloud/api/controlplane/models_gen.py +++ b/src/unikraft_cloud/api/controlplane/models_gen.py @@ -6,9 +6,9 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Literal +from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict # Enumerations, as Literal aliases rather than enum classes: the values stay # plain strings, so `state == "running"` needs no import, and a value the server @@ -138,11 +138,11 @@ class CheckAuthorizationRequest(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - request_id: str | None = None + request_id: str """ The request ID is a unique identifier for the request. This is used to track the request in the system and should be provided by the client. @@ -188,6 +188,10 @@ class CheckAuthorizationResponseData(BaseModel): """The display name of the organization the token is associated with.""" registry: str | None = None """The global image registry.""" + organization_uuid: str | None = None + """The UUID of the organization the token is associated with.""" + user_uuid: str | None = None + """The UUID of the user who approved the login.""" class CloudProviderConfig(BaseModel): @@ -275,6 +279,8 @@ class GetAuthorizationResponseData(BaseModel): """The display name of the organization the token is associated with.""" registry: str | None = None """The global image registry.""" + organization_uuid: str | None = None + """The UUID of the organization the token is associated with.""" class Image(BaseModel): @@ -523,8 +529,8 @@ class NameOrUUID(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) uuid: str | None = None @@ -618,11 +624,11 @@ class NodeActivateRequest(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - csr: str | None = None + csr: str """ The certificate signing request (CSR) for the license which is base64 encoded. @@ -669,8 +675,26 @@ class NodeActivateResponseData(BaseModel): """The issued license certificate in base64 URL encoded PEM format.""" -class ProvisionNodeRequest(BaseModel): - """Request message for creating a new node.""" +class NodeDeactivateRequest(BaseModel): + """ + The request message for license deactivation. The caller is identified by + an HTTP message signature (RFC 9421) made with the node's stable private + key, the same mechanism used by the private node-facing APIs (e.g. + NodeHeartbeat) -- not by any field in this message. + """ + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + serial: str + """The serial number of the last issued certificate.""" + + +class NodeDeactivateResponse(BaseModel): + """The response message for license deactivation.""" # 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. @@ -678,6 +702,25 @@ class ProvisionNodeRequest(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the response.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" + + +class ProvisionNodeRequest(BaseModel): + """Request message for creating a new node.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + name: str | None = None """ Optional name for the node. If not provided, a name will be @@ -685,7 +728,7 @@ class ProvisionNodeRequest(BaseModel): """ cloudprovider: CloudProvider | None = None """The cloud provider where the machine should be provisioned.""" - ssh_keys: list[SSHKey] | None = None + ssh_keys: list[SSHKey] """SSH keys for accessing the node. At least one key is required.""" tags: dict[str, str] | None = None """Optional user-defined tags.""" @@ -756,11 +799,11 @@ class RequestSigninRequest(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - hostname: str | None = None + hostname: str """ The hostname is the name of the machine making the request. This is mandatory as it consitutes a unique identifier for the machine. @@ -874,13 +917,13 @@ class UpdateNodePayload(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - property: MutableNodeProperty | None = None + property: MutableNodeProperty """The property to update.""" - operation: MutableNodeOperation | None = None + operation: MutableNodeOperation """The operation to perform on the property.""" value: str | None = None """ @@ -988,6 +1031,8 @@ class WaitNodesResponseData(BaseModel): NodeActivateRequest, NodeActivateResponse, NodeActivateResponseData, + NodeDeactivateRequest, + NodeDeactivateResponse, ProvisionNodeRequest, ProvisionNodeResponse, ProvisionNodeResponseData, diff --git a/src/unikraft_cloud/api/controlplane/node_activation_service_gen.py b/src/unikraft_cloud/api/controlplane/node_activation_service_gen.py index d3f9ca0..9049300 100644 --- a/src/unikraft_cloud/api/controlplane/node_activation_service_gen.py +++ b/src/unikraft_cloud/api/controlplane/node_activation_service_gen.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from ...core.http import UNSET, ApiClient, TimeoutOption from . import models_gen as models @@ -28,6 +27,13 @@ async def node_activate( Activates a new node, or renews an existing node's license. For first-time activation, a secret must be provided. For renewal, the CSR self-signature is used as proof of key possession and the secret is omitted. + + A renewal request may optionally also carry an HTTP message signature + (RFC 9421) made with the node's current stable private key. When present + and valid, it authorizes the CSR's public key to become the node's new + stable identity even if it differs from the current one -- i.e. key + rotation: prove who you are with the old key, then switch to the new one + named in the CSR. """ return await self.request( models.NodeActivateResponse, @@ -38,3 +44,29 @@ async def node_activate( base_url=base_url, timeout=timeout, ) + + async def node_deactivate( + self, + *, + body: models.NodeDeactivateRequest, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> models.NodeDeactivateResponse: + """ + Deactivates a node's license so it can no longer renew. The node's + issued certificate remains valid until it naturally expires -- this + only prevents renewal. The request must carry an HTTP message signature + (RFC 9421) made with the node's stable private key; the node is + identified by the signature, the same mechanism used by the private + node-facing APIs (e.g. NodeHeartbeat). + """ + return await self.request( + models.NodeDeactivateResponse, + method="POST", + path="/v1/nodes/deactivate", + body=body, + headers=headers, + base_url=base_url, + timeout=timeout, + ) diff --git a/src/unikraft_cloud/api/controlplane/node_service_gen.py b/src/unikraft_cloud/api/controlplane/node_service_gen.py index 6650391..33cf6c3 100644 --- a/src/unikraft_cloud/api/controlplane/node_service_gen.py +++ b/src/unikraft_cloud/api/controlplane/node_service_gen.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from urllib.parse import quote from ...core.http import UNSET, ApiClient, TimeoutOption diff --git a/src/unikraft_cloud/api/platform/__init__.py b/src/unikraft_cloud/api/platform/__init__.py index 5f027fc..d16a757 100644 --- a/src/unikraft_cloud/api/platform/__init__.py +++ b/src/unikraft_cloud/api/platform/__init__.py @@ -9,25 +9,27 @@ from ...core.http import ApiClient, ApiClientConfig, ApiClientGroup from . import models_gen as models +from .audit_gen import AuditApi from .autoscale_gen import AutoscaleApi from .certificates_gen import CertificatesApi from .images_gen import ImagesApi from .instances_gen import InstancesApi from .node_gen import NodeApi -from .service_groups_gen import ServiceGroupsApi +from .services_gen import ServicesApi from .users_gen import UsersApi from .volumes_gen import VolumesApi __all__ = [ "ApiClient", "ApiClientConfig", + "AuditApi", "AutoscaleApi", "CertificatesApi", "ImagesApi", "InstancesApi", "NodeApi", "PlatformApi", - "ServiceGroupsApi", + "ServicesApi", "UsersApi", "VolumesApi", "models", @@ -60,11 +62,13 @@ class PlatformApi(ApiClientGroup): def __init__(self, config: ApiClientConfig) -> None: super().__init__(config) + #: The audit log, as a stream of events. + self.audit = AuditApi(self.config) self.autoscale = AutoscaleApi(self.config) self.certificates = CertificatesApi(self.config) self.images = ImagesApi(self.config) self.instances = InstancesApi(self.config) self.node = NodeApi(self.config) - self.services = ServiceGroupsApi(self.config) + self.services = ServicesApi(self.config) self.users = UsersApi(self.config) self.volumes = VolumesApi(self.config) diff --git a/src/unikraft_cloud/api/platform/audit_gen.py b/src/unikraft_cloud/api/platform/audit_gen.py new file mode 100644 index 0000000..84ec7bb --- /dev/null +++ b/src/unikraft_cloud/api/platform/audit_gen.py @@ -0,0 +1,41 @@ +# Code generated by openapi-gen; DO NOT EDIT. + +from __future__ import annotations + +from collections.abc import AsyncIterator, Mapping + +from ...core.http import UNSET, ApiClient, TimeoutOption, comma_separated +from . import models_gen as models + + +class AuditApi(ApiClient): + """Low-level "plumbing" client for the `audit` resource. + + Methods mirror the OpenAPI specification and return the raw response + envelope. The idiomatic clients are built on top of this. + """ + + def subscribe_audit_events( + self, + *, + events: list[models.AuditEventType] | None = None, + uuid: list[str] | None = None, + tags: list[str] | None = None, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> AsyncIterator[models.AuditEvent]: + """Subscribe to Audit Events""" + return self.stream( + models.AuditEvent, + method="GET", + path="/v1/audit", + query={ + "events": comma_separated(events), + "uuid": comma_separated(uuid), + "tags": comma_separated(tags), + }, + headers=headers, + base_url=base_url, + timeout=timeout, + ) diff --git a/src/unikraft_cloud/api/platform/autoscale_gen.py b/src/unikraft_cloud/api/platform/autoscale_gen.py index c1fda0c..81e6cc3 100644 --- a/src/unikraft_cloud/api/platform/autoscale_gen.py +++ b/src/unikraft_cloud/api/platform/autoscale_gen.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from urllib.parse import quote from ...core.http import UNSET, ApiClient, TimeoutOption @@ -11,7 +10,7 @@ class AutoscaleApi(ApiClient): - """Low-level "plumbing" client for the `Autoscale` resource. + """Low-level "plumbing" client for the `autoscale` resource. Methods mirror the OpenAPI specification and return the raw response envelope. The idiomatic clients are built on top of this. @@ -156,7 +155,7 @@ async def get_autoscale_configuration_policies( self, uuid: str, *, - body: models.GetAutoscaleConfigurationPolicyRequest, + body: models.GetAutoscaleConfigurationPolicyRequest | None = None, headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, diff --git a/src/unikraft_cloud/api/platform/certificates_gen.py b/src/unikraft_cloud/api/platform/certificates_gen.py index b829415..adbadd8 100644 --- a/src/unikraft_cloud/api/platform/certificates_gen.py +++ b/src/unikraft_cloud/api/platform/certificates_gen.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from urllib.parse import quote from ...core.http import UNSET, ApiClient, TimeoutOption @@ -11,7 +10,7 @@ class CertificatesApi(ApiClient): - """Low-level "plumbing" client for the `Certificates` resource. + """Low-level "plumbing" client for the `certificates` resource. Methods mirror the OpenAPI specification and return the raw response envelope. The idiomatic clients are built on top of this. diff --git a/src/unikraft_cloud/api/platform/images_gen.py b/src/unikraft_cloud/api/platform/images_gen.py index 68e175c..df646b0 100644 --- a/src/unikraft_cloud/api/platform/images_gen.py +++ b/src/unikraft_cloud/api/platform/images_gen.py @@ -2,15 +2,14 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from ...core.http import UNSET, ApiClient, TimeoutOption from . import models_gen as models class ImagesApi(ApiClient): - """Low-level "plumbing" client for the `Images` resource. + """Low-level "plumbing" client for the `images` resource. Methods mirror the OpenAPI specification and return the raw response envelope. The idiomatic clients are built on top of this. @@ -65,3 +64,41 @@ async def get_images( base_url=base_url, timeout=timeout, ) + + async def pin_images( + self, + *, + body: list[models.PinImageRequestItem], + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> models.PinImagesResponse: + """Pin Images""" + return await self.request( + models.PinImagesResponse, + method="POST", + path="/v1/images", + body=body, + headers=headers, + base_url=base_url, + timeout=timeout, + ) + + async def unpin_images( + self, + *, + body: list[models.UnpinImageRequestItem], + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> models.UnpinImagesResponse: + """Unpin Images""" + return await self.request( + models.UnpinImagesResponse, + method="DELETE", + path="/v1/images", + body=body, + headers=headers, + base_url=base_url, + timeout=timeout, + ) diff --git a/src/unikraft_cloud/api/platform/instances_gen.py b/src/unikraft_cloud/api/platform/instances_gen.py index be9fd5e..e6b2b62 100644 --- a/src/unikraft_cloud/api/platform/instances_gen.py +++ b/src/unikraft_cloud/api/platform/instances_gen.py @@ -2,16 +2,15 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from urllib.parse import quote -from ...core.http import UNSET, ApiClient, TimeoutOption +from ...core.http import UNSET, ApiClient, TimeoutOption, comma_separated from . import models_gen as models class InstancesApi(ApiClient): - """Low-level "plumbing" client for the `Instances` resource. + """Low-level "plumbing" client for the `instances` resource. Methods mirror the OpenAPI specification and return the raw response envelope. The idiomatic clients are built on top of this. @@ -25,7 +24,7 @@ async def create_checkpoint_instances( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.CreateCheckpointInstancesResponse: - """Create checkpoint instances""" + """Create Checkpoint Instances""" return await self.request( models.CreateCheckpointInstancesResponse, method="POST", @@ -82,7 +81,7 @@ async def delete_checkpoint_instance_by_uuid( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.DeleteCheckpointInstancesResponse: - """Delete checkpoint instance by UUID""" + """Delete Checkpoint Instance by UUID""" return await self.request( models.DeleteCheckpointInstancesResponse, method="DELETE", @@ -100,7 +99,7 @@ async def delete_checkpoint_instances( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.DeleteCheckpointInstancesResponse: - """Delete checkpoint instances""" + """Delete Checkpoint Instances""" return await self.request( models.DeleteCheckpointInstancesResponse, method="DELETE", @@ -196,7 +195,7 @@ async def get_checkpoint_history( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointHistoryResponse: - """Get checkpoint history""" + """Get Checkpoint History""" return await self.request( models.GetCheckpointHistoryResponse, method="GET", @@ -218,7 +217,7 @@ async def get_checkpoint_history_by_uuid( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointHistoryResponse: - """Get checkpoint history by UUID""" + """Get Checkpoint History by UUID""" return await self.request( models.GetCheckpointHistoryResponse, method="GET", @@ -237,7 +236,7 @@ async def get_checkpoint_instance_by_uuid( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointInstancesResponse: - """Get checkpoint instance by UUID""" + """Get Checkpoint Instance by UUID""" return await self.request( models.GetCheckpointInstancesResponse, method="GET", @@ -257,15 +256,15 @@ async def get_checkpoint_instances( name: list[str] | None = None, details: bool | None = None, count: int | None = None, - tags: list[str] | None = None, from_: str | None = None, order: models.PaginationOrder | None = None, sortby: models.PaginationSortBy | None = None, + tags: list[str] | None = None, headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointInstancesResponse: - """List checkpoint instances""" + """List Checkpoint Instances""" return await self.request( models.GetCheckpointInstancesResponse, method="GET", @@ -275,10 +274,10 @@ async def get_checkpoint_instances( "name": name, "details": details, "count": count, - "tags": tags, "from": from_, "order": order, "sortby": sortby, + "tags": comma_separated(tags), }, headers=headers, base_url=base_url, @@ -316,7 +315,7 @@ async def get_instance_history( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointHistoryResponse: - """Get instances checkpoint history""" + """Get Instances Checkpoint History""" return await self.request( models.GetCheckpointHistoryResponse, method="GET", @@ -338,7 +337,7 @@ async def get_instance_history_by_uuid( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.GetCheckpointHistoryResponse: - """Get instance checkpoint history by UUID""" + """Get Instance Checkpoint History by UUID""" return await self.request( models.GetCheckpointHistoryResponse, method="GET", @@ -355,7 +354,7 @@ async def get_instance_logs( name: list[str] | None = None, offset: list[int] | None = None, limit: list[int] | None = None, - body: list[models.GetInstancesLogsRequestItem] | None = None, + body: list[models.GetInstancesLogsRequestItem], headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, @@ -446,9 +445,9 @@ async def get_instances( details: bool | None = None, count: int | None = None, from_: str | None = None, - tags: list[str] | None = None, order: models.PaginationOrder | None = None, sortby: models.PaginationSortBy | None = None, + tags: list[str] | None = None, headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, @@ -464,9 +463,9 @@ async def get_instances( "details": details, "count": count, "from": from_, - "tags": tags, "order": order, "sortby": sortby, + "tags": comma_separated(tags), }, headers=headers, base_url=base_url, @@ -502,10 +501,10 @@ async def get_template_instances( name: list[str] | None = None, details: bool | None = None, count: int | None = None, - tags: list[str] | None = None, from_: str | None = None, order: models.PaginationOrder | None = None, sortby: models.PaginationSortBy | None = None, + tags: list[str] | None = None, headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, @@ -520,10 +519,10 @@ async def get_template_instances( "name": name, "details": details, "count": count, - "tags": tags, "from": from_, "order": order, "sortby": sortby, + "tags": comma_separated(tags), }, headers=headers, base_url=base_url, @@ -656,7 +655,7 @@ async def update_checkpoint_instance_by_uuid( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.UpdateCheckpointInstancesResponse: - """Update checkpoint instance by UUID""" + """Update Checkpoint Instance by UUID""" return await self.request( models.UpdateCheckpointInstancesResponse, method="PATCH", @@ -675,7 +674,7 @@ async def update_checkpoint_instances( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.UpdateCheckpointInstancesResponse: - """Update checkpoint instances""" + """Update Checkpoint Instances""" return await self.request( models.UpdateCheckpointInstancesResponse, method="PATCH", @@ -792,7 +791,7 @@ async def wait_instances( state: list[models.InstanceState] | None = None, timeout_ms: list[int] | None = None, timeout_s: list[int] | None = None, - body: list[models.WaitInstancesRequestItem] | None = None, + body: list[models.WaitInstancesRequestItem], headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, diff --git a/src/unikraft_cloud/api/platform/models_gen.py b/src/unikraft_cloud/api/platform/models_gen.py index c0aa77d..4a995dd 100644 --- a/src/unikraft_cloud/api/platform/models_gen.py +++ b/src/unikraft_cloud/api/platform/models_gen.py @@ -15,12 +15,54 @@ # adds is one line to widen. +AuditEventType = Literal["vm.state_change", "vm.start_failed", "gap"] +""" +The type of an audit event. + +An enum rather than an open union because this is also a query parameter, +and +an open union generates as an interface that a client cannot serialise into +a +filter. Further event types are added here; a Go client decodes one it does +not know as its plain string value rather than failing. +""" + +AuditOrigin = Literal[ + "unknown", + "api", + "guest", + "proxy", + "autoscale", + "scale-to-zero", + "scheduled-op", + "restart", + "update", + "system", + "network", + "autokill", + "mtss", +] +"""What caused the operation.""" + +AuditTrigger = Literal["requested", "observed"] +"""Whether the event was raised by the operation or observed as a result of it.""" + AdjustmentType = Literal["change", "exact", "percentage"] """ AdjustmentType defines the type of adjustment to be made in an autoscaling step policy. """ +GetAutoscaleConfigurationsResponseStatus = Literal["success", "error", "unconfigured"] +"""The status of the response.""" + +StepPolicyMetric = Literal["cpu"] +""" +The step policy is a type of autoscaling policy that scales the number of +instances in a service by a fixed number of instances at each step. It uses +a metric to determine when to scale up or down. +""" + CertificateState = Literal["pending", "valid", "error"] """ The current state of a certificate. @@ -35,58 +77,53 @@ | `valid` | The certificate is valid and can be used by your services. | | `error` | The certificate request failed after multiple attempts. This can happen, for example, if your DNS configuration is not correct, you run -into Let’s Encrypt™ quota limits, or the domain validation process failed -for some other reason. There won’t be any further automatic attempts. | +into Let's Encrypt™ quota limits, or the domain validation process failed +for some other reason. There won't be any further automatic attempts. | """ -ConnectionHandler = Literal["tls", "http", "redirect"] -""" -Connection handlers to use for the service. Handlers define how the service -will handle incoming connections and forward traffic from the Internet to -your application. For example, a service can be configured to terminate TLS -connections, redirect HTTP traffic, or enable HTTP mode for load balancing. -You configure the handlers for every published service port individually. +InlineDataEncoding = Literal["text", "base64"] +"""Encoding type for inline file data.""" -There are currently 3 supported handlers: +PaginationOrder = Literal["asc", "desc"] +"""The sort order used by list endpoints.""" -| Handler | Description | -|------------|-------------| -| `tls` | Terminate the TLS connection at the Unikraft Cloud gateway -using our wildcard certificate issued for the kraft.cloud domain. The -gateway forwards the unencrypted traffic to your application. | -| `http` | Enable HTTP mode on the load balancer to load balance on the -level of individual HTTP requests. In this mode, only HTTP connections are -accepted. If this option is not set the load balancer works in TCP mode and -distributes TCP connections. | -| `redirect` | Redirect traffic from the source port to the destination -port. | +PaginationSortBy = Literal["create_time"] +"""The sort field used by list endpoints.""" -Note that there is a set of constraints when publishing ports: -- Port 80: MUST have "http" and MUST not have "tls" set; -- Port 443: MUST have http and tls set; -- The `redirect` handler can only be set on port 80 (HTTP) to redirect to - port 443 (HTTPS); -- All other ports MUST have tls and MUST not have http set. +ResponseStatus = Literal["success", "error", "partial_success"] +"""The response status of an API request.""" + +SchedPriority = Literal["normal", "medium", "high", "admin"] """ +SchedPriority defines the scheduling priority for an instance. +User requires the `override_vm_priority` permission to change it. -GetAutoscaleConfigurationsResponseStatus = Literal["success", "error", "unconfigured"] -"""The status of the response.""" +The list of available scheduling priorities: -InlineDataEncoding = Literal["text", "base64"] -"""Encoding type for inline file data.""" +| Priority | Description | +|----------|-------------| +| `normal` | Default scheduling priority. | +| `medium` | Medium scheduling priority. | +| `high` | High scheduling priority. | +| `admin` | Admin scheduling priority. | +""" -InstanceFeature = Literal["delete-on-stop"] +InstanceFeature = Literal["delete-on-stop", "nested-virt"] """ Features are specific configurations or capabilities that can be enabled for the instance. The list of available features to enable for the instance: -| Feature | Description | -|------------------|-------------| -| `delete_on_stop` | The instance will be deleted when it is stopped. This +| Feature | Description | +|--------------------|-------------| +| `delete-on-stop` | The instance will be deleted when it is stopped. This is useful for instances that are not needed after they are stopped, such as -temporary or ephemeral instances. | +temporary or ephemeral instances. Cannot be combined with a `restart_policy` +other than `never`. | +| `nested-virt` | Expose virtualization extensions to the guest, so +that it can run virtual machines of its own. Requires the `nested_virt` +permission. | """ InstancePendingUpdateStatus = Literal["pending", "failed"] @@ -97,11 +134,9 @@ The restart policy of an instance. When an instance stops either because the application exits or the instance -crashes, Unikraft Cloud can auto-restart your instance. Auto-restarts are +crashes, Unikraft Cloud can auto-restart your instance. Auto-restarts are performed according to the restart policy configured for a particular -instance. - -The policy can have the following values: +instance. The policy can have the following values: | Policy | Description | |--------------|-------------| @@ -111,19 +146,14 @@ | `on-failure` | Only restart the instance if it crashes. | When an instance stops, the stop reason and the configured restart policy -are -evaluated to decide if a restart should be performed. Unikraft Cloud uses -an -exponential back-off delay (immediate, 5s, 10s, 20s, 40s, ..., 5m) to slow -down restarts in tight crash loops. If an instance runs without problems for -10s the back-off delay is reset and the restart sequence ends. - +are evaluated to decide if a restart should be performed. Unikraft Cloud +uses an exponential back-off delay (immediate, 5s, 10s, 20s, 40s, ..., 5m) +to slow down restarts in tight crash loops. If an instance runs without +problems for 10s the back-off delay is reset and the restart sequence ends. The `restart.attempt` attribute reported in counts the number of restarts -performed in the current sequence. The `restart.next_at` field indicates -when the next restart will take place if a back-off delay is in effect. - -A manual start or stop of the instance aborts the restart sequence and -resets +performed in the current sequence. The `restart.next_at` field indicates +when the next restart will take place if a back-off delay is in effect. A +manual start or stop of the instance aborts the restart sequence and resets the back-off delay. """ @@ -154,6 +184,18 @@ ] """The current state of an instance.""" +InstanceType = Literal["micro", "full"] +""" +The type of virtual machine used to run an instance. + +| Type | Description | +|---------|-------------| +| `micro` | A lightweight microVM (default). Boots in milliseconds and is +suitable for most workloads. | +| `full` | A full virtual machine with broader hardware support, such as +GPU passthrough. Requires a plan with full VM support. | +""" + MutableCheckpointInstanceOperation = Literal["set", "add", "del"] """The operations available on a checkpoint instance's properties.""" @@ -161,7 +203,7 @@ """The mutable properties of a checkpoint instance that can be updated.""" MutableInstanceOperation = Literal["set", "add", "del"] -"""The operations available on an instance's properties.""" +"""Mutable instance operations.""" MutableInstanceProperty = Literal[ "image", @@ -179,73 +221,78 @@ "dependencies", "sched_priority", "plugins", + "annotations", ] """The mutable properties of an instance that can be updated.""" -MutableServiceGroupOperation = Literal["set", "add", "del"] -"""The mutable operations available on a service group's properties.""" - -MutableServiceGroupProperty = Literal["services", "domains", "soft_limit", "hard_limit", "autokill"] -"""The mutable properties of a service group.""" - MutableTemplateInstanceOperation = Literal["set", "add", "del"] -"""The operations available on a template instance's properties.""" +"""Mutable template instance operations.""" MutableTemplateInstanceProperty = Literal["tags", "delete_lock", "autokill"] """The mutable properties of a template instance that can be updated.""" -MutableTemplateVolumeOperation = Literal["set", "add", "del"] -"""The operations available on a template volume's properties.""" +PullPolicy = Literal["always", "if_not_present", "never"] +"""PullPolicy defines when an image should be pulled.""" -MutableTemplateVolumeProperty = Literal["tags", "delete_lock"] -"""The mutable properties of a template volume.""" +ScheduleAction = Literal["start", "stop", "delete", "exec"] +"""The action to perform on a scheduled operation.""" -MutableVolumeOperation = Literal["set", "add", "del"] -"""The operations available on a volume's properties.""" +HealthState = Literal["unknown", "healthy", "degraded"] +"""The health state reported by a single health checker.""" -MutableVolumeProperty = Literal["size_mb", "tags", "quota_policy", "delete_lock"] -"""The mutable properties of a volume.""" +ConnectionHandler = Literal["tls", "http", "redirect"] +""" +Connection handlers to use for the service. -PaginationOrder = Literal["asc", "desc"] -"""The sort order used by list endpoints.""" +Handlers define how the service will handle incoming connections and +forward traffic from the Internet to your application. For example, a +service can be configured to terminate TLS connections, redirect HTTP +traffic, or enable HTTP mode for load balancing. You configure the handlers +for every published service port individually. -PaginationSortBy = Literal["create_time"] -"""The sort field used by list endpoints.""" +There are currently 3 supported handlers: -PullPolicy = Literal["always", "if_not_present", "never"] -"""PullPolicy defines when an image should be pulled.""" +| Handler | Description | +|------------|-------------| +| `tls` | Terminate the TLS connection at the Unikraft Cloud gateway +using our wildcard certificate issued for the kraft.cloud domain. The +gateway forwards the unencrypted traffic to your application. | +| `http` | Enable HTTP mode on the load balancer to load balance on the +level of individual HTTP requests. In this mode, only HTTP connections are +accepted. If this option is not set the load balancer works in TCP mode and +distributes TCP connections. | +| `redirect` | Redirect traffic from the source port to the destination +port. | -ResponseStatus = Literal["success", "error", "partial_success"] -"""The response status of an API request.""" +Note that there is a set of constraints when publishing ports: -SchedPriority = Literal["normal", "medium", "high", "admin"] +- Port 80: MUST have "http" and MUST not have "tls" set; +- Port 443: MUST have http and tls set; +- The `redirect` handler can only be set on port 80 (HTTP) to redirect to +port 443 (HTTPS); +- All other ports MUST have tls and MUST not have http set. """ -SchedPriority defines the scheduling priority for an instance. -User requires the `override_vm_priority` permission to change it. -The list of available scheduling priorities: +MutableServiceGroupOperation = Literal["set", "add", "del"] +"""The mutable operations available on a service group's properties.""" -| Priority | Description | -|----------|-------------| -| `normal` | Default scheduling priority. | -| `medium` | Medium scheduling priority. | -| `high` | High scheduling priority. | -| `admin` | Admin scheduling priority. | -""" +MutableServiceGroupProperty = Literal["services", "domains", "soft_limit", "hard_limit", "autokill"] +"""Mutable service group properties.""" -ScheduleAction = Literal["start", "stop", "delete", "exec"] -"""The action to perform on a scheduled operation.""" +ServiceProtocol = Literal["tcp", "udp"] +"""Protocol for a service.""" -StepPolicyMetric = Literal["cpu"] -""" -The step policy is a type of autoscaling policy that scales the number of -instances in a service by a fixed number of instances at each step. -It uses a metric to determine when to scale up or down. -""" +MutableTemplateVolumeOperation = Literal["set", "add", "del"] +"""Mutable template volume operations.""" -UserPermission = Literal[ - "root", "override_edns_blacklist", "developer", "volume_manager", "override_vm_priority" -] +MutableTemplateVolumeProperty = Literal["tags", "delete_lock"] +"""Mutable template volume properties.""" + +MutableVolumeOperation = Literal["set", "add", "del"] +"""Mutable volume operations.""" + +MutableVolumeProperty = Literal["size_mb", "tags", "quota_policy", "delete_lock"] +"""Mutable volume properties.""" VolumeAccessMode = Literal["rwo", "rox", "rwx"] """ @@ -254,7 +301,7 @@ """ VolumeQuotaPolicy = Literal["static", "dynamic"] -"""VolumeQuotaPolicy defines the quota policy of a volume.""" +"""Quota policy for a volume.""" VolumeState = Literal[ "uninitialized", "initializing", "available", "idle", "mounted", "busy", "error", "template" @@ -262,75 +309,36 @@ """VolumeState defines the state of a volume at a given moment.""" -class AddUsersRequest(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - users: list[User] | None = None - - -class AddUsersResponse(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - data: AddUsersResponseData | None = None - """The response data for this request.""" - op_time_us: int | None = None +class AuditAttribution(BaseModel): """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - - -class AddUsersResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - results: list[DataResult] | None = None - """The status of the operation for each user in the request.""" + The operation an event belongs to. + Events sharing an `operation` describe one overarching action, which is what + makes a state transition attributable to the thing that caused it rather + than + only observable after the fact. + """ -class AttachVolumeByUUIDRequestBody(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - attach_to: NameOrUUID | None = None - """UUID or name of the instance to attach the volume to.""" - at: str | None = None - """ - Path of the mountpoint. - - The path must be absolute, not contain `.` and `..` components, and not - contain colons (`:`). The path must point to an empty directory. If the - directory does not exist, it is created. - """ - readonly: bool | None = None - """Whether the volume should be mounted read-only.""" + operation: str | None = None + """UUID shared by every event belonging to the same operation.""" + kind: AuditOperationKind | None = None + """What was performed on the object.""" + trigger: AuditTrigger | None = None + """Whether this event was raised by the operation or observed after it.""" + origin: AuditOrigin | None = None + """What caused the operation.""" + user: str | None = None + """The user that caused the operation, when one did.""" -class AttachVolumesRequestItem(BaseModel): - """A single request item for attaching a volume to an instance.""" +class AuditEvent(BaseModel): + """One audit event.""" # 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. @@ -338,34 +346,28 @@ class AttachVolumesRequestItem(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - attach_to: NameOrUUID | None = None - """UUID or name of the instance to attach the volume to.""" - at: str | None = None - """ - Path of the mountpoint. + type: AuditEventType | None = None + """The type of event.""" + timestamp: datetime | None = None + """When the event was raised.""" + object: AuditObject | None = None + """The object the event is about. Absent on `gap`, which names no object.""" + attribution: AuditAttribution | None = None + """The operation the event belongs to.""" + data: AuditEventData | None = None + """The event payload. Its fields depend on `type`.""" + dropped: int | None = None + """How many events were lost. Only on `gap`.""" - The path must be absolute, not contain `.` and `..` components, and not - contain colons (`:`). The path must point to an empty directory. If the - directory does not exist, it is created. - """ - readonly: bool | None = None - """Whether the volume should be mounted read-only.""" - uuid: str | None = None - """ - The UUID of the volume to attach. Mutually exclusive with name. - Exactly one of uuid or name must be provided. - """ - name: str | None = None - """ - The name of the volume to attach. Mutually exclusive with UUID. - Exactly one of uuid or name must be provided. + +class AuditEventData(BaseModel): """ + The payload of an audit event. + Which fields are present depends on the event type: -class AttachVolumesResponse(BaseModel): - """ - The response message for attaching one or more volume(s) given their - UUID(s) or name(s). + - `vm.state_change` + - `vm.start_failed` """ # Fields the specification does not describe are kept rather than dropped, @@ -374,66 +376,60 @@ class AttachVolumesResponse(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: AttachVolumesResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + prev: str | None = None + """The state before the transition.""" + new: str | None = None + """The state after the transition.""" + state: str | None = None + """The state the instance was left in after a failed start.""" + error: str | None = None + """The error that failed the start, as an errno name such as `EDQUOT`.""" + stop: AuditStop | None = None + """Why the instance stopped.""" -class AttachVolumesResponseAttachedVolume(BaseModel): +class AuditObject(BaseModel): + """The object an audit event is about.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" + type: AuditObjectType | None = None + """The kind of object.""" uuid: str | None = None - """The UUID of the volume that was attached.""" - name: str | None = None - """The name of the volume that was attached.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ + """The object's UUID.""" + owner: str | None = None + """The UUID of the user the object belongs to.""" + tags: list[str] | None = None + """The tags set on the object.""" -class AttachVolumesResponseData(BaseModel): +class AuditStop(BaseModel): + """Why an instance stopped.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - volumes: list[AttachVolumesResponseAttachedVolume] | None = None - """The volume(s) which were attached by the request.""" + reason: list[str] | None = None + """The origins that contributed to the stop.""" + code: int | None = None + """The kernel stop code.""" + cause: str | None = None + """The stop cause.""" class AutoscalePolicy(BaseModel): """ - AutoscalePolicy defines the autoscale policy for a service. - Right now it contains fields from both the `ondemand` and `step` policies. - They are marked both as optional, so only one of them should be set at a - time. This is a current limitation of the API design. + AutoscalePolicy defines the autoscale policy for a service. Right now it + contains fields from both the `ondemand` and `step` policies. They are + marked both as optional, so only one of them should be set at a time. This + is a current limitation of the API design. """ # Fields the specification does not describe are kept rather than dropped, @@ -445,19 +441,24 @@ class AutoscalePolicy(BaseModel): name: str | None = None """The name of the policy.""" enabled: bool | None = None - """If the policy is enabled.""" + """Whether the policy is enabled.""" metric: StepPolicyMetric | None = None - """Metric to use for the step policy.""" + """Metric to use for the step policy (only for step policies).""" adjustment_type: AdjustmentType | None = None - """The type of adjustment to be made in the step policy.""" + """ + The type of adjustment to be made in the step policy (only for step + policies). + """ steps: list[AutoscalePolicyStep] | None = None """ - The steps for the step policy. - Each step defines an adjustment value and optional bounds. + The steps for the step policy. Each step defines an adjustment value and + optional bounds. """ class AutoscalePolicyStep(BaseModel): + """A single step in a step autoscaling policy.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -472,277 +473,33 @@ class AutoscalePolicyStep(BaseModel): """Upper bound for the step.""" -class Certificate(BaseModel): +class ConfigurationInstanceCreateArgs(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """ - The UUID of the certificate. + roms: InstanceCreateArgsInstanceCreateRequestRoms | None = None + """The ROM to use for the autoscale configuration.""" + template: NameOrUUID | None = None + """The template to use for the autoscale configuration.""" - This is a unique identifier for the certificate that is generated when the - certificate is created. The UUID is used to reference the certificate in - API calls and can be used to identify the certificate in all API calls that - require an identifier. - """ - name: str | None = None - """ - The name of the certificate. - This is a human-readable name that can be used to identify the certificate. - The name must be unique within the context of your account. The name can - also be used to identify the certificate in API calls. +class CreateAutoscaleConfigurationByServiceGroupUUIDRequest(BaseModel): """ - created_at: datetime | None = None - """The time the certificate was created.""" - common_name: str | None = None - """ - The common name (CN) field from the certificate's subject. - - This is typically the primary domain name that the certificate is issued - for. It represents the main identity that the certificate validates. - """ - subject: str | None = None - """ - The complete subject distinguished name (DN) of the certificate. - - This contains the full subject information from the certificate, including - the common name, organization, organizational unit, locality, state, and - country. The subject identifies the entity that the certificate is issued - to. - Only present when the certificate has been issued. - """ - issuer: str | None = None - """ - The complete issuer distinguished name (DN) of the certificate. - - This identifies the Certificate Authority (CA) that issued the certificate. - It contains information about the CA including its common name, - organization, - and country. - Only present when the certificate has been issued. - """ - serial_number: str | None = None - """ - The unique serial number assigned to the certificate by the issuing CA. - - This is a unique identifier within the scope of the issuing CA that can be - used to identify and track the certificate. Serial numbers are typically - represented as hexadecimal strings. - Only present when the certificate has been issued. - """ - not_before: datetime | None = None - """ - The date and time when the certificate becomes valid. - - The certificate should not be trusted before this date. This timestamp - marks the beginning of the certificate's validity period. - Only present when the certificate has been issued. - """ - not_after: datetime | None = None - """ - The date and time when the certificate expires. - - The certificate should not be trusted after this date. This timestamp - marks the end of the certificate's validity period. Certificates should - be renewed before this date to maintain service availability. - Only present when the certificate has been issued. - """ - state: CertificateState | None = None - """ - The current state of the certificate. - - This indicates whether the certificate is pending issuance, valid and - ready for use, or in an error state. See CertificateState enum for - detailed state descriptions. - """ - status: ResponseStatus | None = None - """ - An optional field representing the status of the request. This field is - only set when this message object is used as a response message. - """ - message: str | None = None - """ - An optional message providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. - """ - - -class CheckpointHistoryEntry(BaseModel): - """ - A checkpoint history entry, representing a single checkpoint in the - history of an instance. - """ - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - uuid: str | None = None - """The UUID of the checkpoint.""" - name: str | None = None - """The name of the checkpoint.""" - created_at: datetime | None = None - """The time the checkpoint was created.""" - - -class CloneVolumeByUUIDRequestBody(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - vol_name: str | None = None - """ - The name of the new cloned volume. If not provided, a random name - of the form `vol-X` is generated for you, where `X` is a 5 character - long random alphanumeric suffix. - """ - quota_policy: VolumeQuotaPolicy | None = None - """ - The quota policy for the new cloned volume. If not provided, the quota - policy of the source volume is used. - """ - tags: list[str] | None = None - """A list of tags to assign to the new cloned volume.""" - - -class CloneVolumesRequestItem(BaseModel): - """A single request item describing the volume to clone.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - vol_name: str | None = None - """ - The name of the new cloned volume. If not provided, a random name - of the form `vol-X` is generated for you, where `X` is a 5 character - long random alphanumeric suffix. - """ - quota_policy: VolumeQuotaPolicy | None = None - """ - The quota policy for the new cloned volume. If not provided, the quota - policy of the source volume is used. - """ - tags: list[str] | None = None - """A list of tags to assign to the new cloned volume.""" - uuid: str | None = None - """The UUID of the volume to clone. Mutually exclusive with name.""" - name: str | None = None - """The name of the volume to clone. Mutually exclusive with UUID.""" - - -class CloneVolumesResponse(BaseModel): - """The response message for cloning one or more volume(s).""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: CloneVolumesResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - - -class CloneVolumesResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - volumes: list[CloneVolumesResponseVolume] | None = None - """The volume(s) which were cloned by the request.""" - - -class CloneVolumesResponseVolume(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - status: ResponseStatus | None = None - """The status of the response.""" - uuid: str | None = None - """The UUID of the newly cloned volume.""" - name: str | None = None - """The name of the newly cloned volume.""" - state: VolumeState | None = None - """The state of the volume.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ - - -class ConfigurationInstanceCreateArgs(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - roms: InstanceCreateArgsInstanceCreateRequestRoms | None = None - """The ROM to use for the autoscale configuration.""" - template: NameOrUUID | None = None - """The template to use for the autoscale configuration.""" - - -class CreateAutoscaleConfigurationByServiceGroupUUIDRequest(BaseModel): - """ - The request message to create an autoscale configuration for a service group - based on its UUID. + The request message to create an autoscale configuration for a service group + based on its UUID. """ # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """ - The UUID of the service to create a configuration for. - Mutually exclusive with name. - """ + uuid: str + """The UUID of the service to create a configuration for.""" min_size: int | None = None """The minimum number of instances to keep running.""" max_size: int | None = None @@ -751,10 +508,8 @@ class CreateAutoscaleConfigurationByServiceGroupUUIDRequest(BaseModel): """The warmup time in milliseconds for new instances.""" cooldown_time_ms: int | None = None """The cooldown time in milliseconds for the autoscale configuration.""" - create_args: CreateAutoscaleConfigurationByServiceGroupUUIDRequestInstanceCreateArgs | None = ( - None - ) - """The arguments to use when creating the autoscale configuration.""" + create_args: CreateAutoscaleConfigurationByServiceGroupUUIDRequestInstanceCreateArgs + """The arguments to use when creating instances.""" policies: list[AutoscalePolicy] | None = None """The policies to apply to the autoscale configuration.""" @@ -762,8 +517,8 @@ class CreateAutoscaleConfigurationByServiceGroupUUIDRequest(BaseModel): class CreateAutoscaleConfigurationByServiceGroupUUIDRequestInstanceCreateArgs(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) roms: InstanceCreateArgsInstanceCreateRequestRoms | None = None @@ -780,13 +535,13 @@ class CreateAutoscaleConfigurationPolicyRequest(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - name: str | None = None - """The Name of the service to add a policy to.""" - type: AutoscalePolicy | None = None + name: str + """The name of the policy.""" + type: AutoscalePolicy """The policy type to add to the autoscale configuration.""" @@ -800,22 +555,18 @@ class CreateAutoscaleConfigurationPolicyResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information about the status.""" data: CreateAutoscaleConfigurationPolicyResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" class CreateAutoscaleConfigurationPolicyResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -823,41 +574,40 @@ class CreateAutoscaleConfigurationPolicyResponseData(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) policies: list[CreateAutoscaleConfigurationPolicyResponsePolicy] | None = None - """The policies which were added by the request.""" class CreateAutoscaleConfigurationPolicyResponsePolicy(BaseModel): + """Per-item result for a create autoscale configuration policy operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" uuid: str | None = None - """The UUID of the service of the added policy.""" + """The UUID of the resource.""" name: str | None = None - """The name of the service of the added policy.""" + """The human-readable name of the resource.""" + status: ResponseStatus | None = None + """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information about the status.""" error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional error code providing additional information about the status.""" class CreateAutoscaleConfigurationsRequestConfiguration(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" min_size: int | None = None """The minimum number of instances to keep running.""" max_size: int | None = None @@ -866,20 +616,10 @@ class CreateAutoscaleConfigurationsRequestConfiguration(BaseModel): """The warmup time in milliseconds for new instances.""" cooldown_time_ms: int | None = None """The cooldown time in milliseconds for the autoscale configuration.""" - create_args: ConfigurationInstanceCreateArgs | None = None - """The arguments to use when creating the autoscale configuration.""" + create_args: ConfigurationInstanceCreateArgs + """The arguments to use when creating instances.""" policies: list[AutoscalePolicy] | None = None """The policies to apply to the autoscale configuration.""" - uuid: str | None = None - """ - The UUID of the service to create a configuration for. - Mutually exclusive with name. - """ - name: str | None = None - """ - The name of the service to create a configuration for. - Mutually exclusive with UUID. - """ class CreateAutoscaleConfigurationsResponse(BaseModel): @@ -894,47 +634,39 @@ class CreateAutoscaleConfigurationsResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information about the status.""" data: CreateAutoscaleConfigurationsResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" class CreateAutoscaleConfigurationsResponseConfigurationsResponse(BaseModel): + """Per-item result for a create autoscale configurations operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" uuid: str | None = None - """The UUID of the service where the configuration was created.""" + """The UUID of the resource.""" name: str | None = None - """The name of the service where the configuration was created.""" + """The human-readable name of the resource.""" + status: ResponseStatus | None = None + """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information about the status.""" error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional error code providing additional information about the status.""" class CreateAutoscaleConfigurationsResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -942,48 +674,41 @@ class CreateAutoscaleConfigurationsResponseData(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) service_groups: list[CreateAutoscaleConfigurationsResponseConfigurationsResponse] | None = None - """The configuration(s) which were created by the request.""" -class CreateCertificateRequest(BaseModel): - """The request message for creating/uploading a new certificate.""" - +class DeleteAutoscaleConfigurationPolicyResponse(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - name: str | None = None - """ - The name of the certificate. + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: DeleteAutoscaleConfigurationPolicyResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" - This is a human-readable name that can be used to identify the certificate. - The name must be unique within the context of your account. If no name is - specified, a random name is generated for you. The name can also be used - to identify the certificate in API calls. - """ - cn: str | None = None - """ - The common name (CN) of the certificate. - Deprecated: Use `common_name` instead. - """ - common_name: str | None = None - """ - The common name (CN) of the certificate. +class DeleteAutoscaleConfigurationPolicyResponseData(BaseModel): + """The response data for this request.""" - This must be a fully-qualified domain name (FQDN). Exactly one of `cn` - or `common_name` must be specified. - """ - chain: str | None = None - """The chain of the certificate.""" - pkey: str | None = None - """The private key of the certificate.""" + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + policies: list[DeleteAutoscaleConfigurationPolicyResponsePoliciesResponse] | None = None -class CreateCertificateResponse(BaseModel): - """The response message for creating of a certificate.""" + +class DeleteAutoscaleConfigurationPolicyResponsePoliciesResponse(BaseModel): + """Per-item result for a delete autoscale configuration policy operation.""" # 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. @@ -991,42 +716,49 @@ class CreateCertificateResponse(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + name: str | None = None + """The name of the deleted policy.""" status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: CreateCertificateResponseData | None = None + """An optional message providing additional information about the status.""" + error: int | None = None + """An optional error code providing additional information about the status.""" + + +class DeleteAutoscaleConfigurationsResponse(BaseModel): + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: DeleteAutoscaleConfigurationsResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class CreateCertificateResponseData(BaseModel): +class DeleteAutoscaleConfigurationsResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - certificates: list[Certificate] | None = None - """ - The certificate which was created by this request. - - Note: only one certificate can be specified in the request, so this - will always contain a single entry. - """ + service_groups: list[DeleteAutoscaleConfigurationsResponseServiceGroup] | None = None -class CreateCheckpointInstancesRequestItem(BaseModel): - """A single checkpoint creation request.""" +class DeleteAutoscaleConfigurationsResponseServiceGroup(BaseModel): + """Per-item result for a delete autoscale configurations operation.""" # 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. @@ -1034,23 +766,45 @@ class CreateCheckpointInstancesRequestItem(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - from_: NameOrUUID | None = Field(default=None, alias="from") - """The source instance to create a checkpoint from (by name or UUID).""" + uuid: str | None = None + """The UUID of the resource.""" name: str | None = None - """ - (Optional). The name of the checkpoint. - If not provided, a name will be generated. - """ - timeout_s: int | None = None - """ - Timeout in seconds to wait for the checkpoint to be created. - No wait performed for a value of 0. - """ + """The human-readable name of the resource.""" + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + error: int | None = None + """An optional error code providing additional information about the status.""" -class CreateCheckpointInstancesResponse(BaseModel): - """The response message for creating one or more checkpoint instances.""" +class DeletePolicyRequest(BaseModel): + """The request message to delete an autoscale configuration policy by name.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + """The name of the policy to delete.""" + + +class GetAutoscaleConfigurationPolicyRequest(BaseModel): + """The request message to get an autoscale configuration policy by name.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + """The Name of the policy to get.""" + +class GetAutoscaleConfigurationPolicyResponse(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a @@ -1060,61 +814,163 @@ class CreateCheckpointInstancesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: CreateCheckpointInstancesResponseData | None = None + """An optional message providing additional information about the status.""" + data: GetAutoscaleConfigurationPolicyResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class CreateCheckpointInstancesResponseCheckpointInstance(BaseModel): +class GetAutoscaleConfigurationPolicyResponseData(BaseModel): + """The response data for this request.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + policies: list[GetAutoscaleConfigurationPolicyResponsePolicyResponse] | None = None + + +class GetAutoscaleConfigurationPolicyResponsePolicyResponse(BaseModel): + """Per-item result for a get autoscale configuration policy operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + policy: AutoscalePolicy | None = None + """The policy which was retrieved by the request.""" status: ResponseStatus | None = None - """The status of this particular checkpoint creation operation.""" + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + error: int | None = None + """An optional error code providing additional information about the status.""" + + +class GetAutoscaleConfigurationsResponse(BaseModel): + """The response message for a GetAutoscaleConfigurationsRequest.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: GetAutoscaleConfigurationsResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: GetAutoscaleConfigurationsResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" + + +class GetAutoscaleConfigurationsResponseData(BaseModel): + """The response data for this request.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + service_groups: list[GetAutoscaleConfigurationsResponseServiceGroup] | None = None + + +class GetAutoscaleConfigurationsResponseServiceGroup(BaseModel): + """Per-item result for a get autoscale configurations operation.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + uuid: str | None = None - """The UUID of the checkpoint instance that was created.""" + """The UUID of the resource.""" name: str | None = None - """The name of the checkpoint instance that was created.""" - state: InstanceState | None = None - """The current state of the checkpoint.""" - message: str | None = None + """The human-readable name of the resource.""" + enabled: bool | None = None + """If the autoscale configuration is enabled.""" + min_size: int | None = None + """The minimum number of instances to keep running. Only if enabled is true.""" + max_size: int | None = None + """The maximum number of instances to keep running. Only if enabled is true.""" + warmup_time_ms: int | None = None + """The warmup time in seconds for new instances. Only if enabled is true.""" + cooldown_time_ms: int | None = None """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. + The cooldown time in seconds for the autoscale configuration. Only if + enabled is true. """ + template: ServiceGroupTemplate | None = None + """ + The instance template used for the autoscale configuration. Only if + enabled is true. + """ + policies: list[AutoscalePolicy] | None = None + """The policies applied to the autoscale configuration.""" + message: str | None = None + """An optional message providing additional information about the status.""" error: int | None = None + """An optional error code providing additional information about the status.""" + status: ResponseStatus | None = None + """The status of the response.""" + + +class InstanceCreateArgsInstanceCreateRequestRoms(BaseModel): + """A ROM to use for an autoscale instance create configuration.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + """The name of the ROM.""" + image: str | ImageSpec | None = None + """ + The image of the ROM to use for the autoscale configuration. Mutually + exclusive with `files`. Accepts either a plain image reference string + (`"nginx:latest"`) or an object carrying additional pull configuration + (`{"url": "nginx:latest", "pull_policy": "always"}`). + """ + files: list[InlineFile] | None = None """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + Inline files to use as the ROM content. When specified, the platform + creates an EROFS image from the provided files. Mutually exclusive with + `image`. """ -class CreateCheckpointInstancesResponseData(BaseModel): +class ServiceGroupTemplate(BaseModel): + """The instance template used for an autoscale configuration.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - instances: list[CreateCheckpointInstancesResponseCheckpointInstance] | None = None - """List of checkpoint instances that were created during the operation.""" + uuid: str | None = None + """The UUID of the template used for the autoscale configuration.""" + name: str | None = None + """The name of the template used for the autoscale configuration.""" -class CreateInstanceRequest(BaseModel): - """The request message for creating a new instance.""" +class Certificate(BaseModel): + """Certificate with per-item response envelope fields merged in.""" # 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. @@ -1122,184 +978,87 @@ class CreateInstanceRequest(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" name: str | None = None + """The human-readable name of the resource.""" + created_at: datetime | None = None + """The time the certificate was created.""" + common_name: str | None = None """ - (Optional). The name of the instance. + The common name (CN) field from the certificate's subject. - If not provided, a random name will be generated. The name must be unique. + This is typically the primary domain name that the certificate is issued + for. It represents the main identity that the certificate validates. """ - image: str | ImageSpec | None = None + subject: str | None = None """ - (Optional). The image to use for the instance. + The complete subject distinguished name (DN) of the certificate. - Either an image or a template must be specified. Accepts either a plain - image reference string (`"nginx:latest"`) or an object carrying additional - pull configuration (`{"url": "nginx:latest", "pull_policy": "always"}`). + This contains the full subject information from the certificate, including + the common name, organization, organizational unit, locality, state, and + country. The subject identifies the entity that the certificate is issued + to. + Only present when the certificate has been issued. """ - args: list[str] | None = None - """(Optional). The arguments to pass to the instance when it starts.""" - env: dict[str, str] | None = None - """(Optional). Environment variables to set for the instance.""" - memory_mb: int | None = None - """(Optional). Memory in MB to allocate for the instance. Default is 128.""" - service_group: CreateInstanceRequestServiceGroup | None = None + issuer: str | None = None """ - (Optional). The service group configuration when creating an instance. + The complete issuer distinguished name (DN) of the certificate. - When creating an instance, either a previously created (persistent) service - group can be referenced (either through its name or UUID), or a new - (ephemeral) service group can be created for the instance by specifying the - list of services it should expose and optionally the domains it should use. - Not used by template instances. + This identifies the Certificate Authority (CA) that issued the certificate. + It contains information about the CA including its common name, + organization, + and country. + Only present when the certificate has been issued. """ - volumes: list[CreateInstanceRequestVolume] | None = None + serial_number: str | None = None """ - Volumes to attach to the instance. + The unique serial number assigned to the certificate by the issuing CA. - This list can contain both existing and new volumes to create as part of - the instance creation. Existing volumes can be referenced by their name or - UUID. New volumes can be created by specifying a name, size in MiB, and - mount point in the instance. The mount point is the directory in the - instance where the volume will be mounted. + This is a unique identifier within the scope of the issuing CA that can be + used to identify and track the certificate. Serial numbers are typically + represented as hexadecimal strings. + Only present when the certificate has been issued. """ - autostart: bool | None = None - """ - Whether the instance should start automatically on creation. - Must be set to true when `timeout_s` is specified. - """ - replicas: int | None = None - """ - (Optional). Number of additional replicas to create. The total - number of instances created is `replicas + 1`. Defaults to 0. - """ - restart_policy: InstanceRestartPolicy | None = None - """ - Restart policy for the instance. This defines how the instance - should behave when it stops or crashes. Cannot be combined with - the `delete-on-stop` feature. - """ - scale_to_zero: CreateInstanceScaleToZero | None = None - """ - Scale-to-zero configuration for the instance. Requires - `service_group` to be set. Cannot be combined with the - `delete-on-stop` feature. - """ - vcpus: int | None = None - """ - (Optional). Number of vCPUs to allocate for the instance. - Defaults to 1. - """ - wait_timeout_ms: int | None = None - """ - Deprecated: Use `timeout_s` instead. Timeout in milliseconds to - wait for all new instances to reach running state. Requires - `autostart` to be set. If `timeout_s` is not set, this value is - converted by rounding up to the next full second. No wait - performed for a value of 0. - """ - features: list[InstanceFeature] | None = None - """ - Features to enable for the instance. Features are specific - configurations or capabilities that can be enabled for the - instance. The `scale-to-zero` and `delete-on-stop` features are - mutually exclusive. - """ - timeout_s: int | None = None - """ - Timeout in seconds to wait for all new instances to reach running - state. Requires `autostart` to be set. If you autostart your - new instance, you can wait for it to finish starting with a - blocking API call if you specify a wait timeout greater than - zero. No wait performed for a value of 0. - """ - roms: list[CreateInstanceRequestRom] | None = None - """ - Read-Only Memory (ROM) blobs to attach to the instance. - Unikraft Cloud supports the ability to attach Read-Only Memory (ROM) blobs - to instances. It allows you to create a general-purpose base image and - then customize individual instances by attaching code or data as separate - ROM blobs. - """ - plugins: list[CreateInstanceRequestPlugin] | None = None - """ - (Optional). Plugins to attach to the instance. Plugins let you attach - small helper programs to an instance and reach each one over a direct, - authenticated HTTP endpoint. Each plugin loads from its own ROM image, - mounts at `/uk/plugins/`, and is reachable at - `.../v1/instances//plugins//`. At most 8 plugins - may be attached to an instance. - """ - tags: list[str] | None = None - """(Optional). Tags to associate with the instance.""" - template: CreateInstanceRequestTemplate | None = None - """ - Template instances. - An existing instance can be saved as a template. This template is then - used to create new instances that inherit the exact configuration and - state the original instance had when the template was created. - """ - sched_priority: SchedPriority | None = None - """ - The scheduling priority for the instance. Only settable by - users with scheduling priority override permissions. - """ - schedules: list[Schedule] | None = None - """ - (Optional). Schedules for the instance. Scheduled operations let you - automatically start, stop, delete, or exec a command in the instance on - a calendar-based schedule. For `exec` schedules, set the `args` field - to the command and its arguments. Each instance stores its own - schedules, and cloning preserves them. - """ - autokill: CreateInstanceRequestAutokill | None = None - """ - (Optional). Automatic delete-on-idle/request-limit configuration. - Not used for template instances. - """ - hostname: str | None = None + not_before: datetime | None = None """ - (Optional). The hostname of the instance. + The date and time when the certificate becomes valid. - If not provided, the hostname will be set to the instance name. The - hostname must be a valid DNS label (e.g., "my-instance") and is used for - internal DNS resolution within the Unikraft Cloud network. + The certificate should not be trusted before this date. This timestamp + marks the beginning of the certificate's validity period. + Only present when the certificate has been issued. """ - dependencies: list[NameOrUUID] | None = None + not_after: datetime | None = None """ - (Optional). Dependencies of the instance. + The date and time when the certificate expires. - A list of instance identifiers (name or UUID) that this instance depends - on. Dependencies define startup ordering and can be used to ensure that - prerequisite instances are running before this instance starts. - """ - branch_from: NameOrUUID | None = None - """ - (Optional). Reference to an existing instance to branch from. - The instance can be running, stopped, or a template. If the source - instance is running, a snapshot will be taken asynchronously and the - new instance will wait for it to complete before starting. - Mutually exclusive with `image` and `template`. + The certificate should not be trusted after this date. This timestamp + marks the end of the certificate's validity period. Certificates should + be renewed before this date to maintain service availability. + Only present when the certificate has been issued. """ - checkpoint: NameOrUUID | None = None + state: CertificateState | None = None """ - (Optional). Reference to an existing checkpoint to create the instance - from. The checkpoint must be in the `checkpoint` state. The new instance - will be created with the same configuration and state as the checkpoint. - Mutually exclusive with `image`, `template`, and `branch_from`. + The current state of the certificate. + + This indicates whether the certificate is pending issuance, valid and + ready for use, or in an error state. See CertificateState enum for + detailed state descriptions. """ - gateway: str | None = None - """The default gateway to configure inside the guest.""" - nameserver: str | None = None - """The DNS resolver to configure inside the guest.""" - network_interfaces: list[CreateInstanceRequestNetworkInterface] | None = None - """A list of one to four interfaces to attach""" + validation: CertificateValidation | None = None + """Validation status when state is pending.""" + service_groups: list[ID] | None = None + """Service groups using this certificate.""" -class CreateInstanceRequestAutokill(BaseModel): - """ - Automatic delete-on-idle/request-limit configuration for non-template - instances. - """ +class CertificateValidation(BaseModel): + """Validation status for a pending certificate.""" # 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. @@ -1307,58 +1066,54 @@ class CreateInstanceRequestAutokill(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - time_ms: int | None = None - """ - Time in milliseconds after the instance was stopped before it is deleted. - A value of 0 disables time-based autokill. - """ - num_requests: int | None = None - """ - Maximum number of requests/connections the instance serves before it is - deleted. A value of 0 disables request-based autokill. - """ - + attempt: int | None = None + """The current validation attempt number.""" + next: datetime | None = None + """The next validation attempt time.""" -class CreateInstanceRequestDomain(BaseModel): - """ - The domain configuration for the service group. - A domain defines a publicly accessible domain name for the instance. If - the domain name ends with a period `.`, it must be a valid Fully Qualified - Domain Name (FQDN), otherwise it will become a subdomain of the target - metro. The domain can be associated with an existing certificate by - specifying the certificate's name or UUID. If no certificate is specified - and a FQDN is provided, Unikraft Cloud will automatically generate a new - certificate for the domain based on Let's Encrypt and seek to accomplish a - DNS-01 challenge. - """ +class CreateCertificateRequest(BaseModel): + """The request message for creating/uploading a new certificate.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) name: str | None = None """ - Publicly accessible domain name. + The name of the certificate. - If this name ends in a period `.` it must be a valid Full Qualified - Domain Name (FQDN), e.g. `example.com.`; otherwise it will become a - subdomain of the target metro, e.g. `example` becomes - `example.fra0.unikraft.app`. + This is a human-readable name that can be used to identify the certificate. + The name must be unique within the context of your account. If no name is + specified, a random name is generated for you. The name can also be used + to identify the certificate in API calls. """ - certificate: NameOrUUID | None = None + cn: str | None = None """ - A reference to an existing certificate which can be used for the - specified domain. If unspecified, Unikraft Cloud will - automatically generate a new certificate for the domain based on Let's - Encrypt and seek to accomplish a DNS-01 challenge. + The common name (CN) of the certificate. + + Deprecated: Use `common_name` instead. + """ + common_name: str | None = None + """ + The common name (CN) of the certificate. + + This must be a fully-qualified domain name (FQDN). Exactly one of `cn` + or `common_name` must be specified. """ + chain: str + """ + The certificate chain in PEM format. Required for user-uploaded + certificates. + """ + pkey: str + """The private key in PEM format. Required for user-uploaded certificates.""" -class CreateInstanceRequestNetworkInterface(BaseModel): - """Network interface configuration for a new instance.""" +class CreateCertificateResponse(BaseModel): + """The response message for creating a new certificate.""" # 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. @@ -1366,35 +1121,20 @@ class CreateInstanceRequestNetworkInterface(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - name: str | None = None - """ - The interface name. If omitted, Unikraft Cloud generates one as - -ethX, falling back to eth- when the - instance name is too long. - """ - tap_name: str | None = None - """ - The TAP device to attach the interface to. Provide it together - with ip. - """ - ip: str | None = None - """ - The interface IP address in CIDR notation. Provide it together - with tap_name to bring your own interface. - """ - autoconfig: bool | None = None - """Whether the guest configures the interface itself. Defaults to true.""" - relay: NetworkInterfaceRelay | None = None - """Relay configuration for this interface.""" + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: CreateCertificateResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" -class CreateInstanceRequestPlugin(BaseModel): - """ - A helper program attached to the instance and reachable over a direct, - authenticated HTTP endpoint. A plugin runs inside the instance next to the - main application, loads from its own ROM image, and answers requests that - the Unikraft Cloud API forwards to it. - """ +class CreateCertificateResponseData(BaseModel): + """The response data for this request.""" # 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. @@ -1402,31 +1142,14 @@ class CreateInstanceRequestPlugin(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - name: str | None = None - """ - The plugin name. It becomes the `` segment in the plugin - endpoint (`.../plugins//`). A plugin name has a - maximum length of 63 characters and contains only letters (`a`-`z`, - `A`-`Z`), digits (`0`-`9`), hyphen (`-`), and underscore (`_`). - """ - rom: str | ImageSpec | None = None - """ - The plugin's ROM image. The platform loads the image, mounts it at - `/uk/plugins/`, and runs its `init` program when the plugin - starts. Accepts either a plain image reference string - (`"user/myplugin:latest"`) or an object carrying additional pull - configuration (`{"url": "user/myplugin:latest", "pull_policy": "always"}`). - """ - config: Any | None = None - """ - (Optional). Arbitrary JSON configuration that the platform passes to the - plugin's `init` program on `STDIN`. Any JSON value works, including a - string, a number, or an object. - """ + certificates: list[Certificate] | None = None -class CreateInstanceRequestRom(BaseModel): - """Read-Only Memory (ROM) blob to attach to the instance.""" +class DeleteCertificatesResponse(BaseModel): + """ + The response message for deleting of one or more certificate(s) given their + UUID(s) or name(s). + """ # 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. @@ -1434,42 +1157,20 @@ class CreateInstanceRequestRom(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - name: str | None = None - """The name of the ROM to use for the instance configuration.""" - image: str | ImageSpec | None = None - """ - (Optional). The image of the ROM to use for the instance configuration. - Mutually exclusive with `files`. Accepts either a plain image reference - string (`"nginx:latest"`) or an object carrying additional pull - configuration (`{"url": "nginx:latest", "pull_policy": "always"}`). - """ - files: list[InlineFile] | None = None - """ - (Optional). Inline files to use as the ROM content. When specified, - the platform creates an EROFS image from the provided files. - Mutually exclusive with `image`. - """ - at: str | None = None - """ - (Optional). The path at which the ROM should be automatically mounted - inside the instance. When set, the platform mounts the ROM device at - the specified path so the guest does not need to mount it manually. - When omitted, the ROM is exposed as a raw block device and the guest is - responsible for mounting it. - """ - + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: DeleteCertificatesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" -class CreateInstanceRequestServiceGroup(BaseModel): - """ - The service group configuration when creating an instance. - If no existing (persistent) service group is specified via its identifier, - a new (ephemeral) service group can be created by specifying the services - it should expose. A service defines the configuration settings of an - exposed port by the instance. A service is a combination of a public port, - an internal port, and a set of handlers that define how the service will - handle incoming connections. - """ +class DeleteCertificatesResponseData(BaseModel): + """The response data for this request.""" # 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. @@ -1477,85 +1178,11 @@ class CreateInstanceRequestServiceGroup(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - domains: list[CreateInstanceRequestDomain] | None = None - """ - If no existing (persistent) service group is specified via its - identifier, a new (ephemeral) service group can be created. In addition - to the services it must expose, you can specify which domains it should - use too. - """ - services: list[Service] | None = None - """ - If no existing service group identifier is provided, one or more new - (ephemeral, non-persistent) service(s) can be created with the following - definitions. - """ - soft_limit: int | None = None - """ - The soft limit for the number of services that can be created in this - service group. - """ - hard_limit: int | None = None - """ - The hard limit for the number of services that can be created in this - service group. - """ - uuid: str | None = None - """ - (Optional). Reference an existing (persistent) service group by its - UUID. Mutually exclusive with name. - """ - name: str | None = None - """ - (Optional). Reference an existing (persistent) service group by its - name. Mutually exclusive with UUID. - """ + certificates: list[DeleteCertificatesResponseDeletedCertificate] | None = None -class CreateInstanceRequestTemplate(BaseModel): - """Defines the source template used to build a new instance.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - prepare: bool | None = None - """ - (Optional). Whether the instance needs to run in order to reach template - state - """ - uuid: str | None = None - """ - (Optional). The UUID of a template instance to create the instance from. - Mutually exclusive with name. - """ - name: str | None = None - """ - (Optional). The name of a template instance to create the instance from. - Mutually exclusive with UUID. - """ - create_args: Instance | None = None - """ - (Optional). Configuration parameters to apply when building the new instance - from the source template. - """ - prepare_timeout_s: int | None = None - """ - (Optional). Timeout in seconds for preparing the template before the - preparation is aborted. Only applies when `prepare` is set. A value of - 0 means no timeout. - """ - autokill: TemplateAutokill | None = None - """ - (Optional). Automatic delete-on-idle configuration for the template. - Only applies when `prepare` is set. - """ - - -class CreateInstanceRequestVolume(BaseModel): - """A volume defines a storage volume that can be attached to the instance.""" +class DeleteCertificatesResponseDeletedCertificate(BaseModel): + """Per-item result for a delete certificates operation.""" # 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. @@ -1563,66 +1190,23 @@ class CreateInstanceRequestVolume(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" uuid: str | None = None - """ - The UUID of an existing volume. - - If this is the only specified field, then it will look up an existing - volume by this UUID. - """ + """The UUID of the resource.""" name: str | None = None - """ - The name of the volume. + """The human-readable name of the resource.""" - If this is the only specified field, then it will look up an existing - volume by this name. If the volume does not exist, the request will - fail. If a new volume is intended to be created, then this field must be - specified along with the mount point in the instance and a provisioning - source (size_mb or host_path). - """ - at: str | None = None - """The mount point for the volume in the instance.""" - readonly: bool | None = None - """ - Whether the volume is read-only. - If this field is set to true, the volume will be mounted as read-only in - the instance. This field is optional and defaults to false and is only - applicable when using an existing volume. - """ - quota_policy: str | None = None - """Quota policy for the volume.""" - filesystem: str | None = None - """ - Filesystem type to format or configure. - Without custom configuration, this is either `ext4` or `virtiofs`. - """ - tags: list[str] | None = None - """Tags to assign to the new volume.""" - uid: int | None = None - """Guest UID for managed volumes (host_path mode only).""" - gid: int | None = None - """Guest GID for managed volumes (host_path mode only).""" - args: dict[str, str] | None = None - """Script arguments passed to volume initialization scripts.""" - access_mode: VolumeAccessMode | None = None - """ - Access mode of the volume, controlling sharing behavior. - Defaults to read-write by a single instance (RWO). - """ - size_mb: int | None = None +class GetCertificatesResponse(BaseModel): """ - The size of the volume when creating a new volume. - - When creating a new volume as part of the instance create request, - specify the size of the volume in MiB. + The response message for getting one or more certificate(s) given their + UUID(s) or name(s). """ - host_path: str | None = None - """A host path to create a managed volume from.""" - - -class CreateInstanceResponse(BaseModel): - """The response message for creating a new instance.""" # 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. @@ -1633,112 +1217,103 @@ class CreateInstanceResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: CreateInstanceResponseData | None = None + """An optional message providing additional information about the status.""" + data: GetCertificatesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class CreateInstanceResponseData(BaseModel): +class GetCertificatesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - instances: list[Instance] | None = None - """The instance that was created in this request.""" + certificates: list[Certificate] | None = None -class CreateInstanceScaleToZero(BaseModel): +class UpdateCertificateByUUIDRequestBody(BaseModel): + """The request body for updating a certificate by its UUID or name.""" + # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - policy: InstanceScaleToZeroPolicy | None = None - """The specific policy to use for scaling the instance to zero.""" - stateful: bool | None = None - """ - Whether the instance should be stateful when scaled to zero. If set to - true, the instance will retain its state (e.g., RAM contents) when scaled - to zero. This is useful for instances that need to maintain their state - across scale-to-zero operations. If set to false, the instance will lose - its state when scaled to zero, and it will be restarted from scratch when - scaled back up. - """ - cooldown_time_ms: int | None = None + chain: str """ - The cooldown time in milliseconds before the instance can be scaled to - zero again. This is useful to prevent rapid scaling to zero and back up, - which can lead to performance issues or resource exhaustion. + The new certificate chain. This is the public chain of the certificate in + PEM format. The chain should include the certificate and any intermediate + certificates. """ - notify_time_ms: int | None = None + pkey: str """ - The notification time in milliseconds before the instance is scaled to - zero. This allows the instance to perform any necessary cleanup or state - saving before being scaled down. + The new private key. This is the private key of the certificate in PEM + format. The private key must match the public key in the certificate + chain. """ -class CreateServiceGroupRequest(BaseModel): - """The request message for creating a new service group.""" +class UpdateCertificatesRequestItem(BaseModel): + """A single update operation to be applied to a certificate.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) + uuid: str | None = None + """The UUID of the resource.""" name: str | None = None + """The name of the resource.""" + chain: str """ - Name of the service group. This is a human-readable name that can be used - to identify the service group. The name must be unique within the context - of your account. If no name is specified, a random name is generated for - you. The name can also be used to identify the service group in API calls. + The new certificate chain. This is the public chain of the certificate in + PEM format. The chain should include the certificate and any intermediate + certificates. """ - services: list[Service] | None = None - """Description of exposed services.""" - domains: list[CreateServiceGroupRequestDomain] | None = None - """Description of domains associated with the service group.""" - soft_limit: int | None = None + pkey: str + """ + The new private key. This is the private key of the certificate in PEM + format. The private key must match the public key in the certificate + chain. """ - The soft limit is used by the Unikraft Cloud load balancer to decide when - to wake up another standby instance. - For example, if the soft limit is set to 5 and the service consists of 2 - standby instances, one of the instances receives up to 5 concurrent - requests. The 6th parallel requests wakes up the second instance. If - there are no more standby instances to wake up, the number of requests - assigned to each instance will exceed the soft limit. The load balancer - makes sure that when the number of in-flight requests goes down again, - instances are put into standby as fast as possible. + +class UpdateCertificatesResponse(BaseModel): """ - hard_limit: int | None = None + The response message for updating one or more certificate(s) given their + UUID(s) or name(s). """ - The hard limit defines the maximum number of concurrent requests that an - instance assigned to the this service can handle. - The load balancer will never assign more requests to a single instance. In - case there are no other instances available, excess requests fail (i.e., - they are blocked and not queued). - """ - autokill: CreateServiceGroupRequestAutokill | None = None - """Automatic delete-on-idle configuration.""" + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: UpdateCertificatesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" -class CreateServiceGroupRequestAutokill(BaseModel): - """Automatic delete-on-idle configuration for service groups.""" + +class UpdateCertificatesResponseData(BaseModel): + """The response data for this request.""" # 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. @@ -1746,15 +1321,11 @@ class CreateServiceGroupRequestAutokill(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - time_ms: int | None = None - """ - Time in milliseconds after the service group becomes empty before it is - deleted. A value of 0 disables autokill. - """ + certificates: list[Certificate] | None = None -class CreateServiceGroupRequestDomain(BaseModel): - """A domain name""" +class ID(BaseModel): + """Common identity fields for a resource.""" # 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. @@ -1762,21 +1333,14 @@ class CreateServiceGroupRequestDomain(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + uuid: str | None = None + """The UUID of the resource.""" name: str | None = None - """ - Publicly accessible domain name. If this name ends in a period `.` it must - be a valid Full Qualified Domain Name (FQDN), otherwise it will become a - subdomain of the target metro. - """ - certificate: NameOrUUID | None = None - """ - Use an existing certificate for the domain. If this field is - specified, the domain must be associated with a valid certificate. - """ + """The human-readable name of the resource.""" -class CreateServiceGroupResponse(BaseModel): - """The response message for creating of a service group.""" +class InlineFile(BaseModel): + """An inline file entry represents a single file within an image.""" # 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. @@ -1784,42 +1348,31 @@ class CreateServiceGroupResponse(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: CreateServiceGroupResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + path: str | None = None + """The file path within the image.""" + encoding: InlineDataEncoding | None = None + """The encoding of the data field. Defaults to "text".""" + data: str | None = None + """The file data, encoded according to the encoding field.""" -class CreateServiceGroupResponseData(BaseModel): +class NameOrUUID(BaseModel): + """An identifier for a resource — either a name or a UUID, but not both.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - service_groups: list[ServiceGroup] | None = None - """ - The service group which was created by this request. - - Note: only one service group can be specified in the request, so this - will always contain a single entry. - """ + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" -class CreateTemplateInstancesRequestItem(BaseModel): - """A single template instance to be created.""" +class ResponseError(BaseModel): + """The error response message for an API request.""" # 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. @@ -1827,28 +1380,23 @@ class CreateTemplateInstancesRequestItem(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - timeout_s: int | None = None - """ - Timeout in seconds to wait for the template instances to be created. - A value of -1 means to wait indefinitely until the instance reaches the - desired state. No wait performed for a value of 0. - """ - autokill: ItemAutokill | None = None - """(Optional). Automatic delete-on-idle configuration for the new template.""" - uuid: str | None = None - """ - The UUID of the instance to convert into template. Mutually exclusive - with name. - """ - name: str | None = None - """ - The name of the instance to convert into template. Mutually exclusive - with UUID. - """ + status: int | None = None + """The HTTP status code of the error.""" -class CreateTemplateInstancesResponse(BaseModel): - """The response message for creating one or more template instances.""" +class GetImagesRequestTagOrDigest(BaseModel): + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + digest: str | None = None + tag: str | None = None + + +class GetImagesResponse(BaseModel): + """The response message for getting one or more image(s).""" # 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. @@ -1859,173 +1407,116 @@ class CreateTemplateInstancesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: CreateTemplateInstancesResponseData | None = None + """An optional message providing additional information about the status.""" + data: GetImagesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class CreateTemplateInstancesResponseData(BaseModel): +class GetImagesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - instances: list[CreateTemplateInstancesResponseTemplateInstance] | None = None - """List of template instances that were created during the operation.""" + images: list[Image] | None = None -class CreateTemplateInstancesResponseTemplateInstance(BaseModel): +class Image(BaseModel): + """An image representing a VM which can be deployed on Unikraft Cloud.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of this particular template instance creation operation.""" - uuid: str | None = None - """The UUID of the template instance that was created.""" - name: str | None = None - """The name of the template instance that was created.""" - state: InstanceState | None = None - """The current state of the instance.""" - message: str | None = None + url: str | None = None + """The image URL.""" + created_at: datetime | None = None + """The time the image was created.""" + initrd_or_rom: bool | None = None + """Whether the image is an initrd or ROM.""" + size_in_bytes: int | None = None + """The size of the image in bytes.""" + args: list[str] | None = None + """Command-line arguments for the image.""" + env: dict[str, str] | None = None + """Environment variables for the image.""" + tags: list[str] | None = None + """Tags associated with the image.""" + users: list[str] | None = None + """Users associated with the image.""" + persistent: bool | None = None """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + Whether the image is pinned and exempt from cache eviction. Only + populated (and only ever `true`) for callers with image manager + permissions; omitted otherwise, including when the image is not pinned. """ -class CreateTemplateVolumesResponse(BaseModel): - """The response message for creating one or more template volumes.""" +class PinImageRequestItem(BaseModel): + """The request item for pinning a single image.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None + url: str + """The image URL to pull and pin.""" + credentials: str | None = None """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. + Optional credentials for authenticating to an OCI registry. + Only valid for OCI registry URLs; the platform rejects this + field for non-OCI schemes. """ - data: CreateTemplateVolumesResponseData | None = None - """The response data for this request""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None + headers: dict[str, str] | None = None + """Optional HTTP headers to send when fetching the image.""" + pull_policy: PullPolicy | None = None """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. + Controls when the image is pulled relative to what is already cached on + the node. If unset, this is inferred from the URL. """ - - -class CreateTemplateVolumesResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - volumes: list[CreateTemplateVolumesResponseTemplateVolume] | None = None - """The template volume(s) which were created by the request.""" - - -class CreateTemplateVolumesResponseTemplateVolume(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - status: ResponseStatus | None = None - """The status of the response.""" - uuid: str | None = None - """The UUID of the volume converted into a template.""" - name: str | None = None - """The name of the volume converted into a template.""" - state: VolumeState | None = None - """The state of the volume.""" - message: str | None = None + timeout_s: int """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. + Number of seconds to wait for the pull to complete. Required and must + be non-zero; `-1` waits up to the platform's maximum timeout. """ - error: int | None = None + merge_requests: bool | None = None """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + Avoid duplicate pulls by merging with any in-flight request for the + same image. Defaults to `true`. """ + autokill: PinImageRequestItemAutokill | None = None + """Automatically unpin the image after a period of inactivity.""" -class CreateVolumeRequest(BaseModel): - """The request message for creating a volume.""" +class PinImageRequestItemAutokill(BaseModel): + """Automatically unpin the image after a period of inactivity.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - name: str | None = None - """ - The name of the volume. - - This is a human-readable name that can be used to identify the volume. - The name must be unique within the context of your account. If no name is - specified, a random name of the form `vol-X` is generated for you, where - `X` is a 5 character long random alphanumeric suffix.. The name can also - be used to identify the volume in API calls. - """ - quota_policy: VolumeQuotaPolicy | None = None - """Quota policy for the volume.""" - filesystem: str | None = None - """ - Filesystem type to format or configure. - Without custom configuration, this is either `ext4` or `virtiofs`. - """ - tags: list[str] | None = None - """Tags to assign to the new volume.""" - uid: int | None = None - """Guest UID for managed volumes (host_path mode only).""" - gid: int | None = None - """Guest GID for managed volumes (host_path mode only).""" - args: dict[str, str] | None = None - """Script arguments passed to volume initialization scripts.""" - access_mode: VolumeAccessMode | None = None + time_ms: int """ - The access mode of the volume, controlling volume sharing behavior. - Defaults to `rwo` if not specified. + Automatically unpin the image after this many milliseconds of + inactivity. `0` (the default) disables this. """ - size_mb: int | None = None - """The size of the volume in megabytes.""" - host_path: str | None = None - """A host path to create a managed volume from.""" - template: NameOrUUID | None = None - """Source template volume to clone from.""" -class CreateVolumeResponse(BaseModel): - """The response message for creating of a volume.""" +class PinImagesResponse(BaseModel): + """The response message for pinning one or more images.""" # 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. @@ -2036,32 +1527,35 @@ class CreateVolumeResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: CreateVolumeResponseData | None = None + """An optional message providing additional information about the status.""" + data: PinImagesResponseData | None = None + """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class CreateVolumeResponseData(BaseModel): +class PinImagesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - volumes: list[CreateVolumeResponseVolume] | None = None - """The volume(s) which were created by the request.""" + images: list[PinImagesResponseImage] | None = None + """The result of pinning each requested image.""" -class CreateVolumeResponseVolume(BaseModel): +class PinImagesResponseImage(BaseModel): + """ + The result of pinning a single image. On success, `uuid` through `tags` + are set; on failure, only `message` and `error` are set (the image + being pulled is not otherwise identified in the response). + """ + # 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. # Every field is optional: one response shape serves several requests, and a @@ -2069,54 +1563,49 @@ class CreateVolumeResponseVolume(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) status: ResponseStatus | None = None - """The status of the response.""" - uuid: str | None = None - """UUID of the newly created volume.""" - name: str | None = None - """The name of the newly created volume.""" - state: VolumeState | None = None - """The state of the volume.""" + """Indicates whether the operation was successful for this item.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information.""" error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the image. Only set on success.""" + name: str | None = None + """The name of the image. Only set on success.""" + created_at: datetime | None = None + """The time the image was created. Only set on success.""" + state: str | None = None + """The current state of the image (e.g. `ready`). Only set on success.""" + url: str | None = None + """The image URL. Only set on success.""" + persistent: bool | None = None """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + Whether the image is pinned and exempt from cache eviction. Only set + on success, where it is always `true`. """ + tags: list[str] | None = None + """The tags associated with the image. Only set on success.""" -class DataLicense(BaseModel): - """License information (admin only).""" +class UnpinImageRequestItem(BaseModel): + """The request item for unpinning a single image.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - serial: str | None = None - """The serial number of the license certificate, hex-encoded.""" - valid: bool | None = None - """Whether the license is currently valid.""" - features: list[str] | None = None - """List of enabled features.""" - - -class DataResult(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) + uuid: str + """ + The UUID of the image to unpin. Only UUID is supported; name, URL, + tag, and digest are not. + """ - uuid: str | None = None - added: bool | None = None +class UnpinImagesResponse(BaseModel): + """The response message for unpinning one or more images.""" -class DeleteAutoscaleConfigurationPolicyResponse(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a @@ -2126,33 +1615,31 @@ class DeleteAutoscaleConfigurationPolicyResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: DeleteAutoscaleConfigurationPolicyResponseData | None = None + """An optional message providing additional information about the status.""" + data: UnpinImagesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class DeleteAutoscaleConfigurationPolicyResponseData(BaseModel): +class UnpinImagesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - policies: list[DeleteAutoscaleConfigurationPolicyResponsePoliciesResponse] | None = None - """The policies which were deleted by the request.""" + images: list[UnpinImagesResponseImage] | None = None + """The result of unpinning each requested image.""" -class DeleteAutoscaleConfigurationPolicyResponsePoliciesResponse(BaseModel): +class UnpinImagesResponseImage(BaseModel): + """The result of unpinning a single image.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -2160,86 +1647,84 @@ class DeleteAutoscaleConfigurationPolicyResponsePoliciesResponse(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) status: ResponseStatus | None = None - """The status of the response.""" - name: str | None = None - """The name of the service of the deleted policy.""" + """Indicates whether the operation was successful for this item.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information.""" error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the image.""" + name: str | None = None """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + The name of the image. Only set on success, and only if the image + has a name. """ -class DeleteAutoscaleConfigurationsResponse(BaseModel): +class CheckpointAutokill(BaseModel): + """Automatic delete-on-idle configuration for the checkpoint instance.""" + # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None + time_ms: int | None = None """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. + Time in milliseconds after the checkpoint was last used for restoring + before it is deleted. A value of 0 disables checkpoint autokill. """ - data: DeleteAutoscaleConfigurationsResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None + + +class CheckpointHistoryEntry(BaseModel): """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. + A checkpoint history entry, representing a single checkpoint in the history + of an instance. """ - -class DeleteAutoscaleConfigurationsResponseData(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - service_groups: list[DeleteAutoscaleConfigurationsResponseServiceGroup] | None = None - """The configuration(s) which were deleted by the request.""" + uuid: str | None = None + """The UUID of the checkpoint.""" + name: str | None = None + """The name of the checkpoint.""" + created_at: datetime | None = None + """The time the checkpoint was created.""" -class DeleteAutoscaleConfigurationsResponseServiceGroup(BaseModel): +class CreateCheckpointInstancesRequestItem(BaseModel): + """A single checkpoint creation request.""" + # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - uuid: str | None = None - """The UUID of the service where the configuration was deleted.""" + from_: NameOrUUID = Field(alias="from") + """The source instance to create a checkpoint from (by name or UUID).""" name: str | None = None - """The name of the service where the configuration was deleted.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None + """The name of the checkpoint. If not provided, a name will be generated.""" + timeout_s: int | None = None """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + Timeout in seconds to wait for the checkpoint to be created. + No wait performed for a value of 0. """ + tags: list[str] | None = None + """Tags to associate with the checkpoint.""" + autokill: CheckpointAutokill | None = None + """Automatic delete-on-idle configuration for the new checkpoint.""" -class DeleteCertificatesResponse(BaseModel): +class CreateCheckpointInstancesResponse(BaseModel): """ - The response message for deleting of one or more certificate(s) given their - UUID(s) or name(s). + The response message for creating one or more checkpoint(s) from existing + instance(s). """ # Fields the specification does not describe are kept rather than dropped, @@ -2251,34 +1736,17 @@ class DeleteCertificatesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: DeleteCertificatesResponseData | None = None + """An optional message providing additional information about the status.""" + data: CreateCheckpointInstancesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - - -class DeleteCertificatesResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - certificates: list[DeleteCertificatesResponseDeletedCertificate] | None = None - """The certificate(s) which were deleted by the request.""" + """The operation time in microseconds.""" -class DeleteCertificatesResponseDeletedCertificate(BaseModel): - """Details of the certificate which was deleted by this request.""" +class CreateCheckpointInstancesResponseCheckpointInstance(BaseModel): + """Per-item result for a create checkpoint instances operation.""" # 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. @@ -2287,28 +1755,21 @@ class DeleteCertificatesResponseDeletedCertificate(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) status: ResponseStatus | None = None - """ - Indicates whether the delete operation was successful or not for this - certificate. - """ - uuid: str | None = None - """The UUID of the certificate which was deleted.""" - name: str | None = None - """The name of the certificate which was deleted.""" + """Indicates whether the operation was successful for this item.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information.""" error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + state: InstanceState | None = None + """The current state of the checkpoint.""" -class DeleteCheckpointInstancesResponse(BaseModel): - """The response message for deleting one or more checkpoint instances.""" +class CreateCheckpointInstancesResponseData(BaseModel): + """The response data for this request.""" # 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. @@ -2316,274 +1777,489 @@ class DeleteCheckpointInstancesResponse(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: DeleteCheckpointInstancesResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + instances: list[CreateCheckpointInstancesResponseCheckpointInstance] | None = None + """List of checkpoint instances that were created during the operation.""" -class DeleteCheckpointInstancesResponseCheckpointInstance(BaseModel): +class CreateInstanceRequest(BaseModel): + """The request message for creating a new instance.""" + # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the checkpoint instance that was deleted.""" name: str | None = None - """The name of the checkpoint instance that was deleted.""" - status: ResponseStatus | None = None - """The status of this particular checkpoint instance deletion operation.""" - message: str | None = None """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None + The name of the instance. + + If not provided, a random name will be generated. The name must be + unique. """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + image: str | ImageSpec | None = None """ + The image to use for the instance. - -class DeleteCheckpointInstancesResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - instances: list[DeleteCheckpointInstancesResponseCheckpointInstance] | None = None + Either an image or a template must be specified. Accepts either a plain + image reference string (`"nginx:latest"`) or an object carrying additional + pull configuration (`{"url": "nginx:latest", "pull_policy": "always"}`). """ - List of checkpoint instances that were processed during the delete - operation. + args: list[str] | None = None + """The arguments to pass to the instance when it starts.""" + env: dict[str, str] | None = None + """Environment variables to set for the instance.""" + memory_mb: int | None = None + """Memory in MB to allocate for the instance. Default is 128.""" + service_group: CreateInstanceRequestServiceGroup | None = None """ + The service group configuration when creating an instance. + When creating an instance, either a previously created (persistent) + service group can be referenced (either through its name or UUID), or a + new (ephemeral) service group can be created for the instance by + specifying the list of services it should expose and optionally the + domains it should use. Not used by template instances. + """ + volumes: list[CreateInstanceRequestVolume] | None = None + """ + Volumes to attach to the instance. -class DeleteInstanceByUUIDRequestBody(BaseModel): - """Parameters for deleting the instance by UUID.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - timeout_s: int | None = None + This list can contain both existing and new volumes to create as part of + the instance creation. Existing volumes can be referenced by their name or + UUID. New volumes can be created by specifying a name, size in MiB, and + mount point in the instance. The mount point is the directory in the + instance where the volume will be mounted. + """ + autostart: bool | None = None + """ + Whether the instance should start automatically on creation. + Must be set to true when `timeout_s` is specified. """ - Timeout in seconds to wait for the instance to be deleted. No wait + replicas: int | None = None + """ + Number of additional replicas to create. The total number of instances + created is `replicas + 1`. Defaults to 0. + """ + restart_policy: InstanceRestartPolicy | None = None + """ + Restart policy for the instance. This defines how the instance + should behave when it stops or crashes. Cannot be combined with + the `delete-on-stop` feature. + """ + scale_to_zero: CreateInstanceScaleToZero | None = None + """ + Scale-to-zero configuration for the instance. Requires + `service_group` to be set. Cannot be combined with the + `delete-on-stop` feature. + """ + vcpus: int | None = None + """Number of vCPUs to allocate for the instance. Defaults to 1.""" + wait_timeout_ms: int | None = None + """ + Deprecated: Use `timeout_s` instead. Timeout in milliseconds to + wait for all new instances to reach running state. Requires + `autostart` to be set. If `timeout_s` is not set, this value is + converted by rounding up to the next full second. No wait performed for a value of 0. """ - dont_retain: bool | None = None + features: list[InstanceFeature] | None = None """ - Delete immediately without retention. If the instance is already - being retained, this will force its deletion. Ignored if retention - for instances is not configured. + Features to enable for the instance. Features are specific + configurations or capabilities that can be enabled for the + instance. The `scale-to-zero` and `delete-on-stop` features are + mutually exclusive. + """ + timeout_s: int | None = None + """ + Timeout in seconds to wait for all new instances to reach running + state. Requires `autostart` to be set. If you autostart your + new instance, you can wait for it to finish starting with a + blocking API call if you specify a wait timeout greater than + zero. No wait performed for a value of 0. + """ + roms: list[CreateInstanceRequestRom] | None = None + """ + Read-Only Memory (ROM) blobs to attach to the instance. + Unikraft Cloud supports the ability to attach Read-Only Memory (ROM) blobs + to instances. It allows you to create a general-purpose base image and + then customize individual instances by attaching code or data as separate + ROM blobs. + """ + plugins: list[CreateInstanceRequestPlugin] | None = None + """ + Plugins to attach to the instance. Plugins let you attach small helper + programs to an instance and reach each one over a direct, authenticated + HTTP endpoint. Each plugin loads from its own ROM image, mounts at + `/uk/plugins/`, and is reachable at + `.../v1/instances//plugins//`. At most 8 plugins + may be attached to an instance. """ + tags: list[str] | None = None + """Tags to associate with the instance.""" + annotations: dict[str, str] | None = None + """ + Annotations to associate with the instance. + Unlike tags, annotations also reach the guest: they are included in the + instance's startdata, and selected keys can be injected into the console + log output. -class DeleteInstanceRequestItem(BaseModel): - """The request item for deleting an instance by its UUID or name.""" + Keys follow the Kubernetes annotation key syntax, `[/]`: + the optional prefix is a non-wildcard DNS subdomain of at most 253 + characters, and the name is at most 63 characters of `[-_.a-zA-Z0-9]` + starting and ending with an alphanumeric. Values are unconstrained apart + from ASCII control characters. An instance holds at most 256 annotations. - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) + When the instance inherits annotations from a template, branch, or + checkpoint, the given annotations are merged into them rather than + replacing them. On a key clash the value given here wins. + """ + template: CreateInstanceRequestTemplate | None = None + """ + Template instances. + An existing instance can be saved as a template. This template is then + used to create new instances that inherit the exact configuration and + state the original instance had when the template was created. + """ + sched_priority: SchedPriority | None = None + """ + The scheduling priority for the instance. Only settable by + users with scheduling priority override permissions. + """ + schedules: list[Schedule] | None = None + """ + Schedules for the instance. Scheduled operations let you automatically + start, stop, delete, or exec a command in the instance on a calendar- + based schedule. For `exec` schedules, set the `args` field to the command + and its arguments. Each instance stores its own schedules, and cloning + preserves them. + """ + autokill: CreateInstanceRequestAutokill | None = None + """ + Automatic delete-on-idle/request-limit configuration. Not used for + template instances. + """ + hostname: str | None = None + """ + The hostname of the instance. - timeout_s: int | None = None + If not provided, the hostname will be set to the instance name. The + hostname must be a valid DNS label (e.g., "my-instance") and is used for + internal DNS resolution within the Unikraft Cloud network. """ - Timeout in seconds to wait for the instance to be deleted. No wait - performed for a value of 0. + dependencies: list[NameOrUUID] | None = None """ - dont_retain: bool | None = None + Dependencies of the instance. + + A list of instance identifiers (name or UUID) that this instance depends + on. Dependencies define startup ordering and can be used to ensure that + prerequisite instances are running before this instance starts. """ - Delete immediately without retention. If the instance is already - being retained, this will force its deletion. Ignored if retention - for instances is not configured. + branch_from: NameOrUUID | None = None + """ + Reference to an existing instance to branch from. The instance can be + running or stopped. If the source instance is running, a snapshot will be + taken asynchronously and the new instance will wait for it to complete + before starting. Mutually exclusive with `image` and `template`. + """ + checkpoint: NameOrUUID | None = None + """ + Reference to an existing checkpoint to create the instance from. The + checkpoint must be in the `checkpoint` state. The new instance will be + created with the same configuration and state as the checkpoint. Mutually + exclusive with `image`, `template`, and `branch_from`. + """ + gateway: str | None = None + """The default gateway to configure inside the guest.""" + nameserver: str | None = None + """The DNS resolver to configure inside the guest.""" + network_interfaces: list[CreateInstanceRequestNetworkInterface] | None = None + """Network interfaces to attach to the instance.""" + type: InstanceType | None = None + """ + The type of virtual machine to use for the instance. Defaults to `micro`, + which runs on Firecracker. `full` runs on QEMU instead and is required + for GPU passthrough (see `gpus`) and, in the future, Windows VMs. QEMU- + backed instances currently do not support scale-to-zero, templates, + branching, or checkpointing, and only support block-based volumes (no + virtiofs). Requires a plan with full VM support and cannot be combined + with `template`, `branch_from`, or `checkpoint`. + """ + gpus: int | None = None + """ + Number of GPUs to attach to the instance. Currently restricted to at most + 1. Requires `type` to be `full` and a plan with GPU support. A GPU stays + assigned to the instance, even while stopped, until the instance is + deleted. Cannot be combined with `template`, `branch_from`, or + `checkpoint`. """ - uuid: str | None = None - """Mutually exclusive with name.""" - name: str | None = None - """Mutually exclusive with UUID.""" -class DeleteInstancesResponse(BaseModel): +class CreateInstanceRequestAutokill(BaseModel): """ - The response message for deleting one or more instance(s) given their - UUID(s) - or name(s). + Automatic delete-on-idle/request-limit configuration for non-template + instances. """ # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None + time_ms: int | None = None """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. + Time in milliseconds after the instance was stopped before it is deleted. + A value of 0 disables time-based autokill. """ - data: DeleteInstancesResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None + num_requests: int | None = None """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. + Maximum number of requests/connections the instance serves before it is + deleted. A value of 0 disables request-based autokill. """ -class DeleteInstancesResponseData(BaseModel): +class CreateInstanceRequestDomain(BaseModel): + """ + The domain configuration for the service group. A domain defines a publicly + accessible domain name for the instance. If the domain name ends with a + period `.`, it must be a valid Fully Qualified Domain Name (FQDN), otherwise + it will become a subdomain of the target metro. The domain can be associated + with an existing certificate by specifying the certificate's name or UUID. + If no certificate is specified and a FQDN is provided, Unikraft Cloud will + automatically generate a new certificate for the domain based on Let's + Encrypt and seek to accomplish a DNS-01 challenge. + """ + # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - instances: list[DeleteInstancesResponseInstance] | None = None - """The instance(s) which were deleted by the request.""" + name: str + """ + Publicly accessible domain name. + + If this name ends in a period `.` it must be a valid Full Qualified + Domain Name (FQDN), e.g. `example.com.`; otherwise it will become a + subdomain of the target metro, e.g. `example` becomes + `example.fra0.unikraft.app`. + """ + certificate: NameOrUUID | None = None + """ + A reference to an existing certificate which can be used for the + specified domain. If unspecified, Unikraft Cloud will + automatically generate a new certificate for the domain based on Let's + Encrypt and seek to accomplish a DNS-01 challenge. + """ -class DeleteInstancesResponseInstance(BaseModel): - """Details of the instance which was deleted by this request.""" +class CreateInstanceRequestNetworkInterface(BaseModel): + """Network interface configuration for a new instance.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None + name: str | None = None """ - Indicates whether the start operation was successful or not for this - instance. + The interface name. If omitted, Unikraft Cloud generates one as + -ethX, falling back to eth- when the + instance name is too long. """ - uuid: str | None = None - """The UUID of the instance which was deleted.""" - name: str | None = None - """The name of the instance which was deleted.""" - previous_state: str | None = None - """The previous state of the instance before it was deleted.""" - message: str | None = None + tap_name: str | None = None + """ + The TAP device to attach the interface to. Provide it together + with ip. """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. + ip: str | None = None """ - error: int | None = None + The interface IP address in CIDR notation. Provide it together + with tap_name to bring your own interface. + """ + mac: str | None = None """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + The interface MAC address. Provide it together with tap_name. Must be + a unicast address outside the platform's address pool. """ + autoconfig: bool | None = None + """Whether the guest configures the interface itself. Defaults to true.""" + relay: NetworkInterfaceRelay | None = None + """Relay configuration for this interface.""" -class DeletePolicyRequest(BaseModel): - """The request message to delete an autoscale configuration policy by name.""" +class CreateInstanceRequestPlugin(BaseModel): + """ + A helper program attached to the instance and reachable over a direct, + authenticated HTTP endpoint. A plugin runs inside the instance next to + the main application, loads from its own ROM image, and answers requests + that the Unikraft Cloud API forwards to it. + """ # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - name: str | None = None - """The Name of the policy to delete.""" - + name: str + """ + The plugin name. It becomes the `` segment in the plugin + endpoint (`.../plugins//`). A plugin name has a + maximum length of 63 characters and contains only letters (`a`-`z`, + `A`-`Z`), digits (`0`-`9`), hyphen (`-`), and underscore (`_`). + """ + image: str | ImageSpec | None = None + """ + The plugin's image. The platform loads the image, mounts it at + `/uk/plugins/`, and runs its `init` program when the plugin + starts. Accepts either a plain image reference string + (`"user/myplugin:latest"`) or an object carrying additional pull + configuration (`{"url": "user/myplugin:latest", "pull_policy": "always"}`). + Exactly one of `image` and `rom` must be set. + """ + rom: str | ImageSpec | None = None + """ + The plugin's image, under its former name. The platform still accepts + it and adds a deprecation warning to the response. Exactly one of + `image` and `rom` must be set. -class DeleteServiceGroupsResponse(BaseModel): + Deprecated: Use `image` instead. """ - The response message for deleting of one or more service group(s) given - their - UUID(s) or name(s). + config: Any | None = None """ + Arbitrary JSON configuration that the platform passes to the plugin's + `init` program on `STDIN`. Any JSON value works, including a string, a + number, or an object. + """ + + +class CreateInstanceRequestRom(BaseModel): + """A ROM to attach when creating an instance.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None + name: str + """The name of the ROM to use for the instance configuration.""" + image: str | ImageSpec | None = None """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. + The image of the ROM to use for the instance configuration. Mutually + exclusive with `files`. Accepts either a plain image reference string + (`"nginx:latest"`) or an object carrying additional pull configuration + (`{"url": "nginx:latest", "pull_policy": "always"}`). """ - data: DeleteServiceGroupsResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None + at: str | None = None """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. + The path at which the ROM should be automatically mounted inside the + instance. When set, the platform mounts the ROM device at the specified + path so the guest does not need to mount it manually. When omitted, the + ROM is exposed as a raw block device and the guest is responsible for + mounting it. + """ + files: list[InlineFile] | None = None + """ + Inline files to use as the ROM content. When specified, the platform + creates an EROFS image from the provided files. Mutually exclusive with + `image`. """ -class DeleteServiceGroupsResponseData(BaseModel): +class CreateInstanceRequestServiceGroup(BaseModel): + """ + The service group configuration when creating an instance. If no existing + (persistent) service group is specified via its identifier, a new + (ephemeral) service group can be created by specifying the services it + should expose. A service defines the configuration settings of an exposed + port by the instance. A service is a combination of a public port, an + internal port, and a set of handlers that define how the service will handle + incoming connections. + """ + # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - service_groups: list[DeleteServiceGroupsResponseDeletedServiceGroup] | None = None - """The service group(s) which were deleted by the request.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + domains: list[CreateInstanceRequestDomain] | None = None + services: list[Service] | None = None + soft_limit: int | None = None + hard_limit: int | None = None -class DeleteServiceGroupsResponseDeletedServiceGroup(BaseModel): - """Details of the service group which was deleted by this request.""" +class CreateInstanceRequestTemplate(BaseModel): + """Template configuration when creating an instance.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """ - Indicates whether the delete operation was successful or not for this - service group. - """ uuid: str | None = None - """The UUID of the service group which was deleted.""" + """The UUID of the resource.""" name: str | None = None - """The name of the service group which was deleted.""" - message: str | None = None + """The name of the resource.""" + prepare: bool | None = None + """Whether the instance needs to run in order to reach template state""" + create_args: Instance | None = None """ - An optional message providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. + Configuration parameters to apply when building the new instance from the + source template. """ - error: int | None = None + prepare_timeout_s: int | None = None + """ + Timeout in seconds for preparing the template before the preparation is + aborted. Only applies when `prepare` is set. A value of 0 means no + timeout. + """ + autokill: TemplateAutokill | None = None """ - An optional error code providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. + Automatic delete-on-idle configuration for the template. Only applies + when `prepare` is set. """ -class DeleteTemplateInstancesResponse(BaseModel): - """The response message for deleting one or more template instances.""" +class CreateInstanceRequestVolume(BaseModel): + """A volume defines a storage volume that can be attached to the instance.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + size_mb: int | None = None + host_path: str | None = None + at: str + readonly: bool | None = None + quota_policy: str | None = None + filesystem: str | None = None + tags: list[str] | None = None + uid: int | None = None + gid: int | None = None + access_mode: VolumeAccessMode | None = None + args: dict[str, str] | None = None + + +class CreateInstanceResponse(BaseModel): + """The response message for creating a new instance.""" # 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. @@ -2594,59 +2270,86 @@ class DeleteTemplateInstancesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: DeleteTemplateInstancesResponseData | None = None + """An optional message providing additional information about the status.""" + data: CreateInstanceResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class DeleteTemplateInstancesResponseData(BaseModel): +class CreateInstanceResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - instances: list[DeleteTemplateInstancesResponseTemplateInstance] | None = None - """List of template instances that were processed during the delete operation.""" + instances: list[Instance] | None = None -class DeleteTemplateInstancesResponseTemplateInstance(BaseModel): +class CreateInstanceScaleToZero(BaseModel): + """Scale-to-zero configuration when creating an instance.""" + # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the template instance that was deleted.""" - name: str | None = None - """The name of the template instance that was deleted.""" - status: ResponseStatus | None = None - """The status of this particular template instance deletion operation.""" - message: str | None = None + policy: InstanceScaleToZeroPolicy | None = None + """The specific policy to use for scaling the instance to zero.""" + stateful: bool | None = None + """ + Whether the instance should be stateful when scaled to zero. If set to + true, the instance will retain its state (e.g., RAM contents) when scaled + to zero. This is useful for instances that need to maintain their state + across scale-to-zero operations. If set to false, the instance will lose + its state when scaled to zero, and it will be restarted from scratch when + scaled back up. + """ + cooldown_time_ms: int | None = None """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. + The cooldown time in milliseconds before the instance can be scaled to + zero again. This is useful to prevent rapid scaling to zero and back up, + which can lead to performance issues or resource exhaustion. """ - error: int | None = None + notify_time_ms: int | None = None """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + The notification time in milliseconds before the instance is scaled to + zero. This allows the instance to perform any necessary cleanup or state + saving before being scaled down. """ -class DeleteTemplateVolumesResponse(BaseModel): - """The response message for deleting one or more template volumes.""" +class CreateTemplateInstancesRequestItem(BaseModel): + """A single template instance to be created.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + timeout_s: int | None = None + """ + Timeout in seconds to wait for the template instances to be created. + A value of -1 means to wait indefinitely until the instance reaches the + desired state. No wait performed for a value of 0. + """ + autokill: ItemAutokill | None = None + """Automatic delete-on-idle configuration for the new template.""" + + +class CreateTemplateInstancesResponse(BaseModel): + """The response message for converting one or more instance(s) to templates.""" # 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. @@ -2657,58 +2360,56 @@ class DeleteTemplateVolumesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: DeleteTemplateVolumesResponseData | None = None + """An optional message providing additional information about the status.""" + data: CreateTemplateInstancesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class DeleteTemplateVolumesResponseData(BaseModel): +class CreateTemplateInstancesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - volumes: list[DeleteTemplateVolumesResponseTemplateVolume] | None = None - """The template volume(s) which were deleted by the request.""" + instances: list[CreateTemplateInstancesResponseTemplateInstance] | None = None -class DeleteTemplateVolumesResponseTemplateVolume(BaseModel): +class CreateTemplateInstancesResponseTemplateInstance(BaseModel): + """Per-item result for a create template instances operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the template volume that was deleted.""" - name: str | None = None - """The name of the template volume that was deleted.""" status: ResponseStatus | None = None - """The status of this particular template volume deletion operation.""" + """Indicates whether the operation was successful for this item.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information.""" error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + state: InstanceState | None = None + """The current state of the instance.""" + + +class DeleteCheckpointInstancesResponse(BaseModel): """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + The response message for deleting one or more checkpoint instance(s) given + their UUID(s) or name(s). """ - -class DeleteVolumesResponse(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a @@ -2718,98 +2419,106 @@ class DeleteVolumesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: DeleteVolumesResponseData | None = None + """An optional message providing additional information about the status.""" + data: DeleteCheckpointInstancesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class DeleteVolumesResponseData(BaseModel): +class DeleteCheckpointInstancesResponseCheckpointInstance(BaseModel): + """Per-item result for a delete checkpoint instances operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - volumes: list[DeleteVolumesResponseDeletedVolume] | None = None - """The volume(s) which were deleted by the request.""" + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" -class DeleteVolumesResponseDeletedVolume(BaseModel): +class DeleteCheckpointInstancesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - uuid: str | None = None - """The UUID of the volume that was deleted.""" - name: str | None = None - """The name of the volume that was deleted.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None + instances: list[DeleteCheckpointInstancesResponseCheckpointInstance] | None = None """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + List of checkpoint instances that were processed during the delete + operation. """ -class DetachVolumeByUUIDRequestBody(BaseModel): +class DeleteInstanceByUUIDRequestBody(BaseModel): + """Parameters for deleting the instance by UUID.""" + # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - from_: NameOrUUID | None = Field(default=None, alias="from") + timeout_s: int | None = None + """ + Timeout in seconds to wait for the instance to be deleted. No wait + performed for a value of 0. + """ + dont_retain: bool | None = None """ - (Optional). UUID or name of the instance to detach the volume from. - If not specified, the volume is detached from all instances. + Delete immediately without retention. If the instance is already + being retained, this will force its deletion. Ignored if retention + for instances is not configured. """ -class DetachVolumesRequestItem(BaseModel): - """A single request of detaching a volume.""" +class DeleteInstanceRequestItem(BaseModel): + """The request item for deleting an instance by its UUID or name.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - from_: NameOrUUID | None = Field(default=None, alias="from") - """ - (Optional). UUID or name of the instance to detach the volume from. - If not specified, the volume is detached from all instances. - """ uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + timeout_s: int | None = None """ - The UUID of the volume to detach. Mutually exclusive with name. - Exactly one of uuid or name must be provided. + Timeout in seconds to wait for the instance to be deleted. No wait + performed for a value of 0. """ - name: str | None = None + dont_retain: bool | None = None """ - The name of the volume to detach. Mutually exclusive with UUID. - Exactly one of uuid or name must be provided. + Delete immediately without retention. If the instance is already + being retained, this will force its deletion. Ignored if retention + for instances is not configured. """ -class DetachVolumesResponse(BaseModel): +class DeleteInstancesResponse(BaseModel): + """ + The response message for deleting one or more instance(s) given their + UUID(s) or name(s). + """ + # 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. # Every field is optional: one response shape serves several requests, and a @@ -2819,33 +2528,30 @@ class DetachVolumesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: DetachVolumesResponseData | None = None + """An optional message providing additional information about the status.""" + data: DeleteInstancesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class DetachVolumesResponseData(BaseModel): +class DeleteInstancesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - volumes: list[DetachVolumesResponseDetachedVolume] | None = None - """The volume(s) which were detached by the request.""" + instances: list[DeleteInstancesResponseInstance] | None = None -class DetachVolumesResponseDetachedVolume(BaseModel): +class DeleteInstancesResponseInstance(BaseModel): + """Per-item result for a delete instances operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -2853,34 +2559,23 @@ class DetachVolumesResponseDetachedVolume(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) status: ResponseStatus | None = None - """The status of the response.""" - uuid: str | None = None - """The UUID of the volume that was detached.""" - name: str | None = None - """The name of the volume that was detached.""" + """Indicates whether the operation was successful for this item.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information.""" error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + previous_state: str | None = None + """The previous state of the instance before it was deleted.""" -class Domain(BaseModel): +class DeleteTemplateInstancesResponse(BaseModel): """ - A domain name. - - Domain names are completely specified with all labels in the hierarchy of - the - DNS, having no parts omitted. The domain can be associated with an existing - certificate by specifying the certificate's name or UUID. If no certificate - is specified and a FQDN is provided, Unikraft Cloud will automatically - generate a new certificate for the domain based on Let's Encrypt and seek to - accomplish a DNS-01 challenge. + The response message for deleting one or more template instance(s) given + their UUID(s) or name(s). """ # Fields the specification does not describe are kept rather than dropped, @@ -2889,130 +2584,33 @@ class Domain(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - fqdn: str | None = None - """ - Publicly accessible domain name. If this name ends in a period `.` it must - be a valid Full Qualified Domain Name (FQDN), otherwise it will become a - subdomain of the target metro. - """ - certificate: Certificate | None = None - """ - Use an existing certificate for the domain. If this field is - specified, the domain must be associated with a valid certificate. - """ - - -class GetAutoscaleConfigurationPolicyRequest(BaseModel): - """The request message to get an autoscale configuration policy by name.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - name: str | None = None - """The Name of the policy to get.""" - - -class GetAutoscaleConfigurationPolicyResponse(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: GetAutoscaleConfigurationPolicyResponseData | None = None + """An optional message providing additional information about the status.""" + data: DeleteTemplateInstancesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - - -class GetAutoscaleConfigurationPolicyResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) + """The operation time in microseconds.""" - policies: list[GetAutoscaleConfigurationPolicyResponsePolicyResponse] | None = None - """The policy which was retrieved by the request.""" - - -class GetAutoscaleConfigurationPolicyResponsePolicyResponse(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - status: ResponseStatus | None = None - """The status of the response.""" - policy: AutoscalePolicy | None = None - """The policy which was retrieved by the request.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ - - -class GetAutoscaleConfigurationsResponse(BaseModel): - """The response message for a GetAutoscaleConfigurationsRequest.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - status: GetAutoscaleConfigurationsResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: GetAutoscaleConfigurationsResponseData | None = None +class DeleteTemplateInstancesResponseData(BaseModel): """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - -class GetAutoscaleConfigurationsResponseData(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - service_groups: list[GetAutoscaleConfigurationsResponseServiceGroup] | None = None - """The configuration(s) which were retrieved by the request.""" + instances: list[DeleteTemplateInstancesResponseTemplateInstance] | None = None -class GetAutoscaleConfigurationsResponseServiceGroup(BaseModel): +class DeleteTemplateInstancesResponseTemplateInstance(BaseModel): + """Per-item result for a delete template instances operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -3020,96 +2618,23 @@ class GetAutoscaleConfigurationsResponseServiceGroup(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) status: ResponseStatus | None = None - """The status of the response.""" - uuid: str | None = None - """The UUID of the service where the configuration was created.""" - name: str | None = None - """The name of the service where the configuration was created.""" - enabled: bool | None = None - """If the autoscale configuration is enabled.""" - min_size: int | None = None - """ - The minimum number of instances to keep running. - Only if enabled is true. - """ - max_size: int | None = None - """ - The maximum number of instances to keep running. - Only if enabled is true. - """ - warmup_time_ms: int | None = None - """ - The warmup time in seconds for new instances. - Only if enabled is true. - """ - cooldown_time_ms: int | None = None - """ - The cooldown time in seconds for the autoscale configuration. - Only if enabled is true. - """ - template: ServiceGroupTemplate | None = None - """ - The instance template used for the autoscale configuration. - Only if enabled is true. - """ - policies: list[AutoscalePolicy] | None = None - """The policies applied to the autoscale configuration.""" + """Indicates whether the operation was successful for this item.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information.""" error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ - - -class GetCertificatesResponse(BaseModel): - """ - The response message for getting one or more certificate(s) given their - UUID(s) or name(s). - """ + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: GetCertificatesResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None +class GetCheckpointHistoryResponse(BaseModel): """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. + The response message for getting the checkpoint history of one or more + instance(s) given their UUID(s) or name(s). """ - -class GetCertificatesResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - certificates: list[Certificate] | None = None - """The certificate(s) which were retrieved by the request.""" - - -class GetCheckpointHistoryResponse(BaseModel): - """The response message for getting the checkpoint history.""" - # 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. # Every field is optional: one response shape serves several requests, and a @@ -3119,22 +2644,18 @@ class GetCheckpointHistoryResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information about the status.""" data: GetCheckpointHistoryResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" class GetCheckpointHistoryResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -3146,7 +2667,7 @@ class GetCheckpointHistoryResponseData(BaseModel): class GetCheckpointHistoryResponseInstanceHistory(BaseModel): - """History for a single instance.""" + """Per-item result for a get checkpoint/instance history operation.""" # 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. @@ -3154,28 +2675,25 @@ class GetCheckpointHistoryResponseInstanceHistory(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" uuid: str | None = None - """The UUID of the instance.""" + """The UUID of the resource.""" name: str | None = None - """The name of the instance.""" + """The human-readable name of the resource.""" history: list[CheckpointHistoryEntry] | None = None """The checkpoint history entries.""" - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ class GetCheckpointInstancesResponse(BaseModel): - """The response message for getting one or more checkpoint instances.""" + """ + The response message for getting one or more checkpoint instance(s) given + their UUID(s) or name(s). + """ # 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. @@ -3186,86 +2704,38 @@ class GetCheckpointInstancesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information about the status.""" data: GetCheckpointInstancesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" class GetCheckpointInstancesResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - instances: list[Instance] | None = None - """List of checkpoint instances that were retrieved during the operation.""" - - -class GetImagesRequestTagOrDigest(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - digest: str | None = None - tag: str | None = None - - -class GetImagesResponse(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """An optional message providing additional information about the response.""" - data: GetImagesResponseData | None = None """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - -class GetImagesResponseData(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - images: list[Image] | None = None - """The list of images.""" + instances: list[Instance] | None = None + """List of checkpoint instances that were retrieved during the operation.""" class GetInstanceLogsByUUIDRequestBody(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) offset: int | None = None """ - The byte offset of the log output to receive. A negative sign makes the + The byte offset of the log output to receive. A negative sign makes the offset relative to the end of the log. """ limit: int | None = None @@ -3273,31 +2743,25 @@ class GetInstanceLogsByUUIDRequestBody(BaseModel): class GetInstancesLogsRequestItem(BaseModel): - """A single item in the request.""" + """A single request item to get an instance's logs.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" offset: int | None = None """ - The byte offset of the log output to receive. A negative sign makes the + The byte offset of the log output to receive. A negative sign makes the offset relative to the end of the log. """ limit: int | None = None """The amount of bytes to return at most.""" - uuid: str | None = None - """ - The UUID of the instance to retrieve logs for. Mutually exclusive with - name. - """ - name: str | None = None - """ - The name of the instance to retrieve logs for. Mutually exclusive with - UUID. - """ class GetInstancesLogsResponse(BaseModel): @@ -3315,22 +2779,18 @@ class GetInstancesLogsResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information about the status.""" data: GetInstancesLogsResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" class GetInstancesLogsResponseAvailable(BaseModel): + """Per-item result for a get instances logs operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -3344,6 +2804,8 @@ class GetInstancesLogsResponseAvailable(BaseModel): class GetInstancesLogsResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -3351,48 +2813,38 @@ class GetInstancesLogsResponseData(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) instances: list[GetInstancesLogsResponseLoggedInstance] | None = None - """ - The instance which this requested waited on. - - Note: only one instance can be specified in the request, so this will - always contain a single entry. - """ class GetInstancesLogsResponseLoggedInstance(BaseModel): + """Per-item result for a get instances logs operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" uuid: str | None = None - """The UUID of the instance.""" + """The UUID of the resource.""" name: str | None = None - """The name of the instance.""" + """The human-readable name of the resource.""" output: str | None = None """Base64 encoded log output of the instance.""" available: GetInstancesLogsResponseAvailable | None = None """Description of the log availability.""" range: GetInstancesLogsResponseRange | None = None """ - Description of the range that was returned. Useful for requests with + Description of the range that was returned. Useful for requests with offset relative to end. """ state: InstanceState | None = None """State of the instance when the logs were retrieved.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ - status: ResponseStatus | None = None - """The status of the response.""" class GetInstancesLogsResponseRange(BaseModel): @@ -3410,8 +2862,8 @@ class GetInstancesLogsResponseRange(BaseModel): class GetInstancesMetricsResponse(BaseModel): """ - The response message for getting the metrics of one or more instance(s) - given their UUID(s) or name(s). + The response message for getting the metrics of one or more instance(s) by + their UUID(s) or name(s). """ # Fields the specification does not describe are kept rather than dropped, @@ -3423,22 +2875,18 @@ class GetInstancesMetricsResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information about the status.""" data: GetInstancesMetricsResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" class GetInstancesMetricsResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -3446,43 +2894,50 @@ class GetInstancesMetricsResponseData(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) instances: list[GetInstancesMetricsResponseInstanceMetrics] | None = None - """ - The instance which this requested metrics for. Note: only one instance - can be specified in the request, so this will always contain a single - entry. - """ class GetInstancesMetricsResponseInstanceMetrics(BaseModel): + """Per-item result for a get instances metrics operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" rss_bytes: int | None = None """ Resident set size of the VMM in bytes. The resident set size (RSS) specifies the amount of physical memory that has been touched by the instance and is currently reserved for the - instance on the Unikraft Cloud server. The RSS grows until the instance + instance on the Unikraft Cloud server. The RSS grows until the instance has touched all memory assigned to it via the memory_mb setting and may also exceed this value as supporting services running outside the - instance acquire memory. The RSS is different from the current amount of + instance acquire memory. The RSS is different from the current amount of memory allocated by the application, which is likely to fluctuate over - the lifetime of the application. The RSS is not a cumulative metric. + the lifetime of the application. The RSS is not a cumulative metric. When the instance is stopped rss goes down to 0. """ cpu_time_ms: int | None = None """Consumed CPU time in milliseconds.""" boot_time_us: int | None = None """ - The boot time of the instance in microseconds. We take a pragmatic - approach is to define the boot time. We calculate this as the difference + The boot time of the instance in microseconds. We take a pragmatic + approach is to define the boot time. We calculate this as the difference in time between the moment the virtualization toolstack is invoked to respond to a VM boot request and the moment the OS starts executing user - code (i.e., the end of the guest OS boot process). This is essentially the + code (i.e., the end of the guest OS boot process). This is essentially the time that a user would experience in a deployment, minus the application initialization time, which we leave out since it is independent from the OS. @@ -3490,9 +2945,9 @@ class GetInstancesMetricsResponseInstanceMetrics(BaseModel): net_time_us: int | None = None """ This is the time it took for the user-level application to start listening - on a non-localhost port measured in microseconds. This is the time from + on a non-localhost port measured in microseconds. This is the time from when the instance started until it reasonably ready to start responding to - network requests. This is useful for measuring the time it takes for the + network requests. This is useful for measuring the time it takes for the instance to become operationally ready. """ rx_bytes: int | None = None @@ -3511,61 +2966,11 @@ class GetInstancesMetricsResponseInstanceMetrics(BaseModel): """Number of queued inbound connections and HTTP requests.""" ntotal: int | None = None """Total number of inbound connections and HTTP requests handled.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ - status: ResponseStatus | None = None - """The status of the response.""" class GetInstancesResponse(BaseModel): - """The response after retrieving an instance by its name or UUID.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: GetInstancesResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - - -class GetInstancesResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - instances: list[Instance] | None = None - """The instance(s) that were retrieved by the request.""" - - -class GetServiceGroupsResponse(BaseModel): """ - The response message for getting one or more service group(s) given their + The response message for getting one or more instance(s) given their UUID(s) or name(s). """ @@ -3578,60 +2983,18 @@ class GetServiceGroupsResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: GetServiceGroupsResponseData | None = None + """An optional message providing additional information about the status.""" + data: GetInstancesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - - -class GetServiceGroupsResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - service_groups: list[ServiceGroup] | None = None - """The service group(s) which were retrieved by the request.""" + """The operation time in microseconds.""" -class GetTemplateInstancesResponse(BaseModel): - """The response message for getting one or more template instances.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: GetTemplateInstancesResponseData | None = None +class GetInstancesResponseData(BaseModel): """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - -class GetTemplateInstancesResponseData(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a @@ -3639,51 +3002,12 @@ class GetTemplateInstancesResponseData(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) instances: list[Instance] | None = None - """List of template instances that were retrieved during the operation.""" - - -class GetTemplateVolumesResponse(BaseModel): - """The response message for getting one or more template volumes.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: GetTemplateVolumesResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ -class GetTemplateVolumesResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - volumes: list[Volume] | None = None - """The template volume(s) which were retrieved by the request.""" - - -class GetVolumesResponse(BaseModel): +class GetTemplateInstancesResponse(BaseModel): """ - The response message for getting one or more volume(s) given their - UUID(s) or name(s). + The response message for getting one or more template instance(s) given + their UUID(s) or name(s). """ # Fields the specification does not describe are kept rather than dropped, @@ -3695,86 +3019,25 @@ class GetVolumesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: GetVolumesResponseData | None = None + """An optional message providing additional information about the status.""" + data: GetTemplateInstancesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - - -class GetVolumesResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - volumes: list[Volume] | None = None - """The volume(s) which were retrieved by the request.""" - - -class HealthzResponse(BaseModel): - """The response message for a health check of the platform.""" + """The operation time in microseconds.""" - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """An optional message providing additional information about the response.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - data: HealthzResponseData | None = None +class GetTemplateInstancesResponseData(BaseModel): """The response data for this request.""" - op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - -class HealthzResponseData(BaseModel): - """Additional data returned by the health check.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - checks: dict[str, str] | None = None - versions: dict[str, str] | None = None - license: DataLicense | None = None - - -class Image(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - url: str | None = None - created_at: datetime | None = None - """The time the volume was created.""" - initrd_or_rom: bool | None = None - size_in_bytes: int | None = None - args: list[str] | None = None - env: dict[str, str] | None = None - tags: list[str] | None = None - users: list[str] | None = None + instances: list[Instance] | None = None class ImageSpec(BaseModel): @@ -3782,11 +3045,11 @@ class ImageSpec(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - url: str | None = None + url: str """The image URL""" credentials: str | None = None """ @@ -3803,25 +3066,8 @@ class ImageSpec(BaseModel): """ -class InlineFile(BaseModel): - """An inline file entry represents a single file within an image.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - path: str | None = None - """The file path within the image.""" - encoding: InlineDataEncoding | None = None - """(Optional). The encoding of the data field. Defaults to "text".""" - data: str | None = None - """The file data, encoded according to the encoding field.""" - - class Instance(BaseModel): - """An instance is a micro vm running an application.""" + """Instance with per-item response envelope fields merged in.""" # 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. @@ -3829,67 +3075,60 @@ class Instance(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" uuid: str | None = None - """ - The UUID of the instance. - - This is a unique identifier for the instance that is generated when the - instance is created. The UUID is used to reference the instance in API - calls and can be used to identify the instance in all API calls that - require an instance identifier. - """ + """The UUID of the resource.""" name: str | None = None - """ - The name of the instance. - - This is a human-readable name that can be used to identify the instance. - The name must be unique within the context of your account. The name can - also be used to identify the instance in API calls. - """ + """The human-readable name of the resource.""" created_at: datetime | None = None """The time the instance was created.""" state: InstanceState | None = None """ - The state of the instance. This indicates the current state of the + The state of the instance. This indicates the current state of the instance, such as whether it is running, stopped, or in an error state. """ private_fqdn: str | None = None """ - The internal hostname of the instance. This address can be used privately - within the Unikraft Cloud network to access the instance. It is not + The internal hostname of the instance. This address can be used privately + within the Unikraft Cloud network to access the instance. It is not accessible from the public Internet. """ image: str | None = None """ - The image used to create the instance. This is a reference to the + The image used to create the instance. This is a reference to the Unikraft image that was used to create the instance. """ memory_mb: int | None = None """ - The amount of memory in megabytes allocated for the instance. This is the + The amount of memory in megabytes allocated for the instance. This is the total amount of memory that is available to the instance for its operations. """ vcpus: int | None = None """ - The number of vCPUs allocated for the instance. This is the total + The number of vCPUs allocated for the instance. This is the total number of virtual CPUs that are available to the instance for its operations. """ args: list[str] | None = None """ - The arguments passed to the instance when it was started. This is a + The arguments passed to the instance when it was started. This is a list of command-line arguments that were provided to the instance at - startup. These arguments can be used to configure the behavior of the + startup. These arguments can be used to configure the behavior of the instance and its applications. """ env: dict[str, str] | None = None """Environment variables set for the instance.""" start_count: int | None = None """ - The total number of times the instance has been started. This is a counter + The total number of times the instance has been started. This is a counter that increments each time the instance is started, regardless of whether it - was manually stopped or restarted. This can be useful for tracking the + was manually stopped or restarted. This can be useful for tracking the usage of the instance over time and/or for debugging purposes. Not used for template instances. @@ -3904,14 +3143,14 @@ class Instance(BaseModel): """ started_at: datetime | None = None """ - The time the instance was started. This is the timestamp when the + The time the instance was started. This is the timestamp when the instance was last started. Not used for template instances. """ stopped_at: datetime | None = None """ - The time the instance was stopped. This is the timestamp when the - instance was last stopped. If the instance is currently running, this + The time the instance was stopped. This is the timestamp when the + instance was last stopped. If the instance is currently running, this field will be empty. Not used for template instances. """ @@ -3920,38 +3159,40 @@ class Instance(BaseModel): The total amount of time the instance has been running in milliseconds. Not used for template instances. """ + retained_until: datetime | None = None + """Time when the instance will be permanently deleted (for deleted instances).""" vmm_start_time_us: int | None = None """ - (Developer-only). The time taken between the main controller and the + (Developer-only). The time taken between the main controller and the beginning of execution of the VMM (Virtual Machine Monitor) measured in - microseconds. This field is primarily used for debugging and performance + microseconds. This field is primarily used for debugging and performance analysis purposes. Not used for template instances. """ vmm_load_time_us: int | None = None """ - (Developer-only). The time it took the VMM (Virtual Machine Monitor) to + (Developer-only). The time it took the VMM (Virtual Machine Monitor) to load the instance's kernel and initramfs into VM memory measured in - microseconds. This field is primarily used for debugging and performance + microseconds. This field is primarily used for debugging and performance analysis purposes. Not used for template instances. """ vmm_ready_time_us: int | None = None """ - (Developer-only). The time taken for the VMM (Virtual Machine Monitor) to - become ready to execute the instance measured in microseconds. This is the + (Developer-only). The time taken for the VMM (Virtual Machine Monitor) to + become ready to execute the instance measured in microseconds. This is the time from when the VMM started until it was ready to execute the instance's - code. This field is primarily used for debugging and performance analysis + code. This field is primarily used for debugging and performance analysis purposes. Not used for template instances. """ boot_time_us: int | None = None """ - The boot time of the instance in microseconds. We take a pragmatic - approach is to define the boot time. We calculate this as the difference + The boot time of the instance in microseconds. We take a pragmatic + approach is to define the boot time. We calculate this as the difference in time between the moment the virtualization toolstack is invoked to respond to a VM boot request and the moment the OS starts executing user - code (i.e., the end of the guest OS boot process). This is essentially the + code (i.e., the end of the guest OS boot process). This is essentially the time that a user would experience in a deployment, minus the application initialization time, which we leave out since it is independent from the OS. @@ -3960,59 +3201,59 @@ class Instance(BaseModel): net_time_us: int | None = None """ This is the time it took for the user-level application to start listening - on a non-localhost port measured in microseconds. This is the time from + on a non-localhost port measured in microseconds. This is the time from when the instance started until it reasonably ready to start responding to - network requests. This is useful for measuring the time it takes for the + network requests. This is useful for measuring the time it takes for the instance to become operationally ready. Not used for template instances. """ + template_time_us: int | None = None + """Template creation time in microseconds.""" stop_reason: int | None = None """ The instance stop reason. Provides reason as to why an instance is stopped or in the process of - shutting down. The stop reason is a bitmask that tells you the origin of + shutting down. The stop reason is a bitmask that tells you the origin of the shutdown: - | Bit | 4 | 3 | 2 | 1 | 0 (LSB) - | + | Bit | 4 | 3 | 2 | 1 | 0 (LSB) | |---------|------------|------------|------------|------------|--------------| - | Purpose | [F]orced | [U]ser | [P]latform | [A]pp | [K]ernel - | - - - **Forced**: This was a force stop. A forced stop does not give the - instance a chance to perform a clean shutdown. Bits 0 - (Kernel) and 1 (App) can thus never be set for forced - shutdowns. Consequently, there won't be an `exit_code` or - `stop_code`. - - **User**: Stop initiated by user, e.g. via an API call. + | Purpose | [F]orced | [U]ser | [P]latform | [A]pp | [K]ernel | + + - **Forced**: This was a force stop. A forced stop does not give the + instance a chance to perform a clean shutdown. Bits 0 + (Kernel) and 1 (App) can thus never be set for forced + shutdowns. Consequently, there won't be an `exit_code` or + `stop_code`. + - **User**: Stop initiated by user, e.g. via an API call. - **Platform**: Stop initiated by platform, e.g. an autoscale policy. - - **App**: The Application exited. The `exit_code` field will be set. - - **Kernel**: The kernel exited. The `stop_code` field will be set. + - **App**: The Application exited. The `exit_code` field will be set. + - **Kernel**: The kernel exited. The `stop_code` field will be set. For example, the stop reason will contain the following values in the given scenarios: | Value | Bitmask | Aliases | Scenario | |-------|---------|---------|----------| - | 28 | `11100` | `FUP--` | Forced user-initiated shutdown. | - | 15 | `01111` | `-UPAK` | Regular user-initiated shutdown. The - application and kernel have exited. The exit_code and stop_code indicate if - the application and kernel shut down cleanly. | - | 13 | `01101` | `-UP-K` | The user initiated a shutdown but the - application was forcefully killed by the kernel during shutdown. This can be - the case if the image does not support a clean application exit or the - application crashed after receiving a termination signal. The exit_code - won’t be present in this scenario. | - | 7 | `00111` | `--PAK` | Unikraft Cloud initiated the shutdown, for + | 28 | `11100` | `FUP--` | Forced user-initiated shutdown. | + | 15 | `01111` | `-UPAK` | Regular user-initiated shutdown. The application + and kernel have exited. The exit_code and stop_code indicate if the + application and kernel shut down cleanly. | + | 13 | `01101` | `-UP-K` | The user initiated a shutdown but the application + was forcefully killed by the kernel during shutdown. This can be the case if + the image does not support a clean application exit or the application + crashed after receiving a termination signal. The exit_code won’t be present + in this scenario. | + | 7 | `00111` | `--PAK` | Unikraft Cloud initiated the shutdown, for example, due to scale-to-zero. The application and kernel have exited. The exit_code and stop_code indicate if the application and kernel shut down cleanly. | - | 3 | `00011` | `---AK` | The application exited. The exit_code and + | 3 | `00011` | `---AK` | The application exited. The exit_code and stop_code indicate if the application and kernel shut down cleanly. | - | 1 | `00001` | `----K` | The instance likely expierenced a fatal crash - and the stop_code contains more information about the cause of the crash. | - | 0 | `00000` | `-----` | The stop reason is unknown. | + | 1 | `00001` | `----K` | The instance likely expierenced a fatal crash and + the stop_code contains more information about the cause of the crash. | + | 0 | `00000` | `-----` | The stop reason is unknown. | Not used for template instances. """ exit_code: int | None = None @@ -4020,8 +3261,8 @@ class Instance(BaseModel): The application exit code. This is the code which the application returns upon leaving its main entry - point. The encoding of the exit code is application specific. See the - documentation of the application for more details. Usually, an exit code + point. The encoding of the exit code is application specific. See the + documentation of the application for more details. Usually, an exit code of `0` indicates success / no failure. Not used for template instances. """ @@ -4033,20 +3274,20 @@ class Instance(BaseModel): application. ``` - MSB LSB + MSB LSB ┌──────────────┬──────────┬──────────┬───────────┬────────┐ - │ 31 ────── 24 │ 23 ── 16 │ 15 │ 14 ──── 8 │ 7 ── 0 │ + │ 31 ────── 24 │ 23 ── 16 │ 15 │ 14 ──── 8 │ 7 ── 0 │ ├──────────────┼──────────┼──────────┼───────────┼────────┤ - │ reserved[^1] │ errno │ shutdown │ initlevel │ reason │ + │ reserved[^1] │ errno │ shutdown │ initlevel │ reason │ └──────────────┴──────────┴──────────┴───────────┴────────┘ ``` - - **errno**: The application errno, using Linux's errno.h values. - (Optional, can be 0.) - - **shutdown**: Whether the shutdown originated from the inittable (0) or - from the termtable (1). + - **errno**: The application errno, using Linux's errno.h values. + (Optional, can be 0.) + - **shutdown**: Whether the shutdown originated from the inittable (0) or + from the termtable (1). - **initlevel**: The initlevel at the time of the stop. - - **reason**: The reason for the stop. See `StopCodeReason`. + - **reason**: The reason for the stop. See `StopCodeReason`. [^1]: Reserved for future use. Not used for template instances. @@ -4056,27 +3297,27 @@ class Instance(BaseModel): The restart configuration for the instance. When an instance stops either because the application exits or the instance - crashes, Unikraft Cloud can auto-restart your instance. Auto-restarts are + crashes, Unikraft Cloud can auto-restart your instance. Auto-restarts are performed according to the restart policy configured for a particular instance. The policy can have the following values: - | Policy | Description | + | Policy | Description | |--------------|-------------| - | `never` | Never restart the instance (default). | - | `always` | Always restart the instance when the stop is initiated from + | `never` | Never restart the instance (default). | + | `always` | Always restart the instance when the stop is initiated from within the instance (i.e., the application exits or the instance crashes). | | `on-failure` | Only restart the instance if it crashes. | When an instance stops, the stop reason and the configured restart policy - are evaluated to decide if a restart should be performed. Unikraft Cloud + are evaluated to decide if a restart should be performed. Unikraft Cloud uses an exponential back-off delay (immediate, 5s, 10s, 20s, 40s, ..., 5m) - to slow down restarts in tight crash loops. If an instance runs without + to slow down restarts in tight crash loops. If an instance runs without problems for 10s the back-off delay is reset and the restart sequence ends. The `restart.attempt` attribute reported in counts the number of restarts - performed in the current sequence. The `restart.next_at` field indicates + performed in the current sequence. The `restart.next_at` field indicates when the next restart will take place if a back-off delay is in effect. A manual start or stop of the instance aborts the restart sequence and @@ -4089,24 +3330,26 @@ class Instance(BaseModel): With conventional cloud platforms you need to keep at least one instance running at all times to be able to respond to incoming requests. Performing a just-in-time cold boot is simply too time-consuming and would create a - response latency of multiple seconds. This is not the case with Unikraft - Cloud. Instances on Unikraft Cloud are able to cold boot within + response latency of multiple seconds. This is not the case with Unikraft + Cloud. Instances on Unikraft Cloud are able to cold boot within milliseconds, which allows us to perform low-latency scale-to-zero. To enable scale-to-zero for an instance it is sufficient to add a - `scale_to_zero` configuration block. Unikraft Cloud will then put the + `scale_to_zero` configuration block. Unikraft Cloud will then put the instance into standby if there is no traffic to your service within the - window of a cooldown period. When there is new traffic coming in, it is + window of a cooldown period. When there is new traffic coming in, it is automatically started again. If you have a heavyweight application that takes long to cold boot or has bad first request latency (e.g., with JIT compilation) consider to enable stateful scale-to-zero. """ - volumes: list[InstanceVolume] | None = None - """The list of volumes attached to the instance.""" service_group: InstanceServiceGroup | None = None """The service group configuration for the instance.""" + services: list[str] | None = None + """Services exposed by this instance (format: "protocol:port").""" + volumes: list[InstanceVolume] | None = None + """The list of volumes attached to the instance.""" network_interfaces: list[InstanceNetworkInterface] | None = None """ The network interfaces of the instance. @@ -4114,27 +3357,24 @@ class Instance(BaseModel): """ tags: list[str] | None = None """The tags associated with the instance.""" - status: ResponseStatus | None = None - """ - An optional field representing the status of the request. This field is - only set when this message object is used as a response message. - """ - message: str | None = None - """ - An optional message providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. + annotations: dict[str, str] | None = None """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. + The annotations associated with the instance. + + Keys follow the Kubernetes annotation key syntax, `[/]`: the + optional prefix is a non-wildcard DNS subdomain of at most 253 characters, + and the name is at most 63 characters of `[-_.a-zA-Z0-9]` starting and + ending with an alphanumeric. Values are unconstrained apart from ASCII + control characters, which are rejected because they would corrupt the + console log output annotations can be forwarded to. + + An instance holds at most 256 annotations. """ snapshot: InstanceSnapshot | None = None """The snapshot of the instance, if exists.""" delete_lock: bool | None = None """If set to true, the instance cannot be deleted until the lock is removed.""" + features: list[InstanceFeature] | None = None restart: InstanceRestartAttempt | None = None """ The current restart attempt for the instance. @@ -4150,19 +3390,19 @@ class Instance(BaseModel): """ plugins: list[InstancePlugin] | None = None """ - Plugins attached to the instance. Plugins let you attach small helper + Plugins attached to the instance. Plugins let you attach small helper programs to an instance and reach each one over a direct, authenticated - HTTP endpoint. Each plugin loads from its own ROM image, mounts at + HTTP endpoint. Each plugin loads from its own ROM image, mounts at `/uk/plugins/`, and is reachable at - `.../v1/instances//plugins//`. At most 8 plugins - may be attached to an instance. + `.../v1/instances//plugins//`. At most 8 + plugins may be attached to an instance. """ schedules: list[Schedule] | None = None """ Scheduled operations for this instance. Each schedule defines a calendar expression and an action (`start`, - `stop`, `delete`, or `exec`) to perform at matching times. When the + `stop`, `delete`, or `exec`) to perform at matching times. When the action is `exec`, the `args` field of the schedule specifies the command to run inside the instance. """ @@ -4171,11 +3411,6 @@ class Instance(BaseModel): Automatic delete-on-idle/request-limit configuration. Not used for template instances. """ - template_autokill: InstanceTemplateAutokill | None = None - """ - Template-specific automatic delete-on-idle configuration. - Not used for non-template instances. - """ updates: list[InstancePendingUpdate] | None = None """Queued property changes awaiting application.""" sched_priority: SchedPriority | None = None @@ -4183,6 +3418,11 @@ class Instance(BaseModel): The scheduling priority for the instance. Only present for users with scheduling priority override permissions. """ + template_autokill: InstanceTemplateAutokill | None = None + """ + Template-specific automatic delete-on-idle configuration. + Not used for non-template instances. + """ checkpoint_autokill: InstanceTemplateAutokill | None = None """ Checkpoint-specific automatic delete-on-idle configuration. @@ -4194,13 +3434,19 @@ class Instance(BaseModel): """The default gateway configured inside the guest.""" nameserver: str | None = None """The DNS resolver configured inside the guest.""" + type: InstanceType | None = None + """The type of virtual machine used to run the instance.""" + gpus: list[InstanceGpu] | None = None + """ + GPUs attached to the instance. Only present for instances of type + `full`. + """ class InstanceAutokill(BaseModel): """ Automatic delete-on-idle/request-limit configuration for non-template - instances. - Not used for template instances. + instances. Not used for template instances. """ # Fields the specification does not describe are kept rather than dropped, @@ -4221,33 +3467,26 @@ class InstanceAutokill(BaseModel): """ -class InstanceCreateArgsInstanceCreateRequestRoms(BaseModel): +class InstanceGpu(BaseModel): + """A GPU attached to the instance.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - name: str | None = None - """The name of the ROM to use for the autoscale configuration.""" - image: str | ImageSpec | None = None - """ - (Optional). The image of the ROM to use for the autoscale - configuration. Mutually exclusive with `files`. Accepts either a - plain image reference string (`"nginx:latest"`) or an object carrying - additional pull configuration - (`{"url": "nginx:latest", "pull_policy": "always"}`). - """ - files: list[InlineFile] | None = None + uuid: str | None = None + """The UUID of the GPU.""" + model: str | None = None """ - (Optional). Inline files to use as the ROM content. When specified, - the platform creates an EROFS image from the provided files. - Mutually exclusive with `image`. + The GPU model, given as its PCI vendor and device ID in the form + `:`. """ class InstanceNetworkInterface(BaseModel): - """An instance network interface.""" + """A network interface attached to an instance.""" # 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. @@ -4256,10 +3495,6 @@ class InstanceNetworkInterface(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) uuid: str | None = None - """ - The UUID of the network interface. This is a unique identifier for the - network interface that is generated when the instance is created. - """ private_ip: str | None = None """ The private IP address of the network interface. This is the internal IP @@ -4279,8 +3514,8 @@ class InstanceNetworkInterface(BaseModel): autoconfig: bool | None = None """ Whether the interface is automatically configured inside the guest - (IP address, routes, etc.). When absent or true, autoconfiguration - is enabled. Present and false when the guest is expected to + (IP address, routes, etc.). When absent or true, autoconfiguration + is enabled. Present and false when the guest is expected to configure the interface manually. """ relay: InstanceNetworkInterfaceRelay | None = None @@ -4307,68 +3542,1194 @@ class InstanceNetworkInterfaceRelay(BaseModel): """ -class InstancePendingUpdate(BaseModel): - """A queued property change awaiting application (typically on next restart).""" +class InstancePendingUpdate(BaseModel): + """A queued property change awaiting application on an instance.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + prop: MutableInstanceProperty | None = None + """The property being updated.""" + op: MutableInstanceOperation | None = None + """The patch operation type.""" + value: Any | None = None + """The new value for the property. Type depends on the property being updated.""" + status: InstancePendingUpdateStatus | None = None + """The status of this update.""" + error: str | None = None + """Error message. Only present when status is "failed".""" + + +class InstancePlugin(BaseModel): + """ + A helper program attached to the instance and reachable over a direct, + authenticated HTTP endpoint. A plugin runs inside the instance next to + the main application, loads from its own ROM image, and answers requests + that the Unikraft Cloud API forwards to it. + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str | None = None + """ + The plugin name. It becomes the `` segment in the plugin + endpoint (`.../plugins//`). A plugin name has a + maximum length of 63 characters and contains only letters (`a`-`z`, + `A`-`Z`), digits (`0`-`9`), hyphen (`-`), and underscore (`_`). + """ + image: str | None = None + """ + The plugin's image, given as an image reference string such as + `user/myplugin:latest`. The platform loads the image, mounts it at + `/uk/plugins/`, and runs its `init` program when the + plugin starts. + """ + config: Any | None = None + """ + Arbitrary JSON configuration that the platform passes to the plugin's + `init` program on `STDIN`. Any JSON value works, including a string, a + number, or an object. + """ + + +class InstanceRestartAttempt(BaseModel): + """Restart attempt information for an instance.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + attempt: int | None = None + """ + Current restart attempt number. This is incremented each time the instance + is restarted automatically by the platform. + """ + next_at: datetime | None = None + """Timestamp of the next scheduled restart attempt.""" + + +class InstanceRom(BaseModel): + """A ROM attached to an instance.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str | None = None + """The name of the ROM to use for the instance configuration.""" + image: str | None = None + """ + The image of the ROM to use for the instance configuration. Mutually + exclusive with `files`. + """ + at: str | None = None + """ + The path at which the ROM should be automatically mounted inside the + instance. When set, the platform mounts the ROM device at the specified + path so the guest does not need to mount it manually. When omitted, the + ROM is exposed as a raw block device and the guest is responsible for + mounting it. + """ + files: list[InlineFile] | None = None + """ + Inline files to use as the ROM content. When specified, the platform + creates an EROFS image from the provided files. Mutually exclusive with + `image`. + """ + + +class InstanceScaleToZero(BaseModel): + """ + Scale-to-zero defines the configuration for scaling the instance to zero. + When an instance is scaled-to-zero it can be either stopped (and fully + shutdown) or paused wherein the state of the instance is preserved (e.g., + RAM contents) and the instance can be resumed later without losing its + state, i.e. "stateful". + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + enabled: bool | None = None + """Indicates whether scale-to-zero is enabled for the instance.""" + policy: InstanceScaleToZeroPolicy | None = None + """The specific policy to use for scaling the instance to zero.""" + stateful: bool | None = None + """ + Whether the instance should be stateful when scaled to zero. If set to + true, the instance will retain its state (e.g., RAM contents) when scaled + to zero. This is useful for instances that need to maintain their state + across scale-to-zero operations. If set to false, the instance will lose + its state when scaled to zero, and it will be restarted from scratch when + scaled back up. + """ + cooldown_time_ms: int | None = None + """ + The cooldown time in milliseconds before the instance can be scaled to + zero again. This is useful to prevent rapid scaling to zero and back up, + which can lead to performance issues or resource exhaustion. + """ + notify_time_ms: int | None = None + """ + The notification time in milliseconds before the instance is scaled to + zero. This allows the instance to perform any necessary cleanup or state + saving before being scaled down. + """ + + +class InstanceServiceGroup(BaseModel): + """ + The service group configuration for the instance. This is a reference to the + service group that the instance is part of. The service group defines the + services (e.g. ports, connection handling) that the instance exposes and how + they are configured. + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + domains: list[ServiceGroupInstanceDomain] | None = None + """The domain configuration for the service group.""" + + +class InstanceSnapshot(BaseModel): + """A snapshot reference for an instance.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + + +class InstanceTemplateAutokill(BaseModel): + """Autokill configuration for a template instance.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + time_ms: int | None = None + """ + Time in milliseconds after the template was last used for cloning before + it is deleted. A value of 0 disables template autokill. + """ + + +class InstanceVolume(BaseModel): + """ + A volume defines a storage which can be attached to the instance. Volumes + can be used to store persistent data which should remain available even if + the instance is stopped or restarted. + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + at: str | None = None + """ + The mount point of the volume in the instance. This is the directory in + the instance where the volume will be mounted. + """ + readonly: bool | None = None + """Whether the volume is read-only or not.""" + + +class ItemAutokill(BaseModel): + """Automatic delete-on-idle configuration for the template instance.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + time_ms: int | None = None + """ + Time in milliseconds after the template was last used for cloning before + it is deleted. A value of 0 disables template autokill. + """ + + +class NetworkInterfaceRelay(BaseModel): + """An object that routes all traffic through another interface.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + relay_dns: bool | None = None + """ + Whether the relay forwards DNS requests. Set to false + to let the default DNS server handle them instead. + Defaults to true. + """ + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + + +class Schedule(BaseModel): + """ + A schedule defines when an action should be performed on an instance. + + Each schedule specifies a name, a calendar expression following systemd + calendar event syntax, and an action (start, stop, delete, or exec). + + Calendar expressions format: + + [weekday] [[year-]month-day] [hour:minute[:second]] + + Supported syntax: + + - `*` - Any value + - `5` - Exact value + - `1..5` - Range + - `1..5/2` - Range with step + - `1,2,5` - Comma-separated list + + Example: `*-*-* 09:00:00` - Every day at 09:00 UTC + Example: `Sat,Sun *-*-* 20:00:00` - Every Saturday and Sunday at 20:00 UTC + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str | None = None + """ + The name of the schedule. + + Must be unique within an instance. + """ + when: str | None = None + """ + The calendar expression specifying when the action should be performed. + + Uses systemd calendar event syntax. + See https://www.man7.org/linux/man-pages/man7/systemd.time.7.html + """ + action: ScheduleAction | None = None + """The action to perform at the scheduled time.""" + next_at: datetime | None = None + """ + The timestamp of when the next scheduled action will occur. + + This field is populated only in responses (not settable in requests). + Omitted if no next execution is scheduled. + """ + args: list[str] | None = None + """ + The command to execute when the action is `exec`. + + Required when `action` is `SCHEDULE_ACTION_EXEC`, ignored otherwise. + Each element is a separate argument; the first element is the executable. + """ + + +class ServiceGroupInstanceDomain(BaseModel): + """ + The domain configuration for the service group. Domain names are completely + specified with all labels in the hierarchy of the DNS, having no parts + omitted. The domain can be associated with an existing certificate by + specifying the certificate's name or UUID. If no certificate is specified + and a FQDN is provided, Unikraft Cloud will automatically generate a new + certificate for the domain based on Let's Encrypt and seek to accomplish a + DNS-01 challenge. + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + fqdn: str | None = None + """ + Publicly accessible domain name. + + If this name ends in a period `.` it must be a valid Full Qualified + Domain Name (FQDN), otherwise it will become a subdomain of the target + metro. + """ + certificate: NameOrUUID | None = None + """ + The certificate associated with the domain. + + The certificate is used to secure the domain with TLS/SSL. If no + certificate is specified, Unikraft Cloud will automatically generate a + new certificate for the domain based on Let's Encrypt and seek to + accomplish a DNS-01 challenge. + """ + + +class StartInstanceByUUIDRequestBody(BaseModel): + """Parameters for starting the instance.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + wait_timeout_ms: int | None = None + """ + Deprecated: Use `timeout_s` instead. Timeout in milliseconds to + wait for the instance to reach running state. If `timeout_s` is + not set, this value is converted by rounding up to the next full + second. No wait performed for a value of 0. + """ + timeout_s: int | None = None + """ + Timeout in seconds to wait for the instance to reach running + state. If you start your instance, you can wait for it to + finish starting with a blocking API call if you specify a wait + timeout greater than zero. No wait performed for a value of 0. + """ + + +class StartInstancesRequestItem(BaseModel): + """A single request item to start an instance.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + wait_timeout_ms: int | None = None + """ + Deprecated: Use `timeout_s` instead. Timeout in milliseconds to + wait for the instance to reach running state. If `timeout_s` is + not set, this value is converted by rounding up to the next full + second. No wait performed for a value of 0. + """ + timeout_s: int | None = None + """ + Timeout in seconds to wait for the instance to reach running + state. If you start your instance, you can wait for it to + finish starting with a blocking API call if you specify a wait + timeout greater than zero. No wait performed for a value of 0. + """ + + +class StartInstancesResponse(BaseModel): + """ + The response message for starting one or more instance(s) given their + UUID(s) or name(s). + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: StartInstancesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" + + +class StartInstancesResponseData(BaseModel): + """The response data for this request.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + instances: list[StartInstancesResponseStartedInstance] | None = None + + +class StartInstancesResponseStartedInstance(BaseModel): + """Per-item result for a start instances operation.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + state: str | None = None + """The current state of the instance after this request.""" + previous_state: str | None = None + """The previous state of the instance before it was started.""" + + +class StopInstanceByUUIDRequestBody(BaseModel): + """Parameters for stopping the instance.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + force: bool | None = None + """Whether to immediately force stop the instance.""" + drain_timeout_ms: int | None = None + """ + Timeout for draining connections in milliseconds. + No draining will occur if set to 0. The instance + does not receive new connections in the draining + phase. The instance is stopped when the last + connection has been closed or the timeout expired. + The maximum timeout may vary. Use -1 for the + largest possible value. Ignored if force is set. + + Note: This endpoint does not block. Use the wait + endpoint for the instance to reach the stopped + state. + """ + quick: bool | None = None + """ + Whether to perform a quick shutdown. This flag is + overridden by force. + """ + ifstate: str | None = None + """Only stop the instance if it is in this state.""" + dump: bool | None = None + """ + If set, forces the VMM to shutdown immediately and generate a coredump. + Can only be used in conjunction with force. + """ + + +class StopInstancesRequestItem(BaseModel): + """A single request item to stop an instance.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + force: bool | None = None + """Whether to immediately force stop the instance.""" + drain_timeout_ms: int | None = None + """ + Timeout for draining connections in milliseconds. + No draining will occur if set to 0. The instance + does not receive new connections in the draining + phase. The instance is stopped when the last + connection has been closed or the timeout expired. + The maximum timeout may vary. Use -1 for the + largest possible value. Ignored if force is set. + + Note: This endpoint does not block. Use the wait + endpoint for the instance to reach the stopped + state. + """ + quick: bool | None = None + """ + Whether to perform a quick shutdown. This flag is + overridden by force. + """ + ifstate: str | None = None + """Only stop the instance if it is in this state.""" + dump: bool | None = None + """ + If set, forces the VMM to shutdown immediately and generate a coredump. + Can only be used in conjunction with force. + """ + + +class StopInstancesResponse(BaseModel): + """ + The response message for stopping one or more instance(s) given their + UUID(s) or name(s). + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: StopInstancesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" + + +class StopInstancesResponseData(BaseModel): + """The response data for this request.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + instances: list[StopInstancesResponseStoppedInstance] | None = None + + +class StopInstancesResponseStoppedInstance(BaseModel): + """Per-item result for a stop instances operation.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + state: InstanceState | None = None + """The current state of the instance.""" + previous_state: InstanceState | None = None + """ + The previous state of the instance before the stop operation was + invoked. + """ + + +class SuspendInstanceByUUIDRequestBody(BaseModel): + """Parameters for suspending the instance.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + drain_timeout_ms: int | None = None + """ + Timeout for draining connections in milliseconds. No draining + will occur if set to 0. Use -1 for the largest possible value. + """ + + +class SuspendInstancesRequestItem(BaseModel): + """A single request item to suspend an instance.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + drain_timeout_ms: int | None = None + """ + Timeout for draining connections in milliseconds. No draining + will occur if set to 0. Use -1 for the largest possible value. + """ + + +class SuspendInstancesResponse(BaseModel): + """ + The response message for suspending one or more instance(s) given their + UUID(s) or name(s). + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: SuspendInstancesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" + + +class SuspendInstancesResponseData(BaseModel): + """The response data for this request.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + instances: list[SuspendInstancesResponseSuspendedInstance] | None = None + + +class SuspendInstancesResponseSuspendedInstance(BaseModel): + """Per-item result for a suspend instances operation.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + state: InstanceState | None = None + """The current state of the instance.""" + previous_state: InstanceState | None = None + """ + The previous state of the instance before the suspend operation was + invoked. + """ + + +class TemplateAutokill(BaseModel): + """ + Automatic delete-on-idle configuration for the template instance. Only + applies when `prepare` is set. + """ + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + time_ms: int | None = None + """ + Time in milliseconds after the template was last used for cloning before + it is deleted. A value of 0 disables template autokill. + """ + + +class UpdateCheckpointInstanceByUUIDRequestBody(BaseModel): + """Request body for updating a single checkpoint instance by UUID.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableCheckpointInstanceProperty + """The property to modify.""" + op: MutableCheckpointInstanceOperation + """The operation to perform on the property.""" + value: Any | None = None + """ + The value for the update operation. The type depends on the property and + operation: + - For "tags": array of strings + - For "delete_lock": boolean + - For "autokill": object with time_ms field + """ + + +class UpdateCheckpointInstancesRequestItem(BaseModel): + """A single update operation to be applied to a checkpoint instance.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + id: str | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableCheckpointInstanceProperty + """The property to modify.""" + op: MutableCheckpointInstanceOperation + """The operation to perform on the property.""" + value: Any | None = None + """ + The value for the update operation. The type depends on the property and + operation: + - For "tags": array of strings + - For "delete_lock": boolean + - For "autokill": object with time_ms field + """ + + +class UpdateCheckpointInstancesResponse(BaseModel): + """ + The response message for updating one or more checkpoint instance(s) given + their UUID(s) or name(s). + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: UpdateCheckpointInstancesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" + + +class UpdateCheckpointInstancesResponseCheckpointInstance(BaseModel): + """Per-item result for an update checkpoint instances operation.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + id: str | None = None + """The client-provided ID from the request.""" + + +class UpdateCheckpointInstancesResponseData(BaseModel): + """The response data for this request.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + instances: list[UpdateCheckpointInstancesResponseCheckpointInstance] | None = None + """ + List of checkpoint instances that were processed during the update + operation. + """ + + +class UpdateInstanceByUUIDRequestBody(BaseModel): + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableInstanceProperty + """The property to modify.""" + op: MutableInstanceOperation + """The operation to perform on the property.""" + value: Any | None = None + """ + The value for the update operation. The type depends on the property and + operation: + - For "image": image reference string, or object with image url, + credentials, headers and pull policy + - For "args": string or array of strings + - For "env": object (for SET/ADD) or string/array of strings (for DEL) + - For "memory_mb": integer + - For "vcpus": integer + - For "scale_to_zero": object with cooldown_time_ms, policy, and stateful + fields + - For "tags": array of strings + - For "delete_lock": boolean + - For "schedules": array of schedule objects (with name, when, action, and + optional args fields) + for SET/ADD, or array of schedule names for DEL. Use action "exec" together + with args to + execute a command at the scheduled time. + - For "autokill": object with time_ms and num_requests fields + - For "hostname": string (valid DNS label) + - For "roms": array of ROM objects (with name and image fields) for SET/ADD, + or array of ROM names for DEL + - For "plugins": array of plugin objects (with name, image, and optional + config fields) + for SET/ADD. The deprecated `rom` field is accepted in place of `image`. + - For "dependencies": array of instance identifiers (name or UUID) + - For "sched_priority": SchedPriority enum value ("normal", "medium", + "high", "admin") + - For "annotations": object (for SET/ADD) or string/array of strings (for + DEL) + """ + + +class UpdateInstancesRequestItem(BaseModel): + """A single update operation to be applied to an instance.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + id: str | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableInstanceProperty + """The property to modify.""" + op: MutableInstanceOperation + """The operation to perform on the property.""" + value: Any | None = None + """ + The value for the update operation. The type depends on the property and + operation: + - For "image": image reference string, or object with image url, + credentials, headers and pull policy + - For "args": string or array of strings + - For "env": object (for SET/ADD) or string/array of strings (for DEL) + - For "memory_mb": integer + - For "vcpus": integer + - For "scale_to_zero": object with cooldown_time_ms, policy, and stateful + fields + - For "tags": array of strings + - For "delete_lock": boolean + - For "schedules": array of schedule objects (with name, when, action, and + optional args fields) + for SET/ADD, or array of schedule names for DEL. Use action "exec" together + with args to + execute a command at the scheduled time. + - For "autokill": object with time_ms and num_requests fields + - For "hostname": string (valid DNS label) + - For "roms": array of ROM objects (with name and image fields) for SET/ADD, + or array of ROM names for DEL + - For "plugins": array of plugin objects (with name, image, and optional + config fields) + for SET/ADD. The deprecated `rom` field is accepted in place of `image`. + - For "dependencies": array of instance identifiers (name or UUID) + - For "sched_priority": SchedPriority enum value ("normal", "medium", + "high", "admin") + - For "annotations": object (for SET/ADD) or string/array of strings (for + DEL) + """ + + +class UpdateInstancesResponse(BaseModel): + """ + The response message for updating one or more instance(s) given their + UUID(s) or name(s). + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: UpdateInstancesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" + + +class UpdateInstancesResponseData(BaseModel): + """The response data for this request.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + instances: list[UpdateInstancesResponseUpdatedInstance] | None = None + + +class UpdateInstancesResponseUpdatedInstance(BaseModel): + """Per-item result for an update instances operation.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + id: str | None = None + """The client-provided ID from the request.""" + + +class UpdateTemplateInstanceByUUIDRequestBody(BaseModel): + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableTemplateInstanceProperty + """The property to modify.""" + op: MutableTemplateInstanceOperation + """The operation to perform.""" + value: Any | None = None + """ + The value for the update operation. The type depends on the property and + operation: + - For "tags": array of strings + - For "delete_lock": boolean + - For "autokill": object with time_ms field + """ + + +class UpdateTemplateInstancesRequestItem(BaseModel): + """A single template instance update request item.""" + + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + id: str | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableTemplateInstanceProperty + """The property to modify.""" + op: MutableTemplateInstanceOperation + """The operation to perform.""" + value: Any | None = None + """ + The value for the update operation. The type depends on the property and + operation: + - For "tags": array of strings + - For "delete_lock": boolean + - For "autokill": object with time_ms field + """ + + +class UpdateTemplateInstancesResponse(BaseModel): + """ + The response message for updating one or more template instance(s) given + their UUID(s) or name(s). + """ + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: UpdateTemplateInstancesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" + + +class UpdateTemplateInstancesResponseData(BaseModel): + """The response data for this request.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + instances: list[UpdateTemplateInstancesResponseTemplateInstance] | None = None + + +class UpdateTemplateInstancesResponseTemplateInstance(BaseModel): + """Per-item result for an update template instances operation.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + id: str | None = None + """Client-provided operation ID.""" + + +class WaitInstanceByUUIDRequestBody(BaseModel): + """Wait parameters.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - prop: MutableInstanceProperty | None = None - """The property being updated.""" - op: MutableInstanceOperation | None = None - """The patch operation type.""" - value: Any | None = None + state: InstanceState + """The desired state to wait for. Default is `running`.""" + timeout_ms: int | None = None + """ + Deprecated: Use `timeout_s` instead. Timeout in milliseconds to + wait for the instance to reach the desired state. If `timeout_s` is + not set, this value is converted by rounding up to the next full + second. A value of -1 means to wait indefinitely. """ - The new value for the property. Type depends on the property being - updated. + timeout_s: int | None = None + """ + Timeout in seconds to wait for the instance to reach the desired + state. If the timeout is reached, the request will fail with an + error. A value of -1 means to wait indefinitely until the instance + reaches the desired state. No wait performed for a value of 0. """ - status: InstancePendingUpdateStatus | None = None - """The status of this update.""" - error: str | None = None - """Error message. Only present when status is "failed".""" -class InstancePlugin(BaseModel): - """ - A helper program attached to the instance and reachable over a direct, - authenticated HTTP endpoint. A plugin runs inside the instance next to the - main application, loads from its own ROM image, and answers requests that - the Unikraft Cloud API forwards to it. - """ +class WaitInstancesRequestItem(BaseModel): + """A single request item to wait for an instance's state.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) + uuid: str | None = None + """The UUID of the resource.""" name: str | None = None + """The name of the resource.""" + state: InstanceState | None = None + """The desired state to wait for. Default is `running`.""" + timeout_ms: int | None = None """ - The plugin name. It becomes the `` segment in the plugin - endpoint (`.../plugins//`). A plugin name has a - maximum length of 63 characters and contains only letters (`a`-`z`, - `A`-`Z`), digits (`0`-`9`), hyphen (`-`), and underscore (`_`). - """ - rom: str | None = None - """ - The plugin's ROM image, given as an image reference string such as - `user/myplugin:latest`. The platform loads the image, mounts it at - `/uk/plugins/`, and runs its `init` program when the plugin - starts. + Deprecated: Use `timeout_s` instead. Timeout in milliseconds to + wait for the instance to reach the desired state. If `timeout_s` is + not set, this value is converted by rounding up to the next full + second. A value of -1 means to wait indefinitely. """ - config: Any | None = None + timeout_s: int | None = None """ - (Optional). Arbitrary JSON configuration that the platform passes to the - plugin's `init` program on `STDIN`. Any JSON value works, including a - string, a number, or an object. + Timeout in seconds to wait for the instance to reach the desired + state. If the timeout is reached, the request will fail with an + error. A value of -1 means to wait indefinitely until the instance + reaches the desired state. No wait performed for a value of 0. """ -class InstanceRestartAttempt(BaseModel): - """Records the current restart attempt of an instance.""" +class WaitInstancesResponse(BaseModel): + """ + The response message for waiting for one or more instance(s) to reach a + certain state given their UUID(s) or name(s). + """ # 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. @@ -4376,17 +4737,20 @@ class InstanceRestartAttempt(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - attempt: int | None = None - """ - Current restart attempt number. This is incremented each time the instance - is restarted automatically by the platform. - """ - next_at: datetime | None = None - """Timestamp of the next scheduled restart attempt.""" + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: WaitInstancesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" -class InstanceRom(BaseModel): - """Read-Only Memory (ROM) blob to attach to the instance.""" +class WaitInstancesResponseData(BaseModel): + """The response data for this request.""" # 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. @@ -4394,38 +4758,11 @@ class InstanceRom(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - name: str | None = None - """The name of the ROM to use for the instance configuration.""" - image: str | None = None - """ - (Optional). The image of the ROM to use for the instance configuration. - Mutually exclusive with `files`. - """ - at: str | None = None - """ - (Optional). The path at which the ROM should be automatically mounted - inside the instance. When set, the platform mounts the ROM device at - the specified path so the guest does not need to mount it manually. - When omitted, the ROM is exposed as a raw block device and the guest is - responsible for mounting it. - """ - files: list[InlineFile] | None = None - """ - (Optional). Inline files to use as the ROM content. When specified, - the platform creates an EROFS image from the provided files. - Mutually exclusive with `image`. - """ + instances: list[WaitInstancesResponseWaitedInstance] | None = None -class InstanceScaleToZero(BaseModel): - """ - Scale-to-zero defines the configuration for scaling the instance to zero. - When an instance is scaled-to-zero it can be either stopped (and fully - shutdown) or paused wherein the state of the instance is preserved (e.g., - RAM - contents) and the instance can be resumed later without losing its state, - i.e. "stateful". - """ +class WaitInstancesResponseWaitedInstance(BaseModel): + """Per-item result for a wait instances operation.""" # 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. @@ -4433,41 +4770,22 @@ class InstanceScaleToZero(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - enabled: bool | None = None - """Indicates whether scale-to-zero is enabled for the instance.""" - policy: InstanceScaleToZeroPolicy | None = None - """The specific policy to use for scaling the instance to zero.""" - stateful: bool | None = None - """ - Whether the instance should be stateful when scaled to zero. If set to - true, the instance will retain its state (e.g., RAM contents) when scaled - to zero. This is useful for instances that need to maintain their state - across scale-to-zero operations. If set to false, the instance will lose - its state when scaled to zero, and it will be restarted from scratch when - scaled back up. - """ - cooldown_time_ms: int | None = None - """ - The cooldown time in milliseconds before the instance can be scaled to - zero again. This is useful to prevent rapid scaling to zero and back up, - which can lead to performance issues or resource exhaustion. - """ - notify_time_ms: int | None = None - """ - The notification time in milliseconds before the instance is scaled to - zero. This allows the instance to perform any necessary cleanup or state - saving before being scaled down. - """ - + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + state: InstanceState | None = None + """The current state of the instance.""" -class InstanceServiceGroup(BaseModel): - """ - The service group configuration for the instance. - This is a reference to the service group that the instance is part of. The - service group defines the services (e.g. ports, connection handling) that - the instance exposes and how they are configured. - """ +class DataLicense(BaseModel): + """License information (admin only).""" # 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. @@ -4475,29 +4793,16 @@ class InstanceServiceGroup(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """ - The UUID of the service group. - - This is a unique identifier for the service group that is generated when - the service is created. The UUID is used to reference the service group - in API calls and can be used to identify the service in all API calls - that require an service identifier. - """ - name: str | None = None - """ - The name of the service group. - - This is a human-readable name that can be used to identify the service - group. The name is unique within the context of your account. The name - can also be used to identify the service group in API calls. - """ - domains: list[ServiceGroupInstanceDomain] | None = None - """The domain configuration for the service group.""" + serial: str | None = None + """The serial number of the license certificate, hex-encoded.""" + valid: bool | None = None + """Whether the license is currently valid.""" + features: list[str] | None = None + """List of enabled features.""" -class InstanceSnapshot(BaseModel): - """The snapshot UUID of the instance.""" +class HealthzResponse(BaseModel): + """Standard response envelope wrapping all API responses.""" # 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. @@ -4505,12 +4810,20 @@ class InstanceSnapshot(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the snapshot.""" + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: HealthzResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" -class InstanceTemplateAutokill(BaseModel): - """Automatic delete-on-idle configuration for template instances.""" +class HealthzResponseData(BaseModel): + """Additional data returned by the health check.""" # 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. @@ -4518,172 +4831,141 @@ class InstanceTemplateAutokill(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - time_ms: int | None = None + checks: dict[str, HealthState] | None = None """ - Time in milliseconds after the template was last used for cloning before - it is deleted. A value of 0 disables template autokill. + The health state of each registered checker, keyed by checker name. + Valid keys are "images", "systemd", and "user-defined"; a checker's + key is only present if it is enabled. Checkers report only their + aggregate state; per-check detail (e.g. which default image is + missing, or which user-defined script failed) is not exposed here. """ + versions: dict[str, str] | None = None + license: DataLicense | None = None + """License information (admin only).""" -class InstanceVolume(BaseModel): - """ - A volume defines a storage which can be attached to the instance. - - Volumes can be used to store persistent data which should remain available - even if the instance is stopped or restarted. - """ +class CreateServiceGroupRequest(BaseModel): + """The request message for creating a new service group.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None + name: str | None = None """ - The UUID of the volume. - - This is a unique identifier for the volume that is generated when the - volume is created. The UUID is used to reference the volume in API calls - and can be used to identify the volume in all API calls that require a - volume identifier. + Name of the service group. This is a human-readable name that can be used + to identify the service group. The name must be unique within the context + of your account. If no name is specified, a random name is generated for + you. The name can also be used to identify the service group in API calls. """ - name: str | None = None + services: list[Service] + """Services to expose. At least one service is required.""" + domains: list[CreateServiceGroupRequestDomain] | None = None + """Description of domains associated with the service group.""" + soft_limit: int | None = None """ - The name of the volume. + The soft limit is used by the Unikraft Cloud load balancer to decide when + to wake up another standby instance. - This is a human-readable name that can be used to identify the volume. - The name must be unique within the context of your account. The name can - also be used to identify the volume in API calls. + For example, if the soft limit is set to 5 and the service consists of 2 + standby instances, one of the instances receives up to 5 concurrent + requests. The 6th parallel requests wakes up the second instance. If + there are no more standby instances to wake up, the number of requests + assigned to each instance will exceed the soft limit. The load balancer + makes sure that when the number of in-flight requests goes down again, + instances are put into standby as fast as possible. """ - at: str | None = None + hard_limit: int | None = None """ - The mount point of the volume in the instance. This is the directory in - the instance where the volume will be mounted. + The hard limit defines the maximum number of concurrent requests that an + instance assigned to the this service can handle. + + The load balancer will never assign more requests to a single instance. In + case there are no other instances available, excess requests fail (i.e., + they are blocked and not queued). """ - readonly: bool | None = None - """Whether the volume is read-only or not.""" + autokill: CreateServiceGroupRequestAutokill | None = None + """Automatic delete-on-idle configuration.""" -class ItemAutokill(BaseModel): - """Automatic delete-on-idle configuration for the template instance.""" +class CreateServiceGroupRequestAutokill(BaseModel): + """Autokill configuration when creating a service group.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) time_ms: int | None = None """ - Time in milliseconds after the template was last used for cloning - before it is deleted. A value of 0 disables template autokill. + Time in milliseconds after the service group becomes empty before it is + deleted. A value of 0 disables autokill. """ -class NameOrUUID(BaseModel): - """An identifier for a resource. Either a name or a UUID.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - uuid: str | None = None - """The UUID of the resource.""" - name: str | None = None - """The name of the resource.""" - - -class NetworkInterfaceRelay(BaseModel): - """An object that routes all traffic through another interface.""" +class CreateServiceGroupRequestDomain(BaseModel): + """A domain to attach when creating a service group.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - relay_dns: bool | None = None - """ - Whether the relay forwards DNS requests. Set to false - to let the default DNS server handle them instead. - Defaults to true. - """ - uuid: str | None = None + name: str """ - UUID of the existing interface to relay through. - Mutually exclusive with name. + Publicly accessible domain name. If this name ends in a period `.` it must + be a valid Full Qualified Domain Name (FQDN), otherwise it will become a + subdomain of the target metro. """ - name: str | None = None + certificate: NameOrUUID | None = None """ - Name of the existing interface to relay through. - Mutually exclusive with UUID. + Use an existing certificate for the domain. If this field is + specified, the domain must be associated with a valid certificate. """ -class Quotas(BaseModel): +class CreateServiceGroupResponse(BaseModel): + """The response message for creating a new service group.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the quota.""" - used: QuotasStats | None = None - """Used quota""" - hard: QuotasStats | None = None - """Configured quota limits""" - limits: QuotasLimits | None = None - """Additional limits""" status: ResponseStatus | None = None - """ - An optional field representing the status of the request. This field is - only set when this message object is used as a response message. - """ - message: str | None = None - """ - An optional message providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. - """ + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: CreateServiceGroupResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" -class QuotasLimits(BaseModel): +class CreateServiceGroupResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - min_memory_mb: int | None = None - """Minimum amount of memory assigned to live instances in megabytes""" - max_memory_mb: int | None = None - """Maximum amount of memory assigned to live instances in megabytes""" - min_volume_mb: int | None = None - """Minimum size of a volume in megabytes""" - max_volume_mb: int | None = None - """Maximum size of a volume in megabytes""" - min_autoscale_size: int | None = None - """Minimum size of an autoscale group""" - max_autoscale_size: int | None = None - """Maximum size of an autoscale group""" - min_vcpus: int | None = None - """Minimum number of vCPUs""" - max_vcpus: int | None = None - """Maximum number of vCPUs""" + service_groups: list[ServiceGroup] | None = None -class QuotasResponse(BaseModel): - """The response message for getting the quota of a user given their UUID.""" +class DeleteServiceGroupsResponse(BaseModel): + """ + The response message for deleting of one or more service group(s) given + their UUID(s) or name(s). + """ # 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. @@ -4694,62 +4976,57 @@ class QuotasResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: QuotasResponseData | None = None + """An optional message providing additional information about the status.""" + data: DeleteServiceGroupsResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class QuotasResponseData(BaseModel): +class DeleteServiceGroupsResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - quotas: list[Quotas] | None = None - """The quota(s) which were retrieved by the request.""" + service_groups: list[DeleteServiceGroupsResponseDeletedServiceGroup] | None = None -class QuotasStats(BaseModel): +class DeleteServiceGroupsResponseDeletedServiceGroup(BaseModel): + """Per-item result for a delete service groups operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - instances: int | None = None - """Number of instances""" - live_instances: int | None = None - """Number of instances that are not in the `stopped` state""" - live_vcpus: int | None = None - """Number of vCPUs""" - live_memory_mb: int | None = None - """ - Amount of memory assigned to instances that are not in the `stopped` - state in megabytes - """ - service_groups: int | None = None - """Number of services""" - services: int | None = None - """Number of published network ports over all existing services""" - volumes: int | None = None - """Number of volumes""" - total_volume_mb: int | None = None - """Total size of all volumes in megabytes""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + error: int | None = None + """An optional error code providing additional information about the status.""" -class ResponseError(BaseModel): - """The error response message for an API request.""" +class Domain(BaseModel): + """ + A domain name. Domain names are completely specified with all labels in the + hierarchy of the DNS, having no parts omitted. The domain can be associated + with an existing certificate by specifying the certificate's name or UUID. + If no certificate is specified and a FQDN is provided, Unikraft Cloud will + automatically generate a new certificate for the domain based on Let's + Encrypt and seek to accomplish a DNS-01 challenge. + """ # 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. @@ -4757,29 +5034,23 @@ class ResponseError(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: int | None = None - """The HTTP status code of the error.""" - - -class Schedule(BaseModel): + fqdn: str | None = None + """ + Publicly accessible domain name. If this name ends in a period `.` it must + be a valid Full Qualified Domain Name (FQDN), otherwise it will become a + subdomain of the target metro. + """ + certificate: Certificate | None = None + """ + Use an existing certificate for the domain. If this field is + specified, the domain must be associated with a valid certificate. """ - A schedule defines when an action should be performed on an instance. - - Each schedule specifies a name, a calendar expression following systemd - calendar event syntax, and an action (start, stop, delete, or exec). - - Calendar expressions format: [weekday] [[year-]month-day] - [hour:minute[:second]] - Supported syntax: - - `*` - Any value - - `5` - Exact value - - `1..5` - Range - - `1..5/2` - Range with step - - `1,2,5` - Comma-separated list - Example: `*-*-* 09:00:00` - Every day at 09:00 UTC - Example: `Sat,Sun *-*-* 20:00:00` - Every Saturday and Sunday at 20:00 UTC +class GetServiceGroupsResponse(BaseModel): + """ + The response message for getting one or more service group(s) given their + UUID(s) or name(s). """ # Fields the specification does not describe are kept rather than dropped, @@ -4788,45 +5059,37 @@ class Schedule(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - name: str | None = None - """ - The name of the schedule. + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: GetServiceGroupsResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" - Must be unique within an instance. - """ - when: str | None = None - """ - The calendar expression specifying when the action should be performed. - Uses systemd calendar event syntax. - See https://www.man7.org/linux/man-pages/man7/systemd.time.7.html - """ - action: ScheduleAction | None = None - """The action to perform at the scheduled time.""" - next_at: datetime | None = None - """ - The timestamp of when the next scheduled action will occur. +class GetServiceGroupsResponseData(BaseModel): + """The response data for this request.""" - This field is populated only in responses (not settable in requests). - Unix timestamp in seconds. Omitted if no next execution is scheduled. - """ - args: list[str] | None = None - """ - The command to execute when the action is `exec`. + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) - Required when `action` is `SCHEDULE_ACTION_EXEC`, ignored otherwise. - Each element is a separate argument; the first element is the executable. - """ + service_groups: list[ServiceGroup] | None = None class Service(BaseModel): """ A service connects a public-facing port to an internal destination port on - which an application instance listens on. Additional handlers can be - defined - for each published port in order to define how the service will handle - incoming connections and forward traffic from the Internet to your - application. For example, a service can be configured to terminate TLS + which an application instance listens on. Additional handlers can be + defined for each published port in order to define how the service will + handle incoming connections and forward traffic from the Internet to your + application. For example, a service can be configured to terminate TLS connections, redirect HTTP traffic, or enable HTTP mode for load balancing. """ @@ -4843,16 +5106,18 @@ class Service(BaseModel): """ destination_port: int | None = None """ - The port number that the instance is listening on. This is the internal + The port number that the instance is listening on. This is the internal port which Unikraft Cloud will forward traffic to. """ + protocol: ServiceProtocol | None = None + ip: str | None = None handlers: list[ConnectionHandler] | None = None """ - Connection handlers to use for the service. Handlers define how the + Connection handlers to use for the service. Handlers define how the service will handle incoming connections and forward traffic from the - Internet to your application. For example, a service can be configured + Internet to your application. For example, a service can be configured to terminate TLS connections, redirect HTTP traffic, or enable HTTP mode - for load balancing. You configure the handlers for every published + for load balancing. You configure the handlers for every published service port individually. """ @@ -4860,15 +5125,13 @@ class Service(BaseModel): class ServiceGroup(BaseModel): """ A service group on Unikraft Cloud is used to describe how your application - exposes its functionality to the outside world. Once defined, assigning an + exposes its functionality to the outside world. Once defined, assigning an instance to the service will make it accessible from the Internet. An application, running as an instance, may expose one or more ports, e.g. - it - listens on port 80 because your application exposes a HTTP web service. - This, - along with a set of additional metadata defines how the "service" is - configured and accessed. For example, a service may be configured to use + it listens on port 80 because your application exposes a HTTP web service. + This, along with a set of additional metadata defines how the "service" is + configured and accessed. For example, a service may be configured to use TLS, or be bound to a specific domain name. When an instance is assigned to a service group, it immediately becomes @@ -4877,7 +5140,7 @@ class ServiceGroup(BaseModel): Note: If you do not specify a DNS name when you create a service and you indicate that the application exposes some ports, Unikraft Cloud will - generates a random DNS name for you. Unikraft Cloud also supports custom + generates a random DNS name for you. Unikraft Cloud also supports custom domains like www.example.com and wildcard domains like *.example.com. """ @@ -4887,99 +5150,75 @@ class ServiceGroup(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" uuid: str | None = None - """ - The UUID of the service group. - - This is a unique identifier for the service group that is generated when - the service group is created. The UUID is used to reference the service in - API calls and can be used to identify the service group in all API calls - that require an identifier. - """ + """The UUID of the resource.""" name: str | None = None - """ - The name of the service group. - - This is a human-readable name that can be used to identify the service - group. The name must be unique within the context of your account. The - name can also be used to identify the service in API calls. - """ + """The human-readable name of the resource.""" created_at: datetime | None = None """The time the service was created.""" persistent: bool | None = None """ Indicates if the service will stay remain even after the last instance - detached. If this is set to false, the service will be deleted when the - last instance detached from it. If this is set to true, the service will - remain and can be reused by other instances. This is useful if you want to + detached. If this is set to false, the service will be deleted when the + last instance detached from it. If this is set to true, the service will + remain and can be reused by other instances. This is useful if you want to keep the service configuration, e.g., the published ports, handlers, and domains, even if there are no instances assigned to it. """ autoscale: bool | None = None """ - Indicates if the service has autoscale enabled. See the associated + Indicates if the service has autoscale enabled. See the associated autoscale documentation for more information about how to set this up. Autoscale policies can be set up after the service has been created. """ soft_limit: int | None = None """ The soft limit is used by the Unikraft Cloud load balancer to decide when - to wake up another standby instance. For example, if the soft limit is set + to wake up another standby instance. For example, if the soft limit is set to 5 and the service consists of 2 standby instances, one of the instances - receives up to 5 concurrent requests. The 6th parallel requests wakes up - the second instance. If there are no more standby instances to wake up, + receives up to 5 concurrent requests. The 6th parallel requests wakes up + the second instance. If there are no more standby instances to wake up, the number of requests assigned to each instance will exceed the soft - limit. The load balancer makes sure that when the number of in-flight + limit. The load balancer makes sure that when the number of in-flight requests goes down again, instances are put into standby as fast as possible. """ hard_limit: int | None = None """ The hard limit defines the maximum number of concurrent requests that an - instance assigned to the this service can handle. The load balancer will - never assign more requests to a single instance. In case there are no + instance assigned to the this service can handle. The load balancer will + never assign more requests to a single instance. In case there are no other instances available, excess requests fail (i.e., they are blocked and not queued). """ services: list[Service] | None = None """ List of published network ports for this service and the destination port - to which Unikraft Cloud will forward traffic to. Additional handlers can + to which Unikraft Cloud will forward traffic to. Additional handlers can be defined for each published port in order to define how the service will handle incoming connections and forward traffic from the Internet to your - application. For example, a service can be configured to terminate TLS + application. For example, a service can be configured to terminate TLS connections, redirect HTTP traffic, or enable HTTP mode for load balancing. """ domains: list[Domain] | None = None """ - List of domains associated with the service. Domains are used to access + List of domains associated with the service. Domains are used to access the service over the Internet. """ instances: list[ServiceGroupInstance] | None = None """List of instances assigned to the service.""" - status: ResponseStatus | None = None - """ - An optional field representing the status of the request. This field is - only set when this message object is used as a response message. - """ - message: str | None = None - """ - An optional message providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. - """ autokill: ServiceGroupAutokill | None = None """Automatic delete-on-idle configuration.""" class ServiceGroupAutokill(BaseModel): - """Automatic delete-on-idle configuration for service groups.""" + """Autokill configuration for a service group.""" # 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. @@ -4995,137 +5234,110 @@ class ServiceGroupAutokill(BaseModel): class ServiceGroupInstance(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - uuid: str | None = None - """ - The UUID of the instance. This is a unique identifier for the instance - that is generated when the instance is created. The UUID is used to - reference the instance in API calls and can be used to identify the - instance in all API calls that require an instance identifier. - """ - name: str | None = None - """ - The name of the instance. This is a human-readable name that can be used - to identify the instance. The name must be unique within the context of - your account. If no name is specified, a random name is generated for - you. The name can also be used to identify the instance in API calls. - """ - - -class ServiceGroupInstanceDomain(BaseModel): - """ - The domain configuration for the service group. - - Domain names are completely specified with all labels in the hierarchy of - the DNS, having no parts omitted. The domain can be associated with an - existing certificate by specifying the certificate's name or UUID. If no - certificate is specified and a FQDN is provided, Unikraft Cloud will - automatically generate a new certificate for the domain based on Let's - Encrypt and seek to accomplish a DNS-01 challenge. - """ - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - fqdn: str | None = None - """ - Publicly accessible domain name. - - If this name ends in a period `.` it must be a valid Full Qualified - Domain Name (FQDN), otherwise it will become a subdomain of the target - metro. - """ - certificate: NameOrUUID | None = None - """ - The certificate associated with the domain. - - The certificate is used to secure the domain with TLS/SSL. If no - certificate is specified, Unikraft Cloud will automatically generate a - new certificate for the domain based on Let's Encrypt and seek to - accomplish a DNS-01 challenge. - """ + """An instance belonging to a service group.""" - -class ServiceGroupTemplate(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - name: str | None = None - """The name of the template used for the autoscale configuration.""" uuid: str | None = None - """The UUID of the template used for the autoscale configuration.""" - + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" -class StartInstanceByUUIDRequestBody(BaseModel): - """Parameters for starting the instance.""" +class UpdateServiceGroupByUUIDRequestBody(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - wait_timeout_ms: int | None = None - """ - Deprecated: Use `timeout_s` instead. Timeout in milliseconds to - wait for the instance to reach running state. If `timeout_s` is - not set, this value is converted by rounding up to the next full - second. No wait performed for a value of 0. - """ - timeout_s: int | None = None + id: str | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableServiceGroupProperty + """The property to modify.""" + op: MutableServiceGroupOperation + """The operation to perform.""" + value: Any | None = None """ - Timeout in seconds to wait for the instance to reach running - state. If you start your instance, you can wait for it to - finish starting with a blocking API call if you specify a wait - timeout greater than zero. No wait performed for a value of 0. + The value for the update operation. The type depends on the property and + operation: + - For "image": string + - For "args": string or array of strings + - For "env": object (for SET/ADD) or string/array of strings (for DEL) + - For "memory_mb": integer + - For "vcpus": integer + - For "scale_to_zero": object with cooldown_time_ms, policy, and stateful + fields + - For "tags": array of strings + - For "delete_lock": boolean + - For "schedules": array of schedule objects (with name, when, action, and + optional args fields). + Use action "exec" together with args to execute a command at the scheduled + time. + - For "autokill": object with time_ms and num_requests fields + - For "hostname": string (valid DNS label) + - For "roms": array of ROM objects (with name and image fields) for SET/ADD, + or array of ROM names for DEL + - For "dependencies": array of instance identifiers (name or UUID) + - For "sched_priority": SchedPriority enum value ("normal", "medium", + "high", "admin") """ -class StartInstancesRequestItem(BaseModel): - """A single request item to start an instance.""" - +class UpdateServiceGroupsRequestItem(BaseModel): + """A single update operation to be applied to a service group.""" + # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - wait_timeout_ms: int | None = None - """ - Deprecated: Use `timeout_s` instead. Timeout in milliseconds to - wait for the instance to reach running state. If `timeout_s` is - not set, this value is converted by rounding up to the next full - second. No wait performed for a value of 0. - """ - timeout_s: int | None = None - """ - Timeout in seconds to wait for the instance to reach running - state. If you start your instance, you can wait for it to - finish starting with a blocking API call if you specify a wait - timeout greater than zero. No wait performed for a value of 0. - """ uuid: str | None = None - """The UUID of the instance to start. Mutually exclusive with name.""" + """The UUID of the resource.""" name: str | None = None - """The name of the instance to start. Mutually exclusive with UUID.""" + """The name of the resource.""" + id: str | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableServiceGroupProperty + """The property to modify.""" + op: MutableServiceGroupOperation + """The operation to perform.""" + value: Any | None = None + """ + The value for the update operation. The type depends on the property and + operation: + - For "image": string + - For "args": string or array of strings + - For "env": object (for SET/ADD) or string/array of strings (for DEL) + - For "memory_mb": integer + - For "vcpus": integer + - For "scale_to_zero": object with cooldown_time_ms, policy, and stateful + fields + - For "tags": array of strings + - For "delete_lock": boolean + - For "schedules": array of schedule objects (with name, when, action, and + optional args fields). + Use action "exec" together with args to execute a command at the scheduled + time. + - For "autokill": object with time_ms and num_requests fields + - For "hostname": string (valid DNS label) + - For "roms": array of ROM objects (with name and image fields) for SET/ADD, + or array of ROM names for DEL + - For "dependencies": array of instance identifiers (name or UUID) + - For "sched_priority": SchedPriority enum value ("normal", "medium", + "high", "admin") + """ -class StartInstancesResponse(BaseModel): +class UpdateServiceGroupsResponse(BaseModel): """ - The response message for starting one or more instance(s) given their - UUID(s) - or name(s). + The response message for updating one or more service group(s) given their + UUID(s) or name(s). """ # Fields the specification does not describe are kept rather than dropped, @@ -5137,66 +5349,52 @@ class StartInstancesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: StartInstancesResponseData | None = None + """An optional message providing additional information about the status.""" + data: UpdateServiceGroupsResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class StartInstancesResponseData(BaseModel): +class UpdateServiceGroupsResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - instances: list[StartInstancesResponseStartedInstance] | None = None - """The instance(s) which were started by the request.""" + service_groups: list[UpdateServiceGroupsResponseUpdatedServiceGroup] | None = None -class StartInstancesResponseStartedInstance(BaseModel): +class UpdateServiceGroupsResponseUpdatedServiceGroup(BaseModel): + """Per-item result for an update service groups operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """ - Indicates whether the start operation was successful or not for this - instance. - """ uuid: str | None = None - """The UUID of the instance which was deleted.""" + """The UUID of the resource.""" name: str | None = None - """The name of the instance which was deleted.""" - state: str | None = None - """The current state of the instance after this request.""" - previous_state: str | None = None - """The previous state of the instance before it was deleted.""" + """The human-readable name of the resource.""" + id: str | None = None + """The client-provided ID from the request.""" + status: ResponseStatus | None = None + """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information about the status.""" error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional error code providing additional information about the status.""" -class StopInstanceByUUIDRequestBody(BaseModel): - """Parameters for stopping the instance.""" +class Quotas(BaseModel): + """Quotas with per-item response envelope fields merged in.""" # 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. @@ -5204,38 +5402,24 @@ class StopInstanceByUUIDRequestBody(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - force: bool | None = None - """Whether to immediately force stop the instance.""" - drain_timeout_ms: int | None = None - """ - Timeout for draining connections in milliseconds. - No draining will occur if set to 0. The instance - does not receive new connections in the draining - phase. The instance is stopped when the last - connection has been closed or the timeout expired. - The maximum timeout may vary. Use -1 for the - largest possible value. Ignored if force is set. - - Note: This endpoint does not block. Use the wait - endpoint for the instance to reach the stopped - state. - """ - quick: bool | None = None - """ - Whether to perform a quick shutdown. This flag is - overridden by force. - """ - ifstate: str | None = None - """Only stop the instance if it is in this state.""" - dump: bool | None = None - """ - If set, forces the VMM to shutdown immediately and generate a coredump. - Can only be used in conjunction with force. - """ + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the quota.""" + used: QuotasStats | None = None + """Used quota.""" + hard: QuotasStats | None = None + """Configured quota limits.""" + limits: QuotasLimits | None = None + """Additional limits.""" -class StopInstancesRequestItem(BaseModel): - """A single request item to stop an instance.""" +class QuotasLimits(BaseModel): + """Additional resource limits.""" # 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. @@ -5243,42 +5427,26 @@ class StopInstancesRequestItem(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - force: bool | None = None - """Whether to immediately force stop the instance.""" - drain_timeout_ms: int | None = None - """ - Timeout for draining connections in milliseconds. The instance does not - receive new connections in the draining phase. The instance is stopped - when the last connection has been closed or the timeout expired. The - maximum timeout may vary. Use -1 for the largest possible value. - - Note: This endpoint does not block. Use the wait endpoint for the - instance to reach the stopped state. - """ - quick: bool | None = None - """ - Whether to perform a quick shutdown. This flag is - overridden by force. - """ - ifstate: str | None = None - """Only stop the instance if it is in this state.""" - dump: bool | None = None - """ - If set, forces the VMM to shutdown immediately and generate a coredump. - Can only be used in conjunction with force. - """ - uuid: str | None = None - """The UUID of the instance to stop. Mutually exclusive with name.""" - name: str | None = None - """The name of the instance to stop. Mutually exclusive with UUID.""" + min_memory_mb: int | None = None + """Minimum amount of memory assigned to live instances in megabytes""" + max_memory_mb: int | None = None + """Maximum amount of memory assigned to live instances in megabytes""" + min_volume_mb: int | None = None + """Minimum size of a volume in megabytes""" + max_volume_mb: int | None = None + """Maximum size of a volume in megabytes""" + min_autoscale_size: int | None = None + """Minimum size of an autoscale group""" + max_autoscale_size: int | None = None + """Maximum size of an autoscale group""" + min_vcpus: int | None = None + """Minimum number of vCPUs""" + max_vcpus: int | None = None + """Maximum number of vCPUs""" -class StopInstancesResponse(BaseModel): - """ - The response message for stopping one or more instance(s) given their - UUID(s) - or name(s). - """ +class QuotasResponse(BaseModel): + """The response message for getting the quota of a user given their UUID.""" # 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. @@ -5289,79 +5457,110 @@ class StopInstancesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: StopInstancesResponseData | None = None + """An optional message providing additional information about the status.""" + data: QuotasResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class StopInstancesResponseData(BaseModel): +class QuotasResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - instances: list[StopInstancesResponseStoppedInstance] | None = None - """The instance(s) which were stopped by the request.""" + quotas: list[Quotas] | None = None -class StopInstancesResponseStoppedInstance(BaseModel): +class QuotasStats(BaseModel): + """Quota statistics for resource usage.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the instance.""" - name: str | None = None - """The name of the instance.""" - state: InstanceState | None = None - """The current state of the instance.""" - previous_state: InstanceState | None = None - """The previous state of the instance before the stop operation was invoked.""" - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None + instances: int | None = None + """Number of instances.""" + live_instances: int | None = None + """Number of instances that are not in the stopped state.""" + live_vcpus: int | None = None + """Number of vCPUs.""" + live_memory_mb: int | None = None """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. + Amount of memory assigned to instances that are not in the `stopped` state + in megabytes. """ - error: int | None = None + service_groups: int | None = None + """Number of service groups.""" + services: int | None = None + """Number of published network ports over all existing services.""" + volumes: int | None = None + """Number of volumes.""" + total_volume_mb: int | None = None + """Total size of all volumes in megabytes.""" + + +class AttachVolumeByUUIDRequestBody(BaseModel): + # 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. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + attach_to: NameOrUUID + """UUID or name of the instance to attach the volume to.""" + at: str """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + Path of the mountpoint. + + The path must be absolute, not contain `.` and `..` components, and not + contain colons (`:`). The path must point to an empty directory. If the + directory does not exist, it is created. """ + readonly: bool | None = None + """Whether the volume should be mounted read-only.""" -class SuspendInstanceByUUIDRequestBody(BaseModel): - """Parameters for suspending the instance.""" +class AttachVolumesRequestItem(BaseModel): + """A single request item to attach a volume to an instance.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - drain_timeout_ms: int | None = None + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" + attach_to: NameOrUUID + """UUID or name of the instance to attach the volume to.""" + at: str """ - Timeout for draining connections in milliseconds. No draining - will occur if set to 0. Use -1 for the largest possible value. + Path of the mountpoint. + + The path must be absolute, not contain `.` and `..` components, and not + contain colons (`:`). The path must point to an empty directory. If the + directory does not exist, it is created. """ + readonly: bool | None = None + """Whether the volume should be mounted read-only.""" -class SuspendInstancesRequestItem(BaseModel): - """A single request item to suspend an instance.""" +class AttachVolumesResponse(BaseModel): + """ + The response message for attaching one or more volume(s) given their + UUID(s) or name(s). + """ # 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. @@ -5369,23 +5568,41 @@ class SuspendInstancesRequestItem(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - drain_timeout_ms: int | None = None - """ - Timeout for draining connections in milliseconds. No draining - will occur if set to 0. Use -1 for the largest possible value. - """ - uuid: str | None = None - """The UUID of the instance to suspend. Mutually exclusive with name.""" - name: str | None = None - """The name of the instance to suspend. Mutually exclusive with UUID.""" + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: AttachVolumesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" -class SuspendInstancesResponse(BaseModel): - """ - The response message for suspending one or more instance(s) given their - UUID(s) - or name(s). - """ +class AttachVolumesResponseAttachedVolume(BaseModel): + """Per-item result for an attach volumes operation.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + + +class AttachVolumesResponseData(BaseModel): + """The response data for this request.""" # 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. @@ -5393,68 +5610,65 @@ class SuspendInstancesResponse(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: SuspendInstancesResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + volumes: list[AttachVolumesResponseAttachedVolume] | None = None -class SuspendInstancesResponseData(BaseModel): +class CloneVolumeByUUIDRequestBody(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - instances: list[SuspendInstancesResponseSuspendedInstance] | None = None - """The instance(s) which were suspended by the request.""" + vol_name: str | None = None + """ + The name of the new cloned volume. If not provided, a random name + of the form `vol-X` is generated for you, where `X` is a 5 character + long random alphanumeric suffix. + """ + quota_policy: VolumeQuotaPolicy | None = None + """ + The quota policy for the new cloned volume. If not provided, the quota + policy of the source volume is used. + """ + tags: list[str] | None = None + """A list of tags to assign to the new cloned volume.""" + access_mode: VolumeAccessMode | None = None -class SuspendInstancesResponseSuspendedInstance(BaseModel): +class CloneVolumesRequestItem(BaseModel): + """A single request item to clone a volume.""" + # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) uuid: str | None = None - """The UUID of the instance.""" + """The UUID of the resource.""" name: str | None = None - """The name of the instance.""" - state: InstanceState | None = None - """The current state of the instance.""" - previous_state: InstanceState | None = None - """The previous state of the instance before the suspend operation was invoked.""" - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None + """The name of the resource.""" + vol_name: str | None = None """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. + The name of the new cloned volume. If not provided, a random name + of the form `vol-X` is generated for you, where `X` is a 5 character + long random alphanumeric suffix. """ - error: int | None = None + quota_policy: VolumeQuotaPolicy | None = None """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. + The quota policy for the new cloned volume. If not provided, the quota + policy of the source volume is used. """ + tags: list[str] | None = None + """A list of tags to assign to the new cloned volume.""" + access_mode: VolumeAccessMode | None = None -class TemplateAutokill(BaseModel): +class CloneVolumesResponse(BaseModel): """ - (Optional). Automatic delete-on-idle configuration for the template - instance. Only applies when `prepare` is set. + The response message for cloning one or more volume(s) given their UUID(s) + or name(s). """ # Fields the specification does not describe are kept rather than dropped, @@ -5463,38 +5677,32 @@ class TemplateAutokill(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - time_ms: int | None = None - """ - Time in milliseconds after the template was last used for cloning - before it is deleted. A value of 0 disables template autokill. - """ + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: CloneVolumesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" -class UpdateCertificateByUUIDRequestBody(BaseModel): +class CloneVolumesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - chain: str | None = None - """ - The new certificate chain. - - This is the public chain of the certificate in PEM format. The chain - should include the certificate and any intermediate certificates. - """ - pkey: str | None = None - """ - The new private key. - - This is the private key of the certificate in PEM format. The private - key must match the public key in the certificate chain. - """ + volumes: list[CloneVolumesResponseVolume] | None = None -class UpdateCertificatesRequestItem(BaseModel): - """A single update operation to be applied to a certificate.""" +class CloneVolumesResponseVolume(BaseModel): + """Per-item result for a clone volumes operation.""" # 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. @@ -5502,28 +5710,22 @@ class UpdateCertificatesRequestItem(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - chain: str | None = None - """ - The new certificate chain. - - This is the public chain of the certificate in PEM format. The chain - should include the certificate and any intermediate certificates. - """ - pkey: str | None = None - """ - The new private key. - - This is the private key of the certificate in PEM format. The private - key must match the public key in the certificate chain. - """ + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" uuid: str | None = None - """The UUID of the certificate to update. Mutually exclusive with name.""" + """The UUID of the resource.""" name: str | None = None - """The name of the certificate to update. Mutually exclusive with UUID.""" + """The human-readable name of the resource.""" + state: VolumeState | None = None + """The state of the volume.""" -class UpdateCertificatesResponse(BaseModel): - """The response message for updating one or more certificate(s).""" +class CreateTemplateVolumesResponse(BaseModel): + """The response message for converting one or more volume(s) to templates.""" # 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. @@ -5534,92 +5736,75 @@ class UpdateCertificatesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: UpdateCertificatesResponseData | None = None + """An optional message providing additional information about the status.""" + data: CreateTemplateVolumesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class UpdateCertificatesResponseData(BaseModel): +class CreateTemplateVolumesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - certificates: list[Certificate] | None = None - """The certificate(s) which were updated by the request.""" + volumes: list[CreateTemplateVolumesResponseTemplateVolume] | None = None + """The volume(s) which were attached by the request.""" -class UpdateCheckpointInstanceByUUIDRequestBody(BaseModel): +class CreateTemplateVolumesResponseTemplateVolume(BaseModel): + """Per-item result for a create template volume operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in - the response. - """ - prop: MutableCheckpointInstanceProperty | None = None - """The property to modify.""" - op: MutableCheckpointInstanceOperation | None = None - """The operation to perform on the property.""" - value: Any | None = None - """ - The value for the update operation. The type depends on the property and - operation: - - For "tags": array of strings - - For "delete_lock": boolean - - For "autokill": object with time_ms field - """ + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + state: VolumeState | None = None + """The state of the volume.""" -class UpdateCheckpointInstancesRequestItem(BaseModel): - """A single update operation to be applied to a checkpoint instance.""" +class CreateVolumeRequest(BaseModel): + """The request message for creating a volume.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in - the response. - """ - prop: MutableCheckpointInstanceProperty | None = None - """The property to modify.""" - op: MutableCheckpointInstanceOperation | None = None - """The operation to perform on the property.""" - value: Any | None = None - """ - The value for the update operation. The type depends on the property and - operation: - - For "tags": array of strings - - For "delete_lock": boolean - - For "autokill": object with time_ms field - """ - uuid: str | None = None - """The UUID of the checkpoint instance to update. Mutually exclusive with name.""" name: str | None = None - """The name of the checkpoint instance to update. Mutually exclusive with UUID.""" + size_mb: int | None = None + host_path: str | None = None + template: NameOrUUID | None = None + quota_policy: VolumeQuotaPolicy | None = None + filesystem: str | None = None + tags: list[str] | None = None + uid: int | None = None + gid: int | None = None + access_mode: VolumeAccessMode | None = None + args: dict[str, str] | None = None -class UpdateCheckpointInstancesResponse(BaseModel): - """The response message for updating a checkpoint instance by its UUID.""" +class CreateVolumeResponse(BaseModel): + """The response message for creating a volume.""" # 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. @@ -5630,110 +5815,88 @@ class UpdateCheckpointInstancesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: UpdateCheckpointInstancesResponseData | None = None + """An optional message providing additional information about the status.""" + data: CreateVolumeResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class UpdateCheckpointInstancesResponseCheckpointInstance(BaseModel): +class CreateVolumeResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the checkpoint instance that was updated.""" - name: str | None = None - """The name of the checkpoint instance that was updated.""" - status: ResponseStatus | None = None - """The status of this particular checkpoint instance update operation.""" - id: str | None = None - """(Optional). The client-provided ID from the request.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ + volumes: list[CreateVolumeResponseVolume] | None = None -class UpdateCheckpointInstancesResponseData(BaseModel): +class CreateVolumeResponseVolume(BaseModel): + """Per-item result for a create volume operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - instances: list[UpdateCheckpointInstancesResponseCheckpointInstance] | None = None + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + state: VolumeState | None = None + """The state of the volume.""" + + +class DeleteTemplateVolumesResponse(BaseModel): """ - List of checkpoint instances that were processed during the update - operation. + The response message for deleting one or more template volume(s) given + their UUID(s) or name(s). """ - -class UpdateInstanceByUUIDRequestBody(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in - the response. - """ - prop: MutableInstanceProperty | None = None - """The property to modify.""" - op: MutableInstanceOperation | None = None - """The operation to perform on the property.""" - value: Any | None = None - """ - The value for the update operation. The type depends on the property and - operation: - - For "image": image reference string, or object with image url, - credentials, headers and pull policy - - For "args": string or array of strings - - For "env": object (for SET/ADD) or string/array of strings (for DEL) - - For "memory_mb": integer - - For "vcpus": integer - - For "scale_to_zero": object with cooldown_time_ms, policy, and stateful - fields - - For "tags": array of strings - - For "delete_lock": boolean - - For "schedules": array of schedule objects (with name, when, action, and - optional args fields) for SET/ADD, or array of schedule names for DEL. - Use action "exec" together with args to execute a command at the scheduled - time. - - For "autokill": object with time_ms and num_requests fields - - For "hostname": string (valid DNS label) - - For "roms": array of ROM objects (with name and image fields) for SET/ADD, - or array of ROM names for DEL - - For "plugins": array of plugin objects (with name, rom, and optional - config fields) for SET/ADD - - For "dependencies": array of instance identifiers (name or UUID) - - For "sched_priority": SchedPriority enum value ("normal", "medium", - "high", "admin") - """ + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: DeleteTemplateVolumesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" + + +class DeleteTemplateVolumesResponseData(BaseModel): + """The response data for this request.""" + + # 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. + # Every field is optional: one response shape serves several requests, and a + # summary listing or a failed bulk item carries only some of them. + model_config = ConfigDict(extra="allow", populate_by_name=True) + volumes: list[DeleteTemplateVolumesResponseTemplateVolume] | None = None -class UpdateInstancesRequestItem(BaseModel): - """A single update operation to be applied to an instance.""" + +class DeleteTemplateVolumesResponseTemplateVolume(BaseModel): + """Per-item result for a delete template volumes operation.""" # 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. @@ -5741,51 +5904,23 @@ class UpdateInstancesRequestItem(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in - the response. - """ - prop: MutableInstanceProperty | None = None - """The property to modify.""" - op: MutableInstanceOperation | None = None - """The operation to perform on the property.""" - value: Any | None = None - """ - The value for the update operation. The type depends on the property and - operation: - - For "image": image reference string, or object with image url, - credentials, headers and pull policy - - For "args": string or array of strings - - For "env": object (for SET/ADD) or string/array of strings (for DEL) - - For "memory_mb": integer - - For "vcpus": integer - - For "scale_to_zero": object with cooldown_time_ms, policy, and stateful - fields - - For "tags": array of strings - - For "delete_lock": boolean - - For "schedules": array of schedule objects (with name, when, action, and - optional args fields) for SET/ADD, or array of schedule names for DEL. - Use action "exec" together with args to execute a command at the scheduled - time. - - For "autokill": object with time_ms and num_requests fields - - For "hostname": string (valid DNS label) - - For "roms": array of ROM objects (with name and image fields) for SET/ADD, - or array of ROM names for DEL - - For "plugins": array of plugin objects (with name, rom, and optional - config fields) for SET/ADD - - For "dependencies": array of instance identifiers (name or UUID) - - For "sched_priority": SchedPriority enum value ("normal", "medium", - "high", "admin") - """ + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" uuid: str | None = None - """The UUID of the instance to update. Mutually exclusive with name.""" + """The UUID of the resource.""" name: str | None = None - """The name of the instance to update. Mutually exclusive with UUID.""" + """The human-readable name of the resource.""" -class UpdateInstancesResponse(BaseModel): - """The response message for updating one or more instances.""" +class DeleteVolumesResponse(BaseModel): + """ + The response message for deleting one or more volume(s) given their + UUID(s) or name(s). + """ # 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. @@ -5796,121 +5931,87 @@ class UpdateInstancesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: UpdateInstancesResponseData | None = None + """An optional message providing additional information about the status.""" + data: DeleteVolumesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class UpdateInstancesResponseData(BaseModel): +class DeleteVolumesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - instances: list[UpdateInstancesResponseUpdatedInstance] | None = None - """List of instances that were processed during the update operation.""" + volumes: list[DeleteVolumesResponseDeletedVolume] | None = None -class UpdateInstancesResponseUpdatedInstance(BaseModel): +class DeleteVolumesResponseDeletedVolume(BaseModel): + """Per-item result for a delete volumes operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the instance that was updated.""" - name: str | None = None - """The name of the instance that was updated.""" status: ResponseStatus | None = None - """The status of this particular instance update operation.""" - id: str | None = None - """(Optional). The client-provided ID from the request.""" + """Indicates whether the operation was successful for this item.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information.""" error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" -class UpdateServiceGroupByUUIDRequestBody(BaseModel): +class DetachVolumeByUUIDRequestBody(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in the - response. - """ - prop: MutableServiceGroupProperty | None = None - """The property to modify.""" - op: MutableServiceGroupOperation | None = None - """The operation to perform.""" - value: Any | None = None + from_: NameOrUUID | None = Field(default=None, alias="from") """ - The value for the update operation: - - For "services": array of Service objects (same as for creation) - - For "domains": array of Domain objects (same as for creation) - - For "soft_limit": integer (1–65535), must be <= "hard_limit" - - For "hard_limit": integer (1–65535), must be >= "soft_limit" - - For "autokill": object with time_ms field + UUID or name of the instance to detach the volume from. If not specified, + the volume is detached from all instances. """ -class UpdateServiceGroupsRequestItem(BaseModel): - """A single update operation to be applied to a service group""" +class DetachVolumesRequestItem(BaseModel): + """A single request item to detach a volume from an instance.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) - id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in the - response. - """ - prop: MutableServiceGroupProperty | None = None - """The property to modify.""" - op: MutableServiceGroupOperation | None = None - """The operation to perform.""" - value: Any | None = None - """ - The value for the update operation: - - For "services": array of Service objects (same as for creation) - - For "domains": array of Domain objects (same as for creation) - - For "soft_limit": integer (1–65535), must be <= "hard_limit" - - For "hard_limit": integer (1–65535), must be >= "soft_limit" - - For "autokill": object with time_ms field - """ uuid: str | None = None - """The UUID of the service group to update. Mutually exclusive with name.""" + """The UUID of the resource.""" name: str | None = None - """The name of the service group to update. Mutually exclusive with UUID.""" + """The name of the resource.""" + from_: NameOrUUID | None = Field(default=None, alias="from") + """ + UUID or name of the instance to detach the volume from. If not specified, + the volume is detached from all instances. + """ -class UpdateServiceGroupsResponse(BaseModel): - """The response message for updating one or more service groups.""" +class DetachVolumesResponse(BaseModel): + """ + The response message for detaching one or more volume(s) given their + UUID(s) or name(s). + """ # 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. @@ -5921,89 +6022,74 @@ class UpdateServiceGroupsResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: UpdateServiceGroupsResponseData | None = None + """An optional message providing additional information about the status.""" + data: DetachVolumesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" -class UpdateServiceGroupsResponseData(BaseModel): +class DetachVolumesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - service_groups: list[UpdateServiceGroupsResponseUpdatedServiceGroup] | None = None - """List of service groups that were processed during the update operation.""" + volumes: list[DetachVolumesResponseDetachedVolume] | None = None -class UpdateServiceGroupsResponseUpdatedServiceGroup(BaseModel): +class DetachVolumesResponseDetachedVolume(BaseModel): + """Per-item result for a detach volumes operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the service group that was updated.""" - name: str | None = None - """The name of the service group that was updated.""" status: ResponseStatus | None = None - """The status of this particular service group update operation.""" - id: str | None = None - """(Optional). The client-provided ID from the request.""" + """Indicates whether the operation was successful for this item.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. - """ + """An optional message providing additional information.""" error: int | None = None + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + + +class GetTemplateVolumesResponse(BaseModel): """ - An optional error code providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. + The response message for getting one or more template volume(s) given their + UUID(s) or name(s). """ - -class UpdateTemplateInstanceByUUIDRequestBody(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in - the response. - """ - prop: MutableTemplateInstanceProperty | None = None - """The property to modify.""" - op: MutableTemplateInstanceOperation | None = None - """The operation to perform on the property.""" - value: Any | None = None - """ - The value for the update operation. The type depends on the property and - operation: - - For "tags": array of strings - - For "delete_lock": boolean - - For "autokill": object with time_ms field - """ + status: ResponseStatus | None = None + """The status of the response.""" + message: str | None = None + """An optional message providing additional information about the status.""" + data: GetTemplateVolumesResponseData | None = None + """The response data for this request.""" + errors: list[ResponseError] | None = None + """A list of errors which may have occurred during the request.""" + op_time_us: int | None = None + """The operation time in microseconds.""" -class UpdateTemplateInstancesRequestItem(BaseModel): - """A single update operation to be applied to a template instance.""" +class GetTemplateVolumesResponseData(BaseModel): + """The response data for this request.""" # 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. @@ -6011,31 +6097,14 @@ class UpdateTemplateInstancesRequestItem(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in - the response. - """ - prop: MutableTemplateInstanceProperty | None = None - """The property to modify.""" - op: MutableTemplateInstanceOperation | None = None - """The operation to perform on the property.""" - value: Any | None = None - """ - The value for the update operation. The type depends on the property and - operation: - - For "tags": array of strings - - For "delete_lock": boolean - - For "autokill": object with time_ms field - """ - uuid: str | None = None - """The UUID of the template instance to update. Mutually exclusive with name.""" - name: str | None = None - """The name of the template instance to update. Mutually exclusive with UUID.""" + volumes: list[Volume] | None = None -class UpdateTemplateInstancesResponse(BaseModel): - """The response message for updating a template instance by its UUID.""" +class GetVolumesResponse(BaseModel): + """ + The response message for getting one or more volume(s) given their UUID(s) + or name(s). + """ # 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. @@ -6046,81 +6115,64 @@ class UpdateTemplateInstancesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - data: UpdateTemplateInstancesResponseData | None = None + """An optional message providing additional information about the status.""" + data: GetVolumesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ - - -class UpdateTemplateInstancesResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) + """The operation time in microseconds.""" - instances: list[UpdateTemplateInstancesResponseTemplateInstance] | None = None - """List of template instances that were processed during the update operation.""" +class GetVolumesResponseData(BaseModel): + """The response data for this request.""" -class UpdateTemplateInstancesResponseTemplateInstance(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the template instance that was updated.""" - name: str | None = None - """The name of the template instance that was updated.""" - status: ResponseStatus | None = None - """The status of this particular template instance update operation.""" - id: str | None = None - """(Optional). The client-provided ID from the request.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ + volumes: list[Volume] | None = None class UpdateTemplateVolumeByUUIDRequestBody(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in - the response. - """ - prop: MutableTemplateVolumeProperty | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableTemplateVolumeProperty """The property to modify.""" - op: MutableTemplateVolumeOperation | None = None + op: MutableTemplateVolumeOperation """The operation to perform.""" value: Any | None = None """ The value for the update operation. The type depends on the property and operation: - - For "tags": array of Strings + - For "image": string + - For "args": string or array of strings + - For "env": object (for SET/ADD) or string/array of strings (for DEL) + - For "memory_mb": integer + - For "vcpus": integer + - For "scale_to_zero": object with cooldown_time_ms, policy, and stateful + fields + - For "tags": array of strings - For "delete_lock": boolean + - For "schedules": array of schedule objects (with name, when, action, and + optional args fields). + Use action "exec" together with args to execute a command at the scheduled + time. + - For "autokill": object with time_ms and num_requests fields + - For "hostname": string (valid DNS label) + - For "roms": array of ROM objects (with name and image fields) for SET/ADD, + or array of ROM names for DEL + - For "dependencies": array of instance identifiers (name or UUID) + - For "sched_priority": SchedPriority enum value ("normal", "medium", + "high", "admin") """ @@ -6129,40 +6181,52 @@ class UpdateTemplateVolumesRequestItem(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in - the response. - """ - prop: MutableTemplateVolumeProperty | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableTemplateVolumeProperty """The property to modify.""" - op: MutableTemplateVolumeOperation | None = None + op: MutableTemplateVolumeOperation """The operation to perform.""" value: Any | None = None """ The value for the update operation. The type depends on the property and operation: - - For "tags": array of Strings + - For "image": string + - For "args": string or array of strings + - For "env": object (for SET/ADD) or string/array of strings (for DEL) + - For "memory_mb": integer + - For "vcpus": integer + - For "scale_to_zero": object with cooldown_time_ms, policy, and stateful + fields + - For "tags": array of strings - For "delete_lock": boolean - """ - uuid: str | None = None - """ - The UUID of the template volume to update. Mutually exclusive with - name. - """ - name: str | None = None - """ - The name of the template volume to update. Mutually exclusive with - UUID. + - For "schedules": array of schedule objects (with name, when, action, and + optional args fields). + Use action "exec" together with args to execute a command at the scheduled + time. + - For "autokill": object with time_ms and num_requests fields + - For "hostname": string (valid DNS label) + - For "roms": array of ROM objects (with name and image fields) for SET/ADD, + or array of ROM names for DEL + - For "dependencies": array of instance identifiers (name or UUID) + - For "sched_priority": SchedPriority enum value ("normal", "medium", + "high", "admin") """ class UpdateTemplateVolumesResponse(BaseModel): - """The response message for updating one or more template volumes.""" + """ + The response message for updating one or more template volume(s) given + their UUID(s) or name(s). + """ # 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. @@ -6173,22 +6237,18 @@ class UpdateTemplateVolumesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information about the status.""" data: UpdateTemplateVolumesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" class UpdateTemplateVolumesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -6196,98 +6256,122 @@ class UpdateTemplateVolumesResponseData(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) volumes: list[UpdateTemplateVolumesResponseTemplateVolume] | None = None - """The template volume(s) which were updated by the request.""" class UpdateTemplateVolumesResponseTemplateVolume(BaseModel): + """Per-item result for an update template volumes operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the template volume that was updated.""" - name: str | None = None - """The name of the template volume that was updated.""" status: ResponseStatus | None = None - """The status of this particular volume update operation.""" - id: str | None = None - """(Optional). The client-provided ID from the request.""" + """Indicates whether the operation was successful for this item.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information.""" error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional error code.""" + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The human-readable name of the resource.""" + id: str | None = None + """The client-provided ID from the request.""" class UpdateVolumeByUUIDRequestBody(BaseModel): # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in the - response. - """ - prop: MutableVolumeProperty | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableVolumeProperty """The property to modify.""" - op: MutableVolumeOperation | None = None + op: MutableVolumeOperation """The operation to perform.""" value: Any | None = None """ The value for the update operation. The type depends on the property and operation: - - For "size_mb": unsigned integer - - For "quota_policy": "static" or "dynamic" - - For "tags": array of Strings + - For "image": string + - For "args": string or array of strings + - For "env": object (for SET/ADD) or string/array of strings (for DEL) + - For "memory_mb": integer + - For "vcpus": integer + - For "scale_to_zero": object with cooldown_time_ms, policy, and stateful + fields + - For "tags": array of strings - For "delete_lock": boolean + - For "schedules": array of schedule objects (with name, when, action, and + optional args fields). + Use action "exec" together with args to execute a command at the scheduled + time. + - For "autokill": object with time_ms and num_requests fields + - For "hostname": string (valid DNS label) + - For "roms": array of ROM objects (with name and image fields) for SET/ADD, + or array of ROM names for DEL + - For "dependencies": array of instance identifiers (name or UUID) + - For "sched_priority": SchedPriority enum value ("normal", "medium", + "high", "admin") """ class UpdateVolumesRequestItem(BaseModel): - """A single request item for updating a volume.""" + """A single update operation to be applied to a volume.""" # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. + # A field the specification requires is required here too, so a request + # missing one fails at construction rather than at the server. model_config = ConfigDict(extra="allow", populate_by_name=True) + uuid: str | None = None + """The UUID of the resource.""" + name: str | None = None + """The name of the resource.""" id: str | None = None - """ - (Optional). A client-provided identifier for tracking this operation in the - response. - """ - prop: MutableVolumeProperty | None = None + """A client-provided identifier for tracking this operation in the response.""" + prop: MutableVolumeProperty """The property to modify.""" - op: MutableVolumeOperation | None = None + op: MutableVolumeOperation """The operation to perform.""" value: Any | None = None """ The value for the update operation. The type depends on the property and operation: - - For "size_mb": unsigned integer - - For "quota_policy": "static" or "dynamic" - - For "tags": array of Strings + - For "image": string + - For "args": string or array of strings + - For "env": object (for SET/ADD) or string/array of strings (for DEL) + - For "memory_mb": integer + - For "vcpus": integer + - For "scale_to_zero": object with cooldown_time_ms, policy, and stateful + fields + - For "tags": array of strings - For "delete_lock": boolean + - For "schedules": array of schedule objects (with name, when, action, and + optional args fields). + Use action "exec" together with args to execute a command at the scheduled + time. + - For "autokill": object with time_ms and num_requests fields + - For "hostname": string (valid DNS label) + - For "roms": array of ROM objects (with name and image fields) for SET/ADD, + or array of ROM names for DEL + - For "dependencies": array of instance identifiers (name or UUID) + - For "sched_priority": SchedPriority enum value ("normal", "medium", + "high", "admin") """ - uuid: str | None = None - """The UUID of the volume to update. Mutually exclusive with name.""" - name: str | None = None - """The name of the volume to update. Mutually exclusive with UUID.""" class UpdateVolumesResponse(BaseModel): - """The response message for updating one or more volume(s).""" + """ + The response message for updating one or more volume(s) given their + UUID(s) or name(s). + """ # 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. @@ -6298,22 +6382,18 @@ class UpdateVolumesResponse(BaseModel): status: ResponseStatus | None = None """The status of the response.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information about the status.""" data: UpdateVolumesResponseData | None = None """The response data for this request.""" errors: list[ResponseError] | None = None """A list of errors which may have occurred during the request.""" op_time_us: int | None = None - """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. - """ + """The operation time in microseconds.""" class UpdateVolumesResponseData(BaseModel): + """The response data for this request.""" + # 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. # Every field is optional: one response shape serves several requests, and a @@ -6321,150 +6401,29 @@ class UpdateVolumesResponseData(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) volumes: list[UpdateVolumesResponseUpdatedVolume] | None = None - """List of volumes that were processed during the update operation.""" class UpdateVolumesResponseUpdatedVolume(BaseModel): + """Per-item result for an update volumes operation.""" + # 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. # Every field is optional: one response shape serves several requests, and a # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - uuid: str | None = None - """The UUID of the volume that was updated.""" - name: str | None = None - """The name of the volume that was updated.""" status: ResponseStatus | None = None - """The status of this particular volume update operation.""" - id: str | None = None - """(Optional). The client-provided ID from the request.""" + """Indicates whether the operation was successful for this item.""" message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ + """An optional message providing additional information.""" error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ - - -class User(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - + """An optional error code.""" uuid: str | None = None - """The UUID of the user.""" + """The UUID of the resource.""" name: str | None = None - """The name of the user.""" - auth_token: list[str] | None = None - """Authentication token(s) associated with the user.""" - permissions: list[UserPermission] | None = None - """The permission level of the user.""" - uid: int | None = None - """The user ID (UID) on the host system.""" - disabled: bool | None = None - """Whether the user account is disabled.""" - vmdb: UserVmdb | None = None - """Per-VM Configuration limits for the user.""" - net: UserNet | None = None - """Network configuration limits for the user.""" - vmm: UserVmm | None = None - """Global VM configuration limits for the user.""" - stor: UserStor | None = None - """Storage configuration limits for the user.""" - autoscale: UserAutoscale | None = None - """Autoscale configuration limits for the user.""" - - -class UserAutoscale(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - min_size: int | None = None - """Minimum size of an autoscale group.""" - max_size: int | None = None - """Maximum size of an autoscale group.""" - - -class UserNet(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - max_service_groups: int | None = None - """Maximum number of service groups the user can have at one moment.""" - max_services: int | None = None - """ - Maximum number of services across all service groups the user can have - at one moment. - """ - max_certificates: int | None = None - """Maximum number of TLS certificates the user can have at one moment.""" - - -class UserStor(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - max_volumes: int | None = None - """Maximum number of volumes the user can have at one moment.""" - min_volume_mb: int | None = None - """Minimum size of a volume in MB.""" - max_volume_mb: int | None = None - """Maximum size of a volume in MB.""" - max_total_volume_mb: int | None = None - """Maximum total size of all volumes in MB.""" - - -class UserVmdb(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - max_instances: int | None = None - """Maximum number of VM instances the user can have at one moment.""" - min_memory_mb: int | None = None - """Minimum amount of memory assigned to a VM in MB.""" - def_memory_mb: int | None = None - """Default amount of memory assigned to a VM in MB.""" - max_memory_mb: int | None = None - """Maximum amount of memory assigned to a VM in MB.""" - min_vcpus: int | None = None - """Minimum number of vCPUs assigned to a VM.""" - max_vcpus: int | None = None - """Maximum number of vCPUs assigned to a VM.""" - - -class UserVmm(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - max_vcpus: int | None = None - """Maximum number of vCPUs the user can have assigned to live instances.""" - max_memory_mb: int | None = None - """ - Maximum amount of memory in MB the user can have assigned to live - instances. - """ + """The human-readable name of the resource.""" + id: str | None = None + """The client-provided ID from the request.""" class Volume(BaseModel): @@ -6476,23 +6435,16 @@ class Volume(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) + status: ResponseStatus | None = None + """Indicates whether the operation was successful for this item.""" + message: str | None = None + """An optional message providing additional information.""" + error: int | None = None + """An optional error code.""" uuid: str | None = None - """ - The UUID of the volume. - - This is a unique identifier for the volume that is generated when the - volume is created. The UUID is used to reference the volume in - API calls and can be used to identify the volume in all API calls that - require an identifier. - """ + """The UUID of the resource.""" name: str | None = None - """ - The name of the volume. - - This is a human-readable name that can be used to identify the volume. - The name must be unique within the context of your account. The name can - also be used to identify the volume in API calls. - """ + """The human-readable name of the resource.""" created_at: datetime | None = None """The time the volume was created.""" state: VolumeState | None = None @@ -6517,23 +6469,6 @@ class Volume(BaseModel): Maximum 16 tags are allowed, and each tag may not be longer than 256 characters. """ - status: ResponseStatus | None = None - """ - An optional field representing the status of the request. This field is - only set when this message object is used as a response message. - """ - message: str | None = None - """ - An optional message providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is only set when this message object is used as a response - message, and is useful when the status is not `success`. - """ quota_policy: VolumeQuotaPolicy | None = None """Either static or dynamic reservation.""" delete_lock: bool | None = None @@ -6551,111 +6486,24 @@ class Volume(BaseModel): This field is only available for managed volumes and users with appropriate permissions. """ - args: dict[str, str] | None = None - """ - Optional script arguments that were applied to the custom volume filesystem - initialization scripts. - """ access_mode: VolumeAccessMode | None = None """ The access mode of the volume, controlling volume sharing behavior. Defaults to `rwo` if not specified. """ - - -class VolumeInstanceID(BaseModel): - """Reference to the instance to attach the volume to.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - uuid: str | None = None - """The UUID of the instance that the volume is attached to.""" - name: str | None = None - """The name of the instance that the volume is attached to.""" - - -class VolumeInstanceMount(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - uuid: str | None = None - """The UUID of the instance that the volume is mounted in.""" - name: str | None = None - """The name of the instance that the volume is mounted in.""" - readonly: bool | None = None - """Whether the volume is mounted read-only or read-write.""" - - -class WaitInstanceByUUIDRequestBody(BaseModel): - """Wait parameters.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - state: InstanceState | None = None - """The desired state to wait for. Default is `running`.""" - timeout_ms: int | None = None - """ - Deprecated: Use `timeout_s` instead. Timeout in milliseconds to - wait for the instance to reach the desired state. If `timeout_s` is - not set, this value is converted by rounding up to the next full - second. A value of -1 means to wait indefinitely. - """ - timeout_s: int | None = None - """ - Timeout in seconds to wait for the instance to reach the desired - state. If the timeout is reached, the request will fail with an - error. A value of -1 means to wait indefinitely until the instance - reaches the desired state. No wait performed for a value of 0. - """ - - -class WaitInstancesRequestItem(BaseModel): - """A single wait operation to be applied to an instance.""" - - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - state: InstanceState | None = None - """The desired state to wait for. Default is `running`.""" - timeout_ms: int | None = None - """ - Deprecated: Use `timeout_s` instead. Timeout in milliseconds to - wait for the instance to reach the desired state. If `timeout_s` is - not set, this value is converted by rounding up to the next full - second. A value of -1 means to wait indefinitely. - """ - timeout_s: int | None = None - """ - Timeout in seconds to wait for the instance to reach the desired - state. If the timeout is reached, the request will fail with an - error. A value of -1 means to wait indefinitely until the instance - reaches the desired state. No wait performed for a value of 0. + uid: int | None = None + """Guest UID for managed volumes (host_path mode only).""" + gid: int | None = None + """Guest GID for managed volumes (host_path mode only).""" + args: dict[str, str] | None = None + """ + Optional script arguments that were applied to the custom volume filesystem + initialization scripts. """ - uuid: str | None = None - """The UUID of the instance to wait for. Mutually exclusive with name.""" - name: str | None = None - """The name of the instance to wait for. Mutually exclusive with UUID.""" -class WaitInstancesResponse(BaseModel): - """ - The response message for waiting for one or more instance(s) to reach a - certain state given their UUID(s) or name(s). - """ +class VolumeInstanceID(BaseModel): + """An instance a volume is attached to.""" # 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. @@ -6663,36 +6511,25 @@ class WaitInstancesResponse(BaseModel): # summary listing or a failed bulk item carries only some of them. model_config = ConfigDict(extra="allow", populate_by_name=True) - status: ResponseStatus | None = None - """The status of the response.""" - message: str | None = None + uuid: str | None = None """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. + The UUID of the instance. This is a unique identifier for the instance + that is generated when the instance is created. The UUID is used to + reference the instance in API calls and can be used to identify the + instance in all API calls that require an instance identifier. """ - data: WaitInstancesResponseData | None = None - """The response data for this request.""" - errors: list[ResponseError] | None = None - """A list of errors which may have occurred during the request.""" - op_time_us: int | None = None + name: str | None = None """ - The operation time in microseconds. This is the time it took to process - the request and generate the response. + The name of the instance. This is a human-readable name that can be used + to identify the instance. The name must be unique within the context of + your account. If no name is specified, a random name is generated for + you. The name can also be used to identify the instance in API calls. """ -class WaitInstancesResponseData(BaseModel): - # 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. - # Every field is optional: one response shape serves several requests, and a - # summary listing or a failed bulk item carries only some of them. - model_config = ConfigDict(extra="allow", populate_by_name=True) - - instances: list[WaitInstancesResponseWaitedInstance] | None = None - """The instance(s) which this requested waited on.""" - +class VolumeInstanceMount(BaseModel): + """An instance mount of a volume.""" -class WaitInstancesResponseWaitedInstance(BaseModel): # 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. # Every field is optional: one response shape serves several requests, and a @@ -6700,46 +6537,41 @@ class WaitInstancesResponseWaitedInstance(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) uuid: str | None = None - """The UUID of the instance.""" + """The UUID of the instance that the volume is mounted in.""" name: str | None = None - """The name of the instance.""" - state: InstanceState | None = None - """The current state of the instance.""" - message: str | None = None - """ - An optional message providing additional information about the status. - This field is useful when the status is not `success`. - """ - error: int | None = None - """ - An optional error code providing additional information about the status. - This field is useful when the status is not `success`. - """ - status: ResponseStatus | None = None - """The status of the response.""" + """The name of the instance that the volume is mounted in.""" + readonly: bool | None = None + """Whether the volume is mounted read-only or read-write.""" + + +AuditObjectType = Literal["i", "v"] | str +""" +The kind of object an audit event is about. + +Open: the controller emits the ukpd object prefixes, and events for further +object kinds are added without a breaking change. +""" + +AuditOperationKind = Literal["start", "stop", "drain", "suspend", "restart"] | str +""" +What an operation was performed on the object. + +Open: the set depends on the object type, and instances carry +`start`, `stop`, `drain`, `suspend` and `restart`. +""" # 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 ( - AddUsersRequest, - AddUsersResponse, - AddUsersResponseData, - AttachVolumeByUUIDRequestBody, - AttachVolumesRequestItem, - AttachVolumesResponse, - AttachVolumesResponseAttachedVolume, - AttachVolumesResponseData, + AuditAttribution, + AuditEvent, + AuditEventData, + AuditObject, + AuditStop, AutoscalePolicy, AutoscalePolicyStep, - Certificate, - CheckpointHistoryEntry, - CloneVolumeByUUIDRequestBody, - CloneVolumesRequestItem, - CloneVolumesResponse, - CloneVolumesResponseData, - CloneVolumesResponseVolume, ConfigurationInstanceCreateArgs, CreateAutoscaleConfigurationByServiceGroupUUIDRequest, CreateAutoscaleConfigurationByServiceGroupUUIDRequestInstanceCreateArgs, @@ -6751,9 +6583,55 @@ class WaitInstancesResponseWaitedInstance(BaseModel): CreateAutoscaleConfigurationsResponse, CreateAutoscaleConfigurationsResponseConfigurationsResponse, CreateAutoscaleConfigurationsResponseData, + DeleteAutoscaleConfigurationPolicyResponse, + DeleteAutoscaleConfigurationPolicyResponseData, + DeleteAutoscaleConfigurationPolicyResponsePoliciesResponse, + DeleteAutoscaleConfigurationsResponse, + DeleteAutoscaleConfigurationsResponseData, + DeleteAutoscaleConfigurationsResponseServiceGroup, + DeletePolicyRequest, + GetAutoscaleConfigurationPolicyRequest, + GetAutoscaleConfigurationPolicyResponse, + GetAutoscaleConfigurationPolicyResponseData, + GetAutoscaleConfigurationPolicyResponsePolicyResponse, + GetAutoscaleConfigurationsResponse, + GetAutoscaleConfigurationsResponseData, + GetAutoscaleConfigurationsResponseServiceGroup, + InstanceCreateArgsInstanceCreateRequestRoms, + ServiceGroupTemplate, + Certificate, + CertificateValidation, CreateCertificateRequest, CreateCertificateResponse, CreateCertificateResponseData, + DeleteCertificatesResponse, + DeleteCertificatesResponseData, + DeleteCertificatesResponseDeletedCertificate, + GetCertificatesResponse, + GetCertificatesResponseData, + UpdateCertificateByUUIDRequestBody, + UpdateCertificatesRequestItem, + UpdateCertificatesResponse, + UpdateCertificatesResponseData, + ID, + InlineFile, + NameOrUUID, + ResponseError, + GetImagesRequestTagOrDigest, + GetImagesResponse, + GetImagesResponseData, + Image, + PinImageRequestItem, + PinImageRequestItemAutokill, + PinImagesResponse, + PinImagesResponseData, + PinImagesResponseImage, + UnpinImageRequestItem, + UnpinImagesResponse, + UnpinImagesResponseData, + UnpinImagesResponseImage, + CheckpointAutokill, + CheckpointHistoryEntry, CreateCheckpointInstancesRequestItem, CreateCheckpointInstancesResponse, CreateCheckpointInstancesResponseCheckpointInstance, @@ -6770,33 +6648,10 @@ class WaitInstancesResponseWaitedInstance(BaseModel): CreateInstanceResponse, CreateInstanceResponseData, CreateInstanceScaleToZero, - CreateServiceGroupRequest, - CreateServiceGroupRequestAutokill, - CreateServiceGroupRequestDomain, - CreateServiceGroupResponse, - CreateServiceGroupResponseData, CreateTemplateInstancesRequestItem, CreateTemplateInstancesResponse, CreateTemplateInstancesResponseData, CreateTemplateInstancesResponseTemplateInstance, - CreateTemplateVolumesResponse, - CreateTemplateVolumesResponseData, - CreateTemplateVolumesResponseTemplateVolume, - CreateVolumeRequest, - CreateVolumeResponse, - CreateVolumeResponseData, - CreateVolumeResponseVolume, - DataLicense, - DataResult, - DeleteAutoscaleConfigurationPolicyResponse, - DeleteAutoscaleConfigurationPolicyResponseData, - DeleteAutoscaleConfigurationPolicyResponsePoliciesResponse, - DeleteAutoscaleConfigurationsResponse, - DeleteAutoscaleConfigurationsResponseData, - DeleteAutoscaleConfigurationsResponseServiceGroup, - DeleteCertificatesResponse, - DeleteCertificatesResponseData, - DeleteCertificatesResponseDeletedCertificate, DeleteCheckpointInstancesResponse, DeleteCheckpointInstancesResponseCheckpointInstance, DeleteCheckpointInstancesResponseData, @@ -6805,42 +6660,14 @@ class WaitInstancesResponseWaitedInstance(BaseModel): DeleteInstancesResponse, DeleteInstancesResponseData, DeleteInstancesResponseInstance, - DeletePolicyRequest, - DeleteServiceGroupsResponse, - DeleteServiceGroupsResponseData, - DeleteServiceGroupsResponseDeletedServiceGroup, DeleteTemplateInstancesResponse, DeleteTemplateInstancesResponseData, DeleteTemplateInstancesResponseTemplateInstance, - DeleteTemplateVolumesResponse, - DeleteTemplateVolumesResponseData, - DeleteTemplateVolumesResponseTemplateVolume, - DeleteVolumesResponse, - DeleteVolumesResponseData, - DeleteVolumesResponseDeletedVolume, - DetachVolumeByUUIDRequestBody, - DetachVolumesRequestItem, - DetachVolumesResponse, - DetachVolumesResponseData, - DetachVolumesResponseDetachedVolume, - Domain, - GetAutoscaleConfigurationPolicyRequest, - GetAutoscaleConfigurationPolicyResponse, - GetAutoscaleConfigurationPolicyResponseData, - GetAutoscaleConfigurationPolicyResponsePolicyResponse, - GetAutoscaleConfigurationsResponse, - GetAutoscaleConfigurationsResponseData, - GetAutoscaleConfigurationsResponseServiceGroup, - GetCertificatesResponse, - GetCertificatesResponseData, GetCheckpointHistoryResponse, GetCheckpointHistoryResponseData, GetCheckpointHistoryResponseInstanceHistory, GetCheckpointInstancesResponse, GetCheckpointInstancesResponseData, - GetImagesRequestTagOrDigest, - GetImagesResponse, - GetImagesResponseData, GetInstanceLogsByUUIDRequestBody, GetInstancesLogsRequestItem, GetInstancesLogsResponse, @@ -6853,22 +6680,12 @@ class WaitInstancesResponseWaitedInstance(BaseModel): GetInstancesMetricsResponseInstanceMetrics, GetInstancesResponse, GetInstancesResponseData, - GetServiceGroupsResponse, - GetServiceGroupsResponseData, GetTemplateInstancesResponse, GetTemplateInstancesResponseData, - GetTemplateVolumesResponse, - GetTemplateVolumesResponseData, - GetVolumesResponse, - GetVolumesResponseData, - HealthzResponse, - HealthzResponseData, - Image, ImageSpec, - InlineFile, Instance, InstanceAutokill, - InstanceCreateArgsInstanceCreateRequestRoms, + InstanceGpu, InstanceNetworkInterface, InstanceNetworkInterfaceRelay, InstancePendingUpdate, @@ -6881,21 +6698,9 @@ class WaitInstancesResponseWaitedInstance(BaseModel): InstanceTemplateAutokill, InstanceVolume, ItemAutokill, - NameOrUUID, NetworkInterfaceRelay, - Quotas, - QuotasLimits, - QuotasResponse, - QuotasResponseData, - QuotasStats, - ResponseError, Schedule, - Service, - ServiceGroup, - ServiceGroupAutokill, - ServiceGroupInstance, ServiceGroupInstanceDomain, - ServiceGroupTemplate, StartInstanceByUUIDRequestBody, StartInstancesRequestItem, StartInstancesResponse, @@ -6912,10 +6717,6 @@ class WaitInstancesResponseWaitedInstance(BaseModel): SuspendInstancesResponseData, SuspendInstancesResponseSuspendedInstance, TemplateAutokill, - UpdateCertificateByUUIDRequestBody, - UpdateCertificatesRequestItem, - UpdateCertificatesResponse, - UpdateCertificatesResponseData, UpdateCheckpointInstanceByUUIDRequestBody, UpdateCheckpointInstancesRequestItem, UpdateCheckpointInstancesResponse, @@ -6926,16 +6727,76 @@ class WaitInstancesResponseWaitedInstance(BaseModel): UpdateInstancesResponse, UpdateInstancesResponseData, UpdateInstancesResponseUpdatedInstance, - UpdateServiceGroupByUUIDRequestBody, - UpdateServiceGroupsRequestItem, - UpdateServiceGroupsResponse, - UpdateServiceGroupsResponseData, - UpdateServiceGroupsResponseUpdatedServiceGroup, UpdateTemplateInstanceByUUIDRequestBody, UpdateTemplateInstancesRequestItem, UpdateTemplateInstancesResponse, UpdateTemplateInstancesResponseData, UpdateTemplateInstancesResponseTemplateInstance, + WaitInstanceByUUIDRequestBody, + WaitInstancesRequestItem, + WaitInstancesResponse, + WaitInstancesResponseData, + WaitInstancesResponseWaitedInstance, + DataLicense, + HealthzResponse, + HealthzResponseData, + CreateServiceGroupRequest, + CreateServiceGroupRequestAutokill, + CreateServiceGroupRequestDomain, + CreateServiceGroupResponse, + CreateServiceGroupResponseData, + DeleteServiceGroupsResponse, + DeleteServiceGroupsResponseData, + DeleteServiceGroupsResponseDeletedServiceGroup, + Domain, + GetServiceGroupsResponse, + GetServiceGroupsResponseData, + Service, + ServiceGroup, + ServiceGroupAutokill, + ServiceGroupInstance, + UpdateServiceGroupByUUIDRequestBody, + UpdateServiceGroupsRequestItem, + UpdateServiceGroupsResponse, + UpdateServiceGroupsResponseData, + UpdateServiceGroupsResponseUpdatedServiceGroup, + Quotas, + QuotasLimits, + QuotasResponse, + QuotasResponseData, + QuotasStats, + AttachVolumeByUUIDRequestBody, + AttachVolumesRequestItem, + AttachVolumesResponse, + AttachVolumesResponseAttachedVolume, + AttachVolumesResponseData, + CloneVolumeByUUIDRequestBody, + CloneVolumesRequestItem, + CloneVolumesResponse, + CloneVolumesResponseData, + CloneVolumesResponseVolume, + CreateTemplateVolumesResponse, + CreateTemplateVolumesResponseData, + CreateTemplateVolumesResponseTemplateVolume, + CreateVolumeRequest, + CreateVolumeResponse, + CreateVolumeResponseData, + CreateVolumeResponseVolume, + DeleteTemplateVolumesResponse, + DeleteTemplateVolumesResponseData, + DeleteTemplateVolumesResponseTemplateVolume, + DeleteVolumesResponse, + DeleteVolumesResponseData, + DeleteVolumesResponseDeletedVolume, + DetachVolumeByUUIDRequestBody, + DetachVolumesRequestItem, + DetachVolumesResponse, + DetachVolumesResponseData, + DetachVolumesResponseDetachedVolume, + GetTemplateVolumesResponse, + GetTemplateVolumesResponseData, + GetVolumesResponse, + GetVolumesResponseData, UpdateTemplateVolumeByUUIDRequestBody, UpdateTemplateVolumesRequestItem, UpdateTemplateVolumesResponse, @@ -6946,19 +6807,8 @@ class WaitInstancesResponseWaitedInstance(BaseModel): UpdateVolumesResponse, UpdateVolumesResponseData, UpdateVolumesResponseUpdatedVolume, - User, - UserAutoscale, - UserNet, - UserStor, - UserVmdb, - UserVmm, Volume, VolumeInstanceID, VolumeInstanceMount, - WaitInstanceByUUIDRequestBody, - WaitInstancesRequestItem, - WaitInstancesResponse, - WaitInstancesResponseData, - WaitInstancesResponseWaitedInstance, ): _model.model_rebuild() diff --git a/src/unikraft_cloud/api/platform/node_gen.py b/src/unikraft_cloud/api/platform/node_gen.py index e0224e8..a5661bd 100644 --- a/src/unikraft_cloud/api/platform/node_gen.py +++ b/src/unikraft_cloud/api/platform/node_gen.py @@ -2,15 +2,14 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from ...core.http import UNSET, ApiClient, TimeoutOption from . import models_gen as models class NodeApi(ApiClient): - """Low-level "plumbing" client for the `Node` resource. + """Low-level "plumbing" client for the `node` resource. Methods mirror the OpenAPI specification and return the raw response envelope. The idiomatic clients are built on top of this. diff --git a/src/unikraft_cloud/api/platform/service_groups_gen.py b/src/unikraft_cloud/api/platform/services_gen.py similarity index 96% rename from src/unikraft_cloud/api/platform/service_groups_gen.py rename to src/unikraft_cloud/api/platform/services_gen.py index e655327..094d833 100644 --- a/src/unikraft_cloud/api/platform/service_groups_gen.py +++ b/src/unikraft_cloud/api/platform/services_gen.py @@ -2,16 +2,15 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from urllib.parse import quote from ...core.http import UNSET, ApiClient, TimeoutOption from . import models_gen as models -class ServiceGroupsApi(ApiClient): - """Low-level "plumbing" client for the `ServiceGroups` resource. +class ServicesApi(ApiClient): + """Low-level "plumbing" client for the `services` resource. Methods mirror the OpenAPI specification and return the raw response envelope. The idiomatic clients are built on top of this. diff --git a/src/unikraft_cloud/api/platform/users_gen.py b/src/unikraft_cloud/api/platform/users_gen.py index e3333cf..b53f262 100644 --- a/src/unikraft_cloud/api/platform/users_gen.py +++ b/src/unikraft_cloud/api/platform/users_gen.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from urllib.parse import quote from ...core.http import UNSET, ApiClient, TimeoutOption @@ -11,31 +10,12 @@ class UsersApi(ApiClient): - """Low-level "plumbing" client for the `Users` resource. + """Low-level "plumbing" client for the `users` resource. Methods mirror the OpenAPI specification and return the raw response envelope. The idiomatic clients are built on top of this. """ - async def add_users( - self, - *, - body: models.AddUsersRequest, - headers: Mapping[str, str] | None = None, - base_url: str | None = None, - timeout: TimeoutOption = UNSET, - ) -> models.AddUsersResponse: - """Add Users""" - return await self.request( - models.AddUsersResponse, - method="POST", - path="/v1/users", - body=body, - headers=headers, - base_url=base_url, - timeout=timeout, - ) - async def get_user( self, *, diff --git a/src/unikraft_cloud/api/platform/volumes_gen.py b/src/unikraft_cloud/api/platform/volumes_gen.py index bcbf513..96f01aa 100644 --- a/src/unikraft_cloud/api/platform/volumes_gen.py +++ b/src/unikraft_cloud/api/platform/volumes_gen.py @@ -2,16 +2,15 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Mapping -from typing import Any +from collections.abc import Mapping from urllib.parse import quote -from ...core.http import UNSET, ApiClient, TimeoutOption +from ...core.http import UNSET, ApiClient, TimeoutOption, comma_separated from . import models_gen as models class VolumesApi(ApiClient): - """Low-level "plumbing" client for the `Volumes` resource. + """Low-level "plumbing" client for the `volumes` resource. Methods mirror the OpenAPI specification and return the raw response envelope. The idiomatic clients are built on top of this. @@ -103,7 +102,7 @@ async def create_template_volume( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.CreateTemplateVolumesResponse: - """Create template volume""" + """Create Template Volume""" return await self.request( models.CreateTemplateVolumesResponse, method="POST", @@ -141,7 +140,7 @@ async def delete_template_volume_by_uuid( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.DeleteTemplateVolumesResponse: - """Delete template volume by UUID""" + """Delete Template Volume by UUID""" return await self.request( models.DeleteTemplateVolumesResponse, method="DELETE", @@ -159,7 +158,7 @@ async def delete_template_volumes( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.DeleteTemplateVolumesResponse: - """Delete template volumes""" + """Delete Template Volumes""" return await self.request( models.DeleteTemplateVolumesResponse, method="DELETE", @@ -255,7 +254,7 @@ async def get_template_volume_by_uuid( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.GetTemplateVolumesResponse: - """Get template volume by UUID""" + """Get Template Volume by UUID""" return await self.request( models.GetTemplateVolumesResponse, method="GET", @@ -276,14 +275,14 @@ async def get_template_volumes( details: bool | None = None, count: int | None = None, from_: str | None = None, - tags: list[str] | None = None, order: models.PaginationOrder | None = None, sortby: models.PaginationSortBy | None = None, + tags: list[str] | None = None, headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.GetTemplateVolumesResponse: - """List template volumes""" + """List Template Volumes""" return await self.request( models.GetTemplateVolumesResponse, method="GET", @@ -294,9 +293,9 @@ async def get_template_volumes( "details": details, "count": count, "from": from_, - "tags": tags, "order": order, "sortby": sortby, + "tags": comma_separated(tags), }, headers=headers, base_url=base_url, @@ -333,9 +332,9 @@ async def get_volumes( details: bool | None = None, count: int | None = None, from_: str | None = None, - tags: list[str] | None = None, order: models.PaginationOrder | None = None, sortby: models.PaginationSortBy | None = None, + tags: list[str] | None = None, headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, @@ -351,9 +350,9 @@ async def get_volumes( "details": details, "count": count, "from": from_, - "tags": tags, "order": order, "sortby": sortby, + "tags": comma_separated(tags), }, headers=headers, base_url=base_url, @@ -369,7 +368,7 @@ async def update_template_volume_by_uuid( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.UpdateTemplateVolumesResponse: - """Update template volume by UUID""" + """Update Template Volume by UUID""" return await self.request( models.UpdateTemplateVolumesResponse, method="PATCH", @@ -388,7 +387,7 @@ async def update_template_volumes( base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> models.UpdateTemplateVolumesResponse: - """Update template volumes""" + """Update Template Volumes""" return await self.request( models.UpdateTemplateVolumesResponse, method="PATCH", diff --git a/src/unikraft_cloud/resources/service_groups.py b/src/unikraft_cloud/resources/service_groups.py index 132c713..12503a3 100644 --- a/src/unikraft_cloud/resources/service_groups.py +++ b/src/unikraft_cloud/resources/service_groups.py @@ -12,7 +12,7 @@ from pydantic import BaseModel from ..api.platform import models -from ..api.platform.service_groups_gen import ServiceGroupsApi +from ..api.platform.services_gen import ServicesApi from ..core.fanout import fanout from ..core.handle import HandleSteps, Located, MetroTarget, ResourceHandle from ..core.handle_set import HandleSet @@ -207,14 +207,14 @@ def edit(self, **opts: Any) -> ResourceEditor[Awaitable[list[UpdatedServiceGroup ) -class ServiceGroups(Resource[ServiceGroupsApi]): +class ServiceGroups(Resource[ServicesApi]): """Idiomatic client for Unikraft Cloud service groups.""" noun = _NOUN key = _KEY def __init__(self, session: Session, scope: MetroScope) -> None: - super().__init__(session, scope, ServiceGroupsApi(session.platform)) + super().__init__(session, scope, ServicesApi(session.platform)) def create( self, From a31b9f7800a69ca23a6e2c0135ec724517d21366 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:31:08 +0100 Subject: [PATCH 22/36] feat(api): Add the sandbox plugin plumbing A plugin serves an API of its own from inside an instance, which the platform proxies under the instance on the metro running it. The SDK had no client for that API and no way to build its route. The plumbing is the unikraft-cloud-plugin-sandbox-api package, which plugin-sdk renders. SandboxApi, reached as ukc.api.plugins.sandbox, sends it through the SDK's transport, and for_instance() points it at one instance; a plugin named v1 keeps its route. Signed-off-by: Daniel Vallance --- Makefile | 5 +- README.md | 18 +- pyproject.toml | 2 + src/unikraft_cloud/__init__.py | 8 +- src/unikraft_cloud/api/__init__.py | 14 +- src/unikraft_cloud/api/plugins/__init__.py | 35 + src/unikraft_cloud/api/plugins/_route.py | 45 + .../api/plugins/sandbox/__init__.py | 106 +++ src/unikraft_cloud/client.py | 7 +- src/unikraft_cloud/core/http.py | 8 +- tests/test_core.py | 8 + tests/test_plugins_api.py | 248 +++++ tests/test_transport.py | 11 + uv.lock | 896 +++++++++--------- 14 files changed, 954 insertions(+), 457 deletions(-) create mode 100644 src/unikraft_cloud/api/plugins/__init__.py create mode 100644 src/unikraft_cloud/api/plugins/_route.py create mode 100644 src/unikraft_cloud/api/plugins/sandbox/__init__.py create mode 100644 tests/test_plugins_api.py diff --git a/Makefile b/Makefile index 3432e2f..f9a1cf4 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ all: generate test # Each per-spec target clears its generated modules first, so a tag the spec # drops leaves no stale client behind. The hand-written `__init__.py` files stay. .PHONY: generate -generate: generate-platform generate-controlplane ## Regenerate every plumbing client from the OpenAPI specs. +generate: generate-platform generate-controlplane ## Regenerate the platform and control-plane plumbing from the OpenAPI specs. $(MAKE) fmt # Namespaced schema names (`Instances.Instance`) are not Python identifiers, so @@ -54,6 +54,9 @@ generate-controlplane: ## Regenerate the control-plane API plumbing. --namespace-flatten strip \ -v package=api +# A plugin's client is generated by plugin-sdk from the plugin's own +# specification, and installed as a package of its own. + .PHONY: fmt fmt: ## Format the generated (and all) sources. $(UV) run ruff check --select I --fix $(OUTPUT) diff --git a/README.md b/README.md index a333fc1..d629a3f 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,16 @@ await ukc.api.controlplane.metros.list_metros() # Or per resource, alongside its idiomatic client. await ukc.instances.api.get_instance_metrics(uuid=["..."]) + +# An operation that streams events yields each as it arrives. +async for event in ukc.api.platform.audit.subscribe_audit_events(tags=["prod"]): + print(event.type, event.object.uuid if event.object else None) + +# An operation that answers with bytes returns them with their status and headers. +raw = await ukc.api.plugins.sandbox.for_instance(instance_uuid).commands.get_raw_command_log( + command_uuid, "stdout", range="bytes=0-1023" +) +print(raw.status, raw.byte_range, raw.total_size, raw.content) ``` It can also be used on its own, without the idiomatic layer: @@ -319,8 +329,10 @@ async with PlatformApi(config) as api: The `api/platform` and `api/controlplane` packages are generated from the OpenAPI specification by [`openapi-gen`](https://github.com/unikraft-cloud) using the templates in -[`templates/`](templates). Everything else is hand-written. Files ending in `_gen.py` are -never edited by hand. +[`templates/`](templates). The sandbox plugin's plumbing is generated elsewhere, by +[plugin-sdk](https://github.com/unikraft-cloud/plugin-sdk) from the plugin's own +specification, and installed as the `unikraft-cloud-plugin-sandbox-api` package. +Everything else is hand-written. Files ending in `_gen.py` are never edited by hand. The templates tell request models from response models by name, after the TypeSpec convention `Request*`, and by use: a schema whose name contains @@ -332,7 +344,7 @@ and a `Request`-named schema that a response also carries fails to parse when th leaves a required field out. ```sh -make generate # regenerate both plumbing clients from the specs +make generate # regenerate the platform and control-plane plumbing from the specs make lint # ruff check + format --check make typecheck # mypy make test # pytest diff --git a/pyproject.toml b/pyproject.toml index 2f71fce..18c7375 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ classifiers = [ dependencies = [ "httpx>=0.27", "pydantic>=2.7", + # The sandbox plugin's plumbing, generated from its specification by plugin-sdk. + "unikraft-cloud-plugin-sandbox-api>=1.1.0,<2", ] [project.urls] diff --git a/src/unikraft_cloud/__init__.py b/src/unikraft_cloud/__init__.py index bad5a53..c30c193 100644 --- a/src/unikraft_cloud/__init__.py +++ b/src/unikraft_cloud/__init__.py @@ -4,13 +4,13 @@ """Official Python SDK for the Unikraft Cloud Platform API. The idiomatic client is :class:`UnikraftCloud`. The raw, spec-shaped API stays -available on ``ukc.api`` and from ``unikraft_cloud.api.platform`` and -``unikraft_cloud.api.controlplane``. +available on ``ukc.api`` and from ``unikraft_cloud.api.platform``, +``unikraft_cloud.api.controlplane`` and ``unikraft_cloud.api.plugins``. """ from __future__ import annotations -from .api import Api, ControlPlaneApi, PlatformApi +from .api import Api, ControlPlaneApi, PlatformApi, PluginsApi, SandboxApi from .client import USER_AGENT, MetroClient, Scope, UnikraftCloud from .core.errors import ( AlreadyExistsError, @@ -159,6 +159,7 @@ "PatchItem", "PatchOp", "PlatformApi", + "PluginsApi", "Quotas", "RateLimitError", "RawResponse", @@ -169,6 +170,7 @@ "ResourceEditor", "ResourceHandle", "ResponseError", + "SandboxApi", "Scope", "ScopeOptions", "ServerError", diff --git a/src/unikraft_cloud/api/__init__.py b/src/unikraft_cloud/api/__init__.py index 5644f9d..61ca3af 100644 --- a/src/unikraft_cloud/api/__init__.py +++ b/src/unikraft_cloud/api/__init__.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2026, Unikraft GmbH. All rights reserved. # -# The raw ("plumbing") layer of both Unikraft Cloud APIs, in one place. +# The raw ("plumbing") layer of every Unikraft Cloud API, in one place. # Everything here mirrors the OpenAPI specification: operations are named after # their operation ID, and responses come back as the untouched envelope. # @@ -15,20 +15,23 @@ from ..core.http import ApiClientConfig, ApiClientGroup from .controlplane import ControlPlaneApi from .platform import PlatformApi +from .plugins import PluginsApi +from .plugins.sandbox import SandboxApi -__all__ = ["Api", "ControlPlaneApi", "PlatformApi"] +__all__ = ["Api", "ControlPlaneApi", "PlatformApi", "PluginsApi", "SandboxApi"] class Api(ApiClientGroup): - """Both raw API surfaces behind one set of credentials, as exposed by ``ukc.api``. + """Every raw API surface behind one set of credentials, as exposed by ``ukc.api``. - Both share one connection pool, and closing this releases it -- unless an + They share one connection pool, and closing this releases it -- unless an httpx client was supplied, whose lifetime stays its owner's. .. code-block:: python await ukc.api.platform.instances.get_instances(count=10) await ukc.api.controlplane.metros.list_metros() + await ukc.api.plugins.sandbox.for_instance(uuid).commands.list_commands() """ def __init__(self, platform: ApiClientConfig, control_plane: ApiClientConfig) -> None: @@ -46,6 +49,9 @@ def __init__(self, platform: ApiClientConfig, control_plane: ApiClientConfig) -> self.controlplane = ControlPlaneApi( replace(control_plane, http=self.http) if shared else control_plane ) + #: The official plugin APIs, reached through an instance on the platform + #: API's metro. + self.plugins = PluginsApi(self.config) async def aclose(self) -> None: """Release the pool, and the control plane's own if it made one.""" diff --git a/src/unikraft_cloud/api/plugins/__init__.py b/src/unikraft_cloud/api/plugins/__init__.py new file mode 100644 index 0000000..58de0b9 --- /dev/null +++ b/src/unikraft_cloud/api/plugins/__init__.py @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# The raw ("plumbing") layer of the official plugin APIs. A plugin runs inside +# an instance and is reached through the instance's authenticated plugin route, +# so every plugin client is pointed at one instance before it is used. + +from __future__ import annotations + +from ...core.http import ApiClientConfig, ApiClientGroup +from ._route import plugin_base_url +from .sandbox import SandboxApi + +__all__ = ["PluginsApi", "SandboxApi", "plugin_base_url"] + + +class PluginsApi(ApiClientGroup): + """The raw clients of the official plugins, as exposed by ``ukc.api.plugins``. + + Each is built on the platform API's transport config and points at that + config's metro until :meth:`~SandboxApi.for_instance` names an instance. + Built on its own, the group owns the connection pool its members share, + and :meth:`aclose` or ``async with`` releases it, as for the other API + groups. + + .. code-block:: python + + raw = ukc.metro("fra").api.plugins.sandbox.for_instance(instance.uuid) + res = await raw.commands.list_commands() + """ + + def __init__(self, config: ApiClientConfig) -> None: + super().__init__(config) + #: The sandbox plugin: commands and files inside an instance. + self.sandbox = SandboxApi(self.config) diff --git a/src/unikraft_cloud/api/plugins/_route.py b/src/unikraft_cloud/api/plugins/_route.py new file mode 100644 index 0000000..ccf7923 --- /dev/null +++ b/src/unikraft_cloud/api/plugins/_route.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Where a plugin is reached. Kept apart from the package's __init__ so the +# per-plugin packages can import it without a cycle. + +from __future__ import annotations + +import re +from urllib.parse import quote + +from ...core.http import normalise_base_url + +__all__ = ["plugin_base_url", "plugin_origin"] + +#: The instance route a plugin is proxied under, at the end of a base URL. +_ROUTE = re.compile(r"/v1/instances/[^/]+/plugins/[^/]+$") + + +def plugin_base_url(base_url: str, instance_uuid: str, plugin: str) -> str: + """The plugin route of one instance, to use as a client's ``base_url``. + + A plugin runs inside an instance, and the platform proxies its API under the + instance on the metro that runs it:: + + https://api..unikraft.cloud/v1/instances//plugins// + + ``base_url`` is that metro's API base URL, e.g. ``https://api.fra.unikraft.cloud``, + with or without the ``/v1`` the control plane reports it with. The UUID and + the plugin name are one path segment each, so each is percent-encoded with + nothing left unescaped. + """ + return ( + f"{normalise_base_url(base_url)}/v1/instances/{quote(instance_uuid, safe='')}" + f"/plugins/{quote(plugin, safe='')}" + ) + + +def plugin_origin(base_url: str) -> str: + """The metro API base URL a plugin route was built from. + + A base URL that is no plugin route is its own origin, normalised, so a + client made at a route and one made at a metro build routes alike. + """ + return _ROUTE.sub("", normalise_base_url(base_url)) diff --git a/src/unikraft_cloud/api/plugins/sandbox/__init__.py b/src/unikraft_cloud/api/plugins/sandbox/__init__.py new file mode 100644 index 0000000..a9b2ef7 --- /dev/null +++ b/src/unikraft_cloud/api/plugins/sandbox/__init__.py @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# The sandbox plugin API's "plumbing" layer. The generated clients come from +# the unikraft-cloud-plugin-sandbox-api package, which plugin-sdk renders from +# the plugin's specification; what is here points them at an instance and +# sends their requests through the SDK's transport. + +from __future__ import annotations + +from dataclasses import replace +from types import TracebackType + +from typing_extensions import Self +from unikraft_cloud_plugin_sandbox_api import ( + PLUGIN_NAME, + CommandsApi, + FsApi, + SandboxPluginApi, + models, +) + +from ....core.http import ApiClient, ApiClientConfig, RawResponse +from .._route import plugin_base_url, plugin_origin + +__all__ = [ + "PLUGIN_NAME", + "ApiClient", + "ApiClientConfig", + "CommandsApi", + "FsApi", + "RawResponse", + "SandboxApi", + "SandboxPluginApi", + "models", +] + + +class SandboxApi(SandboxPluginApi): + """Every sandbox plugin API resource, raw, sent through the SDK's transport. + + The plugin API is instance-scoped: the platform proxies it under + ``/v1/instances//plugins//`` on the metro that runs the + instance. :meth:`for_instance` returns this client pointed at one instance's + route; alternatively a single call can be redirected with ``base_url=``. + + A client built on its own owns a connection pool, which every client + scoped from it shares; close the one that made it, as below, or await its + :meth:`aclose`. Reached as ``ukc.api.plugins.sandbox`` it sends through the + session's pool instead, and the session's close releases that. + + .. code-block:: python + + from unikraft_cloud.api.plugins.sandbox import SandboxApi + from unikraft_cloud.core.http import ApiClientConfig + + config = ApiClientConfig( + base_url="https://api.fra.unikraft.cloud", token=os.environ["UKC_TOKEN"] + ) + async with SandboxApi(config) as sandbox: + api = sandbox.for_instance("550e8400-e29b-41d4-a716-446655440000") + res = await api.commands.list_commands() + """ + + def __init__(self, config: ApiClientConfig) -> None: + self._config = config + #: The metro API base URL that instance routes are built from, also for a + #: client made at an instance's route. + self._origin = plugin_origin(config.base_url) + #: The SDK client that every resource sends through, so they share a pool. + self.client = ApiClient(config) + super().__init__(self.client) + + async def aclose(self) -> None: + """Release the connection pool, if this client is the one that made it.""" + await self.client.aclose() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + + def for_instance( + self, + uuid: str, + *, + plugin: str = PLUGIN_NAME, + base_url: str | None = None, + ) -> SandboxApi: + """This client pointed at the plugin route of one instance. + + ``base_url`` is the API base URL of the metro running the instance, when + it is not the one this client was configured with, or routed from. + ``plugin`` is the name the plugin was attached under, when it is not the + default. The scoped client sends through this one's pool, so closing + this one closes it too. + """ + route = plugin_base_url(base_url or self._origin, uuid, plugin) + config = replace(self._config.with_base_url(route), http=self.client.http) + return SandboxApi(config) diff --git a/src/unikraft_cloud/client.py b/src/unikraft_cloud/client.py index 7f89738..756748c 100644 --- a/src/unikraft_cloud/client.py +++ b/src/unikraft_cloud/client.py @@ -87,7 +87,8 @@ class UnikraftCloud(Scope): discovery. The raw, spec-shaped API stays available on :attr:`api`, and from - ``unikraft_cloud.api.platform`` and ``unikraft_cloud.api.controlplane``. + ``unikraft_cloud.api.platform``, ``unikraft_cloud.api.controlplane`` and + ``unikraft_cloud.api.plugins``. The client owns a connection pool, so close it when you are done -- either with ``async with`` or by awaiting :meth:`aclose`. @@ -203,8 +204,8 @@ def __init__( super().__init__(session, _default_scope(metros, chosen_metro)) #: The raw ("plumbing") API surfaces: ``api.platform`` (metro-scoped, - #: pointing at the default metro unless a call passes ``base_url``) and - #: ``api.controlplane``. + #: pointing at the default metro unless a call passes ``base_url``), + #: ``api.controlplane`` and ``api.plugins``. self.api = Api(platform, control_plane) self._metro_clients: dict[str, MetroClient] = {} diff --git a/src/unikraft_cloud/core/http.py b/src/unikraft_cloud/core/http.py index 679b65e..149283c 100644 --- a/src/unikraft_cloud/core/http.py +++ b/src/unikraft_cloud/core/http.py @@ -111,8 +111,9 @@ def with_base_url(self, base_url: str) -> ApiClientConfig: return replace(self, base_url=base_url) -#: The API version prefix the generated operation paths already carry. -_API_VERSION = re.compile(r"/v1$") +#: The API version prefix the generated operation paths already carry, at the +#: end of a base URL, unless it is a plugin's name at the end of its route. +_API_VERSION = re.compile(r"(? str: @@ -120,7 +121,8 @@ def normalise_base_url(base_url: str) -> str: Those paths already carry the ``/v1`` prefix, so a trailing ``/v1`` -- which is how the control plane reports a metro endpoint -- is dropped rather than - duplicated into ``/v1/v1/...``. + doubled into ``/v1/v1/...``. A plugin named so keeps it at the end of its + route. """ return _API_VERSION.sub("", base_url.rstrip("/")) diff --git a/tests/test_core.py b/tests/test_core.py index 5f34d3e..6b08e08 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -83,6 +83,14 @@ def test_drops_a_trailing_v1_the_operation_paths_already_carry(self) -> None: "https://api.staging.example.com" ) + def test_drops_the_v1_whatever_its_case_or_what_comes_before_it(self) -> None: + assert metro_base_url("HTTPS://api.staging.example.com/V1") == ( + "HTTPS://api.staging.example.com" + ) + assert metro_base_url("https://proxy.example.com/ukc/v1/") == ( + "https://proxy.example.com/ukc" + ) + def session(handler: Callable[[httpx.Request], Coroutine[None, None, httpx.Response]]) -> Session: """A session whose control plane answers through `handler`.""" diff --git a/tests/test_plugins_api.py b/tests/test_plugins_api.py new file mode 100644 index 0000000..eb6c310 --- /dev/null +++ b/tests/test_plugins_api.py @@ -0,0 +1,248 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# The plugin plumbing: the instance route a plugin hangs off, and the generated +# sandbox clients on top of it. + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest + +from unikraft_cloud import ( + ApiClientConfig, + NotFoundError, + RawResponse, + UnikraftCloud, + UnikraftCloudError, +) +from unikraft_cloud.api.plugins import PluginsApi, plugin_base_url +from unikraft_cloud.api.plugins.sandbox import SandboxApi, models + +from .conftest import Recorder, envelope, queued + +ROUTE = "https://api.fra.unikraft.cloud/v1/instances/u1/plugins/sandbox" + + +def cloud(recorder: Recorder, **config: Any) -> UnikraftCloud: + return UnikraftCloud(token="tok", transport=recorder.transport, **config) + + +def blob(status: int, body: bytes, **headers: str) -> Recorder: + """A transport answering one raw-bytes payload.""" + return Recorder( + lambda request: httpx.Response( + status, content=body, headers={"content-type": "application/octet-stream", **headers} + ) + ) + + +class TestPluginRoute: + def test_hangs_off_the_instance_on_its_metro(self) -> None: + assert plugin_base_url("https://api.fra.unikraft.cloud/", "u1", "sandbox") == ROUTE + + def test_encodes_each_segment_as_one_segment(self) -> None: + url = plugin_base_url("https://api.fra.unikraft.cloud", "a/b", "my plugin") + assert url.endswith("/v1/instances/a%2Fb/plugins/my%20plugin") + + def test_a_base_url_carrying_v1_is_not_doubled(self) -> None: + # The control plane reports a metro endpoint with its /v1; so does UKC_METRO. + assert plugin_base_url("https://api.fra.unikraft.cloud/v1", "u1", "sandbox") == ROUTE + assert plugin_base_url("https://api.fra.unikraft.cloud/v1/", "u1", "sandbox") == ROUTE + + async def test_a_plugin_named_v1_keeps_its_route(self) -> None: + route = plugin_base_url("https://api.fra.unikraft.cloud/v1", "u1", "v1") + assert route == ROUTE.replace("/sandbox", "/v1") + recorder = queued([(200, envelope({"commands": []}))]) + config = ApiClientConfig(base_url=route, token="tok", transport=recorder.transport) + async with SandboxApi(config) as raw: + await raw.commands.list_commands() + assert recorder.urls == [route + "/commands"] + + +class TestSandboxPlumbing: + async def test_for_instance_targets_the_plugin_route(self) -> None: + recorder = queued([(200, envelope({"commands": ["c1"]}))]) + async with cloud(recorder, metro="fra") as ukc: + raw = ukc.api.plugins.sandbox.for_instance("u1") + res = await raw.commands.list_commands() + assert recorder.urls == [ROUTE + "/commands"] + assert recorder.calls[0].headers["authorization"] == "Bearer tok" + assert res.data is not None and res.data.commands == ["c1"] + + async def test_a_scoped_metro_pins_the_route(self) -> None: + recorder = queued([(200, envelope({"commands": []}))]) + async with cloud(recorder) as ukc: + raw = ukc.metro("dal").api.plugins.sandbox.for_instance("u1") + await raw.commands.list_commands() + assert recorder.calls[0].url.host == "api.dal.unikraft.cloud" + + async def test_the_plugin_name_and_metro_can_be_named_per_instance(self) -> None: + recorder = queued([(200, envelope({"commands": []}))]) + async with cloud(recorder, metro="fra") as ukc: + raw = ukc.api.plugins.sandbox.for_instance( + "u1", plugin="sb2", base_url="https://api.dal.unikraft.cloud" + ) + await raw.commands.list_commands() + assert recorder.urls == [ + "https://api.dal.unikraft.cloud/v1/instances/u1/plugins/sb2/commands" + ] + + async def test_run_command_sends_only_the_fields_given(self) -> None: + recorder = queued([(200, envelope({"uuid": "c1"}))]) + async with cloud(recorder, metro="fra") as ukc: + raw = ukc.api.plugins.sandbox.for_instance("u1") + res = await raw.commands.run_command( + body=models.RunCommandRequest(cmd="echo hi", cwd="/app") + ) + request = recorder.calls[0] + assert (request.method, request.url.path) == ( + "POST", + "/v1/instances/u1/plugins/sandbox/commands", + ) + assert json.loads(request.content) == {"cmd": "echo hi", "cwd": "/app"} + assert res.data is not None and res.data.uuid == "c1" + + async def test_the_envelope_is_parsed_into_the_generated_models(self) -> None: + command = {"uuid": "c1", "cmdline": "echo hi", "cwd": None, "env": None, "exitcode": None} + recorder = queued([(200, envelope(command))]) + async with cloud(recorder, metro="fra") as ukc: + raw = ukc.api.plugins.sandbox.for_instance("u1") + res = await raw.commands.get_command_by_uuid("c1") + assert recorder.urls == [ROUTE + "/commands/c1"] + assert res.data is not None + assert (res.data.cmdline, res.data.exitcode) == ("echo hi", None) + + async def test_a_raw_log_carries_the_range_header_and_returns_bytes(self) -> None: + recorder = blob(206, b"ello", **{"content-range": "bytes 1-4/5"}) + async with cloud(recorder, metro="fra") as ukc: + raw = ukc.api.plugins.sandbox.for_instance("u1") + out = await raw.commands.get_raw_command_log("c1", "stdout", range="bytes=1-4") + request = recorder.calls[0] + assert request.url.path == "/v1/instances/u1/plugins/sandbox/commands/c1/logs/raw/stdout" + assert request.headers["range"] == "bytes=1-4" + assert request.headers["accept"] == "application/octet-stream" + assert isinstance(out, RawResponse) + assert (out.status, out.content, out.content_range) == (206, b"ello", "bytes 1-4/5") + + async def test_a_raw_log_without_a_range_sends_no_range_header(self) -> None: + recorder = blob(200, b"hello") + async with cloud(recorder, metro="fra") as ukc: + raw = ukc.api.plugins.sandbox.for_instance("u1") + out = await raw.commands.get_raw_command_log("c1", "stderr") + assert "range" not in recorder.calls[0].headers + # The SDK's transport hands back its own response, range helper and all. + assert isinstance(out, RawResponse) + assert (out.status, out.content, out.content_range) == (200, b"hello", None) + + async def test_reads_a_file_verbatim(self) -> None: + recorder = blob(200, b"\x00\x01\xff") + async with cloud(recorder, metro="fra") as ukc: + raw = ukc.api.plugins.sandbox.for_instance("u1") + out = await raw.fs.read_raw_file(body=models.ReadFileRequest(path="/app/blob")) + request = recorder.calls[0] + assert (request.method, request.url.path) == ( + "POST", + "/v1/instances/u1/plugins/sandbox/fs/read_raw", + ) + assert json.loads(request.content) == {"path": "/app/blob"} + assert out.content == b"\x00\x01\xff" + + async def test_a_finished_wait_returns_the_empty_envelope(self) -> None: + recorder = queued([(200, {"status": "success", "data": None, "op_time_us": 1})]) + async with cloud(recorder, metro="fra") as ukc: + raw = ukc.api.plugins.sandbox.for_instance("u1") + res = await raw.commands.wait_for_command("c1") + assert recorder.urls == [ROUTE + "/commands/c1/wait"] + assert (res.status, res.data) == ("success", None) + + async def test_an_empty_404_is_not_found(self) -> None: + recorder = Recorder(lambda request: httpx.Response(404)) + async with cloud(recorder, metro="fra") as ukc: + raw = ukc.api.plugins.sandbox.for_instance("u1") + with pytest.raises(NotFoundError): + await raw.commands.get_command_by_uuid("nope") + + async def test_a_wait_that_times_out_keeps_its_status(self) -> None: + still_running = {"status": "error", "message": "still running", "errors": [{"status": 408}]} + recorder = queued([(408, still_running)]) + async with cloud(recorder, metro="fra") as ukc: + raw = ukc.api.plugins.sandbox.for_instance("u1") + with pytest.raises(UnikraftCloudError) as caught: + await raw.commands.wait_for_command_with_timeout( + "c1", body=models.CommandWaitTimeoutRequest(timeout_s=1.5) + ) + assert caught.value.status == 408 + assert json.loads(recorder.calls[0].content) == {"timeout_s": 1.5} + + +class TestStandaloneClient: + def _config(self, recorder: Recorder) -> ApiClientConfig: + return ApiClientConfig( + base_url="https://api.fra.unikraft.cloud", token="tok", transport=recorder.transport + ) + + async def test_a_plugins_group_owns_the_pool_its_members_share(self) -> None: + recorder = queued([(200, envelope({"commands": []}))]) + async with PluginsApi(self._config(recorder)) as plugins: + scoped = plugins.sandbox.for_instance("u1") + assert scoped.client.http is plugins.http + await scoped.commands.list_commands() + assert plugins.http.is_closed + assert recorder.urls == [ROUTE + "/commands"] + + async def test_a_scoped_client_from_a_v1_base_url_takes_the_metro_route(self) -> None: + recorder = queued([(200, envelope({"commands": []}))]) + config = ApiClientConfig( + base_url="https://api.fra.unikraft.cloud/v1", token="tok", transport=recorder.transport + ) + async with SandboxApi(config) as root: + await root.for_instance("u1").commands.list_commands() + assert recorder.urls == [ROUTE + "/commands"] + + async def test_a_client_made_at_a_route_scopes_from_the_metro_too(self) -> None: + recorder = queued([(200, envelope({"commands": []}))] * 2) + # Built at the route itself, as a client made through a plugin's route is. + config = ApiClientConfig(base_url=ROUTE, token="tok", transport=recorder.transport) + async with SandboxApi(config) as raw: + await raw.commands.list_commands() + await raw.for_instance("u2").commands.list_commands() + assert recorder.urls == [ROUTE + "/commands", ROUTE.replace("/u1/", "/u2/") + "/commands"] + + async def test_scoped_clients_share_the_pool_their_root_closes(self) -> None: + recorder = queued([(200, envelope({"commands": []}))] * 2) + async with SandboxApi(self._config(recorder)) as root: + scoped = root.for_instance("u1") + assert scoped.client.http is root.client.http + await scoped.commands.list_commands() + # Scoping a scoped client starts from the metro again, not from its route. + again = scoped.for_instance("u2") + assert again.client.http is root.client.http + await again.commands.list_commands() + assert root.client.http.is_closed + assert recorder.urls == [ + ROUTE + "/commands", + "https://api.fra.unikraft.cloud/v1/instances/u2/plugins/sandbox/commands", + ] + + async def test_a_scoped_client_can_be_closed_without_harm(self) -> None: + recorder = queued([(200, envelope({"commands": []}))]) + root = SandboxApi(self._config(recorder)) + scoped = root.for_instance("u1") + # Only the client that made the pool releases it. + await scoped.aclose() + assert not root.client.http.is_closed + await root.commands.list_commands(base_url=ROUTE) + await root.aclose() + assert root.client.http.is_closed + + async def test_a_client_scoped_to_another_metro_scopes_from_there(self) -> None: + recorder = queued([(200, envelope({"commands": []}))]) + dal = "https://api.dal.unikraft.cloud" + async with SandboxApi(self._config(recorder)) as root: + there = root.for_instance("u1", base_url=dal) + await there.for_instance("u2").commands.list_commands() + assert recorder.urls == [f"{dal}/v1/instances/u2/plugins/sandbox/commands"] diff --git a/tests/test_transport.py b/tests/test_transport.py index 4bbe359..9e9786e 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -604,6 +604,17 @@ async def test_both_surfaces_share_one_pool_by_default(self) -> None: await api.aclose() assert api.platform.instances.http.is_closed + async def test_an_instance_scoped_plugin_client_shares_the_pool(self) -> None: + recorder = queued([(200, envelope())]) + config = ApiClientConfig( + base_url="https://api.fra.unikraft.cloud", transport=recorder.transport + ) + api = Api(config, config.with_base_url("https://cp.example")) + scoped = api.plugins.sandbox.for_instance("u1") + assert scoped.client.http is api.platform.instances.http + await api.aclose() + assert scoped.client.http.is_closed + class TestClosedClients: async def test_a_closed_client_says_so(self) -> None: diff --git a/uv.lock b/uv.lock index 8429721..e511289 100644 --- a/uv.lock +++ b/uv.lock @@ -1,17 +1,18 @@ version = 1 +revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version < '3.15'", "python_full_version >= '3.15'", + "python_full_version < '3.15'", ] [[package]] name = "annotated-types" version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893 } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427 }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] @@ -23,100 +24,100 @@ dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176 } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813 }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] name = "ast-serialize" version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638 } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924 }, - { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662 }, - { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072 }, - { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497 }, - { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101 }, - { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001 }, - { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545 }, - { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961 }, - { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877 }, - { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903 }, - { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070 }, - { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602 }, - { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848 }, - { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556 }, - { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822 }, - { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153 }, - { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711 }, - { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740 }, - { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021 }, - { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715 }, - { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347 }, - { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916 }, - { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135 }, - { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040 }, - { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278 }, - { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029 }, - { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575 }, - { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396 }, - { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987 }, - { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904 }, - { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967 }, - { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041 }, - { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367 }, - { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178 }, - { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811 }, - { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518 }, - { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319 }, - { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914 }, - { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007 }, - { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588 }, - { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575 }, - { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015 }, - { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808 }, - { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251 }, - { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574 }, - { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075 }, - { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018 }, - { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951 }, - { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544 }, - { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671 }, - { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685 }, - { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977 }, - { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335 }, - { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858 }, - { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839 }, - { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655 }, + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, ] [[package]] name = "backports-asyncio-runner" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313 }, + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] [[package]] name = "certifi" version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -124,20 +125,20 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] @@ -148,9 +149,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -163,157 +164,157 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "idna" version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "librt" version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338 } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673 }, - { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547 }, - { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355 }, - { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459 }, - { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398 }, - { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474 }, - { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484 }, - { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534 }, - { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087 }, - { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575 }, - { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142 }, - { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714 }, - { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426 }, - { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057 }, - { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039 }, - { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067 }, - { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087 }, - { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608 }, - { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723 }, - { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002 }, - { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607 }, - { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422 }, - { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303 }, - { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084 }, - { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307 }, - { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686 }, - { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917 }, - { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886 }, - { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885 }, - { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021 }, - { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267 }, - { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136 }, - { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670 }, - { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688 }, - { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904 }, - { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427 }, - { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155 }, - { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890 }, - { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163 }, - { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812 }, - { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688 }, - { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138 }, - { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974 }, - { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292 }, - { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029 }, - { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194 }, - { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568 }, - { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153 }, - { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336 }, - { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661 }, - { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487 }, - { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201 }, - { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467 }, - { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139 }, - { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050 }, - { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700 }, - { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194 }, - { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231 }, - { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996 }, - { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188 }, - { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833 }, - { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088 }, - { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215 }, - { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173 }, - { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512 }, - { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073 }, - { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080 }, - { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164 }, - { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616 }, - { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890 }, - { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287 }, - { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868 }, - { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619 }, - { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138 }, - { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258 }, - { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467 }, - { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523 }, - { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638 }, - { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795 }, - { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147 }, - { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397 }, - { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542 }, - { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709 }, - { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891 }, - { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301 }, - { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921 }, - { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561 }, - { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417 }, - { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381 }, - { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034 }, - { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827 }, - { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843 }, - { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510 }, - { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543 }, - { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452 }, - { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768 }, - { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122 }, - { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371 }, - { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258 }, - { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432 }, - { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108 }, - { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681 }, - { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736 }, - { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673 }, - { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081 }, - { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228 }, - { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487 }, - { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448 }, - { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686 }, - { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668 }, - { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396 }, - { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313 }, - { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847 }, - { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736 }, - { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454 }, - { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296 }, - { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217 }, - { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934 }, - { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763 }, - { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313 }, - { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889 }, - { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700 }, + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] [[package]] @@ -328,88 +329,88 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104 } +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774 }, - { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127 }, - { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437 }, - { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457 }, - { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281 }, - { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008 }, - { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329 }, - { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725 }, - { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128 }, - { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742 }, - { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418 }, - { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164 }, - { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472 }, - { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800 }, - { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523 }, - { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189 }, - { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696 }, - { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904 }, - { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342 }, - { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346 }, - { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525 }, - { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298 }, - { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768 }, - { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586 }, - { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411 }, - { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790 }, - { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919 }, - { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510 }, - { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909 }, - { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581 }, - { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807 }, - { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144 }, - { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389 }, - { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664 }, - { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237 }, - { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252 }, - { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495 }, - { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155 }, - { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155 }, - { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351 }, - { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490 }, - { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869 }, - { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113 }, - { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292 }, + { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774, upload-time = "2026-07-13T11:28:38.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127, upload-time = "2026-07-13T11:30:19.57Z" }, + { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437, upload-time = "2026-07-13T11:28:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457, upload-time = "2026-07-13T11:29:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281, upload-time = "2026-07-13T11:32:23.413Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008, upload-time = "2026-07-13T11:34:17.332Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329, upload-time = "2026-07-13T11:32:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "packaging" version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] name = "pathspec" version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -422,9 +423,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] @@ -434,122 +435,122 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146 }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769 }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958 }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118 }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876 }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703 }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042 }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231 }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388 }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769 }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312 }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817 }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085 }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311 }, - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872 }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255 }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827 }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051 }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314 }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146 }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685 }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420 }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122 }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573 }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139 }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433 }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513 }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114 }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298 }, - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158 }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724 }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742 }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418 }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274 }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940 }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516 }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854 }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306 }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044 }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133 }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464 }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823 }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919 }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604 }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079 }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179 }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926 }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785 }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733 }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534 }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732 }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627 }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141 }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325 }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990 }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978 }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354 }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238 }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251 }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593 }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226 }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605 }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777 }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641 }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404 }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219 }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594 }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542 }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146 }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309 }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736 }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575 }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624 }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325 }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589 }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552 }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984 }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417 }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527 }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024 }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696 }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590 }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782 }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146 }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492 }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604 }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828 }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000 }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286 }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071 }, + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -565,9 +566,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -579,97 +580,97 @@ dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514 } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] name = "ruff" version = "0.16.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811 } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925 }, - { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662 }, - { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771 }, - { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825 }, - { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437 }, - { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761 }, - { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364 }, - { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720 }, - { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130 }, - { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634 }, - { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807 }, - { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891 }, - { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063 }, - { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038 }, - { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850 }, - { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338 }, - { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065 }, + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, ] [[package]] name = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "typing-extensions" version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -679,9 +680,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] @@ -691,6 +692,7 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "pydantic" }, + { name = "unikraft-cloud-plugin-sandbox-api" }, ] [package.dev-dependencies] @@ -705,6 +707,7 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "pydantic", specifier = ">=2.7" }, + { name = "unikraft-cloud-plugin-sandbox-api", specifier = ">=1.1.0,<2" }, ] [package.metadata.requires-dev] @@ -714,3 +717,16 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "ruff", specifier = ">=0.6" }, ] + +[[package]] +name = "unikraft-cloud-plugin-sandbox-api" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/89/7c9369cfb048a71c8121e0078c5727409523d96ec04324a220742dfb0187/unikraft_cloud_plugin_sandbox_api-1.1.0.tar.gz", hash = "sha256:17bfc7462939d784962a11b8c2ad90b197b2508343a212ff2f780fae0870a4e6", size = 9402, upload-time = "2026-10-06T10:28:14.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/ca/6589996d027342e942238ceb88f21cecf0b9d763a4af8be176dad66b8b65/unikraft_cloud_plugin_sandbox_api-1.1.0-py3-none-any.whl", hash = "sha256:ca3a132d2d942a8f0334cbbae446829a1082242b040ec30b37307a53b69ba7f3", size = 13398, upload-time = "2026-10-06T10:28:13.902Z" }, +] From fa67f8681c2c54d3db7784930874924e7b2a6465 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:32:23 +0100 Subject: [PATCH 23/36] fix(core): Repeat a lookup that failed A handle memoised its lookup as a task and kept it however it ended, so a name not listed yet, or a metro that did not answer, was raised again by every later await. A set kept a failed lookup too. A lookup that failed is made again on the next await, for the handles that only look a resource up, for sets, and for the lookup an operation chained onto such a handle starts from; a create, and the chained operation itself, keep their one outcome. Signed-off-by: Daniel Vallance --- src/unikraft_cloud/core/handle.py | 39 ++++++++--- src/unikraft_cloud/core/handle_set.py | 8 ++- src/unikraft_cloud/resources/certificates.py | 1 + src/unikraft_cloud/resources/instances.py | 1 + .../resources/service_groups.py | 1 + src/unikraft_cloud/resources/volumes.py | 1 + tests/test_client.py | 69 +++++++++++++++++++ 7 files changed, 108 insertions(+), 12 deletions(-) diff --git a/src/unikraft_cloud/core/handle.py b/src/unikraft_cloud/core/handle.py index 8fb749a..867175e 100644 --- a/src/unikraft_cloud/core/handle.py +++ b/src/unikraft_cloud/core/handle.py @@ -76,13 +76,17 @@ class HandleSteps(Generic[T]): #: before a chained `wait()` runs); false for a handle that merely identifies #: a resource, which is what keeps `get(ref).suspend()` down to one request. sequential: bool = False + #: Whether `locate` only reads, so the next await repeats a lookup that + #: failed. Unset for a create; a chained operation inherits it from its parent. + lookup: bool = False class ResourceHandle(Generic[T]): """A lazily-evaluated reference to one resource in one metro. - Nothing is sent until the handle is awaited or a chained operation runs, and - each step is performed at most once however many times it is awaited. + Nothing is sent until the handle is awaited or a chained operation runs. + However many times it is awaited, an operation is performed once, and a + lookup once unless it failed, in which case the next await repeats it. """ def __init__(self, steps: HandleSteps[T]) -> None: @@ -147,6 +151,7 @@ async def locate() -> Located[R]: what=self._steps.what, sequential=True, options=self._steps.options, + lookup=self._steps.lookup, ) def _options( @@ -172,7 +177,7 @@ async def _next(self) -> MetroTarget: return (await self._locate()).target def _locate(self) -> Awaitable[Located[T]]: - if self._located is None: + if self._located is None or (self._steps.lookup and spent(self._located)): # Memoised as a task rather than a coroutine so several awaits, and # several chained operations, share the one lookup. self._located = asyncio.ensure_future(self._steps.locate()) @@ -180,16 +185,30 @@ def _locate(self) -> Awaitable[Located[T]]: # cancel the lookup every other caller is waiting on. return asyncio.shield(self._located) - def _evaluate(self) -> Awaitable[T]: - if self._value is None: - self._value = asyncio.ensure_future(self._read()) - return asyncio.shield(self._value) - - async def _read(self) -> T: + async def _evaluate(self) -> T: located = await self._locate() if located.value is not None: return located.value - return await self._steps.fetch(located.target) + # Memoised apart from the lookup, so a chained operation's one outcome + # stands while its parent's lookup is repeated. A handle that only + # looks a resource up fetches it by a read, made again if it failed. + retried = self._steps.lookup and not self._steps.sequential + if self._value is None or (retried and spent(self._value)): + self._value = asyncio.ensure_future(self._steps.fetch(located.target)) + return await asyncio.shield(self._value) + + +def spent(task: asyncio.Future[Any] | None) -> bool: + """Whether a memoised task cannot serve another caller. + + One that failed or was cancelled is not an answer, and one made on another + event loop cannot be awaited on this one; either is made afresh. + """ + if task is None: + return True + if task.get_loop() is not asyncio.get_running_loop(): + return True + return task.done() and (task.cancelled() or task.exception() is not None) def _explicit_target(target: MetroTarget, opts: CallOptions) -> MetroTarget: diff --git a/src/unikraft_cloud/core/handle_set.py b/src/unikraft_cloud/core/handle_set.py index 4f020fe..33fe399 100644 --- a/src/unikraft_cloud/core/handle_set.py +++ b/src/unikraft_cloud/core/handle_set.py @@ -13,7 +13,7 @@ from typing import Any, Generic, TypeVar from .fanout import MetroFailure, MetroFanoutError -from .handle import ResourceHandle +from .handle import ResourceHandle, spent from .metro import Metro __all__ = ["HandleSet"] @@ -49,10 +49,14 @@ def __repr__(self) -> str: def handles(self) -> Awaitable[list[H]]: """The individual handles, one per metro holding the resource.""" - if self._handles is None: + # A set only looks its matches up, so a lookup that failed is made + # again by the next operation on the set; one a caller gave up on + # runs on, shielded, and serves the next operation. + if spent(self._handles): self._handles = asyncio.ensure_future(self._locate()) # Shielded: every operation on the set shares this task, so one giving # up on it must not cancel the lookup the others are waiting on. + assert self._handles is not None return asyncio.shield(self._handles) async def where(self) -> list[Metro]: diff --git a/src/unikraft_cloud/resources/certificates.py b/src/unikraft_cloud/resources/certificates.py index 271f6a2..1b1912f 100644 --- a/src/unikraft_cloud/resources/certificates.py +++ b/src/unikraft_cloud/resources/certificates.py @@ -224,6 +224,7 @@ def get( fetch=lambda target: self.read(target, opts), what=f"certificate {describe_ref(ref)}", options=call, + lookup=True, ), ) diff --git a/src/unikraft_cloud/resources/instances.py b/src/unikraft_cloud/resources/instances.py index f00637c..4aa3df1 100644 --- a/src/unikraft_cloud/resources/instances.py +++ b/src/unikraft_cloud/resources/instances.py @@ -639,6 +639,7 @@ def get( fetch=lambda target: self.read(target, opts), what=f"instance {describe_ref(ref)}", options=call, + lookup=True, ), ) diff --git a/src/unikraft_cloud/resources/service_groups.py b/src/unikraft_cloud/resources/service_groups.py index 12503a3..5d4e058 100644 --- a/src/unikraft_cloud/resources/service_groups.py +++ b/src/unikraft_cloud/resources/service_groups.py @@ -275,6 +275,7 @@ def get( fetch=lambda target: self.read(target, opts), what=f"service group {describe_ref(ref)}", options=call, + lookup=True, ), ) diff --git a/src/unikraft_cloud/resources/volumes.py b/src/unikraft_cloud/resources/volumes.py index 2a31c8e..0f77589 100644 --- a/src/unikraft_cloud/resources/volumes.py +++ b/src/unikraft_cloud/resources/volumes.py @@ -373,6 +373,7 @@ def get( fetch=lambda target: self.read(target, opts), what=f"volume {describe_ref(ref)}", options=call, + lookup=True, ), ) diff --git a/tests/test_client.py b/tests/test_client.py index d4e5452..0ff66e4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -484,6 +484,75 @@ async def test_a_lookup_on_one_metro_says_which_name_is_missing(self) -> None: assert caught.value.status == 404 +class TestLookupsAreRepeated: + async def test_a_name_not_listed_yet_is_looked_up_again(self) -> None: + rows: dict[str, list[dict[str, Any]]] = {"fra": [], "dal": []} + recorder = cloud(instances=lambda where: rows[where]) + async with client(recorder) as ukc: + handle = ukc.instances.get(name="web") + with pytest.raises(NotFoundError): + await handle + rows["fra"] = [instance("u1", "web")] + found = await handle + assert (found.metro, found.uuid) == ("fra", "u1") + assert sorted(recorder.metros("/v1/instances")) == ["dal", "dal", "fra", "fra"] + + async def test_a_metro_that_failed_to_answer_is_asked_again(self) -> None: + down = ["dal"] + recorder = cloud(instances=lambda where: [instance(f"{where}-1", "web")], failing=down) + async with client(recorder) as ukc: + matches = ukc.instances.each(name="web") + with pytest.raises(MetroFanoutError): + await matches.size() + down.clear() + assert await matches.size() == 2 + + async def test_a_create_that_failed_is_not_sent_again(self) -> None: + recorder = queued([(500, {"status": "error", "message": "boom"})]) + async with client(recorder, metro="fra") as ukc: + handle = ukc.instances.create(image="org/app:latest") + with pytest.raises(ServerError): + await handle + with pytest.raises(ServerError): + await handle + assert len(recorder.calls) == 1 + + async def test_an_operation_chained_onto_a_name_not_listed_yet_runs_once_it_is(self) -> None: + rows: dict[str, list[dict[str, Any]]] = {"fra": [], "dal": []} + recorder = cloud(instances=lambda where: rows[where]) + async with client(recorder) as ukc: + suspend = ukc.instances.get(name="web").suspend() + with pytest.raises(NotFoundError): + await suspend + assert "/v1/instances/suspend" not in recorder.paths + rows["fra"] = [instance("u1", "web")] + suspended = await suspend + # The operation itself ran once, and its outcome stands. + assert await suspend is suspended + assert recorder.paths.count("/v1/instances/suspend") == 1 + assert sorted(recorder.metros("/v1/instances")) == ["dal", "dal", "fra", "fra"] + + async def test_an_operation_that_failed_is_not_sent_again(self) -> None: + broken = [True] + + def route(request: httpx.Request) -> tuple[int, Any]: + if request.url.path == "/v1/instances/suspend": + if broken[0]: + return 500, {"status": "error", "message": "boom"} + return 200, envelope({"instances": [changed_instance()]}) + return 200, envelope({"instances": [instance("u1", "web")]}) + + recorder = routed(route) + async with client(recorder, metro="fra") as ukc: + suspend = ukc.instances.get(name="web").suspend() + with pytest.raises(ServerError): + await suspend + broken[0] = False + with pytest.raises(ServerError): + await suspend + assert recorder.paths.count("/v1/instances/suspend") == 1 + + class TestPinnedScope: async def test_refuses_a_metro_the_pinned_endpoint_does_not_serve(self) -> None: recorder = cloud() From 9b3a57e43da8de804915a21f30bbb787c90bc8d8 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Tue, 15 Sep 2026 19:13:56 +0100 Subject: [PATCH 24/36] feat(plugins): Add the sandbox client The SDK had no client for the sandbox plugin. A caller had to find the instance's UUID, build the plugin's route and drive the plumbing by hand to run a command or move a file. sandbox() and plugin() on an instance handle give a Sandbox or a Plugin, which read the instance only when its UUID is not known. Sandbox runs, follows and signals commands and moves files; output is polled, and a timeout interrupts a command, then waits for it. Signed-off-by: Daniel Vallance --- src/unikraft_cloud/__init__.py | 21 + src/unikraft_cloud/plugins/__init__.py | 170 +++++ src/unikraft_cloud/plugins/sandbox.py | 816 ++++++++++++++++++++++ src/unikraft_cloud/resources/instances.py | 64 ++ tests/conftest.py | 22 +- tests/fake_sandbox.py | 334 +++++++++ tests/test_plugins.py | 162 +++++ tests/test_sandbox.py | 771 ++++++++++++++++++++ 8 files changed, 2355 insertions(+), 5 deletions(-) create mode 100644 src/unikraft_cloud/plugins/__init__.py create mode 100644 src/unikraft_cloud/plugins/sandbox.py create mode 100644 tests/fake_sandbox.py create mode 100644 tests/test_plugins.py create mode 100644 tests/test_sandbox.py diff --git a/src/unikraft_cloud/__init__.py b/src/unikraft_cloud/__init__.py index c30c193..0337313 100644 --- a/src/unikraft_cloud/__init__.py +++ b/src/unikraft_cloud/__init__.py @@ -59,6 +59,17 @@ from .core.resource import MetroGroup, Resource, ScopeOptions from .core.response import Ref, RefLike, or_absent from .core.session import Session, SessionConfig +from .plugins import Plugin, PluginRoute +from .plugins.sandbox import ( + Command, + CommandLogs, + ExecResult, + ExecTimeoutError, + OutputChunk, + PluginNotReadyError, + Sandbox, + SandboxFiles, +) from .resources.certificates import ( Certificate, CertificateHandle, @@ -125,6 +136,8 @@ "CertificateHandle", "CertificateSet", "Certificates", + "Command", + "CommandLogs", "ControlPlaneApi", "DeletedCertificate", "DeletedInstance", @@ -132,6 +145,8 @@ "DeletedVolume", "DetachedVolume", "ErrorKind", + "ExecResult", + "ExecTimeoutError", "HandleSet", "HandleSteps", "Instance", @@ -156,9 +171,13 @@ "MetroScope", "MetroTarget", "NotFoundError", + "OutputChunk", "PatchItem", "PatchOp", "PlatformApi", + "Plugin", + "PluginNotReadyError", + "PluginRoute", "PluginsApi", "Quotas", "RateLimitError", @@ -170,7 +189,9 @@ "ResourceEditor", "ResourceHandle", "ResponseError", + "Sandbox", "SandboxApi", + "SandboxFiles", "Scope", "ScopeOptions", "ServerError", diff --git a/src/unikraft_cloud/plugins/__init__.py b/src/unikraft_cloud/plugins/__init__.py new file mode 100644 index 0000000..60e99ba --- /dev/null +++ b/src/unikraft_cloud/plugins/__init__.py @@ -0,0 +1,170 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Plugins: helper programs loaded into an instance beside its workload, each +# reached over the instance's authenticated plugin route on the metro that runs +# it. This is the generic part -- addressing one -- that every typed plugin +# client (the sandbox, for one) is built on. + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypeVar + +from ..api.plugins import plugin_base_url +from ..core.handle import MetroTarget, spent +from ..core.http import ApiClientConfig +from ..core.metro import Metro + +if TYPE_CHECKING: + from ..resources.instances import Instance + +__all__ = ["Plugin", "PluginRoute", "ResolvedInstance"] + +C = TypeVar("C") + + +@dataclass(frozen=True) +class PluginRoute: + """Where a plugin on one instance is reached. + + The platform proxies a plugin's API under the instance that runs it, on the + metro holding that instance:: + + https://api..unikraft.cloud/v1/instances//plugins// + + The proxy resolves the instance by UUID, which is why the route carries one. + """ + + #: The metro holding the instance. + metro: Metro + #: That metro's API base URL. + base_url: str + #: The instance the plugin runs in. + instance_uuid: str + #: The name the plugin was attached under. + name: str + + @property + def url(self) -> str: + """The route itself, to use as a client's ``base_url``.""" + return plugin_base_url(self.base_url, self.instance_uuid, self.name) + + +@dataclass(frozen=True) +class ResolvedInstance: + """What a handle knows about its instance once it has been located. + + Whatever is already in hand travels along so it need not be fetched again: a + UUID reference carries the UUID, and a handle that located, created or + waited for the instance carries what that returned. A name pinned to one + metro carries neither, and costs one read. + """ + + #: The instance's reference and the metro holding it. + target: MetroTarget + #: The UUID, when it is known without a request. + uuid: str | None = None + #: The instance, when the handle already has it. + instance: Instance | None = None + + +class Plugin: + """A plugin on one instance, addressed lazily. + + Obtained from an instance handle. Nothing is sent until the route is needed, + and resolving it reads the instance only when its UUID is not yet known. + + .. code-block:: python + + plugin = ukc.instances.get(name="web").plugin("sandbox") + route = await plugin.route() + raw = await plugin.client(SandboxApi) + """ + + def __init__( + self, + name: str, + *, + resolve: Callable[[], Awaitable[ResolvedInstance]], + read: Callable[[MetroTarget], Awaitable[Instance]], + config: ApiClientConfig, + ) -> None: + #: The name the plugin was attached under. + self.name = name + self._resolve = resolve + self._read = read + self._config = config + self._route: asyncio.Future[PluginRoute] | None = None + self._reading: asyncio.Future[Instance] | None = None + self._target: MetroTarget | None = None + self._instance: Instance | None = None + + async def route(self) -> PluginRoute: + """Where the plugin is reached, resolving the instance once. + + Concurrent callers share one lookup, and one a caller gave up on runs + on and serves the next call. A lookup that failed is not kept: the + next call looks the instance up again. + """ + if spent(self._route): + # Memoised as a task rather than a coroutine so that concurrent + # callers -- a wait_ready() racing a run() -- share the one lookup. + self._route = asyncio.ensure_future(self._locate()) + # Shielded: the task is shared, so one caller giving up on it must not + # cancel the lookup every other caller is waiting on. + assert self._route is not None + return await asyncio.shield(self._route) + + async def _locate(self) -> PluginRoute: + resolved = await self._resolve() + self._target = resolved.target + self._instance = resolved.instance + uuid = resolved.uuid + if uuid is None: + self._instance = await self._read(resolved.target) + uuid = self._instance.uuid + # The route is built from the UUID, so a read that reports none + # leaves the plugin unaddressable. + if uuid is None: # pragma: no cover - the platform always reports one + raise RuntimeError(f"the instance reported no UUID to address {self.name} by") + return PluginRoute( + metro=resolved.target.metro, + base_url=resolved.target.base_url, + instance_uuid=uuid, + name=self.name, + ) + + async def instance(self) -> Instance: + """The instance the plugin runs in, read once. + + Concurrent callers share the one read, and one the caller waiting on it + gave up on runs on and serves the next call. A read that failed is made + again on the next call. + """ + await self.route() + if self._instance is not None: + return self._instance + if spent(self._reading): + self._reading = asyncio.ensure_future(self._read_instance()) + assert self._reading is not None + return await asyncio.shield(self._reading) + + async def _read_instance(self) -> Instance: + target = self._target + if target is None: # pragma: no cover - _locate sets it before route() returns + raise RuntimeError("plugin route resolved without a target") + self._instance = await self._read(target) + return self._instance + + async def client(self, factory: Callable[[ApiClientConfig], C]) -> C: + """Build a client of any kind pointed at the plugin's route. + + ``factory`` receives the platform transport configuration -- token, + headers, connection pool -- re-pointed at the route, so a generated + plumbing client such as ``SandboxApi`` works as it is. + """ + route = await self.route() + return factory(self._config.with_base_url(route.url)) diff --git a/src/unikraft_cloud/plugins/sandbox.py b/src/unikraft_cloud/plugins/sandbox.py new file mode 100644 index 0000000..318c084 --- /dev/null +++ b/src/unikraft_cloud/plugins/sandbox.py @@ -0,0 +1,816 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# The sandbox plugin: shell commands and files inside an instance, over the +# plugin's HTTP API. A command is started; while an unbounded wait for it +# runs alongside, its output is polled, quickly while output arrives and +# backing off while it does not; and a timeout interrupts the command and +# then waits for it to end. + +from __future__ import annotations + +import asyncio +import base64 +import contextlib +from collections.abc import AsyncGenerator, Mapping, Sequence +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import Any, Literal, TypeVar + +import httpx + +from ..api.plugins.sandbox import PLUGIN_NAME, SandboxApi, models +from ..core.errors import AuthenticationError, ErrorKind, UnikraftCloudError +from ..core.fanout import AmbiguousRefError, MetroFanoutError +from ..core.handle import spent +from . import Plugin + +__all__ = [ + "INTERRUPT_SIGNAL", + "LOG_CHUNK_SIZE", + "PLUGIN_NAME", + "POLL_INTERVAL", + "POLL_MAX_FAILURES", + "POLL_MAX_INTERVAL", + "READY_FIRST_INTERVAL", + "READY_MAX_INTERVAL", + "STDIN_CHUNK_SIZE", + "Command", + "CommandLogs", + "ExecResult", + "ExecTimeoutError", + "OutputChunk", + "OutputStream", + "PluginNotReadyError", + "Sandbox", + "SandboxFiles", +] + +#: How long :meth:`Sandbox.wait_ready` waits before its second probe, and the +#: most it waits between two. The wait doubles from the first to the second. +READY_FIRST_INTERVAL = 0.25 +READY_MAX_INTERVAL = 2.0 +#: How often a running command's output is polled: this often while output +#: arrives, doubling up to the cap while it does not. +POLL_INTERVAL = 0.1 +POLL_MAX_INTERVAL = 1.0 +#: How many consecutive failed polls give up on a command's output. +POLL_MAX_FAILURES = 3 +#: How much of each output stream one poll asks for, in bytes. +LOG_CHUNK_SIZE = 1 << 20 +#: How much standard input one request carries, in bytes. +STDIN_CHUNK_SIZE = 32 << 10 +#: The signal a command is sent when its caller's timeout elapses: SIGINT. +INTERRUPT_SIGNAL = 2 +#: How long the interrupt itself is given to be delivered. +_SIGNAL_TIMEOUT = 5.0 + +# Named so tests can stand in for it and assert the backoff without waiting. +_sleep = asyncio.sleep + +#: One of a command's two output streams. +OutputStream = Literal["stdout", "stderr"] + + +class PluginNotReadyError(UnikraftCloudError, TimeoutError): + """The plugin did not answer within the time allowed. + + An :class:`UnikraftCloudError` and a builtin :class:`TimeoutError` both, as + :class:`~unikraft_cloud.WaitTimeoutError` is, so one ``except`` catches + every SDK failure and another every timeout. The last failure is chained + as the cause. A plugin that never answers is often an instance that is not + running; the plugin cannot say so itself, so a caller that wants to tell + the two apart reads the instance's state. + """ + + def __init__( + self, message: str, *, plugin: str, attempts: int, kind: ErrorKind = "http" + ) -> None: + super().__init__(message, kind=kind) + #: The plugin that was probed. + self.plugin = plugin + #: How many probes were made. + self.attempts = attempts + + +class ExecTimeoutError(UnikraftCloudError, TimeoutError): + """A command ran past its timeout and is still running. + + Raised when the interrupt a timeout sends could not be delivered, or when + a grace period was given and the command outlived it, and the command has + not ended by itself either. It is left as it is, so the caller can signal + or inspect it. An :class:`UnikraftCloudError` and a builtin + :class:`TimeoutError` both. + """ + + def __init__(self, command: Command) -> None: + super().__init__(f"command {command.uuid} is still running", kind="http") + #: The command, still running in the sandbox. + self.command = command + + +@dataclass(frozen=True) +class OutputChunk: + """A piece of a command's output, as it arrived.""" + + #: Which stream it came from. + stream: OutputStream + #: The bytes, decoded from the wire. + data: bytes + + +@dataclass(frozen=True) +class CommandLogs: + """The requested parts of a command's two output streams.""" + + stdout: bytes + stderr: bytes + #: How many bytes each stream holds in total. + stdout_available: int + stderr_available: int + + +@dataclass(frozen=True) +class ExecResult: + """A finished command: its exit code and everything it wrote.""" + + uuid: str + #: The exit code, or minus the signal number when a signal ended the command: + #: ``-2`` after the interrupt that a timeout sends. + exit_code: int + stdout: bytes + stderr: bytes + #: Whether an interrupt was sent because the timeout elapsed. The command + #: may have ended on its own regardless; ``exit_code`` says how it ended. + interrupted: bool = False + + +D = TypeVar("D") + + +def _payload(data: D | None, what: str) -> D: + """The payload of an envelope, or a parse error when the plugin sent none. + + The wire models keep every field optional, so an answer that arrived + without its payload is caught here, where it can be named, rather than + at the first attribute read. + """ + if data is None: + raise UnikraftCloudError(f"the plugin answered {what} without a payload", kind="parse") + return data + + +class Sandbox: + """The sandbox plugin on one instance. + + Obtained from an instance handle with :meth:`~InstanceHandle.sandbox`. The + route is resolved on first use, so building one sends nothing. + + .. code-block:: python + + sb = ukc.metro("fra").instances.get(uuid=uuid).sandbox() + await sb.wait_ready(timeout=60) + result = await sb.exec("make test", cwd="/app") + print(result.exit_code, result.stdout.decode()) + """ + + def __init__(self, plugin: Plugin) -> None: + #: The plugin this client speaks to. + self.plugin = plugin + #: The sandbox filesystem. + self.fs = SandboxFiles(self) + self._api: asyncio.Future[SandboxApi] | None = None + + async def api(self) -> SandboxApi: + """The raw plumbing client, pointed at the plugin's route. + + Built once and shared. A build the caller waiting on it gave up on + runs on and serves the next call; one that failed is not kept, so the + next call builds it again. + """ + if spent(self._api): + self._api = asyncio.ensure_future(self.plugin.client(SandboxApi)) + assert self._api is not None + return await asyncio.shield(self._api) + + async def ready(self) -> bool: + """Whether the plugin is up and serving, by listing its commands. + + A failure means it is not: either not yet, or not any more. An + instance that cannot be found counts as a plugin not answering. A + failure that waiting cannot change -- a rejected token, a name that + several instances hold -- is raised instead. + """ + try: + api = await self.api() + await api.commands.list_commands() + except UnikraftCloudError as err: + if _permanent(err): + raise + return False + return True + + async def wait_ready(self, timeout: float = 60.0) -> None: + """Block until the plugin answers, or raise :class:`PluginNotReadyError`. + + Probes with a backoff that starts at :data:`READY_FIRST_INTERVAL` and + settles at :data:`READY_MAX_INTERVAL`, so a plugin that comes up quickly + is not waited on longer than it takes, and one that never comes up is not + hammered. Locating the instance counts against ``timeout`` too, and an + instance not found yet is probed again like a plugin not up yet, so one + call also waits out an instance still being created, and each probe's + read is bounded by what is left of ``timeout``. A ``timeout`` of zero + or less makes exactly one attempt. A failure that cannot change + with time is not waited out: a rejected token or a name that several + instances hold is raised at once, and a create that failed, whose + outcome the handle keeps, at the next probe. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + interval = READY_FIRST_INTERVAL + attempts = 0 + last: UnikraftCloudError | None = None + while True: + attempts += 1 + try: + api = await self.api() + await api.commands.list_commands( + timeout=_probe_timeout(api.client.config.timeout, deadline - loop.time()) + ) + return + except UnikraftCloudError as err: + # A memoised failure returns the same error object again; waiting + # does not change it or the errors that `_permanent` names. + if err is last or _permanent(err): + raise + last = err + remaining = deadline - loop.time() + if remaining <= 0: + raise PluginNotReadyError( + f"plugin {self.plugin.name!r} did not answer within {timeout}s " + f"({attempts} attempts)", + plugin=self.plugin.name, + attempts=attempts, + kind=last.kind, + ) from last + await _sleep(min(interval, remaining)) + interval = min(interval * 2, READY_MAX_INTERVAL) + + async def run( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + ) -> Command: + """Start a command and return a handle on it. + + A ``str`` is a command line the sandbox's shell interprets. A sequence + is an argument vector run as it is, with no shell and nothing to quote, + which is the form to use with paths or arguments from elsewhere. + ``cwd`` and ``env`` are passed on exactly as given; the plugin defines + what a command sees when they are omitted. + """ + fields: dict[str, Any] = {} + if cwd is not None: + fields["cwd"] = cwd + if env is not None: + fields["env"] = dict(env) + line: str | list[str] = cmd if isinstance(cmd, str) else list(cmd) + api = await self.api() + res = await api.commands.run_command(body=models.RunCommandRequest(cmd=line, **fields)) + started = _payload(res.data, "a command start") + if not started.uuid: + raise UnikraftCloudError("the plugin started a command but named no UUID", kind="parse") + return Command(self, started.uuid) + + async def commands(self) -> list[Command]: + """Every command the plugin knows of, in the order they were started.""" + api = await self.api() + res = await api.commands.list_commands() + listed = _payload(res.data, "the command listing") + return [Command(self, uuid) for uuid in listed.commands or []] + + def command(self, uuid: str) -> Command: + """A handle on a command started earlier, by its UUID.""" + return Command(self, uuid) + + async def exec( + self, + cmd: str | Sequence[str], + *, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + stdin: bytes | str | None = None, + timeout: float | None = None, + wait_delay: float | None = None, + ) -> ExecResult: + """Run a command to completion and return its output and exit code. + + ``cmd`` is a command line for the shell, or an argument vector, as for + :meth:`run`. A non-zero exit code is reported, not raised. See + :meth:`Command.collect` for what ``timeout`` and ``wait_delay`` do. + """ + _check_following(timeout, wait_delay) + # Encoded before the command starts, so a `str` that cannot be is refused first. + if isinstance(stdin, str): + stdin = stdin.encode() + command = await self.run(cmd, cwd=cwd, env=env) + return await command.collect(stdin=stdin, timeout=timeout, wait_delay=wait_delay) + + +class Command: + """A shell command running, or finished, inside a sandbox.""" + + def __init__(self, sandbox: Sandbox, uuid: str) -> None: + #: The sandbox the command runs in. + self.sandbox = sandbox + #: The UUID the plugin assigned it. + self.uuid = uuid + + def __repr__(self) -> str: + return f"Command({self.uuid!r})" + + async def get(self) -> models.GetCommandData: + """The command line, working directory, environment and exit code. + + The exit code is ``None`` while the command still runs. + """ + api = await self.sandbox.api() + res = await api.commands.get_command_by_uuid(self.uuid) + return _payload(res.data, f"command {self.uuid}") + + async def _exit_code(self) -> int: + """The exit code of a command that has ended, read with a poll's patience.""" + failures = 0 + interval = POLL_INTERVAL + while True: + try: + info = await self.get() + except UnikraftCloudError: + failures += 1 + if failures >= POLL_MAX_FAILURES: + raise + await _sleep(interval) + interval = min(interval * 2, POLL_MAX_INTERVAL) + continue + if info.exitcode is None: + raise UnikraftCloudError( + f"command {self.uuid} ended without reporting an exit code", kind="parse" + ) + return info.exitcode + + async def _ended(self) -> bool: + """Whether the command has exited, by one read; a read that fails says no.""" + try: + return (await self.get()).exitcode is not None + except UnikraftCloudError: + return False + + async def wait(self, timeout: float | None = None) -> int | None: + """Wait for the command to end and return its exit code. + + With a ``timeout``, returns ``None`` if the command is still running + when it elapses. Without one, blocks until the command ends. + """ + _check_following(timeout, None) + api = await self.sandbox.api() + if timeout is None: + await api.commands.wait_for_command(self.uuid) + else: + try: + await api.commands.wait_for_command_with_timeout( + self.uuid, body=models.CommandWaitTimeoutRequest(timeout_s=timeout) + ) + except UnikraftCloudError as err: + if err.status == 408: + return None + raise + return await self._exit_code() + + async def logs( + self, + *, + stdout_offset: int = 0, + stderr_offset: int = 0, + limit: int | None = None, + ) -> CommandLogs: + """The output collected so far, from the given offsets. + + Each stream is returned from its offset to its end, or for at most + ``limit`` bytes. The totals say how much each stream holds, so a caller + reading incrementally knows when it has everything. + """ + api = await self.sandbox.api() + ranges: dict[str, Any] = {} if limit is None else {"limit": limit} + body = models.CommandLogsRequest( + stdout=models.CommandLogsRange(offset=stdout_offset, **ranges), + stderr=models.CommandLogsRange(offset=stderr_offset, **ranges), + ) + res = await api.commands.get_command_logs(self.uuid, body=body) + # A stream the plugin left out is one with nothing in it. + logs = _payload(res.data, f"the logs of command {self.uuid}") + return CommandLogs( + stdout=base64.b64decode(logs.stdout or ""), + stderr=base64.b64decode(logs.stderr or ""), + stdout_available=logs.stdout_available or 0, + stderr_available=logs.stderr_available or 0, + ) + + async def stdout(self) -> bytes: + """The whole standard output stream, verbatim.""" + api = await self.sandbox.api() + return (await api.commands.get_raw_command_log(self.uuid, "stdout")).content + + async def stderr(self) -> bytes: + """The whole standard error stream, verbatim.""" + api = await self.sandbox.api() + return (await api.commands.get_raw_command_log(self.uuid, "stderr")).content + + async def stream(self) -> AsyncGenerator[OutputChunk, None]: + """Follow the command's output until it ends. + + An unbounded wait for the command runs alongside the polling, so the + stream ends as soon as the command does, with whatever output remained. + Polling backs off while the command is quiet and speeds back up when + it writes; :data:`POLL_MAX_FAILURES` consecutive failed polls raise. + """ + api = await self.sandbox.api() + ended = asyncio.ensure_future(api.commands.wait_for_command(self.uuid)) + cursor = _OutputCursor() + interval = POLL_INTERVAL + failures = 0 + try: + while True: + if ended.done(): + # A failed wait is the command's end too, just not a clean one. + ended.result() + # Drain what the polls have not read yet, forgiving a failed + # poll as the loop below does. + while True: + try: + chunks = await self._poll(api, cursor) + except UnikraftCloudError: + failures += 1 + if failures >= POLL_MAX_FAILURES: + raise + await _sleep(interval) + interval = min(interval * 2, POLL_MAX_INTERVAL) + continue + failures = 0 + for chunk in chunks: + yield chunk + if not chunks or cursor.drained: + return + try: + chunks = await self._poll(api, cursor) + except UnikraftCloudError: + failures += 1 + if failures >= POLL_MAX_FAILURES: + raise + interval = min(interval * 2, POLL_MAX_INTERVAL) + else: + failures = 0 + for chunk in chunks: + yield chunk + interval = POLL_INTERVAL if chunks else min(interval * 2, POLL_MAX_INTERVAL) + # Sleep out the interval, but wake as soon as the command ends. + await asyncio.wait({ended}, timeout=interval) + finally: + if not ended.done(): + ended.cancel() + with contextlib.suppress(asyncio.CancelledError, UnikraftCloudError): + await ended + elif not ended.cancelled(): + # A wait that failed after it was last looked at is read here, + # or the loop would report its error as never retrieved. + ended.exception() + + async def _poll(self, api: SandboxApi, cursor: _OutputCursor) -> list[OutputChunk]: + """Read what each stream has written since the cursor, and advance it.""" + body = models.CommandLogsRequest( + stdout=models.CommandLogsRange(offset=cursor.stdout, limit=LOG_CHUNK_SIZE), + stderr=models.CommandLogsRange(offset=cursor.stderr, limit=LOG_CHUNK_SIZE), + ) + res = await api.commands.get_command_logs(self.uuid, body=body) + logs = _payload(res.data, f"the logs of command {self.uuid}") + cursor.stdout_available = logs.stdout_available or 0 + cursor.stderr_available = logs.stderr_available or 0 + chunks: list[OutputChunk] = [] + if logs.stdout: + data = base64.b64decode(logs.stdout) + cursor.stdout += len(data) + chunks.append(OutputChunk("stdout", data)) + if logs.stderr: + data = base64.b64decode(logs.stderr) + cursor.stderr += len(data) + chunks.append(OutputChunk("stderr", data)) + return chunks + + async def collect( + self, + *, + stdin: bytes | str | None = None, + timeout: float | None = None, + wait_delay: float | None = None, + ) -> ExecResult: + """Follow the command to its end and return its output and exit code. + + ``stdin``, when given, is fed to the command in chunks and then closed. + A feed that fails ends the collection with its error: a command left + waiting for input that never comes might never end. + + When ``timeout`` elapses the command is interrupted with + :data:`INTERRUPT_SIGNAL` and then waited for, so a command that heeds + the interrupt still reports its exit code, marked ``interrupted``. With + ``wait_delay`` as well, a command that ignores the interrupt is given up + on after that long, and :class:`ExecTimeoutError` is raised with it + attached; the same is raised at once when the interrupt could not be + sent, since nothing would end the command. A command found to have + ended by itself in the meantime is followed to the end of its output + instead. ``wait_delay`` needs a ``timeout`` to follow. + The command is not deleted afterwards; :meth:`delete` does that. + """ + _check_following(timeout, wait_delay) + # Encoded here, so a `str` that cannot be is refused before anything is sent. + payload = stdin.encode() if isinstance(stdin, str) else stdin + stdout, stderr = bytearray(), bytearray() + + async def consume() -> None: + # Closed on the spot when the collection ends early, rather than + # whenever the generator is collected. + async with contextlib.aclosing(self.stream()) as chunks: + async for chunk in chunks: + (stdout if chunk.stream == "stdout" else stderr).extend(chunk.data) + + feeder = None if payload is None else asyncio.ensure_future(self.feed_stdin(payload)) + consumer = asyncio.ensure_future(consume()) + following = asyncio.ensure_future(_followed(self, consumer, feeder)) + interrupted = False + try: + if timeout is None: + await following + else: + try: + await asyncio.wait_for(asyncio.shield(following), timeout) + except asyncio.TimeoutError: + interrupted = True + await self._cut_short(following, wait_delay) + finally: + tasks = [task for task in (following, consumer, feeder) if task is not None] + pending = [task for task in tasks if not task.done()] + for task in pending: + task.cancel() + # Gathered rather than awaited one by one under a suppression: the + # tasks' own cancellations are results here, while a cancellation of + # this call arriving now still propagates. + if pending: + await asyncio.gather(*pending, return_exceptions=True) + # A task that failed after the collection last looked at it is read + # here, or the loop would report its error as never retrieved. + for task in tasks: + if not task.cancelled(): + task.exception() + exit_code = await self._exit_code() + return ExecResult( + uuid=self.uuid, + exit_code=exit_code, + stdout=bytes(stdout), + stderr=bytes(stderr), + interrupted=interrupted, + ) + + async def _cut_short(self, following: asyncio.Future[None], wait_delay: float | None) -> None: + """Interrupt a command past its timeout, then wait out the grace for its end. + + A command that cannot be interrupted, or outlives the grace, is left + running and raised as :class:`ExecTimeoutError`; one that has ended by + itself in the meantime is followed to the end of its output instead. + """ + failure = await self._interrupt() + if failure is not None: + # Nothing is on its way to end the command, so waiting for its + # end would be waiting for nothing, unless it has ended already. + if not await self._ended(): + raise ExecTimeoutError(self) from failure + elif wait_delay is not None: + try: + await asyncio.wait_for(asyncio.shield(following), wait_delay) + return + except asyncio.TimeoutError: + if not await self._ended(): + raise ExecTimeoutError(self) from None + await following + + async def _interrupt(self) -> Exception | None: + """Send the interrupt, giving it a bounded time to be delivered. + + Returns what went wrong when it could not be sent, rather than raising: + the caller decides what a command that cannot be interrupted means. + """ + try: + await asyncio.wait_for(self.signal(INTERRUPT_SIGNAL), _SIGNAL_TIMEOUT) + except (asyncio.TimeoutError, UnikraftCloudError) as err: + return err + return None + + async def write_stdin(self, data: bytes | str, *, eof: bool = False) -> None: + """Feed bytes into the command's standard input, in one request. + + ``eof`` closes standard input after the data. A ``str`` is sent UTF-8 + encoded. + """ + api = await self.sandbox.api() + payload = data.encode() if isinstance(data, str) else data + body = models.CommandStdinRequest(data=base64.b64encode(payload).decode(), eof=eof) + await api.commands.write_command_stdin(self.uuid, body=body) + + async def feed_stdin(self, data: bytes | str) -> None: + """Feed all of ``data`` to standard input, a chunk per request, then close it.""" + payload = data.encode() if isinstance(data, str) else data + for start in range(0, len(payload), STDIN_CHUNK_SIZE): + await self.write_stdin(payload[start : start + STDIN_CHUNK_SIZE]) + await self.close_stdin() + + async def close_stdin(self) -> None: + """Close the command's standard input.""" + await self.write_stdin(b"", eof=True) + + async def signal(self, signal: int | str) -> None: + """Send a signal, by number or by name with or without the ``SIG`` prefix.""" + api = await self.sandbox.api() + await api.commands.signal_command( + self.uuid, body=models.CommandSignalRequest(signal=signal) + ) + + async def delete(self) -> None: + """Forget the command. It must have ended; the plugin refuses otherwise. + + The refusal is a ``409``, raised as a plain :class:`UnikraftCloudError` + that says the command still runs: wait for it or signal it first. + """ + api = await self.sandbox.api() + try: + await api.commands.delete_command_by_uuid(self.uuid) + except UnikraftCloudError as err: + if err.status != 409: + raise + raise UnikraftCloudError( + f"command {self.uuid} is still running, so the plugin keeps its record; " + "wait for it or signal it first", + kind="http", + status=409, + errors=err.errors, + body=err.body, + ) from err + + async def delete_logs(self) -> None: + """Forget the command's output, freeing memory in the sandbox. + + Forgetting the output of a running command closes its pipes, which may + make it fail. Reading the output afterwards is answered with ``410``. + """ + api = await self.sandbox.api() + await api.commands.delete_command_logs_by_uuid(self.uuid) + + +def _probe_timeout(timeout: float | httpx.Timeout | None, remaining: float) -> float: + """A probe's timeout: the client's read bounded by what is left, a second at least.""" + current = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout) + read = remaining if current.read is None else min(current.read, remaining) + return max(read, 1.0) + + +async def _followed( + command: Command, consumer: asyncio.Future[None], feeder: asyncio.Future[None] | None +) -> None: + """Await the consumer, unless the feed fails while the command runs, which raises first. + + A feed the plugin refuses because the command has ended already is no + failure of the collection: the command's own exit code tells that story. + """ + tasks = {consumer} if feeder is None else {consumer, feeder} + while True: + done, tasks = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + if consumer in done: + return consumer.result() + for task in done: + try: + task.result() + except UnikraftCloudError: + if not await command._ended(): + raise + + +def _check_following(timeout: float | None, wait_delay: float | None) -> None: + """Refuse a timeout, or a grace after it, that makes no sense, before anything is sent.""" + if timeout is not None and timeout < 0: + raise ValueError("`timeout` is a number of seconds, zero or more, or None.") + if wait_delay is None: + return + if timeout is None: + raise TypeError("`wait_delay` is the grace after `timeout` elapses; give a `timeout` too.") + if wait_delay < 0: + raise ValueError("`wait_delay` is a number of seconds, zero or more, or None.") + + +class _OutputCursor: + """How far each output stream has been read, and how much of it exists.""" + + def __init__(self) -> None: + self.stdout = 0 + self.stderr = 0 + self.stdout_available = 0 + self.stderr_available = 0 + + @property + def drained(self) -> bool: + return self.stdout_available <= self.stdout and self.stderr_available <= self.stderr + + +class SandboxFiles: + """The filesystem inside a sandbox.""" + + def __init__(self, sandbox: Sandbox) -> None: + self._sandbox = sandbox + + async def mkdir(self, path: str, *, parents: bool = False) -> None: + """Create a directory, and with ``parents`` any missing directory above it.""" + api = await self._sandbox.api() + await api.fs.create_directory(body=models.MkdirRequest(path=path, parents=parents)) + + async def read(self, path: str) -> bytes: + """The contents of a file, verbatim.""" + api = await self._sandbox.api() + return (await api.fs.read_raw_file(body=models.ReadFileRequest(path=path))).content + + async def read_text(self, path: str, encoding: str = "utf-8") -> str: + """The contents of a text file.""" + return (await self.read(path)).decode(encoding) + + async def write(self, path: str, data: bytes | str, *, append: bool = False) -> None: + """Write a file, creating it if missing, in one request. + + ``bytes`` travel base64-encoded and a ``str`` as UTF-8, which are the two + encodings the plugin accepts. ``append`` adds to the file instead of + replacing it. + """ + encoding, payload = _encode(data) + api = await self._sandbox.api() + await api.fs.write_file( + body=models.WriteFileRequest(path=path, append=append, encoding=encoding, data=payload) + ) + + async def upload( + self, path: str, filename: str, data: bytes | str, *, parents: bool = False + ) -> str: + """Write a file at ``path``, or at ``path/filename`` when ``path`` is a directory. + + A ``path`` ending in ``/`` names a directory, and so does one the plugin + reports to be one, in which case the file goes inside it under + ``filename``; the path the file landed at is returned. ``parents`` + creates the directories above it first, and a file that is there is + replaced, as the CLI's upload has it. + """ + if not filename or "/" in filename: + raise ValueError("`filename` is one path segment: the file's name inside a directory.") + target = f"{path}{filename}" if path.endswith("/") else path + if parents: + await self._make_parents(target) + try: + await self.write(target, data) + except UnikraftCloudError as err: + if target != path or not _is_a_directory(err): + raise + # The path names a directory without its trailing slash. + target = f"{path}/{filename}" + await self.write(target, data) + return target + + async def _make_parents(self, path: str) -> None: + """Create the directories above ``path``.""" + parent = str(PurePosixPath(path).parent) + if parent not in (".", "/"): + await self.mkdir(parent, parents=True) + + +def _permanent(err: UnikraftCloudError) -> bool: + """Whether waiting cannot change a failure: a rejected token, or a name held twice.""" + if isinstance(err, MetroFanoutError): + # Every metro asked refused the token; one that could not answer may yet. + return bool(err.failures) and all( + isinstance(failure.error, AuthenticationError) for failure in err.failures + ) + return isinstance(err, (AuthenticationError, AmbiguousRefError)) + + +def _is_a_directory(err: UnikraftCloudError) -> bool: + """Whether a write failed because its path is a directory, as the plugin reports it.""" + return err.status == 500 and "is a directory" in str(err).lower() + + +def _encode(data: bytes | str) -> tuple[models.FileEncoding, str]: + """Pick the wire encoding for file data: the plugin takes UTF-8 text or base64.""" + if isinstance(data, str): + return "utf-8", data + return "base64", base64.b64encode(data).decode() diff --git a/src/unikraft_cloud/resources/instances.py b/src/unikraft_cloud/resources/instances.py index 4aa3df1..d706e4f 100644 --- a/src/unikraft_cloud/resources/instances.py +++ b/src/unikraft_cloud/resources/instances.py @@ -33,6 +33,8 @@ ) from ..core.response import Ref, RefLike, describe_ref, matched_entries, or_absent from ..core.session import Session +from ..plugins import Plugin, ResolvedInstance +from ..plugins.sandbox import PLUGIN_NAME, Sandbox from ._shared import ( at_metro, filter_of, @@ -475,6 +477,59 @@ def _then( """Chain another operation onto this handle.""" return InstanceHandle(self._instances, self._chained(fetch, opts)) + def plugin(self, name: str) -> Plugin: + """A plugin attached to the instance, addressed lazily. + + Nothing is sent until the plugin's route is needed. Resolving it runs + this handle's own operation first, if it is one, and reads the instance + only when its UUID is not yet known: a UUID reference, a located or + created instance, and a waited-for instance all already know it. + + .. code-block:: python + + route = await ukc.instances.get(name="web").plugin("sandbox").route() + """ + + async def resolve() -> ResolvedInstance: + target = await self._next() + held: object = (await self._locate()).value + value = self._value + if ( + held is None + and value is not None + and value.done() + and not value.cancelled() + and value.exception() is None + ): + held = value.result() + uuid = target.ref.uuid + if uuid is None: + known = getattr(held, "uuid", None) + if isinstance(known, str) and known: + uuid = known + return ResolvedInstance( + target=target, + uuid=uuid, + instance=held if isinstance(held, Instance) else None, + ) + + # Addressing a plugin consumes the handle the way chaining does: it is + # the plugin that will send something, not this handle. + self._consumed = True + return self._instances._plugin(name, resolve) + + def sandbox(self, *, plugin: str = PLUGIN_NAME) -> Sandbox: + """The sandbox plugin on the instance: commands and files inside it. + + ``plugin`` names the plugin when it was attached under another name. + + .. code-block:: python + + sb = ukc.metro("fra").instances.get(uuid=uuid).sandbox() + result = await sb.exec("uname -a") + """ + return Sandbox(self.plugin(plugin)) + class InstanceSet(HandleSet["InstanceHandle[Instance]", Instance]): """Every instance matching one reference, one per metro that holds it. @@ -551,6 +606,15 @@ class Instances(Resource[InstancesApi]): def __init__(self, session: Session, scope: MetroScope) -> None: super().__init__(session, scope, InstancesApi(session.platform)) + def _plugin(self, name: str, resolve: Callable[[], Awaitable[ResolvedInstance]]) -> Plugin: + """A plugin on the instance a handle resolves to. Used by :class:`InstanceHandle`.""" + return Plugin( + name, + resolve=resolve, + read=lambda target: self.read(target, {}), + config=self._session.platform, + ) + def create( self, *, diff --git a/tests/conftest.py b/tests/conftest.py index c28e6c8..7ccb703 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,8 @@ from __future__ import annotations -from collections.abc import Callable, Iterator, Sequence +import inspect +from collections.abc import Awaitable, Callable, Iterator, Sequence from typing import Any import httpx @@ -39,17 +40,28 @@ def _no_ambient_config(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("UKC_METRO", raising=False) +#: A handler answering one request, at once or after awaiting something. +Handler = Callable[[httpx.Request], "httpx.Response | Awaitable[httpx.Response]"] + + class Recorder: - """A transport that records every request it was asked to send.""" + """A transport that records every request it was asked to send. + + The handler may be async, so a fake can block the way a real long-poll does. + The SDK only ever sends through an async client, which awaits the result. + """ - def __init__(self, handler: Callable[[httpx.Request], httpx.Response]) -> None: + def __init__(self, handler: Handler) -> None: self.calls: list[httpx.Request] = [] self.transport = httpx.MockTransport(self._send) self._handler = handler - def _send(self, request: httpx.Request) -> httpx.Response: + async def _send(self, request: httpx.Request) -> httpx.Response: self.calls.append(request) - return self._handler(request) + result = self._handler(request) + if inspect.isawaitable(result): + return await result + return result @property def urls(self) -> list[str]: diff --git a/tests/fake_sandbox.py b/tests/fake_sandbox.py new file mode 100644 index 0000000..08fa375 --- /dev/null +++ b/tests/fake_sandbox.py @@ -0,0 +1,334 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# An in-memory sandbox plugin behind an instance's plugin route, plus just +# enough of the platform API -- metros and instance lookups -- for a handle to +# resolve the route. The handler is async so the unbounded `wait` can block the +# way the real one does, and finish when the command does. + +from __future__ import annotations + +import asyncio +import base64 +import json +from typing import Any + +import httpx + +from .conftest import Recorder, envelope, instance, metro, metro_of + +#: Signals that count as an interrupt a command heeds, by number or name, and +#: the number each one stands for. +_INTERRUPTS: dict[int | str, int] = {2: 2, 15: 15, "INT": 2, "SIGINT": 2, "TERM": 15, "SIGTERM": 15} + + +def empty_envelope() -> dict[str, Any]: + """The envelope of an operation that carries no data: `data` is null.""" + return {"status": "success", "data": None, "op_time_us": 1} + + +def error_envelope(status: int, message: str) -> dict[str, Any]: + return {"status": "error", "message": message, "errors": [{"status": status}], "op_time_us": 1} + + +class FakeCommand: + """One command's state: what it was started with, its output, and its end.""" + + def __init__( + self, + uuid: str, + cmdline: str, + cwd: str | None, + env: dict[str, str] | None, + *, + stdout: bytes = b"", + stderr: bytes = b"", + exit_code: int = 0, + exits_after_polls: int = 0, + heeds_interrupt: bool = True, + ) -> None: + self.uuid = uuid + self.cmdline = cmdline + self.cwd = cwd + self.env = env + self.stdout = stdout + self.stderr = stderr + #: The exit code the command ends with on its own. + self.exit_code = exit_code + #: How many output polls it survives before ending on its own; 0 ends + #: it as soon as it starts, and a very large number never does. + self.exits_after_polls = exits_after_polls + self.heeds_interrupt = heeds_interrupt + self.exitcode: int | None = None + self.exited = asyncio.Event() + self.polls = 0 + self.stdin: list[tuple[bytes, bool]] = [] + self.signals: list[int | str] = [] + self.logs_deleted = False + + def finish(self, code: int) -> None: + self.exitcode = code + self.exited.set() + + @property + def running(self) -> bool: + return self.exitcode is None + + +class FakeSandbox: + """A sandbox plugin on one instance, answering over a recording transport. + + Register how the next started command behaves with :meth:`on_run`; a + command started without a script ends at once with exit code 0 and no output. + """ + + def __init__( + self, + *, + uuid: str = "u1", + name: str = "web", + plugin: str = "sandbox", + metros: tuple[str, ...] = ("fra", "dal"), + instance_metro: str = "fra", + ) -> None: + self.uuid = uuid + self.name = name + self.plugin = plugin + self.prefix = f"/v1/instances/{uuid}/plugins/{plugin}" + self.metros = metros + #: The metro that holds the instance; other metros report no match. + self.instance_metro = instance_metro + self.instance = instance(uuid, name) + self.commands: dict[str, FakeCommand] = {} + self.scripts: list[dict[str, Any]] = [] + self.files: dict[str, bytes] = {} + self.dirs: list[tuple[str, bool]] = [] + #: How many readiness probes fail before the plugin answers, and how. + self.unready = 0 + self.unready_status = 503 + #: How many output polls fail before they succeed again. + self.failing_polls = 0 + #: How many signals fail before they work again, how many writes to a + #: command's standard input, and how many reads of a command. + self.failing_signals = 0 + self.failing_stdin = 0 + self.failing_reads = 0 + self.recorder = Recorder(self.handle) + self._next = 1 + + @property + def transport(self) -> httpx.MockTransport: + return self.recorder.transport + + def on_run(self, **behaviour: Any) -> None: + """How the next started command behaves; see :class:`FakeCommand`.""" + self.scripts.append(behaviour) + + def plugin_paths(self) -> list[str]: + """The plugin-route paths requested, relative to the route.""" + return [p[len(self.prefix) :] for p in self.recorder.paths if p.startswith(self.prefix)] + + # -- the handler --------------------------------------------------------- + + async def handle(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/v1/metros": + return _json(200, envelope({"metros": [metro(code) for code in self.metros]})) + if path in ("/v1/instances", "/v1/instances/wait"): + return self._platform_instances(request) + if path == self.prefix or path.startswith(self.prefix + "/"): + return await self._plugin(request, path[len(self.prefix) :]) + return _json(404, error_envelope(404, f"no route for {path}")) + + def _platform_instances(self, request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return _json(200, envelope({"instances": [self.instance]})) + params = request.url.params + matches = params.get("uuid") in (None, self.uuid) and params.get("name") in ( + None, + self.name, + ) + if metro_of(request) != self.instance_metro or not matches: + return _json(200, envelope({"instances": []})) + if request.url.path.endswith("/wait"): + waited = {"uuid": self.uuid, "name": self.name, "state": "running"} + return _json(200, envelope({"instances": [waited]})) + return _json(200, envelope({"instances": [self.instance]})) + + async def _plugin(self, request: httpx.Request, sub: str) -> httpx.Response: + method = request.method + body: dict[str, Any] = json.loads(request.content) if request.content else {} + + if sub == "/commands" and method == "GET": + if self.unready > 0: + self.unready -= 1 + return _json(self.unready_status, error_envelope(self.unready_status, "not yet")) + return _json(200, envelope({"commands": list(self.commands)})) + if sub == "/commands" and method == "POST": + return self._run(body) + + if sub.startswith("/commands/"): + parts = sub[len("/commands/") :].split("/") + command = self.commands.get(parts[0]) + if command is None: + return httpx.Response(404) + rest = "/".join(parts[1:]) + return await self._command(request, command, rest, body) + + if sub.startswith("/fs/") and method == "POST": + return self._fs(sub[len("/fs/") :], body) + return httpx.Response(404) + + def _run(self, body: dict[str, Any]) -> httpx.Response: + uuid = f"c{self._next}" + self._next += 1 + script = self.scripts.pop(0) if self.scripts else {} + command = FakeCommand(uuid, body["cmd"], body.get("cwd"), body.get("env"), **script) + self.commands[uuid] = command + if command.exits_after_polls == 0: + command.finish(command.exit_code) + return _json(200, envelope({"uuid": uuid})) + + async def _command( + self, request: httpx.Request, command: FakeCommand, op: str, body: dict[str, Any] + ) -> httpx.Response: + method = request.method + if op == "" and method == "GET": + if self.failing_reads > 0: + self.failing_reads -= 1 + return _json(500, error_envelope(500, "read lost")) + return _json( + 200, + envelope( + { + "uuid": command.uuid, + "cmdline": command.cmdline, + "cwd": command.cwd, + "env": command.env, + "exitcode": command.exitcode, + } + ), + ) + if op == "" and method == "DELETE": + if command.running: + return _json(409, error_envelope(409, "still running")) + del self.commands[command.uuid] + return _json(200, empty_envelope()) + if op == "logs" and method == "DELETE": + command.logs_deleted = True + return _json(200, empty_envelope()) + if op == "logs" and method == "GET": + return self._logs(command, body) + if op.startswith("logs/raw/"): + return self._raw_log(command, op[len("logs/raw/") :], request.headers.get("range")) + if op == "stdin": + if command.logs_deleted: + return httpx.Response(410) + if self.failing_stdin > 0: + self.failing_stdin -= 1 + return _json(502, error_envelope(502, "bad gateway")) + # As the plugin: a close after the end is fine, data for an ended command is not. + if not command.running and not (body.get("eof") and not body["data"]): + return _json(410, error_envelope(410, "Command logs deleted")) + command.stdin.append((base64.b64decode(body["data"]), bool(body.get("eof")))) + return _json(200, empty_envelope()) + if op == "signal": + if self.failing_signals > 0: + self.failing_signals -= 1 + return _json(500, error_envelope(500, "signal lost")) + signal = body["signal"] + command.signals.append(signal) + if command.heeds_interrupt and signal in _INTERRUPTS and command.running: + # Ended by the signal, which the plugin reports as minus its number. + command.finish(-_INTERRUPTS[signal]) + return _json(200, empty_envelope()) + if op == "wait": + await command.exited.wait() + return _json(200, empty_envelope()) + if op == "wait_timeout": + try: + await asyncio.wait_for(command.exited.wait(), body["timeout_s"]) + except asyncio.TimeoutError: + return _json(408, error_envelope(408, "still running")) + return _json(200, empty_envelope()) + return httpx.Response(404) + + def _logs(self, command: FakeCommand, body: dict[str, Any]) -> httpx.Response: + if command.logs_deleted: + return httpx.Response(410) + if self.failing_polls > 0: + self.failing_polls -= 1 + return _json(500, error_envelope(500, "flaky")) + command.polls += 1 + if command.running and command.polls >= command.exits_after_polls: + command.finish(command.exit_code) + + def part(data: bytes, spec: dict[str, Any] | None) -> str: + spec = spec or {} + offset = spec.get("offset", 0) + limit = spec.get("limit") + end = len(data) if limit is None else min(len(data), offset + limit) + return base64.b64encode(data[offset:end]).decode() + + return _json( + 200, + envelope( + { + "stdout": part(command.stdout, body.get("stdout")), + "stderr": part(command.stderr, body.get("stderr")), + "stdout_available": len(command.stdout), + "stderr_available": len(command.stderr), + } + ), + ) + + def _raw_log(self, command: FakeCommand, stream: str, byte_range: str | None) -> httpx.Response: + if command.logs_deleted: + return httpx.Response(410) + data = {"stdout": command.stdout, "stderr": command.stderr}.get(stream) + if data is None: + return httpx.Response(404) + headers = {"content-type": "application/octet-stream"} + if byte_range is None: + return httpx.Response(200, content=data, headers=headers) + start_s, _, end_s = byte_range.removeprefix("bytes=").partition("-") + start = int(start_s) + if start >= len(data): + return httpx.Response(416, headers={"content-range": f"bytes */{len(data)}"}) + end = len(data) - 1 if end_s == "" else min(int(end_s), len(data) - 1) + headers["content-range"] = f"bytes {start}-{end}/{len(data)}" + return httpx.Response(206, content=data[start : end + 1], headers=headers) + + def _fs(self, op: str, body: dict[str, Any]) -> httpx.Response: + if op == "mkdir": + self.dirs.append((body["path"], body["parents"])) + return _json(200, empty_envelope()) + if op == "read": + data = self.files.get(body["path"]) + if data is None: + return _json(500, error_envelope(500, "No such file or directory")) + return _json(200, envelope({"contents": base64.b64encode(data).decode()})) + if op == "read_raw": + data = self.files.get(body["path"]) + if data is None: + return httpx.Response(404) + return httpx.Response( + 200, content=data, headers={"content-type": "application/octet-stream"} + ) + if op == "write": + path = body["path"] + if path.rstrip("/") in {d for d, _ in self.dirs}: + return _json(500, error_envelope(500, "Is a directory (os error 21)")) + data = _decode(body) + self.files[path] = self.files.get(path, b"") + data if body["append"] else data + return _json(200, empty_envelope()) + return httpx.Response(404) + + +def _decode(body: dict[str, Any]) -> bytes: + data: str = body["data"] + return data.encode() if body["encoding"] == "utf-8" else base64.b64decode(data) + + +def _json(status: int, body: dict[str, Any]) -> httpx.Response: + return httpx.Response(status, json=body) diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..d82b462 --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Addressing a plugin from an instance handle: what resolving its route costs +# in requests, and that the route is what a client gets pointed at. + +from __future__ import annotations + +import asyncio +import gc +import warnings +from typing import Any + +import pytest + +from unikraft_cloud import NotFoundError, UnikraftCloud +from unikraft_cloud.api.plugins.sandbox import SandboxApi +from unikraft_cloud.core.http import ApiClientConfig + +from .fake_sandbox import FakeSandbox + +ROUTE = "https://api.fra.unikraft.cloud/v1/instances/u1/plugins/sandbox" + + +def cloud(fake: FakeSandbox, **config: Any) -> UnikraftCloud: + return UnikraftCloud(token="tok", transport=fake.transport, **config) + + +class TestResolvingTheRoute: + async def test_a_uuid_pinned_to_a_metro_sends_nothing(self) -> None: + fake = FakeSandbox() + async with cloud(fake, metro="fra") as ukc: + route = await ukc.instances.get(uuid="u1").plugin("sandbox").route() + assert route.url == ROUTE + assert (route.metro, route.instance_uuid, route.name) == ("fra", "u1", "sandbox") + assert fake.recorder.calls == [] + + async def test_a_name_pinned_to_a_metro_costs_one_read(self) -> None: + fake = FakeSandbox() + async with cloud(fake, metro="fra") as ukc: + route = await ukc.instances.get(name="web").plugin("sandbox").route() + assert route.instance_uuid == "u1" + assert fake.recorder.paths == ["/v1/instances"] + assert fake.recorder.calls[0].url.params["name"] == "web" + + async def test_a_name_found_by_searching_metros_is_not_read_again(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + plugin = ukc.instances.get(name="web").plugin("sandbox") + route = await plugin.route() + found = await plugin.instance() + assert (route.metro, route.instance_uuid) == ("fra", "u1") + assert found.uuid == "u1" + # Discovery, then one search per metro; the match travelled back with it. + assert fake.recorder.paths == ["/v1/metros", "/v1/instances", "/v1/instances"] + + async def test_a_created_instance_knows_its_uuid(self) -> None: + fake = FakeSandbox() + async with cloud(fake, metro="fra") as ukc: + route = await ukc.instances.create(image="nginx:latest").plugin("sandbox").route() + assert route.instance_uuid == "u1" + assert [(c.method, c.url.path) for c in fake.recorder.calls] == [("POST", "/v1/instances")] + + async def test_a_chained_wait_runs_first_and_its_answer_carries_the_uuid(self) -> None: + fake = FakeSandbox() + async with cloud(fake, metro="fra") as ukc: + handle = ukc.instances.get(name="web").wait(state="running") + route = await handle.plugin("sandbox").route() + assert route.instance_uuid == "u1" + assert fake.recorder.paths == ["/v1/instances/wait"] + + async def test_concurrent_callers_share_one_resolution(self) -> None: + fake = FakeSandbox() + async with cloud(fake, metro="fra") as ukc: + plugin = ukc.instances.get(name="web").plugin("sandbox") + await asyncio.gather(plugin.route(), plugin.route(), plugin.instance()) + assert fake.recorder.paths == ["/v1/instances"] + + async def test_the_instance_is_read_once_when_only_its_uuid_was_known(self) -> None: + fake = FakeSandbox() + async with cloud(fake, metro="fra") as ukc: + plugin = ukc.instances.get(uuid="u1").plugin("sandbox") + await plugin.route() + first = await plugin.instance() + second = await plugin.instance() + assert first is second + assert fake.recorder.paths == ["/v1/instances"] + + async def test_concurrent_reads_of_a_uuid_pinned_instance_share_one_request(self) -> None: + fake = FakeSandbox() + async with cloud(fake, metro="fra") as ukc: + plugin = ukc.instances.get(uuid="u1").plugin("sandbox") + first, second = await asyncio.gather(plugin.instance(), plugin.instance()) + assert first is second + assert fake.recorder.paths == ["/v1/instances"] + + async def test_a_read_that_failed_is_made_again(self) -> None: + fake = FakeSandbox(instance_metro="dal") + async with cloud(fake, metro="fra") as ukc: + plugin = ukc.instances.get(uuid="u1").plugin("sandbox") + with pytest.raises(NotFoundError): + await plugin.instance() + fake.instance_metro = "fra" + assert (await plugin.instance()).uuid == "u1" + assert fake.recorder.paths == ["/v1/instances", "/v1/instances"] + + async def test_the_plugin_name_is_the_last_segment(self) -> None: + fake = FakeSandbox() + async with cloud(fake, metro="fra") as ukc: + route = await ukc.instances.get(uuid="u1").plugin("logger").route() + assert route.url.endswith("/v1/instances/u1/plugins/logger") + + +class TestClients: + async def test_a_factory_is_given_the_transport_config_at_the_route(self) -> None: + fake = FakeSandbox() + seen: list[ApiClientConfig] = [] + + def factory(config: ApiClientConfig) -> ApiClientConfig: + seen.append(config) + return config + + async with cloud(fake, metro="fra") as ukc: + got = await ukc.instances.get(uuid="u1").plugin("sandbox").client(factory) + assert got is seen[0] + assert (seen[0].base_url, seen[0].token) == (ROUTE, "tok") + + async def test_a_generated_client_speaks_to_the_plugin(self) -> None: + fake = FakeSandbox() + async with cloud(fake, metro="fra") as ukc: + raw = await ukc.instances.get(uuid="u1").plugin("sandbox").client(SandboxApi) + res = await raw.commands.list_commands() + assert fake.recorder.urls == [ROUTE + "/commands"] + assert fake.recorder.calls[0].headers["authorization"] == "Bearer tok" + assert res.data is not None and res.data.commands == [] + + async def test_the_sandbox_shortcut_is_the_sandbox_plugin(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + sb = ukc.metro("fra").instances.get(uuid="u1").sandbox() + assert await sb.ready() is True + assert fake.recorder.urls == [ROUTE + "/commands"] + + async def test_another_plugin_name_can_be_named_for_the_sandbox(self) -> None: + fake = FakeSandbox(plugin="sb2") + async with cloud(fake, metro="fra") as ukc: + sb = ukc.instances.get(uuid="u1").sandbox(plugin="sb2") + assert await sb.ready() is True + assert fake.recorder.paths == ["/v1/instances/u1/plugins/sb2/commands"] + + async def test_a_handle_that_addressed_a_plugin_does_not_warn_when_dropped(self) -> None: + fake = FakeSandbox() + async with cloud(fake, metro="fra") as ukc: + # Recorded, not raised: __del__ swallows an exception, so an "error" + # filter would hide the very warning this looks for. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + handle = ukc.instances.get(uuid="u1") + handle.plugin("sandbox") + del handle + gc.collect() + assert not [w for w in caught if w.category is RuntimeWarning] diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 0000000..f0a66a2 --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,771 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# The sandbox client against an in-memory plugin: readiness, commands and how +# they end, output following, and the filesystem. The polling intervals are +# shortened so a whole command lifecycle takes milliseconds. + +from __future__ import annotations + +import asyncio +import base64 +import json +from typing import Any + +import httpx +import pytest + +from unikraft_cloud import ( + AlreadyExistsError, + AmbiguousRefError, + AuthenticationError, + ExecTimeoutError, + MetroFanoutError, + NotFoundError, + OutputChunk, + PluginNotReadyError, + Sandbox, + UnikraftCloud, + UnikraftCloudError, +) +from unikraft_cloud.plugins import sandbox as sandbox_module + +from .conftest import envelope +from .fake_sandbox import FakeCommand, FakeSandbox + +NEVER = 10**9 + + +@pytest.fixture(autouse=True) +def _quick_polling(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sandbox_module, "POLL_INTERVAL", 0.001) + monkeypatch.setattr(sandbox_module, "POLL_MAX_INTERVAL", 0.002) + + +def cloud(fake: FakeSandbox) -> UnikraftCloud: + return UnikraftCloud(token="tok", metro="fra", transport=fake.transport) + + +def sandbox(ukc: UnikraftCloud) -> Sandbox: + return ukc.instances.get(uuid="u1").sandbox() + + +def body_of(fake: FakeSandbox, index: int) -> Any: + return json.loads(fake.recorder.calls[index].content) + + +class Everywhere(FakeSandbox): + """A platform whose every metro lists the instance.""" + + def _platform_instances(self, request: httpx.Request) -> httpx.Response: + if request.method == "GET" and not request.url.path.endswith("/wait"): + return httpx.Response(200, json=envelope({"instances": [self.instance]})) + return super()._platform_instances(request) + + +class TestReadiness: + async def test_ready_is_whether_listing_commands_works(self) -> None: + fake = FakeSandbox() + fake.unready = 1 + async with cloud(fake) as ukc: + sb = sandbox(ukc) + assert await sb.ready() is False + assert await sb.ready() is True + + async def test_ready_is_false_for_an_instance_that_cannot_be_found(self) -> None: + fake = FakeSandbox(instance_metro="dal") + async with cloud(fake) as ukc: + # The client is pinned to fra, where no instance of that name is. + assert await ukc.instances.get(name="web").sandbox().ready() is False + assert fake.plugin_paths() == [] + + async def test_wait_ready_returns_as_soon_as_the_plugin_answers(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + await sandbox(ukc).wait_ready(timeout=5) + assert fake.plugin_paths() == ["/commands"] + + async def test_wait_ready_backs_off_from_a_quarter_second_to_two( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + slept: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + slept.append(seconds) + + monkeypatch.setattr(sandbox_module, "_sleep", fake_sleep) + fake = FakeSandbox() + fake.unready = 5 + async with cloud(fake) as ukc: + await sandbox(ukc).wait_ready(timeout=60) + assert slept == [0.25, 0.5, 1.0, 2.0, 2.0] + assert fake.plugin_paths() == ["/commands"] * 6 + + async def test_a_route_not_found_yet_is_probed_again( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_sleep(seconds: float) -> None: + pass + + # The proxy answers 404 while the plugin boots, and 502 or 504 once + # it is routed but not yet serving. + monkeypatch.setattr(sandbox_module, "_sleep", fake_sleep) + fake = FakeSandbox() + fake.unready, fake.unready_status = 2, 404 + async with cloud(fake) as ukc: + await sandbox(ukc).wait_ready(timeout=60) + assert fake.plugin_paths() == ["/commands"] * 3 + + async def test_a_rejected_token_is_raised_at_once(self) -> None: + fake = FakeSandbox() + fake.unready, fake.unready_status = NEVER, 403 + async with cloud(fake) as ukc: + with pytest.raises(AuthenticationError): + await sandbox(ukc).wait_ready(timeout=60) + with pytest.raises(AuthenticationError): + await sandbox(ukc).ready() + assert fake.plugin_paths() == ["/commands"] * 2 + + async def test_a_name_held_by_several_instances_is_raised_at_once(self) -> None: + fake = Everywhere() + async with UnikraftCloud(token="tok", transport=fake.transport) as ukc: + sb = ukc.instances.get(name="web").sandbox() + with pytest.raises(AmbiguousRefError): + await sb.wait_ready(timeout=60) + with pytest.raises(AmbiguousRefError): + await sb.ready() + assert fake.plugin_paths() == [] + + async def test_a_zero_timeout_makes_exactly_one_attempt(self) -> None: + fake = FakeSandbox() + fake.unready = 3 + async with cloud(fake) as ukc: + with pytest.raises(PluginNotReadyError) as caught: + await sandbox(ukc).wait_ready(timeout=0) + assert (caught.value.plugin, caught.value.attempts) == ("sandbox", 1) + assert isinstance(caught.value.__cause__, UnikraftCloudError) + assert caught.value.__cause__.status == 503 + assert isinstance(caught.value, TimeoutError) + + async def test_wait_ready_gives_up_at_the_deadline( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr(sandbox_module, "_sleep", fake_sleep) + fake = FakeSandbox() + fake.unready = NEVER + async with cloud(fake) as ukc: + with pytest.raises(PluginNotReadyError) as caught: + await sandbox(ukc).wait_ready(timeout=0.02) + assert caught.value.attempts > 1 + + async def test_a_probe_is_bounded_by_what_is_left_of_the_timeout(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + await sandbox(ukc).wait_ready(timeout=5) + async with UnikraftCloud( + token="tok", metro="fra", transport=fake.transport, timeout=2.0 + ) as ukc: + await sandbox(ukc).wait_ready(timeout=5) + reads = [ + call.extensions["timeout"]["read"] + for call in fake.recorder.calls + if call.url.path.endswith("/commands") + ] + # The client's unbounded read is bounded by the deadline; a shorter one stands. + assert len(reads) == 2 and 0 < reads[0] <= 5 and reads[1] == 2.0 + + +class TestCommands: + async def test_run_sends_only_what_it_was_given(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + sb = sandbox(ukc) + first = await sb.run("echo hi", cwd="/app") + second = await sb.run("env", env={"A": "1"}) + assert body_of(fake, 0) == {"cmd": "echo hi", "cwd": "/app"} + assert body_of(fake, 1) == {"cmd": "env", "env": {"A": "1"}} + assert (first.uuid, second.uuid) == ("c1", "c2") + + async def test_get_reports_the_command_as_started_and_its_exit_code(self) -> None: + fake = FakeSandbox() + fake.on_run(exit_code=3) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("false", cwd="/app") + info = await command.get() + assert (info.cmdline, info.cwd, info.env, info.exitcode) == ("false", "/app", None, 3) + + async def test_wait_with_a_timeout_reports_a_running_command_as_none(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("sleep 1000") + assert await command.wait(timeout=0.01) is None + fake.commands["c1"].finish(4) + assert await command.wait(timeout=1) == 4 + assert await command.wait() == 4 + assert body_of(fake, 1) == {"timeout_s": 0.01} + assert fake.plugin_paths()[-2:] == ["/commands/c1/wait", "/commands/c1"] + + async def test_logs_are_decoded_and_ranged(self) -> None: + fake = FakeSandbox() + fake.on_run(stdout=b"hello world", stderr=b"oops") + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + whole = await command.logs() + part = await command.logs(stdout_offset=6, stderr_offset=1, limit=3) + assert (whole.stdout, whole.stderr) == (b"hello world", b"oops") + assert (whole.stdout_available, whole.stderr_available) == (11, 4) + assert (part.stdout, part.stderr) == (b"wor", b"ops") + assert body_of(fake, 2) == { + "stdout": {"offset": 6, "limit": 3}, + "stderr": {"offset": 1, "limit": 3}, + } + + async def test_whole_streams_come_back_verbatim(self) -> None: + fake = FakeSandbox() + fake.on_run(stdout=b"\x00\x01", stderr=b"\xff") + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + assert await command.stdout() == b"\x00\x01" + assert await command.stderr() == b"\xff" + + async def test_stdin_is_fed_in_chunks_and_then_closed(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER) + data = bytes(range(256)) * 300 # 76800 bytes: two full chunks and a tail + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("cat") + await command.feed_stdin(data) + writes = fake.commands["c1"].stdin + assert [len(chunk) for chunk, _ in writes] == [32768, 32768, 11264, 0] + assert [eof for _, eof in writes] == [False, False, False, True] + assert b"".join(chunk for chunk, _ in writes) == data + + async def test_stdin_takes_text_and_can_close_in_the_same_request(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("cat") + await command.write_stdin("héllo", eof=True) + assert fake.commands["c1"].stdin == [("héllo".encode(), True)] + assert body_of(fake, 1) == { + "data": base64.b64encode("héllo".encode()).decode(), + "eof": True, + } + + async def test_signals_go_by_number_or_name(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER, heeds_interrupt=False) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + await command.signal(9) + await command.signal("TERM") + assert fake.commands["c1"].signals == [9, "TERM"] + + async def test_a_signal_that_ends_a_command_is_reported_as_minus_its_number(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER) + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + interrupted = await sandbox(ukc).run("x") + terminated = await sandbox(ukc).run("y") + await interrupted.signal("INT") + await terminated.signal("SIGTERM") + assert await interrupted.wait(timeout=5) == -2 + assert await terminated.wait(timeout=5) == -15 + + async def test_a_running_command_cannot_be_deleted_but_a_finished_one_can(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + sb = sandbox(ukc) + command = await sb.run("x") + with pytest.raises(UnikraftCloudError) as caught: + await command.delete() + assert caught.value.status == 409 + fake.commands["c1"].finish(0) + await command.delete() + assert await sb.commands() == [] + + async def test_forgetting_logs_makes_them_gone(self) -> None: + fake = FakeSandbox() + fake.on_run(stdout=b"x") + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + await command.delete_logs() + with pytest.raises(UnikraftCloudError) as caught: + await command.logs() + assert caught.value.status == 410 + + async def test_commands_are_listed_and_reattached_by_uuid(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + sb = sandbox(ukc) + await sb.run("a") + await sb.run("b") + listed = await sb.commands() + again = sb.command("c2") + info = await again.get() + assert [c.uuid for c in listed] == ["c1", "c2"] + assert info.cmdline == "b" + + +class TestStreaming: + async def test_yields_output_as_it_arrives_then_drains_after_the_end( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sandbox_module, "LOG_CHUNK_SIZE", 4) + fake = FakeSandbox() + fake.on_run(stdout=b"abcdefgh", stderr=b"E", exits_after_polls=1) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + chunks = [chunk async for chunk in command.stream()] + assert chunks == [ + OutputChunk("stdout", b"abcd"), + OutputChunk("stderr", b"E"), + OutputChunk("stdout", b"efgh"), + ] + + async def test_waits_for_the_end_alongside_the_polling(self) -> None: + fake = FakeSandbox() + fake.on_run(stdout=b"hi", exits_after_polls=2) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + chunks = [chunk async for chunk in command.stream()] + assert b"".join(c.data for c in chunks) == b"hi" + paths = fake.plugin_paths() + assert "/commands/c1/wait" in paths + assert "/commands/c1/wait_timeout" not in paths + + async def test_gives_up_after_three_failed_polls_in_a_row(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER) + fake.failing_polls = 3 + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + with pytest.raises(UnikraftCloudError) as caught: + [chunk async for chunk in command.stream()] + assert caught.value.status == 500 + + async def test_recovers_from_fewer_failed_polls(self) -> None: + fake = FakeSandbox() + fake.on_run(stdout=b"ok", exits_after_polls=1) + fake.failing_polls = 2 + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + chunks = [chunk async for chunk in command.stream()] + assert chunks == [OutputChunk("stdout", b"ok")] + + +class TestExec: + async def test_collects_the_output_and_exit_code_without_deleting(self) -> None: + fake = FakeSandbox() + fake.on_run(stdout=b"out", stderr=b"err", exit_code=2, exits_after_polls=1) + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("make", cwd="/app", env={"CI": "1"}) + assert (result.uuid, result.exit_code, result.stdout, result.stderr) == ( + "c1", + 2, + b"out", + b"err", + ) + assert result.interrupted is False + assert "c1" in fake.commands + assert fake.recorder.calls[0].method == "POST" + assert body_of(fake, 0) == {"cmd": "make", "cwd": "/app", "env": {"CI": "1"}} + assert all(c.method != "DELETE" for c in fake.recorder.calls) + + async def test_feeds_stdin_while_the_command_runs(self) -> None: + fake = FakeSandbox() + fake.on_run(stdout=b"echoed", exits_after_polls=2) + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("cat", stdin="input") + assert result.stdout == b"echoed" + assert fake.commands["c1"].stdin == [(b"input", False), (b"", True)] + + async def test_a_timeout_interrupts_and_then_waits_for_the_end(self) -> None: + fake = FakeSandbox() + fake.on_run(stdout=b"partial", exits_after_polls=NEVER) + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("sleep 1000", timeout=0.02) + assert fake.commands["c1"].signals == [2] + assert (result.exit_code, result.interrupted, result.stdout) == (-2, True, b"partial") + + async def test_a_command_that_ignores_the_interrupt_is_given_up_on(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER, heeds_interrupt=False) + async with cloud(fake) as ukc: + with pytest.raises(ExecTimeoutError) as caught: + await sandbox(ukc).exec("trap '' INT; sleep 1000", timeout=0.02, wait_delay=0.02) + assert caught.value.command.uuid == "c1" + assert isinstance(caught.value, TimeoutError) + assert fake.commands["c1"].signals == [2] + assert fake.commands["c1"].running + + async def test_without_a_grace_period_it_waits_for_a_stubborn_command(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=6, exit_code=1, heeds_interrupt=False) + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("x", timeout=0.001) + assert (result.exit_code, result.interrupted) == (1, True) + + async def test_a_feed_that_fails_ends_the_exec_rather_than_the_command(self) -> None: + fake = FakeSandbox() + fake.failing_stdin = 1 + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + with pytest.raises(UnikraftCloudError) as caught: + await sandbox(ukc).exec("cat", stdin=b"never arrives", timeout=5) + assert caught.value.status == 502 + # The command waits for input that will not come; it is the caller's now. + assert fake.commands["c1"].running + assert fake.commands["c1"].stdin == [] + assert fake.commands["c1"].signals == [] + + async def test_an_unencodable_input_is_refused_before_the_command_starts(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + with pytest.raises(UnicodeEncodeError): + await sandbox(ukc).exec("cat", stdin="lone \udc80 surrogate") + assert fake.commands == {} + + async def test_the_final_read_of_the_exit_code_is_retried(self) -> None: + fake = FakeSandbox() + fake.failing_reads = 1 + fake.on_run(stdout=b"out", exit_code=3, exits_after_polls=1) + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("x") + assert (result.exit_code, result.stdout) == (3, b"out") + assert fake.plugin_paths().count("/commands/c1") == 2 + + async def test_a_command_that_ends_before_its_input_arrives_reports_its_exit_code( + self, + ) -> None: + fake = FakeSandbox() + fake.on_run(exit_code=127, exits_after_polls=0) + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("nosuchcmd", stdin=b"never read") + # The plugin refused the input of an ended command; the exit code is the story. + assert (result.exit_code, result.interrupted) == (127, False) + assert fake.commands["c1"].stdin == [] + + +class TestFiles: + async def test_mkdir_with_and_without_parents(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + fs = sandbox(ukc).fs + await fs.mkdir("/app/x") + await fs.mkdir("/app/y/z", parents=True) + assert fake.dirs == [("/app/x", False), ("/app/y/z", True)] + + async def test_bytes_are_written_as_base64_and_text_as_utf8(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + fs = sandbox(ukc).fs + await fs.write("/app/blob", b"\x00\xff") + await fs.write("/app/note", "héllo") + assert body_of(fake, 0)["encoding"] == "base64" + assert body_of(fake, 1)["encoding"] == "utf-8" + assert fake.files == {"/app/blob": b"\x00\xff", "/app/note": "héllo".encode()} + + async def test_append_adds_to_a_file(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + fs = sandbox(ukc).fs + await fs.write("/app/log", "a") + await fs.write("/app/log", "b", append=True) + assert fake.files["/app/log"] == b"ab" + + async def test_read_returns_bytes_and_read_text_decodes(self) -> None: + fake = FakeSandbox() + fake.files["/app/f"] = "héllo".encode() + async with cloud(fake) as ukc: + fs = sandbox(ukc).fs + assert await fs.read("/app/f") == "héllo".encode() + assert await fs.read_text("/app/f") == "héllo" + assert fake.plugin_paths() == ["/fs/read_raw", "/fs/read_raw"] + + async def test_a_missing_file_is_not_found(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + with pytest.raises(NotFoundError): + await sandbox(ukc).fs.read("/nope") + + async def test_upload_lands_in_a_directory_named_either_way(self) -> None: + fake = FakeSandbox() + fake.dirs.append(("/app", False)) + async with cloud(fake) as ukc: + fs = sandbox(ukc).fs + assert await fs.upload("/app/", "a.bin", b"1") == "/app/a.bin" + # Without its slash, the plugin's refusal says the path is a directory. + assert await fs.upload("/app", "b.bin", b"2") == "/app/b.bin" + assert await fs.upload("/deep/c.txt", "ignored", "3", parents=True) == "/deep/c.txt" + # A file that is there is replaced, as the CLI's upload has it. + assert await fs.upload("/app/a.bin", "ignored", b"9") == "/app/a.bin" + assert fake.files == {"/app/a.bin": b"9", "/app/b.bin": b"2", "/deep/c.txt": b"3"} + assert ("/deep", True) in fake.dirs + ops = [c.url.path.rsplit("/", 1)[1] for c in fake.recorder.calls if "/fs/" in c.url.path] + assert ops == ["write", "write", "write", "mkdir", "write", "write"] + + async def test_upload_wants_a_filename_that_is_one_segment(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + with pytest.raises(ValueError, match="one path segment"): + await sandbox(ukc).fs.upload("/app/", "", b"1") + with pytest.raises(ValueError, match="one path segment"): + await sandbox(ukc).fs.upload("/app", "a/b", b"1") + assert fake.plugin_paths() == [] + + +class SlowReads(FakeSandbox): + """A platform whose instance reads take a moment.""" + + def __init__(self, delay: float) -> None: + super().__init__() + self.delay = delay + self.reads = 0 + + async def handle(self, request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/instances" and request.method == "GET": + self.reads += 1 + await asyncio.sleep(self.delay) + return await super().handle(request) + + +class LateInstance(FakeSandbox): + """A platform that lists the instance only from the given read on.""" + + def __init__(self, listed_from: int) -> None: + super().__init__() + self.listed_from = listed_from + self.reads = 0 + + async def handle(self, request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/instances" and request.method == "GET": + self.reads += 1 + if self.reads < self.listed_from: + return httpx.Response(200, json=envelope({"instances": []})) + return await super().handle(request) + + +class TestResolvingAgain: + async def test_a_lookup_the_caller_gave_up_on_still_serves_the_next_call(self) -> None: + fake = SlowReads(delay=0.05) + async with cloud(fake) as ukc: + sb = ukc.instances.get(name="web").sandbox() + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(sb.ready(), 0.001) + # The lookup went on without its first caller; nobody is poisoned. + assert await sb.ready() is True + assert (await sb.exec("x")).exit_code == 0 + assert fake.reads == 1 + + async def test_wait_ready_waits_for_an_instance_not_listed_yet( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr(sandbox_module, "_sleep", fake_sleep) + fake = LateInstance(listed_from=3) + async with cloud(fake) as ukc: + sb = ukc.instances.get(name="web").sandbox() + await sb.wait_ready(timeout=60) + assert await sb.ready() is True + assert fake.reads == 3 + assert fake.plugin_paths() == ["/commands"] * 2 + + async def test_wait_ready_searches_every_metro_again_for_an_instance_not_listed_yet( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr(sandbox_module, "_sleep", fake_sleep) + fake = LateInstance(listed_from=3) + async with UnikraftCloud(token="tok", transport=fake.transport) as ukc: + # Account-wide, the name is searched for in every metro; a search + # that found nothing is made again rather than kept as the answer. + sb = ukc.instances.get(name="web").sandbox() + await sb.wait_ready(timeout=60) + assert fake.reads >= 3 + assert fake.plugin_paths() == ["/commands"] + + async def test_an_instance_never_listed_is_a_plugin_not_ready(self) -> None: + fake = LateInstance(listed_from=NEVER) + async with cloud(fake) as ukc: + sb = ukc.instances.get(name="web").sandbox() + with pytest.raises(PluginNotReadyError) as caught: + await sb.wait_ready(timeout=0) + assert isinstance(caught.value.__cause__, NotFoundError) + assert isinstance(caught.value, UnikraftCloudError) + + async def test_wait_ready_sleeps_no_longer_than_the_deadline_allows( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + slept: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + slept.append(seconds) + + monkeypatch.setattr(sandbox_module, "_sleep", fake_sleep) + fake = FakeSandbox() + fake.unready = 1 + async with cloud(fake) as ukc: + await sandbox(ukc).wait_ready(timeout=0.1) + # The first interval would be a quarter second; the deadline is nearer. + assert len(slept) == 1 and 0.09 < slept[0] <= 0.1 + + +class RefusedCreate(FakeSandbox): + """A platform that refuses to create the instance a plugin would run in.""" + + def _platform_instances(self, request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return httpx.Response(400, json={"status": "error", "message": "no such image"}) + return super()._platform_instances(request) + + +class ForbiddenLookups(FakeSandbox): + """A platform whose every metro refuses the token for an instance lookup.""" + + def _platform_instances(self, request: httpx.Request) -> httpx.Response: + if request.method == "GET": + return httpx.Response(403, json={"status": "error", "message": "forbidden"}) + return super()._platform_instances(request) + + +class TestFailuresThatCannotChange: + async def test_wait_ready_raises_a_create_that_failed_at_once( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr(sandbox_module, "_sleep", fake_sleep) + fake = RefusedCreate() + async with cloud(fake) as ukc: + sb = ukc.instances.create(image="org/missing:v1").sandbox() + with pytest.raises(UnikraftCloudError) as caught: + await sb.wait_ready(timeout=60) + assert await sb.ready() is False + # The handle keeps the create's one outcome, so probing again would + # only raise the same failure again: it is raised as it is, not as a + # plugin not ready, and the create is not sent again. + assert caught.value.status == 400 + assert not isinstance(caught.value, PluginNotReadyError) + assert [call.method for call in fake.recorder.calls] == ["POST"] + + async def test_a_token_every_metro_refuses_is_raised_at_once(self) -> None: + fake = ForbiddenLookups() + async with UnikraftCloud(token="tok", transport=fake.transport) as ukc: + sb = ukc.instances.get(name="web").sandbox() + with pytest.raises(MetroFanoutError) as caught: + await sb.wait_ready(timeout=60) + assert caught.value.status == 403 + with pytest.raises(MetroFanoutError): + await sb.ready() + # One round of lookups per call: nothing was waited out. + assert len(fake.recorder.metros("/v1/instances")) == 4 + + +class ExitedBeforeSignal(FakeSandbox): + """A plugin whose command exits just as it is signalled, so the signal is refused.""" + + async def _command( + self, request: httpx.Request, command: FakeCommand, op: str, body: dict[str, Any] + ) -> httpx.Response: + if op == "signal": + command.finish(3) + return httpx.Response(409, json={"status": "error", "message": "process has exited"}) + return await super()._command(request, command, op, body) + + +class TestTimeoutsThatCannotInterrupt: + async def test_a_lost_interrupt_raises_rather_than_waiting_for_nothing(self) -> None: + fake = FakeSandbox() + fake.failing_signals = 1 + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + with pytest.raises(ExecTimeoutError) as caught: + await sandbox(ukc).exec("x", timeout=0.01) + assert isinstance(caught.value, UnikraftCloudError) + assert caught.value.command.uuid == "c1" + # Why the interrupt was lost travels along. + assert isinstance(caught.value.__cause__, UnikraftCloudError) + assert caught.value.__cause__.status == 500 + assert fake.commands["c1"].running + + async def test_a_command_that_exited_as_it_was_interrupted_reports_its_end(self) -> None: + fake = ExitedBeforeSignal() + fake.on_run(stdout=b"all of it", exits_after_polls=NEVER) + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("x", timeout=0.01) + assert (result.exit_code, result.interrupted, result.stdout) == (3, True, b"all of it") + + async def test_a_negative_timeout_or_grace_is_refused(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + with pytest.raises(ValueError, match="timeout"): + await sandbox(ukc).exec("x", timeout=-1) + with pytest.raises(ValueError, match="wait_delay"): + await sandbox(ukc).exec("x", timeout=1, wait_delay=-1) + assert fake.commands == {} + + async def test_a_grace_period_needs_a_timeout(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + with pytest.raises(TypeError, match="timeout"): + await sandbox(ukc).exec("x", wait_delay=1) + assert fake.commands == {} + + async def test_a_cancelled_exec_leaves_the_command_running_unsignalled(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + running = asyncio.ensure_future(sandbox(ukc).exec("x")) + await asyncio.sleep(0.01) + running.cancel() + with pytest.raises(asyncio.CancelledError): + await running + assert fake.commands["c1"].running + assert fake.commands["c1"].signals == [] + + +class TestCommandShapes: + async def test_an_argument_vector_runs_without_a_shell(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + await sandbox(ukc).run(["echo", "hi there"]) + await sandbox(ukc).exec(("printf", "%s", "a b"), cwd="/app") + assert body_of(fake, 0) == {"cmd": ["echo", "hi there"]} + assert body_of(fake, 1) == {"cmd": ["printf", "%s", "a b"], "cwd": "/app"} + + async def test_deleting_a_running_command_says_it_still_runs(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + with pytest.raises(UnikraftCloudError, match="still running") as caught: + await command.delete() + assert caught.value.status == 409 + assert not isinstance(caught.value, AlreadyExistsError) + assert isinstance(caught.value.__cause__, UnikraftCloudError) + + async def test_the_sandbox_errors_are_the_sdks_and_timeouts_both(self) -> None: + assert issubclass(PluginNotReadyError, UnikraftCloudError) + assert issubclass(PluginNotReadyError, TimeoutError) + assert issubclass(ExecTimeoutError, UnikraftCloudError) + assert issubclass(ExecTimeoutError, TimeoutError) + + async def test_a_negative_wait_is_refused_before_it_is_sent(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + with pytest.raises(ValueError, match="zero or more"): + await command.wait(timeout=-1) + assert "/commands/c1/wait_timeout" not in fake.plugin_paths() From d251f949eed6b6b2e353fcec9bfeabc50774af2a Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:34:35 +0100 Subject: [PATCH 25/36] feat(plugins): Stream output and forget a command collect() gathered a command's output for the result, so a caller that wanted it as it arrived had to poll the command itself, and with it re-implement the interrupt and the grace a timeout gives. A finished command also stayed in the plugin until deleted. An on_output sink is handed each chunk as it is read, an awaitable it returns awaited before the next, while the result still carries everything. forget drops the plugin's record once the command has ended; a command still running is kept. Signed-off-by: Daniel Vallance --- src/unikraft_cloud/__init__.py | 2 + src/unikraft_cloud/plugins/sandbox.py | 55 +++++++++++++++-- tests/test_sandbox.py | 85 +++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/unikraft_cloud/__init__.py b/src/unikraft_cloud/__init__.py index 0337313..4e2c04f 100644 --- a/src/unikraft_cloud/__init__.py +++ b/src/unikraft_cloud/__init__.py @@ -66,6 +66,7 @@ ExecResult, ExecTimeoutError, OutputChunk, + OutputSink, PluginNotReadyError, Sandbox, SandboxFiles, @@ -172,6 +173,7 @@ "MetroTarget", "NotFoundError", "OutputChunk", + "OutputSink", "PatchItem", "PatchOp", "PlatformApi", diff --git a/src/unikraft_cloud/plugins/sandbox.py b/src/unikraft_cloud/plugins/sandbox.py index 318c084..fa9ad51 100644 --- a/src/unikraft_cloud/plugins/sandbox.py +++ b/src/unikraft_cloud/plugins/sandbox.py @@ -12,7 +12,8 @@ import asyncio import base64 import contextlib -from collections.abc import AsyncGenerator, Mapping, Sequence +import inspect +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass from pathlib import PurePosixPath from typing import Any, Literal, TypeVar @@ -40,6 +41,7 @@ "ExecResult", "ExecTimeoutError", "OutputChunk", + "OutputSink", "OutputStream", "PluginNotReadyError", "Sandbox", @@ -119,6 +121,12 @@ class OutputChunk: data: bytes +#: Where a command's output goes as it arrives: called with each chunk. It may +#: return an awaitable, awaited before the next chunk is read; any other +#: return value, such as the count a file's ``write`` reports, is ignored. +OutputSink = Callable[[OutputChunk], "Awaitable[None] | object | None"] + + @dataclass(frozen=True) class CommandLogs: """The requested parts of a command's two output streams.""" @@ -304,19 +312,37 @@ async def exec( stdin: bytes | str | None = None, timeout: float | None = None, wait_delay: float | None = None, + on_output: OutputSink | None = None, + forget: bool = False, ) -> ExecResult: """Run a command to completion and return its output and exit code. ``cmd`` is a command line for the shell, or an argument vector, as for :meth:`run`. A non-zero exit code is reported, not raised. See - :meth:`Command.collect` for what ``timeout`` and ``wait_delay`` do. + :meth:`Command.collect` for what the other arguments do. + + .. code-block:: python + + result = await sb.exec( + "make test", + cwd="/app", + timeout=600, + on_output=lambda chunk: print(chunk.data.decode(errors="replace"), end=""), + forget=True, + ) """ _check_following(timeout, wait_delay) # Encoded before the command starts, so a `str` that cannot be is refused first. if isinstance(stdin, str): stdin = stdin.encode() command = await self.run(cmd, cwd=cwd, env=env) - return await command.collect(stdin=stdin, timeout=timeout, wait_delay=wait_delay) + return await command.collect( + stdin=stdin, + timeout=timeout, + wait_delay=wait_delay, + on_output=on_output, + forget=forget, + ) class Command: @@ -513,12 +539,16 @@ async def collect( stdin: bytes | str | None = None, timeout: float | None = None, wait_delay: float | None = None, + on_output: OutputSink | None = None, + forget: bool = False, ) -> ExecResult: """Follow the command to its end and return its output and exit code. ``stdin``, when given, is fed to the command in chunks and then closed. A feed that fails ends the collection with its error: a command left - waiting for input that never comes might never end. + waiting for input that never comes might never end. ``on_output`` is + handed each piece of output as it arrives, beside its being collected + for the result. When ``timeout`` elapses the command is interrupted with :data:`INTERRUPT_SIGNAL` and then waited for, so a command that heeds @@ -529,7 +559,13 @@ async def collect( sent, since nothing would end the command. A command found to have ended by itself in the meantime is followed to the end of its output instead. ``wait_delay`` needs a ``timeout`` to follow. - The command is not deleted afterwards; :meth:`delete` does that. + + ``forget`` drops the plugin's record of the command once it has ended, + as :meth:`delete` would; otherwise the record stays until it is deleted. + A command still running when the collection gives up is kept either way, + and so is one left behind when this call fails or is cancelled: the + command runs on in the sandbox, with its record, for the caller to + signal or forget. """ _check_following(timeout, wait_delay) # Encoded here, so a `str` that cannot be is refused before anything is sent. @@ -542,6 +578,10 @@ async def consume() -> None: async with contextlib.aclosing(self.stream()) as chunks: async for chunk in chunks: (stdout if chunk.stream == "stdout" else stderr).extend(chunk.data) + if on_output is not None: + handed = on_output(chunk) + if inspect.isawaitable(handed): + await handed feeder = None if payload is None else asyncio.ensure_future(self.feed_stdin(payload)) consumer = asyncio.ensure_future(consume()) @@ -572,6 +612,11 @@ async def consume() -> None: if not task.cancelled(): task.exception() exit_code = await self._exit_code() + if forget: + # The command has ended, however it ended, so the record can go. A + # refusal to drop it does not fail a collection that succeeded. + with contextlib.suppress(UnikraftCloudError): + await self.delete() return ExecResult( uuid=self.uuid, exit_code=exit_code, diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index f0a66a2..aa1087a 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -9,6 +9,7 @@ import asyncio import base64 +import io import json from typing import Any @@ -521,6 +522,90 @@ async def test_upload_wants_a_filename_that_is_one_segment(self) -> None: assert fake.plugin_paths() == [] +class TestOutputSinks: + async def test_output_is_handed_over_as_it_arrives_and_still_collected( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sandbox_module, "LOG_CHUNK_SIZE", 4) + fake = FakeSandbox() + fake.on_run(stdout=b"abcdefgh", stderr=b"E", exits_after_polls=1) + seen: list[OutputChunk] = [] + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("x", on_output=seen.append) + assert seen == [ + OutputChunk("stdout", b"abcd"), + OutputChunk("stderr", b"E"), + OutputChunk("stdout", b"efgh"), + ] + assert (result.stdout, result.stderr) == (b"abcdefgh", b"E") + + async def test_a_coroutine_sink_is_awaited_before_the_next_chunk( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sandbox_module, "LOG_CHUNK_SIZE", 2) + fake = FakeSandbox() + fake.on_run(stdout=b"abcd", exits_after_polls=1) + order: list[str] = [] + + async def sink(chunk: OutputChunk) -> None: + order.append(f"start {chunk.data.decode()}") + await asyncio.sleep(0) + order.append(f"end {chunk.data.decode()}") + + async with cloud(fake) as ukc: + await sandbox(ukc).exec("x", on_output=sink) + assert order == ["start ab", "end ab", "start cd", "end cd"] + + async def test_a_sink_that_returns_a_value_is_fine(self) -> None: + fake = FakeSandbox() + fake.on_run(stdout=b"abcd", exits_after_polls=1) + sink = io.BytesIO() + async with cloud(fake) as ukc: + # A file's write returns the count written; it is no awaitable. + result = await sandbox(ukc).exec("x", on_output=lambda chunk: sink.write(chunk.data)) + assert (sink.getvalue(), result.stdout) == (b"abcd", b"abcd") + + async def test_a_sink_that_raises_fails_the_exec_and_keeps_the_command(self) -> None: + fake = FakeSandbox() + fake.on_run(stdout=b"hello", exits_after_polls=NEVER) + + def sink(chunk: OutputChunk) -> None: + raise RuntimeError("sink broke") + + async with cloud(fake) as ukc: + with pytest.raises(RuntimeError, match="sink broke"): + await sandbox(ukc).exec("x", on_output=sink, timeout=5) + assert fake.commands["c1"].running + + +class TestForgetting: + async def test_forget_drops_the_command_once_it_ended(self) -> None: + fake = FakeSandbox() + fake.on_run(stdout=b"out", exit_code=3, exits_after_polls=1) + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("x", forget=True) + assert (result.exit_code, result.stdout) == (3, b"out") + assert "c1" not in fake.commands + assert [c.method for c in fake.recorder.calls if c.method == "DELETE"] == ["DELETE"] + + async def test_an_interrupted_command_that_ended_is_forgotten_too(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("sleep 1000", timeout=0.02, forget=True) + assert result.interrupted + assert "c1" not in fake.commands + + async def test_a_command_given_up_on_is_kept(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=NEVER, heeds_interrupt=False) + async with cloud(fake) as ukc: + with pytest.raises(ExecTimeoutError): + await sandbox(ukc).exec("x", timeout=0.02, wait_delay=0.02, forget=True) + assert "c1" in fake.commands + assert all(c.method != "DELETE" for c in fake.recorder.calls) + + class SlowReads(FakeSandbox): """A platform whose instance reads take a moment.""" From 9114eda8d45e5aced9d61bda2a5e529ca85172fe Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:34:55 +0100 Subject: [PATCH 26/36] feat(plugins): Move files in pieces and streams The plugin reads a request body of two mebibytes at most, and a whole file came back in one payload. A caller moving anything larger chunked its writes by hand and held a download in memory. write() sends data longer than a chunk in appended pieces and can create the directories above the file; upload_file() streams a local file the same way. stream() and read_to() hand a file over as it arrives, and examples/sandbox.py walks through the client. Signed-off-by: Daniel Vallance --- README.md | 1 + examples/sandbox.py | 79 +++++++++++ src/unikraft_cloud/plugins/sandbox.py | 183 +++++++++++++++++++++--- tests/test_sandbox.py | 195 ++++++++++++++++++++++++++ 4 files changed, 439 insertions(+), 19 deletions(-) create mode 100644 examples/sandbox.py diff --git a/README.md b/README.md index d629a3f..9ea34c7 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,7 @@ async with PlatformApi(config) as api: - [`examples/quickstart.py`](examples/quickstart.py) — create, wait, read logs, list, suspend, delete - [`examples/update.py`](examples/update.py) — patch objects and the staged editor +- [`examples/sandbox.py`](examples/sandbox.py) — run commands and move files through the sandbox plugin - [`examples/plumbing.py`](examples/plumbing.py) — the raw API on its own ## Development diff --git a/examples/sandbox.py b/examples/sandbox.py new file mode 100644 index 0000000..42858eb --- /dev/null +++ b/examples/sandbox.py @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# The sandbox plugin: run commands and move files inside an instance. Run with: +# UKC_TOKEN=... uv run examples/sandbox.py /: + +from __future__ import annotations + +import asyncio +import sys +import tempfile +from pathlib import Path + +from unikraft_cloud import ExecTimeoutError, OutputChunk, UnikraftCloud, UnikraftCloudError + + +def show(chunk: OutputChunk) -> None: + """Print a piece of output as it arrives, on the stream it came from.""" + out = sys.stderr if chunk.stream == "stderr" else sys.stdout + out.write(chunk.data.decode(errors="replace")) + out.flush() + + +async def main(image: str) -> None: + async with UnikraftCloud() as ukc: # token from UKC_TOKEN + fra = ukc.metro("fra") + + # The plugin is attached at creation. The instance only has to stay up: + # the plugin runs the commands. + instance = await fra.instances.create( + image=image, + memory_mb=1024, + args=["/bin/sh", "-c", "sleep infinity"], + plugins=[{"name": "sandbox", "image": "plugins/sandbox:latest", "config": {}}], + autostart=True, + timeout_s=60, + ) + handle = fra.instances.get(uuid=instance.uuid) + try: + sb = handle.sandbox() + await sb.wait_ready(timeout=60) + + # A shell line, with its output printed as it arrives and collected. + result = await sb.exec("echo hello; uname -a", on_output=show, forget=True) + print(f"exit code {result.exit_code}") + + # An argument vector runs without a shell: nothing to quote. + result = await sb.exec(["printf", "%s\n", "a b c"]) + print(result.stdout.decode(), end="") + + # A timeout interrupts the command; a grace period bounds the wait + # for one that ignores the interrupt. + try: + result = await sb.exec("sleep 300", timeout=2, wait_delay=5) + print(f"interrupted: exit code {result.exit_code}") + except ExecTimeoutError as err: + await err.command.signal("KILL") + print(f"gave up on {err.command.uuid}") + + # Files move in chunks, so no request size limit applies. + await sb.fs.write("/tmp/hello.txt", "hello from the SDK\n", parents=True) + print(await sb.fs.read_text("/tmp/hello.txt"), end="") + with tempfile.TemporaryDirectory() as tmp: + local = Path(tmp) / "hello.txt" + size = await sb.fs.read_to("/tmp/hello.txt", local) + print(f"downloaded {size} bytes to {local.name}") + finally: + await handle.delete() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} /:", file=sys.stderr) + raise SystemExit(2) + try: + asyncio.run(main(sys.argv[1])) + except UnikraftCloudError as err: + print(f"API error ({err.status or '?'}): {err}", file=sys.stderr) + raise SystemExit(1) from err diff --git a/src/unikraft_cloud/plugins/sandbox.py b/src/unikraft_cloud/plugins/sandbox.py index fa9ad51..8af1e07 100644 --- a/src/unikraft_cloud/plugins/sandbox.py +++ b/src/unikraft_cloud/plugins/sandbox.py @@ -13,9 +13,11 @@ import base64 import contextlib import inspect +import json +import os from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from typing import Any, Literal, TypeVar import httpx @@ -36,6 +38,7 @@ "READY_FIRST_INTERVAL", "READY_MAX_INTERVAL", "STDIN_CHUNK_SIZE", + "WRITE_CHUNK_SIZE", "Command", "CommandLogs", "ExecResult", @@ -62,6 +65,9 @@ LOG_CHUNK_SIZE = 1 << 20 #: How much standard input one request carries, in bytes. STDIN_CHUNK_SIZE = 32 << 10 +#: How many bytes of a file one write request carries, text counted as JSON +#: escapes it. The plugin reads two mebibytes of body at most; base64 adds a third. +WRITE_CHUNK_SIZE = 1 << 20 #: The signal a command is sent when its caller's timeout elapses: SIGINT. INTERRUPT_SIGNAL = 2 #: How long the interrupt itself is given to be delivered. @@ -793,51 +799,178 @@ async def read_text(self, path: str, encoding: str = "utf-8") -> str: """The contents of a text file.""" return (await self.read(path)).decode(encoding) - async def write(self, path: str, data: bytes | str, *, append: bool = False) -> None: - """Write a file, creating it if missing, in one request. + async def stream( + self, path: str, *, chunk_size: int | None = None + ) -> AsyncGenerator[bytes, None]: + """The contents of a file as they arrive, for one too large to hold whole. - ``bytes`` travel base64-encoded and a ``str`` as UTF-8, which are the two - encodings the plugin accepts. ``append`` adds to the file instead of - replacing it. + Leave the loop to stop early; the connection is released when the + generator is closed, which ``contextlib.aclosing`` does on the spot. """ + if chunk_size is not None: + _check_chunk_size(chunk_size) + api = await self._sandbox.api() + # The generated `read_raw_file` answers with the payload whole; the SDK + # client it sends through can hand the same payload, from the same + # route, over as it arrives. + chunks = api.client.stream_bytes( + method="POST", + path="/fs/read_raw", + body=models.ReadFileRequest(path=path), + chunk_size=chunk_size, + ) + try: + async for chunk in chunks: + yield chunk + finally: + await chunks.aclose() + + async def read_to( + self, path: str, local: str | os.PathLike[str], *, chunk_size: int | None = None + ) -> int: + """Copy a file out of the sandbox to a local path and return its size in bytes. + + The file is written as it arrives, so one larger than memory travels + too. It is written beside ``local`` and moved into place once whole, so + a download that fails leaves whatever was at ``local`` as it was. + """ + if chunk_size is not None: + _check_chunk_size(chunk_size) + target = Path(local) + partial = target.with_name(f".{target.name}.partial") + size = 0 + try: + with partial.open("wb") as handle: + async with contextlib.aclosing(self.stream(path, chunk_size=chunk_size)) as chunks: + async for chunk in chunks: + await asyncio.to_thread(handle.write, chunk) + size += len(chunk) + partial.replace(target) + except BaseException: + partial.unlink(missing_ok=True) + raise + return size + + async def write( + self, + path: str, + data: bytes | str, + *, + append: bool = False, + parents: bool = False, + chunk_size: int = WRITE_CHUNK_SIZE, + ) -> None: + """Write a file, creating it if missing. + + ``bytes`` travel base64-encoded, and a ``str`` as UTF-8 when it fits one + request, which are the two encodings the plugin accepts. ``append`` adds + to the file instead of replacing it, and ``parents`` creates the + directories above it first. + + The plugin bounds a request body, so data that does not fit + ``chunk_size`` -- bytes by their count, text as JSON escapes it -- goes + in that many bytes per request, base64-encoded: the first piece replaces + the file, or with ``append`` adds to it, and the rest are appended to it. + """ + _check_chunk_size(chunk_size) + if parents: + await self._make_parents(path) + if _fits(data, chunk_size): + await self._write(path, data, append=append) + return + payload = data.encode() if isinstance(data, str) else data + for start in range(0, len(payload), chunk_size): + piece = payload[start : start + chunk_size] + await self._write(path, piece, append=append or start > 0) + + async def upload_file( + self, + local: str | os.PathLike[str], + path: str, + *, + parents: bool = False, + chunk_size: int = WRITE_CHUNK_SIZE, + ) -> str: + """Copy a local file into the sandbox and return the path it landed at. + + A ``path`` ending in ``/`` names a directory, and so does one the plugin + reports to be one; the file keeps its name inside it. The file is read + and sent ``chunk_size`` bytes at a time, so one larger than memory + travels too. ``parents`` creates the directories above the target first. + """ + _check_chunk_size(chunk_size) + source = Path(local) + target = f"{path}{source.name}" if path.endswith("/") else path + if parents: + await self._make_parents(target) + try: + await self._send_file(source, target, chunk_size) + except UnikraftCloudError as err: + if target != path or not _is_a_directory(err): + raise + # The path names a directory without its trailing slash. + target = f"{path}/{source.name}" + await self._send_file(source, target, chunk_size) + return target + + async def _send_file(self, source: Path, target: str, chunk_size: int) -> None: + """Send a local file in pieces: the first replaces the target, the rest append.""" + with source.open("rb") as handle: + written = False + while True: + piece = await asyncio.to_thread(handle.read, chunk_size) + if not piece and written: + break + await self._write(target, piece, append=written) + written = True + if len(piece) < chunk_size: + break + + async def _write(self, path: str, data: bytes | str, *, append: bool) -> None: + """One write request, with the data encoded the way the plugin takes it.""" encoding, payload = _encode(data) api = await self._sandbox.api() await api.fs.write_file( body=models.WriteFileRequest(path=path, append=append, encoding=encoding, data=payload) ) + async def _make_parents(self, path: str) -> None: + """Create the directories above ``path``.""" + parent = str(PurePosixPath(path).parent) + if parent not in (".", "/"): + await self.mkdir(parent, parents=True) + async def upload( - self, path: str, filename: str, data: bytes | str, *, parents: bool = False + self, + path: str, + filename: str, + data: bytes | str, + *, + parents: bool = False, + chunk_size: int = WRITE_CHUNK_SIZE, ) -> str: """Write a file at ``path``, or at ``path/filename`` when ``path`` is a directory. A ``path`` ending in ``/`` names a directory, and so does one the plugin reports to be one, in which case the file goes inside it under ``filename``; the path the file landed at is returned. ``parents`` - creates the directories above it first, and a file that is there is - replaced, as the CLI's upload has it. + creates the directories above it first, and the data travels as + :meth:`write` sends it, which replaces a file that is there. """ + _check_chunk_size(chunk_size) if not filename or "/" in filename: raise ValueError("`filename` is one path segment: the file's name inside a directory.") target = f"{path}{filename}" if path.endswith("/") else path - if parents: - await self._make_parents(target) try: - await self.write(target, data) + await self.write(target, data, parents=parents, chunk_size=chunk_size) except UnikraftCloudError as err: if target != path or not _is_a_directory(err): raise # The path names a directory without its trailing slash. target = f"{path}/{filename}" - await self.write(target, data) + await self.write(target, data, parents=parents, chunk_size=chunk_size) return target - async def _make_parents(self, path: str) -> None: - """Create the directories above ``path``.""" - parent = str(PurePosixPath(path).parent) - if parent not in (".", "/"): - await self.mkdir(parent, parents=True) - def _permanent(err: UnikraftCloudError) -> bool: """Whether waiting cannot change a failure: a rejected token, or a name held twice.""" @@ -854,6 +987,18 @@ def _is_a_directory(err: UnikraftCloudError) -> bool: return err.status == 500 and "is a directory" in str(err).lower() +def _check_chunk_size(chunk_size: int) -> None: + if chunk_size <= 0: + raise ValueError("`chunk_size` is a number of bytes per request, one or more.") + + +def _fits(data: bytes | str, chunk_size: int) -> bool: + """Whether the data goes in one request: bytes by their count, text as JSON escapes it.""" + if isinstance(data, str): + return len(json.dumps(data)) <= chunk_size + return len(data) <= chunk_size + + def _encode(data: bytes | str) -> tuple[models.FileEncoding, str]: """Pick the wire encoding for file data: the plugin takes UTF-8 text or base64.""" if isinstance(data, str): diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index aa1087a..c7ffaa5 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -11,6 +11,7 @@ import base64 import io import json +from pathlib import Path from typing import Any import httpx @@ -606,6 +607,113 @@ async def test_a_command_given_up_on_is_kept(self) -> None: assert all(c.method != "DELETE" for c in fake.recorder.calls) +class TestFileTransfers: + async def test_a_large_write_goes_in_appended_pieces(self) -> None: + fake = FakeSandbox() + data = bytes(range(10)) + async with cloud(fake) as ukc: + await sandbox(ukc).fs.write("/app/blob", data, chunk_size=4) + assert fake.files["/app/blob"] == data + bodies = [body_of(fake, i) for i in range(3)] + assert [len(base64.b64decode(b["data"])) for b in bodies] == [4, 4, 2] + assert [b["append"] for b in bodies] == [False, True, True] + + async def test_a_large_text_travels_as_base64_pieces(self) -> None: + fake = FakeSandbox() + text = "héllo wörld" + async with cloud(fake) as ukc: + await sandbox(ukc).fs.write("/app/note", text, chunk_size=4) + assert fake.files["/app/note"] == text.encode() + assert all(body_of(fake, i)["encoding"] == "base64" for i in range(4)) + + async def test_an_appended_large_write_appends_from_the_start(self) -> None: + fake = FakeSandbox() + fake.files["/app/log"] = b"old" + async with cloud(fake) as ukc: + await sandbox(ukc).fs.write("/app/log", b"12345678", append=True, chunk_size=4) + assert fake.files["/app/log"] == b"old12345678" + + async def test_parents_are_made_before_the_write(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + fs = sandbox(ukc).fs + await fs.write("/deep/path/f", b"1", parents=True) + await fs.write("top", b"2", parents=True) + assert fake.dirs == [("/deep/path", True)] + assert fake.plugin_paths() == ["/fs/mkdir", "/fs/write", "/fs/write"] + + async def test_upload_file_streams_a_local_file_in_pieces(self, tmp_path: Path) -> None: + source = tmp_path / "blob.bin" + data = bytes(range(256)) * 3 + source.write_bytes(data) + fake = FakeSandbox() + async with cloud(fake) as ukc: + landed = await sandbox(ukc).fs.upload_file(source, "/app/blob.bin", chunk_size=256) + assert landed == "/app/blob.bin" + assert fake.files["/app/blob.bin"] == data + assert [body_of(fake, i)["append"] for i in range(3)] == [False, True, True] + + async def test_a_large_upload_goes_in_pieces_to_where_a_small_one_lands(self) -> None: + fake = FakeSandbox() + fake.dirs.append(("/dir", False)) + async with cloud(fake) as ukc: + sb = sandbox(ukc) + big = b"abcdefghij" + assert await sb.fs.upload("/dir/", "big.bin", big, chunk_size=4) == "/dir/big.bin" + # The first piece is what the plugin refuses; the pieces then go inside. + assert await sb.fs.upload("/dir", "also.bin", big, chunk_size=4) == "/dir/also.bin" + landed = await sb.fs.upload("/deep/exact.bin", "x", big, chunk_size=4, parents=True) + assert landed == "/deep/exact.bin" + assert fake.files["/dir/big.bin"] == fake.files["/dir/also.bin"] == big + assert fake.files["/deep/exact.bin"] == big + ops = [c.url.path.rsplit("/", 1)[1] for c in fake.recorder.calls if "/fs/" in c.url.path] + assert ops == ["write"] * 3 + ["write"] * 4 + ["mkdir"] + ["write"] * 3 + assert ("/deep", True) in fake.dirs + + async def test_upload_file_keeps_the_name_inside_a_directory(self, tmp_path: Path) -> None: + source = tmp_path / "a.txt" + source.write_bytes(b"") + fake = FakeSandbox() + async with cloud(fake) as ukc: + landed = await sandbox(ukc).fs.upload_file(source, "/app/in/", parents=True) + assert landed == "/app/in/a.txt" + assert fake.files["/app/in/a.txt"] == b"" + assert fake.dirs == [("/app/in", True)] + + async def test_a_file_streams_out_and_can_be_copied_to_disk(self, tmp_path: Path) -> None: + fake = FakeSandbox() + fake.files["/app/big"] = b"0123456789" + target = tmp_path / "out" / "big" + target.parent.mkdir() + async with cloud(fake) as ukc: + fs = sandbox(ukc).fs + chunks = [chunk async for chunk in fs.stream("/app/big", chunk_size=4)] + size = await fs.read_to("/app/big", target, chunk_size=4) + assert chunks == [b"0123", b"4567", b"89"] + assert (size, target.read_bytes()) == (10, b"0123456789") + assert fake.plugin_paths() == ["/fs/read_raw", "/fs/read_raw"] + + async def test_a_missing_file_fails_before_the_first_chunk(self) -> None: + fake = FakeSandbox() + async with cloud(fake) as ukc: + with pytest.raises(NotFoundError): + [chunk async for chunk in sandbox(ukc).fs.stream("/nope")] + + async def test_upload_file_lands_inside_a_directory_named_without_its_slash( + self, tmp_path: Path + ) -> None: + source = tmp_path / "blob.bin" + source.write_bytes(b"abcdefgh") + fake = FakeSandbox() + fake.dirs.append(("/app", False)) + async with cloud(fake) as ukc: + landed = await sandbox(ukc).fs.upload_file(source, "/app", chunk_size=4) + assert (landed, fake.files["/app/blob.bin"]) == ("/app/blob.bin", b"abcdefgh") + ops = [c.url.path.rsplit("/", 1)[1] for c in fake.recorder.calls if "/fs/" in c.url.path] + # The first piece is what the plugin refused; the file then went inside whole. + assert ops == ["write"] * 3 + + class SlowReads(FakeSandbox): """A platform whose instance reads take a moment.""" @@ -854,3 +962,90 @@ async def test_a_negative_wait_is_refused_before_it_is_sent(self) -> None: with pytest.raises(ValueError, match="zero or more"): await command.wait(timeout=-1) assert "/commands/c1/wait_timeout" not in fake.plugin_paths() + + +class TestFileEdges: + async def test_a_failed_download_leaves_the_local_file_as_it_was(self, tmp_path: Path) -> None: + local = tmp_path / "out.bin" + local.write_bytes(b"before") + fake = FakeSandbox() + async with cloud(fake) as ukc: + with pytest.raises(NotFoundError): + await sandbox(ukc).fs.read_to("/nope", local) + assert local.read_bytes() == b"before" + assert sorted(tmp_path.iterdir()) == [local] + + async def test_a_download_lands_whole_under_its_own_name(self, tmp_path: Path) -> None: + fake = FakeSandbox() + fake.files["/f"] = b"payload" + async with cloud(fake) as ukc: + size = await sandbox(ukc).fs.read_to("/f", tmp_path / "f.bin") + assert size == 7 + assert sorted(p.name for p in tmp_path.iterdir()) == ["f.bin"] + + async def test_the_file_stream_takes_the_route_the_generated_read_takes(self) -> None: + fake = FakeSandbox() + fake.files["/f"] = b"data" + async with cloud(fake) as ukc: + sb = sandbox(ukc) + assert await sb.fs.read("/f") == b"data" + assert b"".join([chunk async for chunk in sb.fs.stream("/f")]) == b"data" + reads = [(c.method, c.url.path) for c in fake.recorder.calls if "/fs/" in c.url.path] + assert len(reads) == 2 and reads[0] == reads[1] + + async def test_an_upload_of_a_whole_number_of_pieces_sends_no_extra_piece( + self, tmp_path: Path + ) -> None: + local = tmp_path / "even.bin" + local.write_bytes(b"abcdefgh") + fake = FakeSandbox() + async with cloud(fake) as ukc: + await sandbox(ukc).fs.upload_file(local, "/even", chunk_size=4) + await sandbox(ukc).fs.write("/empty", b"") + writes = [ + json.loads(c.content) for c in fake.recorder.calls if c.url.path.endswith("/fs/write") + ] + assert [(w["path"], w["append"]) for w in writes] == [ + ("/even", False), + ("/even", True), + ("/empty", False), + ] + assert (fake.files["/even"], fake.files["/empty"]) == (b"abcdefgh", b"") + + async def test_a_chunk_size_below_one_is_refused(self, tmp_path: Path) -> None: + local = tmp_path / "x" + local.write_bytes(b"x") + fake = FakeSandbox() + async with cloud(fake) as ukc: + with pytest.raises(ValueError, match="one or more"): + await sandbox(ukc).fs.write("/x", b"data", chunk_size=0) + with pytest.raises(ValueError, match="one or more"): + await sandbox(ukc).fs.upload_file(local, "/x", chunk_size=-1) + with pytest.raises(ValueError, match="one or more"): + await sandbox(ukc).fs.read_to("/x", local, chunk_size=0) + with pytest.raises(ValueError, match="one or more"): + async for _ in sandbox(ukc).fs.stream("/x", chunk_size=-1): + pass + assert fake.plugin_paths() == [] + + async def test_text_that_escapes_past_a_request_goes_as_base64_pieces(self) -> None: + fake = FakeSandbox() + text = "é" * 600 + async with cloud(fake) as ukc: + # 1200 bytes of UTF-8, but 3600 once JSON escapes each `é` as `\u00e9`. + await sandbox(ukc).fs.write("/t", text, chunk_size=1000) + await sandbox(ukc).fs.upload("/d/", "t", text, chunk_size=1000) + # ASCII text that fits travels as it is, in one request. + await sandbox(ukc).fs.write("/a", "plain", chunk_size=1000) + writes = [ + json.loads(c.content) for c in fake.recorder.calls if c.url.path.endswith("/fs/write") + ] + assert [(w["path"], w["encoding"], w["append"]) for w in writes] == [ + ("/t", "base64", False), + ("/t", "base64", True), + ("/d/t", "base64", False), + ("/d/t", "base64", True), + ("/a", "utf-8", False), + ] + assert fake.files["/t"] == fake.files["/d/t"] == text.encode() + assert fake.files["/a"] == b"plain" From 5b854692f9b0cbaf40e66d221f1d581e9276eaf7 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:35:30 +0100 Subject: [PATCH 27/36] feat(plugins): Slice a wait under the proxy limit The metro proxy ends a plugin request after 60s. An unbounded wait for a command's end, and any wait longer than that, was cut off with the proxy's 504 page, so a long command could not be waited for or followed to its end. A wait is sent as waits of at most WAIT_SLICE, thirty seconds, until the command ends or the caller's timeout runs out, each given a read timeout that outlasts it; one the proxy drops is retried as a poll is. The README gains the sandbox section. Signed-off-by: Daniel Vallance --- README.md | 63 ++++++++ src/unikraft_cloud/plugins/sandbox.py | 93 ++++++++++-- tests/fake_sandbox.py | 24 ++- tests/test_sandbox.py | 203 +++++++++++++++++++++++++- 4 files changed, 364 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 9ea34c7..89688dd 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,69 @@ for quota in await ukc.users.quotas(): print(quota.metro, quota.used, quota.hard) ``` +## The sandbox plugin + +A plugin is a helper loaded into an instance beside its workload, and reached through the +instance on the metro that runs it. The sandbox plugin runs commands and moves files +inside an instance; `sandbox()` on an instance handle is its client, and nothing is sent +until it is used. + +```python +from unikraft_cloud import ExecTimeoutError + +fra = ukc.metro("fra") +inst = await fra.instances.create( + image="org/app:latest", + memory_mb=1024, + plugins=[{"name": "sandbox", "image": "plugins/sandbox:latest", "config": {}}], + autostart=True, + timeout_s=60, +) +sb = fra.instances.get(uuid=inst.uuid).sandbox() +await sb.wait_ready(timeout=60) # until the plugin answers; PluginNotReadyError otherwise + +# A shell line, or an argument vector run without a shell. The exit code is reported, not +# raised; output is collected, and handed over as it arrives if you ask. +result = await sb.exec("make test", cwd="/app", on_output=lambda chunk: print(chunk.data)) +result = await sb.exec(["python", "-c", "print('no quoting needed')"]) +print(result.exit_code, result.stdout, result.stderr) + +# A timeout interrupts the command and waits for it to end; with a grace period a command +# that ignores the interrupt is given up on, and the error carries it for you to signal. +try: + await sb.exec("./long-build", timeout=600, wait_delay=10, forget=True) +except ExecTimeoutError as err: + await err.command.signal("KILL") + +# Start, feed, follow and finish a command yourself. +cmd = await sb.run("sort") +await cmd.feed_stdin(b"b\na\n") +await cmd.close_stdin() +async for chunk in cmd.stream(): # until the command ends + ... +code = await cmd.wait(timeout=30) # None while it still runs +await cmd.delete() # once it has ended + +# Files move in chunks, so no request size limit applies; a download lands whole or not +# at all. +await sb.fs.write("/app/config.json", data, parents=True) +await sb.fs.upload_file("./bundle.tar", "/app/") +async for piece in sb.fs.stream("/app/out.bin"): + ... +await sb.fs.read_to("/app/out.bin", "./out.bin") +``` + +`ExecResult.exit_code` is negative for a command a signal ended: `-2` after the +interrupt a timeout sends. A command left behind when `exec` fails or is cancelled keeps +running in the sandbox, for you to signal or forget. `PluginNotReadyError` and +`ExecTimeoutError` are `UnikraftCloudError` and builtin `TimeoutError` both, like +`WaitTimeoutError`. + +Any other plugin is addressed the same way: `plugin("name")` on an instance handle resolves +its route once, and `.client(SomeApi)` builds a client there. The sandbox plumbing is +`ukc.api.plugins.sandbox.for_instance(uuid)`, and the generated clients it wraps come from +the `unikraft-cloud-plugin-sandbox-api` package. + ## The plumbing layer Every operation in the specification is available raw, returning the response envelope diff --git a/src/unikraft_cloud/plugins/sandbox.py b/src/unikraft_cloud/plugins/sandbox.py index 8af1e07..b1d277e 100644 --- a/src/unikraft_cloud/plugins/sandbox.py +++ b/src/unikraft_cloud/plugins/sandbox.py @@ -2,10 +2,10 @@ # Copyright (c) 2026, Unikraft GmbH. All rights reserved. # # The sandbox plugin: shell commands and files inside an instance, over the -# plugin's HTTP API. A command is started; while an unbounded wait for it -# runs alongside, its output is polled, quickly while output arrives and -# backing off while it does not; and a timeout interrupts the command and -# then waits for it to end. +# plugin's HTTP API. A command is started; while a wait for its end runs +# alongside, its output is polled, quickly while output arrives and backing +# off while it does not; and a timeout interrupts the command and then +# waits for it to end. from __future__ import annotations @@ -19,6 +19,7 @@ from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Any, Literal, TypeVar +from urllib.parse import quote import httpx @@ -38,6 +39,7 @@ "READY_FIRST_INTERVAL", "READY_MAX_INTERVAL", "STDIN_CHUNK_SIZE", + "WAIT_SLICE", "WRITE_CHUNK_SIZE", "Command", "CommandLogs", @@ -68,6 +70,11 @@ #: How many bytes of a file one write request carries, text counted as JSON #: escapes it. The plugin reads two mebibytes of body at most; base64 adds a third. WRITE_CHUNK_SIZE = 1 << 20 +#: The longest one wait for a command's end, in seconds. The metro proxy ends +#: a plugin request after 60s, so a longer wait is sent as several of these. +WAIT_SLICE = 30.0 +#: The extra read time that a wait request gets beyond its wait, in seconds. +_WAIT_MARGIN = 10.0 #: The signal a command is sent when its caller's timeout elapses: SIGINT. INTERRUPT_SIGNAL = 2 #: How long the interrupt itself is given to be delivered. @@ -407,18 +414,61 @@ async def wait(self, timeout: float | None = None) -> int | None: """ _check_following(timeout, None) api = await self.sandbox.api() - if timeout is None: - await api.commands.wait_for_command(self.uuid) - else: + if not await self._wait_for_end(api, timeout): + return None + return await self._exit_code() + + async def _wait_for_end(self, api: SandboxApi, timeout: float | None = None) -> bool: + """Wait up to ``timeout`` for the command to end, in waits of at most + :data:`WAIT_SLICE`; return ``False`` if it still runs at the timeout. + + A wait that fails for any reason but the command still running is + retried with backoff, up to :data:`POLL_MAX_FAILURES` times in a row, + as a poll of the output is: one hiccup of the proxy in a long wait must + not lose the command. A plugin that answers before the slice is up is + asked again after a pause, not at once. + """ + loop = asyncio.get_running_loop() + left = timeout + deadline = None if timeout is None else loop.time() + timeout + failures = 0 + interval = POLL_INTERVAL + while True: + slice_s = WAIT_SLICE if left is None else min(WAIT_SLICE, left) + started = loop.time() try: - await api.commands.wait_for_command_with_timeout( - self.uuid, body=models.CommandWaitTimeoutRequest(timeout_s=timeout) + # The read timeout must outlast the slice that the plugin holds the + # request for; the generated method has one timeout for all phases. + await api.client.request( + models.EmptyResponse, + method="POST", + path=f"/commands/{quote(self.uuid, safe='')}/wait_timeout", + body=models.CommandWaitTimeoutRequest(timeout_s=slice_s), + timeout=_outlasting(api.client.config.timeout, slice_s), ) except UnikraftCloudError as err: if err.status == 408: - return None - raise - return await self._exit_code() + failures = 0 + # Paced, up to a poll's longest pause, so a plugin that does + # not hold the request is neither hammered nor left unwatched. + early = min(slice_s - (loop.time() - started), POLL_MAX_INTERVAL) + if early > 0: + left = None if deadline is None else max(deadline - loop.time(), 0) + await _sleep(early if left is None else min(early, left)) + else: + failures += 1 + if failures >= POLL_MAX_FAILURES: + raise + # Backed off, but never past the deadline. + left = None if deadline is None else max(deadline - loop.time(), 0) + await _sleep(interval if left is None else min(interval, left)) + interval = min(interval * 2, POLL_MAX_INTERVAL) + if deadline is not None: + left = deadline - loop.time() + if left <= 0: + return False + else: + return True async def logs( self, @@ -462,13 +512,14 @@ async def stderr(self) -> bytes: async def stream(self) -> AsyncGenerator[OutputChunk, None]: """Follow the command's output until it ends. - An unbounded wait for the command runs alongside the polling, so the + A wait for the command's end runs alongside the polling, so the stream ends as soon as the command does, with whatever output remained. Polling backs off while the command is quiet and speeds back up when - it writes; :data:`POLL_MAX_FAILURES` consecutive failed polls raise. + it writes; :data:`POLL_MAX_FAILURES` consecutive failed polls raise, + and so does a wait that failed as often. """ api = await self.sandbox.api() - ended = asyncio.ensure_future(api.commands.wait_for_command(self.uuid)) + ended = asyncio.ensure_future(self._wait_for_end(api)) cursor = _OutputCursor() interval = POLL_INTERVAL failures = 0 @@ -765,6 +816,18 @@ def _check_following(timeout: float | None, wait_delay: float | None) -> None: raise ValueError("`wait_delay` is a number of seconds, zero or more, or None.") +def _outlasting( + timeout: float | httpx.Timeout | None, seconds: float +) -> float | httpx.Timeout | None: + """A request timeout whose read outlasts a wait of ``seconds`` the plugin holds.""" + current = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout) + if current.read is None or current.read >= seconds + _WAIT_MARGIN: + return timeout + return httpx.Timeout( + connect=current.connect, read=seconds + _WAIT_MARGIN, write=current.write, pool=current.pool + ) + + class _OutputCursor: """How far each output stream has been read, and how much of it exists.""" diff --git a/tests/fake_sandbox.py b/tests/fake_sandbox.py index 08fa375..51ade33 100644 --- a/tests/fake_sandbox.py +++ b/tests/fake_sandbox.py @@ -11,6 +11,7 @@ import asyncio import base64 import json +from collections.abc import Awaitable from typing import Any import httpx @@ -113,6 +114,12 @@ def __init__( self.failing_signals = 0 self.failing_stdin = 0 self.failing_reads = 0 + #: How many waits for a command's end fail, other than by the command + #: still running, before they work again. + self.failing_waits = 0 + #: How long the metro proxy lets a plugin request run before it + #: answers 504 itself; None lets every request run to its end. + self.proxy_timeout: float | None = None self.recorder = Recorder(self.handle) self._next = 1 @@ -137,9 +144,19 @@ async def handle(self, request: httpx.Request) -> httpx.Response: if path in ("/v1/instances", "/v1/instances/wait"): return self._platform_instances(request) if path == self.prefix or path.startswith(self.prefix + "/"): - return await self._plugin(request, path[len(self.prefix) :]) + return await self._behind_proxy(self._plugin(request, path[len(self.prefix) :])) return _json(404, error_envelope(404, f"no route for {path}")) + async def _behind_proxy(self, answer: Awaitable[httpx.Response]) -> httpx.Response: + """The plugin's answer, or the proxy's HTML 504 when it takes too long.""" + if self.proxy_timeout is None: + return await answer + try: + return await asyncio.wait_for(answer, self.proxy_timeout) + except asyncio.TimeoutError: + page = "

504 Gateway Time-out

" + return httpx.Response(504, text=page, headers={"content-type": "text/html"}) + def _platform_instances(self, request: httpx.Request) -> httpx.Response: if request.method == "POST": return _json(200, envelope({"instances": [self.instance]})) @@ -246,6 +263,11 @@ async def _command( await command.exited.wait() return _json(200, empty_envelope()) if op == "wait_timeout": + if body["timeout_s"] < 0: + return _json(400, error_envelope(400, "timeout is not a representable duration")) + if self.failing_waits > 0: + self.failing_waits -= 1 + return _json(502, error_envelope(502, "bad gateway")) try: await asyncio.wait_for(command.exited.wait(), body["timeout_s"]) except asyncio.TimeoutError: diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index c7ffaa5..577a5da 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -9,6 +9,7 @@ import asyncio import base64 +import gc import io import json from pathlib import Path @@ -209,7 +210,41 @@ async def test_wait_with_a_timeout_reports_a_running_command_as_none(self) -> No assert await command.wait(timeout=1) == 4 assert await command.wait() == 4 assert body_of(fake, 1) == {"timeout_s": 0.01} - assert fake.plugin_paths()[-2:] == ["/commands/c1/wait", "/commands/c1"] + assert body_of(fake, -2) == {"timeout_s": sandbox_module.WAIT_SLICE} + assert fake.plugin_paths()[-2:] == ["/commands/c1/wait_timeout", "/commands/c1"] + + async def test_wait_without_a_timeout_outlasts_the_proxy_limit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sandbox_module, "WAIT_SLICE", 0.01) + fake = FakeSandbox() + fake.proxy_timeout = 0.05 + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("sleep 1000") + asyncio.get_running_loop().call_later(0.2, fake.commands["c1"].finish, 4) + assert await command.wait() == 4 + paths = fake.plugin_paths() + assert "/commands/c1/wait" not in paths + assert paths.count("/commands/c1/wait_timeout") > 1 + + async def test_a_long_timeout_is_sent_as_waits_under_the_proxy_limit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sandbox_module, "WAIT_SLICE", 0.01) + fake = FakeSandbox() + fake.proxy_timeout = 0.05 + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("sleep 1000") + assert await command.wait(timeout=0.2) is None + slices = [ + json.loads(call.content)["timeout_s"] + for call in fake.recorder.calls + if call.url.path.endswith("/wait_timeout") + ] + assert len(slices) > 1 + assert all(s <= 0.01 for s in slices) async def test_logs_are_decoded_and_ranged(self) -> None: fake = FakeSandbox() @@ -339,8 +374,21 @@ async def test_waits_for_the_end_alongside_the_polling(self) -> None: chunks = [chunk async for chunk in command.stream()] assert b"".join(c.data for c in chunks) == b"hi" paths = fake.plugin_paths() - assert "/commands/c1/wait" in paths - assert "/commands/c1/wait_timeout" not in paths + assert "/commands/c1/wait_timeout" in paths + assert "/commands/c1/wait" not in paths + + async def test_follows_a_command_that_outlasts_the_proxy_limit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sandbox_module, "WAIT_SLICE", 0.01) + fake = FakeSandbox() + fake.proxy_timeout = 0.05 + fake.on_run(stdout=b"done", exits_after_polls=NEVER) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("sleep 1000") + asyncio.get_running_loop().call_later(0.2, fake.commands["c1"].finish, 0) + chunks = [chunk async for chunk in command.stream()] + assert b"".join(c.data for c in chunks) == b"done" async def test_gives_up_after_three_failed_polls_in_a_row(self) -> None: fake = FakeSandbox() @@ -866,6 +914,155 @@ async def test_a_token_every_metro_refuses_is_raised_at_once(self) -> None: assert len(fake.recorder.metros("/v1/instances")) == 4 +class Impatient(FakeSandbox): + """A plugin that answers a wait at once instead of holding it for its slice.""" + + async def _command( + self, request: httpx.Request, command: FakeCommand, op: str, body: dict[str, Any] + ) -> httpx.Response: + if op == "wait_timeout" and command.running: + return httpx.Response(408, json={"status": "error", "message": "still running"}) + return await super()._command(request, command, op, body) + + +class TestWaitHiccups: + async def test_a_wait_answered_early_is_paced_to_its_slice( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sandbox_module, "POLL_MAX_INTERVAL", 10.0) + fake = Impatient() + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + assert await command.wait(timeout=0.05) is None + # Paced, the plugin is asked once or twice in the time, not hundreds of times. + assert fake.plugin_paths().count("/commands/c1/wait_timeout") <= 2 + + async def test_the_pacing_still_notices_an_end_within_a_poll_pause(self) -> None: + fake = Impatient() + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + asyncio.get_running_loop().call_later(0.02, fake.commands["c1"].finish, 0) + # Paced to a poll's longest pause at most, not to the whole slice. + assert await asyncio.wait_for(command.wait(), 2) == 0 + + async def test_a_wait_slice_is_given_longer_than_the_clients_read_timeout(self) -> None: + fake = FakeSandbox() + fake.on_run(exits_after_polls=1) + async with UnikraftCloud( + token="tok", metro="fra", transport=fake.transport, timeout=5.0 + ) as ukc: + await sandbox(ukc).exec("x") + reads = { + call.url.path.rsplit("/", 1)[-1]: call.extensions["timeout"]["read"] + for call in fake.recorder.calls + if call.url.path.startswith(fake.prefix) + } + # The plugin holds a wait for the whole slice; every other request keeps + # the client's own timeout. + assert reads["wait_timeout"] == sandbox_module.WAIT_SLICE + sandbox_module._WAIT_MARGIN + assert reads["logs"] == 5.0 + + async def test_a_backoff_after_a_failed_wait_stops_at_the_deadline( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + slept: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + slept.append(seconds) + + monkeypatch.setattr(sandbox_module, "_sleep", fake_sleep) + monkeypatch.setattr(sandbox_module, "POLL_INTERVAL", 10.0) + fake = FakeSandbox() + fake.failing_waits = 1 + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + assert await command.wait(timeout=0.05) is None + assert slept and all(pause <= 0.05 for pause in slept) + + async def test_a_wait_that_fails_once_or_twice_is_retried( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr(sandbox_module, "_sleep", fake_sleep) + fake = FakeSandbox() + fake.failing_waits = 2 + fake.on_run(stdout=b"out", exits_after_polls=2) + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("x", forget=True) + assert (result.exit_code, result.stdout) == (0, b"out") + assert fake.plugin_paths().count("/commands/c1/wait_timeout") >= 3 + assert "c1" not in fake.commands + + async def test_a_wait_that_keeps_failing_gives_up( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr(sandbox_module, "_sleep", fake_sleep) + fake = FakeSandbox() + fake.failing_waits = NEVER + fake.on_run(exits_after_polls=NEVER) + async with cloud(fake) as ukc: + with pytest.raises(UnikraftCloudError) as caught: + await sandbox(ukc).exec("x") + assert caught.value.status == 502 + assert fake.plugin_paths().count("/commands/c1/wait_timeout") == 3 + + async def test_a_failed_poll_after_the_end_is_retried_too(self) -> None: + fake = FakeSandbox() + fake.failing_polls = 2 + fake.on_run(stdout=b"out", exits_after_polls=0) + async with cloud(fake) as ukc: + result = await sandbox(ukc).exec("x") + assert result.stdout == b"out" + + async def test_too_many_failed_polls_after_the_end_give_up(self) -> None: + fake = FakeSandbox() + fake.failing_polls = 3 + fake.on_run(stdout=b"out", exits_after_polls=0) + async with cloud(fake) as ukc: + with pytest.raises(UnikraftCloudError) as caught: + await sandbox(ukc).exec("x") + assert caught.value.status == 500 + + async def test_a_stream_left_early_leaves_no_stray_failure_behind( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr(sandbox_module, "_sleep", fake_sleep) + fake = FakeSandbox() + fake.failing_waits = NEVER + fake.on_run(stdout=b"hello", exits_after_polls=NEVER) + loop = asyncio.get_running_loop() + stray: list[str | None] = [] + loop.set_exception_handler(lambda loop, context: stray.append(context.get("message"))) + try: + async with cloud(fake) as ukc: + command = await sandbox(ukc).run("x") + stream = command.stream() + async for chunk in stream: + assert chunk.data == b"hello" + break + # The wait behind the stream has failed by now; leaving the + # stream must still account for it. + await asyncio.sleep(0.02) + await stream.aclose() + del stream, command + gc.collect() + await asyncio.sleep(0) + finally: + loop.set_exception_handler(None) + assert stray == [] + + class ExitedBeforeSignal(FakeSandbox): """A plugin whose command exits just as it is signalled, so the signal is refused.""" From 75a4418971a4d305dc691fc80d80ac3723c6ad18 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Tue, 15 Sep 2026 19:13:41 +0100 Subject: [PATCH 28/36] feat(resources): Add the idiomatic images client The SDK had no idiomatic images client, and images are reported per metro, so an account-wide view meant asking every metro in scope and merging the answers by hand. Images, reached as scope.images, lists every metro in scope and tags each image with its metro; a partial answer raises with what did arrive. A lookup is a filtered listing, as in the CLI, and one metro's view is that metro's client. Signed-off-by: Daniel Vallance --- README.md | 4 +- src/unikraft_cloud/__init__.py | 3 + src/unikraft_cloud/client.py | 3 + src/unikraft_cloud/resources/images.py | 93 ++++++++++++++++++++++++++ tests/conftest.py | 13 ++++ tests/test_images.py | 83 +++++++++++++++++++++++ 6 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 src/unikraft_cloud/resources/images.py create mode 100644 tests/test_images.py diff --git a/README.md b/README.md index 89688dd..951419a 100644 --- a/README.md +++ b/README.md @@ -267,8 +267,8 @@ except MetroFanoutError as err: ## Resources -`instances`, `volumes`, `services`, `certificates` and `users` hang off any scope — -`ukc`, `ukc.metro("fra")` or `ukc.metros([...])`. +`instances`, `images`, `volumes`, `services`, `certificates` and `users` hang off any +scope — `ukc`, `ukc.metro("fra")` or `ukc.metros([...])`. Creating one takes the properties the API describes as keyword arguments, and a property it does not have is a `TypeError` rather than a field the server quietly ignores. diff --git a/src/unikraft_cloud/__init__.py b/src/unikraft_cloud/__init__.py index 4e2c04f..f498da7 100644 --- a/src/unikraft_cloud/__init__.py +++ b/src/unikraft_cloud/__init__.py @@ -78,6 +78,7 @@ CertificateSet, DeletedCertificate, ) +from .resources.images import Image, Images from .resources.instances import ( DeletedInstance, Instance, @@ -150,6 +151,8 @@ "ExecTimeoutError", "HandleSet", "HandleSteps", + "Image", + "Images", "Instance", "InstanceEditor", "InstanceHandle", diff --git a/src/unikraft_cloud/client.py b/src/unikraft_cloud/client.py index 756748c..3d62f05 100644 --- a/src/unikraft_cloud/client.py +++ b/src/unikraft_cloud/client.py @@ -23,6 +23,7 @@ ) from .core.session import Session, SessionConfig from .resources.certificates import Certificates +from .resources.images import Images from .resources.instances import Instances from .resources.service_groups import ServiceGroups from .resources.users import Users @@ -46,6 +47,8 @@ def __init__(self, session: Session, scope: MetroScope) -> None: self.scope = scope #: Instances (microVMs). self.instances = Instances(session, scope) + #: Images: what each metro's nodes cache, and what the registry holds. + self.images = Images(session, scope) #: Persistent volumes. self.volumes = Volumes(session, scope) #: Service groups (load-balanced networking). diff --git a/src/unikraft_cloud/resources/images.py b/src/unikraft_cloud/resources/images.py new file mode 100644 index 0000000..2e93499 --- /dev/null +++ b/src/unikraft_cloud/resources/images.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Images: what each metro's nodes have cached, from the platform API, and what +# the registry holds, from the control plane. The registry is what a node can +# pull from, so an existence check asks it, as the CLI's `images get` does. + +from __future__ import annotations + +# `Images.list` shadows the builtin inside that class body, so annotations +# there spell the builtin out. +import builtins +from collections.abc import Mapping + +from ..api.platform import models +from ..api.platform.images_gen import ImagesApi +from ..core.fanout import MetroFailure, fanout_error, fanout_settled +from ..core.http import UNSET, TimeoutOption +from ..core.metro import MetroEndpoint, MetroScope +from ..core.resource import Resource, list_tagged +from ..core.session import Session +from ._shared import options, scoped + +__all__ = ["Image", "Images"] + +_KEY = "images" + + +class Image(models.Image): + """An image a metro's nodes have cached, tagged with the metro that reported it.""" + + metro: str + + +class Images(Resource[ImagesApi]): + """Idiomatic client for the images an account can start instances from. + + Images are reported per metro, so an account-wide view asks every metro in + scope and merges the answers; each image carries the metro it came from. + Looking one up is a filtered listing, and a single metro's view is one + metro's client: ``ukc.metro("fra").images.list(tag=...)``. + """ + + noun = "image" + + def __init__(self, session: Session, scope: MetroScope) -> None: + super().__init__(session, scope, ImagesApi(session.platform)) + + async def list( + self, + *, + tag: str | None = None, + digest: str | None = None, + metros: MetroScope | None = None, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> builtins.list[Image]: + """The images in every metro in scope, optionally filtered. + + ``tag`` and ``digest`` narrow the listing the way the API does; both + are passed on as given. The API answers a metro's whole listing in one + page, so this returns a list rather than a listing to page through. + + .. code-block:: python + + for image in await ukc.images.list(tag="org/app:latest"): + print(image.metro, image.url, image.size_in_bytes) + """ + opts = scoped(options(headers, base_url, timeout), metros) + endpoints = await self._endpoints(opts) + + async def per_metro(endpoint: MetroEndpoint) -> builtins.list[Image]: + res = await self.api.get_images(tag=tag, digest=digest, **self._call(endpoint, opts)) + return list_tagged(res, _KEY, endpoint.metro, Image) + + if len(endpoints) == 1: + return await per_metro(endpoints[0]) + + outcomes = await fanout_settled(endpoints, per_metro) + images: builtins.list[Image] = [] + failures: builtins.list[MetroFailure] = [] + for outcome in outcomes: + if outcome.ok: + images.extend(outcome.value) + else: + failures.append(MetroFailure(metro=outcome.endpoint.metro, error=outcome.error)) + if failures: + error = fanout_error(len(endpoints), failures) + # A partial answer is still an answer; carry what did arrive. + error.results = list(images) + raise error + return images diff --git a/tests/conftest.py b/tests/conftest.py index 7ccb703..ea49e3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,7 @@ "certificate", "changed_instance", "envelope", + "image", "instance", "instance_logs", "metro", @@ -225,6 +226,18 @@ def quotas(uuid: str = "q1", **overrides: Any) -> dict[str, Any]: return {"uuid": uuid, "used": stats, "hard": stats, "limits": limits, **overrides} +def image(url: str = "index.unikraft.io/org/app:latest", **overrides: Any) -> dict[str, Any]: + """An image, carrying every field the specification requires.""" + return { + "url": url, + "created_at": "2026-01-01T00:00:00Z", + "initrd_or_rom": True, + "size_in_bytes": 4096, + "tags": ["latest"], + **overrides, + } + + def metro(code: str, *, name: str | None = None) -> dict[str, Any]: """One metro as the control plane reports it. diff --git a/tests/test_images.py b/tests/test_images.py new file mode 100644 index 0000000..03158bc --- /dev/null +++ b/tests/test_images.py @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Images: a per-metro listing merged across the scope, with the API's filters +# passed on as given. + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest + +from unikraft_cloud import MetroFanoutError, UnikraftCloud + +from .conftest import Recorder, envelope, image, metro, metro_of, routed + + +def registry(**per_metro: list[dict[str, Any]]) -> Recorder: + """A transport whose image listing differs per metro; a missing metro fails.""" + + def route(request: httpx.Request) -> tuple[int, Any]: + if request.url.path == "/v1/metros": + return 200, envelope({"metros": [metro("fra"), metro("dal")]}) + if request.url.path == "/v1/images": + code = metro_of(request) + if code not in per_metro: + return 500, {"status": "error", "message": f"{code} is down"} + return 200, envelope({"images": per_metro[code]}) + return 404, {"status": "error", "message": f"no route for {request.url.path}"} + + return routed(route) + + +class TestImages: + async def test_lists_one_metro_tagged(self) -> None: + recorder = registry(fra=[image()]) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + found = await ukc.images.list() + assert [(i.metro, i.url, i.size_in_bytes) for i in found] == [ + ("fra", "index.unikraft.io/org/app:latest", 4096) + ] + assert recorder.paths == ["/v1/images"] + + async def test_passes_the_filters_on_as_query_parameters(self) -> None: + recorder = registry(fra=[]) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + await ukc.images.list(tag="org/app:latest", digest="sha256:abc") + params = recorder.calls[0].url.params + assert (params["tag"], params["digest"]) == ("org/app:latest", "sha256:abc") + + async def test_sends_no_filter_it_was_not_given(self) -> None: + recorder = registry(fra=[]) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + await ukc.images.list() + assert str(recorder.calls[0].url.params) == "" + + async def test_merges_every_metro_in_scope(self) -> None: + recorder = registry( + fra=[image("index.unikraft.io/org/app:latest")], + dal=[image("index.unikraft.io/org/other:v2")], + ) + async with UnikraftCloud(token="tok", transport=recorder.transport) as ukc: + found = await ukc.images.list() + assert sorted((i.metro, i.url) for i in found) == [ + ("dal", "index.unikraft.io/org/other:v2"), + ("fra", "index.unikraft.io/org/app:latest"), + ] + + async def test_a_metro_that_fails_reports_the_rest(self) -> None: + recorder = registry(fra=[image()]) + async with UnikraftCloud(token="tok", transport=recorder.transport) as ukc: + with pytest.raises(MetroFanoutError) as caught: + await ukc.images.list() + assert [f.metro for f in caught.value.failures] == ["dal"] + assert [i.metro for i in caught.value.results] == ["fra"] + + async def test_keeps_the_raw_escape_hatch(self) -> None: + recorder = registry(fra=[image()]) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + res = await ukc.images.api.get_images() + assert res.data is not None and res.data.images is not None + assert res.data.images[0].url == "index.unikraft.io/org/app:latest" From 1275dc663d4b125b86dbaa3465547038ea181c12 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Wed, 23 Sep 2026 18:38:12 +0100 Subject: [PATCH 29/36] feat(resources): Look images up in the registry The images client lists what each metro's nodes have cached, which is not whether an image can be pulled: the cache drops images the registry keeps and keeps ones it has dropped. Callers asked the OCI registry directly, with its token exchange, to find out. Images.find and Images.exists ask the control plane, which answers from the registry itself and needs only the SDK's token. A reference may carry a tag, a digest, a registry host or a scheme, and a bare one means the latest tag. Signed-off-by: Daniel Vallance --- README.md | 16 +++ src/unikraft_cloud/__init__.py | 3 +- src/unikraft_cloud/resources/images.py | 143 ++++++++++++++++++++++++- tests/test_images.py | 134 ++++++++++++++++++++++- 4 files changed, 293 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 951419a..68bebab 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,22 @@ for quota in await ukc.users.quotas(): print(quota.metro, quota.used, quota.hard) ``` +### Images + +The metros report what their nodes have cached; the registry says what a node can pull. +`find` and `exists` ask the control plane, which answers from the registry itself, and +read a reference as the CLI does: a registry host in front is ignored, a bare name is in +the `official` namespace, and no tag means `latest`. + +```python +for image in await ukc.images.list(): + print(image.metro, image.url, image.size_in_bytes) + +if not await ukc.images.exists("org/app:1.2.3"): + build_and_push("org/app:1.2.3") +found = await ukc.images.find("org/app@sha256:...") # a tag of that digest, or None +``` + ## The sandbox plugin A plugin is a helper loaded into an instance beside its workload, and reached through the diff --git a/src/unikraft_cloud/__init__.py b/src/unikraft_cloud/__init__.py index f498da7..221f953 100644 --- a/src/unikraft_cloud/__init__.py +++ b/src/unikraft_cloud/__init__.py @@ -78,7 +78,7 @@ CertificateSet, DeletedCertificate, ) -from .resources.images import Image, Images +from .resources.images import Image, Images, RegistryImage from .resources.instances import ( DeletedInstance, Instance, @@ -189,6 +189,7 @@ "RawResponse", "Ref", "RefLike", + "RegistryImage", "Remove", "Resource", "ResourceEditor", diff --git a/src/unikraft_cloud/resources/images.py b/src/unikraft_cloud/resources/images.py index 2e93499..e07bddb 100644 --- a/src/unikraft_cloud/resources/images.py +++ b/src/unikraft_cloud/resources/images.py @@ -10,20 +10,31 @@ # `Images.list` shadows the builtin inside that class body, so annotations # there spell the builtin out. import builtins +import re from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from ..api.controlplane.images_gen import ImagesApi as RegistryApi from ..api.platform import models from ..api.platform.images_gen import ImagesApi from ..core.fanout import MetroFailure, fanout_error, fanout_settled from ..core.http import UNSET, TimeoutOption from ..core.metro import MetroEndpoint, MetroScope from ..core.resource import Resource, list_tagged +from ..core.response import envelope_entries from ..core.session import Session from ._shared import options, scoped -__all__ = ["Image", "Images"] +__all__ = ["Image", "Images", "RegistryImage"] _KEY = "images" +#: The namespace a bare image name belongs to, as the CLI resolves one. +_DEFAULT_NAMESPACE = "official" +#: A digest is ``:``, as the OCI specification spells it. +_DIGEST = re.compile(r"^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[0-9a-f]+$", re.IGNORECASE) +#: A tag is a word, as the OCI specification spells it; an empty one is none. +_TAG = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$") class Image(models.Image): @@ -32,6 +43,75 @@ class Image(models.Image): metro: str +@dataclass(frozen=True) +class RegistryImage: + """One tag of an image in the registry, as the control plane reports it.""" + + #: The repository, ``/``. + repository: str + #: The tag. + tag: str + #: The digest of the manifest the tag points at. + digest: str | None = None + #: The size of the image, in bytes. + size: int | None = None + #: When the tag was pushed. + push_time: datetime | None = None + + @property + def reference(self) -> str: + """The image reference, ``:``.""" + return f"{self.repository}:{self.tag}" + + +@dataclass(frozen=True) +class _ImageRef: + """Where in the registry an image reference points.""" + + namespace: str + repository: str + tag: str | None + digest: str | None + + +def _parse_ref(ref: str) -> _ImageRef: + """Split ``[/][/][:][@]`` into its parts. + + The reference is read as the CLI reads one: a scheme and a registry host + are dropped, a bare name belongs to the ``official`` namespace, and neither + a tag nor a digest means the ``latest`` tag. + """ + rest = ref.split("://", 1)[-1] + rest, at, digest = rest.partition("@") + head, _, name = rest.rpartition("/") + name, colon, tag = name.partition(":") + segments = [segment for segment in head.split("/") if segment] + # A registry host carries a dot or a port, or is `localhost`; a namespace + # never does. + if segments and ("." in segments[0] or ":" in segments[0] or segments[0] == "localhost"): + segments = segments[1:] + if not name: + raise ValueError( + f"An image reference names an image, `[/][:]`; got {ref!r}." + ) + if colon and not _TAG.match(tag): + raise ValueError( + f"An image tag is a word of letters, digits, `_`, `.` and `-`; got {tag!r} in {ref!r}." + ) + if not segments: + segments = [_DEFAULT_NAMESPACE] + if at and not _DIGEST.match(digest): + raise ValueError(f"An image digest is `:`; got {digest!r} in {ref!r}.") + if not tag and not digest: + tag = "latest" + return _ImageRef( + namespace=segments[0], + repository="/".join([*segments, name]), + tag=tag or None, + digest=digest.lower() or None, + ) + + class Images(Resource[ImagesApi]): """Idiomatic client for the images an account can start instances from. @@ -39,12 +119,17 @@ class Images(Resource[ImagesApi]): scope and merges the answers; each image carries the metro it came from. Looking one up is a filtered listing, and a single metro's view is one metro's client: ``ukc.metro("fra").images.list(tag=...)``. + + What the metros report is what their nodes have cached. Whether the + registry holds an image at all -- and so whether a node can pull it -- is + the control plane's to say, through :meth:`find` and :meth:`exists`. """ noun = "image" def __init__(self, session: Session, scope: MetroScope) -> None: super().__init__(session, scope, ImagesApi(session.platform)) + self._registry = RegistryApi(session.control_plane) async def list( self, @@ -91,3 +176,59 @@ async def per_metro(endpoint: MetroEndpoint) -> builtins.list[Image]: error.results = list(images) raise error return images + + async def find( + self, + ref: str, + *, + headers: Mapping[str, str] | None = None, + timeout: TimeoutOption = UNSET, + ) -> RegistryImage | None: + """The registry's record of an image, or ``None`` when it holds no such tag. + + ``ref`` is ``/:`` or ``/@``; + with neither the tag is ``latest``, a bare name is in the ``official`` + namespace, and a registry host in front is ignored, as the CLI has it. + The control plane answers from the registry itself, so a match is an + image a node can pull -- unlike :meth:`list`, which reports what the + nodes have cached. It lists the whole namespace to answer, so the cost + grows with the namespace; a namespace the registry does not have holds + no image. + + .. code-block:: python + + if await ukc.images.find("org/app:1.2.3") is None: + build_and_push("org/app:1.2.3") + """ + wanted = _parse_ref(ref) + res = await self._registry.list_images( + details=True, namespace=[wanted.namespace], headers=headers, timeout=timeout + ) + for image in envelope_entries(res, _KEY): + if image.name != wanted.repository: + continue + for tag in image.tags or []: + if tag.name is None: + continue + if wanted.tag is not None and tag.name != wanted.tag: + continue + if wanted.digest is not None and (tag.digest or "").lower() != wanted.digest: + continue + return RegistryImage( + repository=image.name, + tag=tag.name, + digest=tag.digest, + size=tag.size, + push_time=tag.push_time, + ) + return None + + async def exists( + self, + ref: str, + *, + headers: Mapping[str, str] | None = None, + timeout: TimeoutOption = UNSET, + ) -> bool: + """Whether the registry holds an image; see :meth:`find`.""" + return await self.find(ref, headers=headers, timeout=timeout) is not None diff --git a/tests/test_images.py b/tests/test_images.py index 03158bc..ee1b707 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -11,7 +11,7 @@ import httpx import pytest -from unikraft_cloud import MetroFanoutError, UnikraftCloud +from unikraft_cloud import MetroFanoutError, NotFoundError, UnikraftCloud, UnikraftCloudError from .conftest import Recorder, envelope, image, metro, metro_of, routed @@ -81,3 +81,135 @@ async def test_keeps_the_raw_escape_hatch(self) -> None: res = await ukc.images.api.get_images() assert res.data is not None and res.data.images is not None assert res.data.images[0].url == "index.unikraft.io/org/app:latest" + + +CONTROL_PLANE = "controlplane.unikraft.cloud" + + +def control_plane(*images: dict[str, Any]) -> Recorder: + """A transport whose control plane lists these images for any namespace.""" + + def route(request: httpx.Request) -> tuple[int, Any]: + if request.url.host == CONTROL_PLANE and request.url.path == "/v1/images": + return 200, envelope({"images": list(images)}) + return 404, {"status": "error", "message": f"no route for {request.url}"} + + return routed(route) + + +def registry_image(name: str = "org/app", *tags: str, digest: str = "sha256:abc") -> dict[str, Any]: + """An image as the control plane reports it, with details.""" + return { + "name": name, + "tags": [ + {"name": tag, "digest": digest, "size": 4096, "push_time": "2026-01-01T00:00:00Z"} + for tag in (tags or ("latest",)) + ], + } + + +class TestRegistry: + async def test_find_asks_the_control_plane_about_the_namespace(self) -> None: + recorder = control_plane(registry_image("org/app", "1.2.3", "latest")) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + found = await ukc.images.find("org/app:1.2.3") + assert found is not None + assert (found.repository, found.tag, found.digest, found.size) == ( + "org/app", + "1.2.3", + "sha256:abc", + 4096, + ) + assert found.reference == "org/app:1.2.3" + assert found.push_time is not None and found.push_time.year == 2026 + sent = recorder.calls[0] + assert sent.url.host == CONTROL_PLANE + assert (sent.url.params["details"], sent.url.params["namespace"]) == ("true", "org") + assert sent.headers["authorization"] == "Bearer tok" + + async def test_a_missing_tag_is_none(self) -> None: + recorder = control_plane(registry_image("org/app", "latest"), registry_image("org/other")) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + assert await ukc.images.find("org/app:1.2.3") is None + assert await ukc.images.find("org/nothing:latest") is None + assert await ukc.images.exists("org/app") is True + assert await ukc.images.exists("org/app:1.2.3") is False + + async def test_find_takes_a_digest_a_registry_host_and_a_scheme(self) -> None: + recorder = control_plane(registry_image("org/app", "1.2.3", digest="sha256:ABC")) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + by_digest = await ukc.images.find("org/app@sha256:abc") + by_host = await ukc.images.find("unikraft.io/org/app:1.2.3") + by_port = await ukc.images.find("oci://localhost:5000/org/app:1.2.3") + assert by_digest is not None and by_digest.tag == "1.2.3" + assert by_host is not None and by_port is not None + assert all(call.url.params["namespace"] == "org" for call in recorder.calls) + + async def test_a_bare_name_is_an_official_image_as_the_cli_has_it(self) -> None: + recorder = control_plane(registry_image("official/nginx", "latest")) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + found = await ukc.images.find("nginx") + assert found is not None and found.reference == "official/nginx:latest" + assert recorder.calls[0].url.params["namespace"] == "official" + + async def test_localhost_is_a_registry_host_and_a_namespace_may_nest(self) -> None: + recorder = control_plane(registry_image("org/sub/app", "v1")) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + nested = await ukc.images.find("org/sub/app:v1") + hosted = await ukc.images.find("localhost/org/sub/app:v1") + assert nested is not None and nested.repository == "org/sub/app" + assert hosted is not None + assert all(call.url.params["namespace"] == "org" for call in recorder.calls) + + async def test_a_tag_and_a_digest_together_must_both_match(self) -> None: + recorder = control_plane(registry_image("org/app", "v1", "v2", digest="sha256:abc")) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + assert await ukc.images.exists("org/app:v2@sha256:abc") is True + assert await ukc.images.exists("org/app:v2@sha256:def") is False + assert await ukc.images.exists("org/app:v3@sha256:abc") is False + + async def test_an_empty_or_malformed_tag_is_refused(self) -> None: + recorder = control_plane() + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + with pytest.raises(ValueError, match="tag"): + await ukc.images.find("org/app:") + with pytest.raises(ValueError, match="tag"): + await ukc.images.find("org/app:v1:v2") + assert recorder.calls == [] + + async def test_a_malformed_reference_is_refused_before_anything_is_sent(self) -> None: + recorder = control_plane() + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + with pytest.raises(ValueError, match="digest"): + await ukc.images.find("org/app@sha256") + with pytest.raises(ValueError, match="digest"): + await ukc.images.find("org/app@") + with pytest.raises(ValueError, match="names an image"): + await ukc.images.find("org/") + assert recorder.calls == [] + + async def test_a_namespace_the_registry_does_not_have_holds_no_image(self) -> None: + def route(request: httpx.Request) -> tuple[int, Any]: + return 200, envelope({"images": []}) + + recorder = routed(route) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + assert await ukc.images.find("nobody/app:latest") is None + + async def test_a_control_plane_route_that_is_not_there_is_a_failure(self) -> None: + def route(request: httpx.Request) -> tuple[int, Any]: + return 404, {"status": "error", "message": "no such route"} + + recorder = routed(route) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + with pytest.raises(NotFoundError): + await ukc.images.find("org/app:latest") + + async def test_a_control_plane_failure_is_a_failure(self) -> None: + def route(request: httpx.Request) -> tuple[int, Any]: + return 503, {"status": "error", "message": "registry unavailable"} + + recorder = routed(route) + async with UnikraftCloud(token="tok", metro="fra", transport=recorder.transport) as ukc: + with pytest.raises(UnikraftCloudError, match="registry unavailable"): + await ukc.images.exists("org/app") From efb2928a95d395e5ec51d44446e079cae601828f Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:35:49 +0100 Subject: [PATCH 30/36] feat(resources): Decode why an instance stopped The API reports a stop as two integers, a reason bitmask and a code whose meaning depends on it. The Go SDK decodes both; this one handed them over raw, so every caller decoded the platform's image-pull failure and the kernel's out-of-memory for itself. Stop, StopReason and the platform and kernel codes port the Go package; Instance.stop reads them off an instance and describe_stop() puts them in words, as the CLI reports a stop, e.g. "kernel crash: out of memory (ENOMEM)". Signed-off-by: Daniel Vallance --- src/unikraft_cloud/__init__.py | 6 + src/unikraft_cloud/resources/instances.py | 13 + src/unikraft_cloud/resources/stop.py | 436 ++++++++++++++++++++++ tests/test_stop.py | 157 ++++++++ 4 files changed, 612 insertions(+) create mode 100644 src/unikraft_cloud/resources/stop.py create mode 100644 tests/test_stop.py diff --git a/src/unikraft_cloud/__init__.py b/src/unikraft_cloud/__init__.py index 221f953..d53c94f 100644 --- a/src/unikraft_cloud/__init__.py +++ b/src/unikraft_cloud/__init__.py @@ -104,6 +104,7 @@ ServiceGroupSet, UpdatedServiceGroup, ) +from .resources.stop import KernelStopCode, KernelStopReason, PlatformStopCode, Stop, StopReason from .resources.users import Quotas, Users from .resources.volumes import ( AttachedVolume, @@ -161,6 +162,8 @@ "InstanceMetrics", "InstanceSet", "Instances", + "KernelStopCode", + "KernelStopReason", "Listing", "Located", "Metro", @@ -180,6 +183,7 @@ "PatchItem", "PatchOp", "PlatformApi", + "PlatformStopCode", "Plugin", "PluginNotReadyError", "PluginRoute", @@ -209,6 +213,8 @@ "Session", "SessionConfig", "StartedInstance", + "Stop", + "StopReason", "StoppedInstance", "SuspendedInstance", "TimeoutOption", diff --git a/src/unikraft_cloud/resources/instances.py b/src/unikraft_cloud/resources/instances.py index d706e4f..b7b3413 100644 --- a/src/unikraft_cloud/resources/instances.py +++ b/src/unikraft_cloud/resources/instances.py @@ -45,6 +45,7 @@ scoped, tag_first, ) +from .stop import Stop, StopReason __all__ = [ "DeletedInstance", @@ -82,6 +83,18 @@ class Instance(models.Instance): metro: str + @property + def stop(self) -> Stop | None: + """Why the instance stopped, decoded; ``None`` while the API reports no reason.""" + if self.stop_reason is None: + return None + return Stop(StopReason(self.stop_reason), self.stop_code) + + def describe_stop(self) -> str: + """Why the instance stopped, in words; empty while the API reports no reason.""" + stop = self.stop + return "" if stop is None else str(stop) + class StartedInstance(models.StartInstancesResponseStartedInstance): """What a start reported, tagged with the metro that served it.""" diff --git a/src/unikraft_cloud/resources/stop.py b/src/unikraft_cloud/resources/stop.py new file mode 100644 index 0000000..c5886a9 --- /dev/null +++ b/src/unikraft_cloud/resources/stop.py @@ -0,0 +1,436 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Why an instance stopped, decoded from the two integers the API reports for +# it: a reason bitmask that says who initiated the stop, and a code the +# platform or the kernel left behind. The shape follows the Go SDK's +# platform/stop package, so the two SDKs describe a stop the same way. + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum, IntFlag +from typing import ClassVar + +__all__ = [ + "KernelStopCode", + "KernelStopReason", + "PlatformStopCode", + "Stop", + "StopReason", + "errno_name", +] + + +class StopReason(IntFlag): + """Who initiated a stop, as the bits of the API's ``stop_reason``. + + Each bit names an origin, and the named combinations are the scenarios + the API documents. A forced stop gives the instance no chance to shut down, + so it never carries the ``APPLICATION`` or ``KERNEL`` bits. + + .. code-block:: python + + reason = StopReason(instance.stop_reason) + if reason & StopReason.KERNEL: + print(instance.stop_code) + """ + + #: The kernel exited; ``stop_code`` says how. + KERNEL = 1 + #: The application exited; ``exit_code`` says how. + APPLICATION = 2 + #: The platform initiated the stop, e.g. an autoscale policy. + PLATFORM = 4 + #: The user initiated the stop, e.g. through the API. + USER = 8 + #: The stop was forced. + FORCED = 16 + + #: The API does not know why the instance stopped. + UNKNOWN = 0 + #: The kernel exited on its own: a crash. + KERNEL_CRASH = KERNEL + #: The application exited and took the kernel with it. + APP_EXIT = APPLICATION | KERNEL + #: The platform stopped the instance cleanly, e.g. scale-to-zero. + PLATFORM_SHUTDOWN = PLATFORM | APPLICATION | KERNEL + #: The user stopped the instance, but the kernel had to kill the application. + USER_SHUTDOWN_INCOMPLETE = USER | PLATFORM | KERNEL + #: The user stopped the instance and it shut down cleanly. + USER_SHUTDOWN_COMPLETE = USER | PLATFORM | APPLICATION | KERNEL + #: The user force-stopped the instance. + FORCED_USER_SHUTDOWN = FORCED | USER | PLATFORM + + def __format__(self, spec: str) -> str: + # A number format keeps the bits; any other reads as the scenario's name. + if spec and spec[-1] in "bcdoxXn": + return format(int(self), spec) + return format(str(self), spec) + + @property + def forced(self) -> bool: + """Whether the stop was forced.""" + return bool(self & StopReason.FORCED) + + @property + def origin(self) -> str: + """Who initiated the stop: ``platform``, ``user``, ``app``, ``kernel`` or ``unknown``.""" + if self & StopReason.PLATFORM and not self & StopReason.USER: + return "platform" + if self & StopReason.USER: + return "user" + if self & StopReason.APPLICATION: + return "app" + if self & StopReason.KERNEL: + return "kernel" + return "unknown" + + @property + def flags(self) -> str: + """The five-letter form the API documentation uses, e.g. ``-UPAK`` or ``FUP--``.""" + bits = ( + (StopReason.FORCED, "F"), + (StopReason.USER, "U"), + (StopReason.PLATFORM, "P"), + (StopReason.APPLICATION, "A"), + (StopReason.KERNEL, "K"), + ) + return "".join(letter if self & bit else "-" for bit, letter in bits) + + def __str__(self) -> str: + named = _REASONS.get(int(self)) + if named is not None: + return named + origin = self.origin + if origin == "unknown": + return "unknown stop" + return f"forced {origin} stop" if self.forced else f"{origin} stop" + + +#: How the documented scenarios read. +_REASONS = { + int(StopReason.UNKNOWN): "unknown stop", + int(StopReason.KERNEL_CRASH): "kernel crash", + int(StopReason.APP_EXIT): "app exit", + int(StopReason.PLATFORM_SHUTDOWN): "platform shutdown", + int(StopReason.USER_SHUTDOWN_INCOMPLETE): "user shutdown incomplete", + int(StopReason.USER_SHUTDOWN_COMPLETE): "user shutdown complete", + int(StopReason.FORCED_USER_SHUTDOWN): "forced user shutdown", +} + + +class PlatformStopCode(int): + """The code of a stop the platform alone initiated: why a node gave up on the instance. + + An ``int``, so a code this SDK does not know yet still comes through and + compares to the constants here. + """ + + #: The platform did not say. + UNKNOWN: ClassVar[int] = 0 + #: The node could not pull the image. + IMAGE_PULL_FAILED: ClassVar[int] = 1 + + def __str__(self) -> str: + return _PLATFORM_CODES.get(int(self), f"code({int(self)})") + + def __repr__(self) -> str: + return f"{type(self).__name__}({int(self)})" + + +_PLATFORM_CODES = { + PlatformStopCode.UNKNOWN: "unknown", + PlatformStopCode.IMAGE_PULL_FAILED: "image pull failed", +} + + +class KernelStopReason(IntEnum): + """The reason byte of a kernel stop code: what made the kernel stop.""" + + #: The kernel shut down as asked. + OK = 0 + #: An assertion failed. + EXP = 1 + #: An arithmetic error, such as a division by zero. + MATH = 2 + #: An invalid instruction. + INVLOP = 3 + #: A page fault; the errno says which kind. + PGFAULT = 4 + #: A segmentation fault. + SEGFAULT = 5 + #: A hardware error. + HWERR = 6 + #: A security violation. + SECERR = 7 + + +#: How each kernel stop reason reads. +_KERNEL_REASONS = { + KernelStopReason.OK: "", + KernelStopReason.EXP: "assertion error", + KernelStopReason.MATH: "arithmetic error", + KernelStopReason.INVLOP: "instruction error", + KernelStopReason.PGFAULT: "page fault", + KernelStopReason.SEGFAULT: "segmentation fault", + KernelStopReason.HWERR: "hardware error", + KernelStopReason.SECERR: "security violation", +} + +#: The Linux errno values a page fault refines. +_ENOMEM = 12 +_EFAULT = 14 +_EPERM = 1 + + +class KernelStopCode(int): + """A kernel stop code: the errno, the init level and the reason it packs. + + The kernel packs several details into the API's ``stop_code``: the reason + in the low byte, the init level and the shutdown table above it, and the + application's errno above those, using Linux's ``errno.h`` values. + """ + + @property + def reason(self) -> int: + """The reason byte; :class:`KernelStopReason` names the known values.""" + return int(self) & 0xFF + + @property + def reason_name(self) -> str: + """The reason's name, e.g. ``PGFAULT``, or ``reason(N)`` for one not known here.""" + try: + return KernelStopReason(self.reason).name + except ValueError: + return f"reason({self.reason})" + + @property + def init_level(self) -> int: + """The init level the kernel was at when it stopped.""" + return (int(self) >> 8) & 0x7F + + @property + def shutdown_table(self) -> int: + """Where the shutdown came from: ``0`` for the inittable, ``1`` for the termtable.""" + return (int(self) >> 15) & 0x1 + + @property + def errno(self) -> int: + """The application's errno, or ``0`` for none.""" + return (int(self) >> 16) & 0xFF + + @property + def errno_name(self) -> str: + """The errno's name, e.g. ``ENOMEM``, or an empty string for none.""" + return errno_name(self.errno) + + @property + def description(self) -> str: + """What made the kernel stop, in words; empty for a clean shutdown.""" + reason = self.reason + if reason == KernelStopReason.PGFAULT: + if self.errno == _ENOMEM: + return "out of memory" + if self.errno in (_EFAULT, _EPERM): + return "illegal memory access" + try: + return _KERNEL_REASONS[KernelStopReason(reason)] + except ValueError: + return "unexpected error" + + def __str__(self) -> str: + text = self.description + name = self.errno_name + return f"{text} ({name})" if name else text + + def __repr__(self) -> str: + return f"{type(self).__name__}({int(self)})" + + +@dataclass(frozen=True) +class Stop: + """Why an instance stopped: the API's ``stop_reason`` and ``stop_code``, decoded. + + ``str()`` reads the way the CLI reports a stop, e.g. ``platform stop: image + pull failed`` or ``kernel crash: out of memory (ENOMEM)``. + """ + + #: Who initiated the stop. + reason: StopReason + #: The code left behind, when there is one; its meaning depends on the reason. + code: int | None = None + + @property + def platform_code(self) -> PlatformStopCode | None: + """The platform's code, for a stop the platform alone initiated.""" + if self.code is None or self.reason != StopReason.PLATFORM: + return None + return PlatformStopCode(self.code) + + @property + def kernel_code(self) -> KernelStopCode | None: + """The kernel's code, for a stop the kernel took part in.""" + if self.code is None or not self.reason & StopReason.KERNEL: + return None + return KernelStopCode(self.code) + + def __str__(self) -> str: + detail = "" + if self.code is not None: + platform, kernel = self.platform_code, self.kernel_code + if platform is not None: + detail = str(platform) + elif kernel is not None: + detail = str(kernel) + else: + detail = f"code({self.code})" + text = str(self.reason) + return f"{text}: {detail}" if detail else text + + +def errno_name(errno: int) -> str: + """The name of a Linux errno, e.g. ``ENOMEM``; ``errno(N)`` for an unknown one. + + Zero is no error, and reads as an empty string. + """ + if errno == 0: + return "" + return _ERRNO_NAMES.get(errno, f"errno({errno})") + + +#: Linux errno values by name, since the kernel uses Linux's numbering whatever +#: this SDK runs on. +_ERRNO_NAMES: dict[int, str] = { + 1: "EPERM", + 2: "ENOENT", + 3: "ESRCH", + 4: "EINTR", + 5: "EIO", + 6: "ENXIO", + 7: "E2BIG", + 8: "ENOEXEC", + 9: "EBADF", + 10: "ECHILD", + 11: "EAGAIN", + 12: "ENOMEM", + 13: "EACCES", + 14: "EFAULT", + 15: "ENOTBLK", + 16: "EBUSY", + 17: "EEXIST", + 18: "EXDEV", + 19: "ENODEV", + 20: "ENOTDIR", + 21: "EISDIR", + 22: "EINVAL", + 23: "ENFILE", + 24: "EMFILE", + 25: "ENOTTY", + 26: "ETXTBSY", + 27: "EFBIG", + 28: "ENOSPC", + 29: "ESPIPE", + 30: "EROFS", + 31: "EMLINK", + 32: "EPIPE", + 33: "EDOM", + 34: "ERANGE", + 35: "EDEADLOCK", + 36: "ENAMETOOLONG", + 37: "ENOLCK", + 38: "ENOSYS", + 39: "ENOTEMPTY", + 40: "ELOOP", + 42: "ENOMSG", + 43: "EIDRM", + 44: "ECHRNG", + 45: "EL2NSYNC", + 46: "EL3HLT", + 47: "EL3RST", + 48: "ELNRNG", + 49: "EUNATCH", + 50: "ENOCSI", + 51: "EL2HLT", + 52: "EBADE", + 53: "EBADR", + 54: "EXFULL", + 55: "ENOANO", + 56: "EBADRQC", + 57: "EBADSLT", + 59: "EBFONT", + 60: "ENOSTR", + 61: "ENODATA", + 62: "ETIME", + 63: "ENOSR", + 64: "ENONET", + 65: "ENOPKG", + 66: "EREMOTE", + 67: "ENOLINK", + 68: "EADV", + 69: "ESRMNT", + 70: "ECOMM", + 71: "EPROTO", + 72: "EMULTIHOP", + 73: "EDOTDOT", + 74: "EBADMSG", + 75: "EOVERFLOW", + 76: "ENOTUNIQ", + 77: "EBADFD", + 78: "EREMCHG", + 79: "ELIBACC", + 80: "ELIBBAD", + 81: "ELIBSCN", + 82: "ELIBMAX", + 83: "ELIBEXEC", + 84: "EILSEQ", + 85: "ERESTART", + 86: "ESTRPIPE", + 87: "EUSERS", + 88: "ENOTSOCK", + 89: "EDESTADDRREQ", + 90: "EMSGSIZE", + 91: "EPROTOTYPE", + 92: "ENOPROTOOPT", + 93: "EPROTONOSUPPORT", + 94: "ESOCKTNOSUPPORT", + 95: "ENOTSUP", + 96: "EPFNOSUPPORT", + 97: "EAFNOSUPPORT", + 98: "EADDRINUSE", + 99: "EADDRNOTAVAIL", + 100: "ENETDOWN", + 101: "ENETUNREACH", + 102: "ENETRESET", + 103: "ECONNABORTED", + 104: "ECONNRESET", + 105: "ENOBUFS", + 106: "EISCONN", + 107: "ENOTCONN", + 108: "ESHUTDOWN", + 109: "ETOOMANYREFS", + 110: "ETIMEDOUT", + 111: "ECONNREFUSED", + 112: "EHOSTDOWN", + 113: "EHOSTUNREACH", + 114: "EALREADY", + 115: "EINPROGRESS", + 116: "ESTALE", + 117: "EUCLEAN", + 118: "ENOTNAM", + 119: "ENAVAIL", + 120: "EISNAM", + 121: "EREMOTEIO", + 122: "EDQUOT", + 123: "ENOMEDIUM", + 124: "EMEDIUMTYPE", + 125: "ECANCELED", + 126: "ENOKEY", + 127: "EKEYEXPIRED", + 128: "EKEYREVOKED", + 129: "EKEYREJECTED", + 130: "EOWNERDEAD", + 131: "ENOTRECOVERABLE", + 132: "ERFKILL", + 133: "EHWPOISON", +} diff --git a/tests/test_stop.py b/tests/test_stop.py new file mode 100644 index 0000000..eefee83 --- /dev/null +++ b/tests/test_stop.py @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Decoding why an instance stopped: the reason bitmask, the platform's and the +# kernel's codes, and how the three read together. + +from __future__ import annotations + +import pytest + +from unikraft_cloud import ( + Instance, + KernelStopCode, + KernelStopReason, + PlatformStopCode, + Stop, + StopReason, +) +from unikraft_cloud.resources.stop import errno_name + +from .conftest import instance + +ENOMEM, EFAULT = 12, 14 + + +def kernel_code( + reason: int, *, errno: int = 0, init_level: int = 0, termtable: bool = False +) -> int: + """Pack a kernel stop code the way the kernel does.""" + return (errno << 16) | (int(termtable) << 15) | (init_level << 8) | reason + + +class TestStopReason: + @pytest.mark.parametrize( + ("value", "text", "flags", "origin"), + [ + (0, "unknown stop", "-----", "unknown"), + (1, "kernel crash", "----K", "kernel"), + (3, "app exit", "---AK", "app"), + (7, "platform shutdown", "--PAK", "platform"), + (13, "user shutdown incomplete", "-UP-K", "user"), + (15, "user shutdown complete", "-UPAK", "user"), + (28, "forced user shutdown", "FUP--", "user"), + ], + ) + def test_names_the_documented_scenarios( + self, value: int, text: str, flags: str, origin: str + ) -> None: + reason = StopReason(value) + assert (str(reason), reason.flags, reason.origin) == (text, flags, origin) + + def test_describes_an_undocumented_combination_by_its_origin(self) -> None: + assert str(StopReason(4)) == "platform stop" + assert str(StopReason.FORCED | StopReason.PLATFORM) == "forced platform stop" + assert str(StopReason.FORCED) == "unknown stop" + + def test_keeps_bits_it_does_not_know(self) -> None: + reason = StopReason(32 | 1) + assert reason & StopReason.KERNEL + assert reason.origin == "kernel" + + def test_formats_by_name_unless_a_number_format_is_asked_for(self) -> None: + reason = StopReason.PLATFORM_SHUTDOWN + assert f"{reason}" == "platform shutdown" + assert f"{reason:>20}" == " platform shutdown" + assert f"{reason:d}" == "7" + assert f"{reason:#x}" == "0x7" + + def test_forced_is_the_top_bit(self) -> None: + assert StopReason.FORCED_USER_SHUTDOWN.forced + assert not StopReason.USER_SHUTDOWN_COMPLETE.forced + + +class TestPlatformStopCode: + def test_names_a_failed_image_pull(self) -> None: + code = PlatformStopCode(1) + assert code == PlatformStopCode.IMAGE_PULL_FAILED + assert str(code) == "image pull failed" + assert str(PlatformStopCode(0)) == "unknown" + + def test_carries_a_code_it_does_not_know(self) -> None: + assert str(PlatformStopCode(9)) == "code(9)" + assert repr(PlatformStopCode(9)) == "PlatformStopCode(9)" + + +class TestKernelStopCode: + def test_unpacks_every_field(self) -> None: + code = KernelStopCode(kernel_code(4, errno=ENOMEM, init_level=5, termtable=True)) + assert (code.reason, code.errno, code.init_level, code.shutdown_table) == (4, ENOMEM, 5, 1) + assert code.reason == KernelStopReason.PGFAULT + assert (code.reason_name, code.errno_name) == ("PGFAULT", "ENOMEM") + + def test_reads_the_spec_example(self) -> None: + # A clean shutdown from the termtable at init level 1, no errno. + code = KernelStopCode(33024) + assert (code.reason, code.init_level, code.shutdown_table, code.errno) == (0, 1, 1, 0) + assert str(code) == "" + + @pytest.mark.parametrize( + ("packed", "text"), + [ + (kernel_code(4, errno=ENOMEM), "out of memory (ENOMEM)"), + (kernel_code(4, errno=EFAULT), "illegal memory access (EFAULT)"), + (kernel_code(4), "page fault"), + (kernel_code(1), "assertion error"), + (kernel_code(2), "arithmetic error"), + (kernel_code(3), "instruction error"), + (kernel_code(5), "segmentation fault"), + (kernel_code(6), "hardware error"), + (kernel_code(7), "security violation"), + (kernel_code(9, errno=200), "unexpected error (errno(200))"), + ], + ) + def test_describes_the_reason_and_the_errno(self, packed: int, text: str) -> None: + assert str(KernelStopCode(packed)) == text + + def test_names_an_unknown_reason_by_number(self) -> None: + assert KernelStopCode(kernel_code(9)).reason_name == "reason(9)" + + def test_errno_names_are_linux_values(self) -> None: + assert (errno_name(0), errno_name(16), errno_name(133)) == ("", "EBUSY", "EHWPOISON") + assert errno_name(250) == "errno(250)" + + +class TestStop: + def test_a_platform_stop_carries_the_platforms_code(self) -> None: + stop = Stop(StopReason.PLATFORM, 1) + assert stop.platform_code == PlatformStopCode.IMAGE_PULL_FAILED + assert stop.kernel_code is None + assert str(stop) == "platform stop: image pull failed" + + def test_a_kernel_stop_carries_the_kernels_code(self) -> None: + stop = Stop(StopReason.KERNEL_CRASH, kernel_code(4, errno=ENOMEM)) + assert stop.platform_code is None + assert stop.kernel_code is not None and stop.kernel_code.errno == ENOMEM + assert str(stop) == "kernel crash: out of memory (ENOMEM)" + + def test_a_clean_exit_reads_as_the_reason_alone(self) -> None: + assert str(Stop(StopReason.APP_EXIT, 0)) == "app exit" + assert str(Stop(StopReason.FORCED_USER_SHUTDOWN)) == "forced user shutdown" + + def test_a_code_with_no_owner_is_shown_raw(self) -> None: + assert str(Stop(StopReason.USER | StopReason.PLATFORM, 3)) == "user stop: code(3)" + + +class TestInstanceStop: + def test_an_instance_decodes_its_own_stop(self) -> None: + stopped = Instance.model_validate( + {**instance(state="stopped", stop_reason=4, stop_code=1), "metro": "fra"} + ) + assert stopped.stop == Stop(StopReason.PLATFORM, 1) + assert stopped.describe_stop() == "platform stop: image pull failed" + + def test_a_running_instance_has_no_stop(self) -> None: + running = Instance.model_validate({**instance(), "metro": "fra"}) + assert running.stop is None + assert running.describe_stop() == "" From b940ad23b9e89bd0d8b6a1c51cf984f0aab9a29a Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:36:11 +0100 Subject: [PATCH 31/36] feat(resources): Report an instance that stopped A create that waited for its instance to run raised a bare "API reported an error" when the instance stopped instead. The failed item names the instance and nothing else, so the reason survived only on the stopped instance, which every caller had to read. Instances.create reads that instance and raises InstanceStoppedError with its decoded stop; ResponseError carries an item's state. A create that waits stretches its read timeout past that wait, as wait() does, and raises a lapsed wait as WaitTimeoutError. Signed-off-by: Daniel Vallance --- README.md | 20 ++- src/unikraft_cloud/__init__.py | 2 + src/unikraft_cloud/core/errors.py | 2 + src/unikraft_cloud/core/response.py | 2 + src/unikraft_cloud/resources/instances.py | 171 +++++++++++++++++++-- tests/test_client.py | 176 ++++++++++++++++++++++ 6 files changed, 363 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 68bebab..57d0a6c 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,8 @@ specification requires -- a service group without its `services`, an instance's entry without its `at` -- is pydantic's `ValidationError`. A `wait()` that runs out of time raises `WaitTimeoutError`, which is also a builtin -`TimeoutError`, and carries the state the API last saw: +`TimeoutError`, and carries the state the API last saw, with the instances it named on +`err.errors` -- so a `create()` whose wait lapsed still hands over the instance it made: ```python try: @@ -246,6 +247,23 @@ except TimeoutError as err: print(err.state) # e.g. "starting" ``` +A `create()` whose instance stopped instead of running -- a node could not pull its +image, say -- raises `InstanceStoppedError`. The instance is read back so the error +carries the decoded reason, and every instance read back carries the same decoding as +its `stop`: + +```python +from unikraft_cloud import InstanceStoppedError, PlatformStopCode + +fra = ukc.metro("fra") +try: + await fra.instances.create(image="org/app:latest", autostart=True, timeout_s=30) +except InstanceStoppedError as err: + print(err.stop) # e.g. "platform stop: image pull failed" + if err.stop and err.stop.platform_code == PlatformStopCode.IMAGE_PULL_FAILED: + await fra.instances.get(uuid=err.instance.uuid).delete() +``` + When the API attaches a warning to an answer -- a deprecated field, say -- the SDK issues it as a Python `UnikraftCloudWarning`, so the standard `warnings` filters apply. diff --git a/src/unikraft_cloud/__init__.py b/src/unikraft_cloud/__init__.py index d53c94f..933795d 100644 --- a/src/unikraft_cloud/__init__.py +++ b/src/unikraft_cloud/__init__.py @@ -89,6 +89,7 @@ InstanceMetrics, Instances, InstanceSet, + InstanceStoppedError, StartedInstance, StoppedInstance, SuspendedInstance, @@ -161,6 +162,7 @@ "InstanceLogs", "InstanceMetrics", "InstanceSet", + "InstanceStoppedError", "Instances", "KernelStopCode", "KernelStopReason", diff --git a/src/unikraft_cloud/core/errors.py b/src/unikraft_cloud/core/errors.py index 00f23c9..39a4040 100644 --- a/src/unikraft_cloud/core/errors.py +++ b/src/unikraft_cloud/core/errors.py @@ -47,6 +47,8 @@ class ResponseError: uuid: str | None = None #: The name of the item that failed, when the API names it that way. name: str | None = None + #: The state the item was in, when the API reports it beside the failure. + state: str | None = None class UnikraftCloudError(Exception): diff --git a/src/unikraft_cloud/core/response.py b/src/unikraft_cloud/core/response.py index cb0df12..61bf72a 100644 --- a/src/unikraft_cloud/core/response.py +++ b/src/unikraft_cloud/core/response.py @@ -194,12 +194,14 @@ def _item_error(entry: Any) -> ResponseError: message = _field(entry, "message") uuid = _field(entry, "uuid") name = _field(entry, "name") + state = _field(entry, "state") return ResponseError( status=None if code is None else _STATUS_FOR_CODE.get(code), message=message if isinstance(message, str) else None, code=code, uuid=uuid if isinstance(uuid, str) else None, name=name if isinstance(name, str) else None, + state=state if isinstance(state, str) else None, ) diff --git a/src/unikraft_cloud/resources/instances.py b/src/unikraft_cloud/resources/instances.py index b7b3413..52da5ce 100644 --- a/src/unikraft_cloud/resources/instances.py +++ b/src/unikraft_cloud/resources/instances.py @@ -7,14 +7,14 @@ # there spell the builtin out. import builtins from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence -from typing import Any, TypeVar, get_args +from typing import Any, TypeGuard, TypeVar, get_args import httpx from pydantic import BaseModel from ..api.platform import models from ..api.platform.instances_gen import InstancesApi -from ..core.errors import WaitTimeoutError +from ..core.errors import NotFoundError, ResponseError, UnikraftCloudError, WaitTimeoutError from ..core.fanout import fanout from ..core.handle import HandleSteps, Located, MetroTarget, ResourceHandle from ..core.handle_set import HandleSet @@ -56,6 +56,7 @@ "InstanceLogs", "InstanceMetrics", "InstanceSet", + "InstanceStoppedError", "Instances", "StartedInstance", "StoppedInstance", @@ -154,31 +155,114 @@ class InstanceHistory(models.GetCheckpointHistoryResponseInstanceHistory): InstanceEditor = ResourceEditor["InstanceHandle[UpdatedInstance]"] +class InstanceStoppedError(UnikraftCloudError): + """An instance stopped instead of running. + + :meth:`Instances.create` raises this when the instance it made stopped + before it was running -- a node could not pull its image, say, or its kernel + crashed. The instance is read back so that the reason travels with the + error, and it is left as it is: read its console log, or delete it. One + gone already -- deleted on stop, say -- is described from the failed item + alone, with no reason to give. + + .. code-block:: python + + try: + web = await fra.instances.create(image="org/app:latest", autostart=True, timeout_s=30) + except InstanceStoppedError as err: + print(err.stop) # e.g. "platform stop: image pull failed" + await fra.instances.get(uuid=err.instance.uuid).delete() + """ + + #: The instance, as it was read after it stopped, or as the failed item + #: described it when it was gone already. + instance: Instance + + def __init__( + self, + message: str, + *, + instance: Instance, + errors: tuple[ResponseError, ...] | None = None, + body: Any = None, + ) -> None: + super().__init__(message, kind="http", errors=errors, body=body) + self.instance = instance + + @property + def stop(self) -> Stop | None: + """Why it stopped, decoded, when the platform says.""" + return self.instance.stop + + +def _ref_of(instance: models.Instance) -> Ref: + """The reference to address an instance the API reported by, its UUID first.""" + return Ref(uuid=instance.uuid) if instance.uuid else Ref(name=instance.name) + + def _timed_out(res: BaseModel, wanted: str | None) -> None: """Report a wait that ran out of time as a timeout, not as any old failure. The API answers a lapsed wait with an error envelope that still carries the - state it last saw, which is what a caller wants to know. + state it last saw, which is what a caller wants to know, and names the + instances, which travel on ``errors``: a create whose wait lapsed still + hands over the instance it made. """ message = getattr(res, "message", None) if getattr(res, "status", None) != "error" or not isinstance(message, str): return if "timed out" not in message.lower(): return + state = _first_state(res) entries = getattr(getattr(res, "data", None), _KEY, None) or [] - state = next((getattr(entry, "state", None) for entry in entries), None) + errors = tuple( + ResponseError( + uuid=getattr(entry, "uuid", None), + name=getattr(entry, "name", None), + state=getattr(entry, "state", None), + ) + for entry in entries + ) error = WaitTimeoutError( f"Timed out waiting for the instance{f' to be {wanted!r}' if wanted else ''}" f"{f'; it is {state!r}.' if state else '.'}", kind="http", + errors=errors or None, body=res, ) error.state = state raise error +def _first_state(res: BaseModel) -> str | None: + """The state the first reported item is in, when the response names one.""" + entries = getattr(getattr(res, "data", None), _KEY, None) or [] + return next((getattr(entry, "state", None) for entry in entries), None) + + +def _create_wait_seconds(spec: Mapping[str, Any]) -> float | None: + """How long a create asks the API to wait for the instance to run, in seconds. + + ``timeout_s`` wins; the deprecated ``wait_timeout_ms`` is read when it is + the only one given, rounded up to whole seconds. ``None`` is a create that + does not wait; less than zero, one that waits as long as the API lets it. + """ + seconds = spec.get("timeout_s") + if _is_number(seconds): + return seconds + millis = spec.get("wait_timeout_ms") + if _is_number(millis): + return -(-millis // 1000) if millis >= 0 else -1 + return None + + +def _is_number(value: Any) -> TypeGuard[float]: + """Whether a value is a number the API takes as seconds; a bool is none.""" + return isinstance(value, (int, float)) and not isinstance(value, bool) + + def _outlives( - timeout: float | httpx.Timeout | None, seconds: int | None + timeout: float | httpx.Timeout | None, seconds: float | None ) -> float | httpx.Timeout | None: """A timeout that outlasts a server-side wait of ``seconds``. @@ -652,8 +736,14 @@ def create( ) """ call = options(headers, base_url, timeout) - opts = scoped(call, metros) check_spec(spec, models.CreateInstanceRequest, self.noun) + # A create that waits for the instance to run, by `timeout_s` or the + # deprecated `wait_timeout_ms`, holds the connection that long, so the + # read timeout outlasts it, as it does for `wait()`. + wait = _create_wait_seconds(spec) + if wait is not None and "timeout" not in call: + call["timeout"] = _outlives(self._session.platform.timeout, wait if wait >= 0 else None) + opts = scoped(call, metros) if spec.get("replicas"): raise TypeError( "`replicas` creates several instances, and this returns a handle to one. " @@ -665,9 +755,21 @@ def create( async def created() -> Located[Instance]: endpoint = await self._one_endpoint("Creating an instance", opts) res = await self.api.create_instance(body=body, **self._call(endpoint, opts)) - instance = first_tagged(res, _KEY, endpoint.metro, Instance, "instance") - ref = Ref(uuid=instance.uuid) if instance.uuid else Ref(name=instance.name) - target = MetroTarget(metro=endpoint.metro, base_url=endpoint.base_url, ref=ref) + # A create that waited for the instance to run reports a lapsed wait + # the way `wait` does; an instance that stopped, whatever the + # message says, is a failed item read for its stop below. + if wait is not None and _first_state(res) not in ("stopped", "deleted"): + _timed_out(res, "running") + try: + instance = first_tagged(res, _KEY, endpoint.metro, Instance, "instance") + except UnikraftCloudError as err: + stopped = await self._stopped(err, endpoint, opts) + if stopped is None: + raise + raise stopped from err + target = MetroTarget( + metro=endpoint.metro, base_url=endpoint.base_url, ref=_ref_of(instance) + ) # A create reports less than a read does, so the handle reads the # instance rather than carrying the create's answer forward. return Located(target=target) @@ -913,6 +1015,57 @@ async def read(self, target: MetroTarget, opts: CallOptions) -> Instance: ) return tag_first(res, _KEY, target, Instance, "instance") + async def _stopped( + self, err: UnikraftCloudError, endpoint: MetroEndpoint, opts: ScopeOptions + ) -> InstanceStoppedError | None: + """The failed create as a stopped instance, when that is what it was. + + The API reports an instance that stopped before it was running as a + failed item naming the instance and its state, with no error code of + its own, so the instance is read for the reason; one deleted on stop + is reported in the ``deleted`` state, and what the item says is all + there is. An item in another state, or one carrying the API's own + error code -- a name already taken, say -- is that failure, and stays + as it is. + """ + item = next((error for error in err.errors or () if error.uuid), None) + if item is None or item.uuid is None: + return None + if item.state is not None and item.state not in ("stopped", "deleted"): + return None + if item.state is None and item.code is not None: + return None + uuid = item.uuid + instance: Instance | None = None + if item.state != "deleted": + target = MetroTarget( + metro=endpoint.metro, base_url=endpoint.base_url, ref=Ref(uuid=uuid) + ) + try: + instance = await self.read(target, opts) + except NotFoundError: + # Gone between the create's answer and the read. + instance = None + except UnikraftCloudError: + return None + if instance is not None and instance.state not in ("stopped", "deleted"): + return None + gone = instance is None or instance.state == "deleted" + if instance is None: + # What the failed item said is all there is. + instance = Instance( + uuid=uuid, name=item.name, state=item.state or "stopped", metro=endpoint.metro + ) + detail = instance.describe_stop() + return InstanceStoppedError( + f"instance {describe_ref(_ref_of(instance))} stopped before it was running" + + (f": {detail}" if detail else "") + + ("; it is gone already" if gone else ""), + instance=instance, + errors=err.errors, + body=err.body, + ) + async def _find(self, endpoint: MetroEndpoint, ref: Ref, opts: ScopeOptions) -> Instance | None: """Look for one instance in one metro; absent is not a failure.""" diff --git a/tests/test_client.py b/tests/test_client.py index 0ff66e4..a1867d1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -15,10 +15,13 @@ from unikraft_cloud import ( REMOVE, + AlreadyExistsError, AmbiguousRefError, AuthenticationError, + InstanceStoppedError, MetroFanoutError, NotFoundError, + PlatformStopCode, Ref, ServerError, UnikraftCloud, @@ -187,6 +190,8 @@ async def test_a_wait_that_ran_out_of_time_is_a_timeout(self) -> None: assert isinstance(caught.value, TimeoutError) assert caught.value.state == "stopped" assert "'running'" in str(caught.value) + assert caught.value.errors is not None + assert (caught.value.errors[0].uuid, caught.value.errors[0].state) == ("u1", "stopped") async def test_a_wait_outlives_the_timeout_it_asked_the_api_for(self) -> None: waited = {"uuid": "u1", "name": "web", "state": "running"} @@ -794,6 +799,24 @@ async def test_an_explicit_timeout_beats_an_injected_clients(self) -> None: await ukc.instances.get(name="web") assert recorder.calls[0].extensions["timeout"]["read"] == 9.0 + async def test_a_create_that_waits_outlives_the_timeout_it_asked_the_api_for(self) -> None: + recorder = queued([(200, envelope({"instances": [instance()]}))] * 2) + async with client(recorder, metro="fra", timeout=5.0) as ukc: + await ukc.instances.create(image="org/app:latest", autostart=True, timeout_s=30) + await ukc.instances.create(image="org/app:latest") + waited, plain = [call for call in recorder.calls if call.method == "POST"] + # The wait the API was asked for, plus the margin every wait is given. + assert waited.extensions["timeout"]["read"] == 40 + assert plain.extensions["timeout"]["read"] == 5.0 + + async def test_the_deprecated_wait_in_milliseconds_is_outlived_too(self) -> None: + recorder = queued([(200, envelope({"instances": [instance()]}))] * 2) + async with client(recorder, metro="fra", timeout=5.0) as ukc: + await ukc.instances.create(image="org/app:latest", autostart=True, wait_timeout_ms=2500) + (waited,) = [call for call in recorder.calls if call.method == "POST"] + # 2.5 seconds rounds up to 3, plus the margin every wait is given. + assert waited.extensions["timeout"]["read"] == 13 + async def test_defaults_to_bounding_connecting_but_not_reading(self) -> None: recorder = queued([(200, envelope({"instances": [instance()]}))]) async with client(recorder, metro="fra") as ukc: @@ -942,3 +965,156 @@ async def test_any_other_failure_says_how_to_skip_discovery(self) -> None: with pytest.raises(UnikraftCloudError, match="Name the metros you want") as caught: [inst async for inst in ukc.instances.list()] assert caught.value.status == 500 + + +class TestCreateFailures: + def _stopped_create(self, *rows: dict[str, Any]) -> Recorder: + """A create whose autostart wait ended on a stopped instance, then its read.""" + failed = {"status": "error", "uuid": "u1", "name": "web", "state": "stopped"} + return queued( + [ + (200, envelope({"instances": [failed]}, status="error")), + (200, envelope({"instances": list(rows)})), + ] + ) + + async def test_an_instance_that_stopped_is_read_for_the_reason(self) -> None: + recorder = self._stopped_create(instance(state="stopped", stop_reason=4, stop_code=1)) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(InstanceStoppedError) as caught: + await ukc.instances.create(name="web", image="org/app:latest", autostart=True) + err = caught.value + assert err.instance.uuid == "u1" + assert err.stop is not None + assert err.stop.platform_code == PlatformStopCode.IMAGE_PULL_FAILED + assert ( + str(err) + == 'instance uuid "u1" stopped before it was running: platform stop: image pull failed' + ) + assert isinstance(err.__cause__, UnikraftCloudError) + assert err.errors is not None and err.errors[0].uuid == "u1" + assert [call.method for call in recorder.calls] == ["POST", "GET"] + assert recorder.calls[1].url.params["uuid"] == "u1" + + async def test_a_stop_the_api_reports_as_a_lapsed_wait_is_still_a_stop(self) -> None: + failed = {"status": "error", "uuid": "u1", "name": "web", "state": "stopped"} + lapsed = envelope( + {"instances": [failed]}, status="error", message="Timed out waiting for instance" + ) + recorder = queued( + [(200, lapsed), (200, envelope({"instances": [instance(state="stopped")]}))] + ) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(InstanceStoppedError): + await ukc.instances.create(name="web", image="org/app:latest", timeout_s=30) + + async def test_a_create_that_did_not_wait_has_not_timed_out(self) -> None: + failed = {"status": "error", "uuid": "u1", "name": "web", "state": "starting"} + lapsed = envelope( + {"instances": [failed]}, status="error", message="Timed out waiting for instance" + ) + recorder = queued([(200, lapsed)]) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(UnikraftCloudError) as caught: + await ukc.instances.create(name="web", image="org/app:latest") + assert not isinstance(caught.value, WaitTimeoutError) + + async def test_an_instance_that_stopped_without_a_reason_still_says_so(self) -> None: + recorder = self._stopped_create(instance(state="stopped")) + async with client(recorder, metro="fra") as ukc: + with pytest.raises( + InstanceStoppedError, match=r"stopped before it was running$" + ) as caught: + await ukc.instances.create(name="web", image="org/app:latest") + assert caught.value.stop is None + + async def test_an_instance_deleted_on_stop_is_still_a_stopped_instance(self) -> None: + # As the API reports it: the item is in the `deleted` state, nothing left to read. + gone = {"status": "error", "uuid": "u1", "name": "web", "state": "deleted"} + recorder = queued([(200, envelope({"instances": [gone]}, status="error"))]) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(InstanceStoppedError) as caught: + await ukc.instances.create(name="web", image="org/app:latest", timeout_s=30) + err = caught.value + assert (err.instance.uuid, err.instance.name, err.instance.state) == ( + "u1", + "web", + "deleted", + ) + assert (err.instance.metro, err.stop) == ("fra", None) + assert str(err).endswith("stopped before it was running; it is gone already") + assert len(recorder.calls) == 1 + + async def test_an_instance_gone_before_it_is_read_is_still_a_stopped_instance(self) -> None: + recorder = self._stopped_create() + async with client(recorder, metro="fra") as ukc: + with pytest.raises(InstanceStoppedError) as caught: + await ukc.instances.create(name="web", image="org/app:latest") + err = caught.value + assert (err.instance.uuid, err.instance.state, err.stop) == ("u1", "stopped", None) + assert str(err).endswith("; it is gone already") + assert len(recorder.calls) == 2 + + async def test_an_instance_not_stopped_after_all_keeps_the_original_error(self) -> None: + recorder = self._stopped_create(instance(state="running")) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(UnikraftCloudError) as caught: + await ukc.instances.create(name="web", image="org/app:latest") + assert not isinstance(caught.value, InstanceStoppedError) + + async def test_a_failure_with_the_apis_own_code_is_not_read_as_a_stop(self) -> None: + taken = {"status": "error", "uuid": "u1", "name": "web", "error": 23, "message": "taken"} + recorder = queued( + [ + (200, envelope({"instances": [taken]}, status="error")), + (200, envelope({"instances": [instance(state="stopped")]})), + ] + ) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(AlreadyExistsError, match="taken"): + await ukc.instances.create(name="web", image="org/app:latest") + assert len(recorder.calls) == 1 + + async def test_a_failed_item_in_another_state_is_not_read_as_a_stop(self) -> None: + starting = {"status": "error", "uuid": "u1", "name": "web", "state": "starting"} + recorder = queued([(200, envelope({"instances": [starting]}, status="error"))]) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(UnikraftCloudError) as caught: + await ukc.instances.create(name="web", image="org/app:latest") + assert not isinstance(caught.value, InstanceStoppedError) + assert caught.value.errors is not None and caught.value.errors[0].state == "starting" + assert len(recorder.calls) == 1 + + async def test_a_failure_naming_no_instance_is_not_read(self) -> None: + refused = {"status": "error", "message": "Failed to create. Quota exceeded", "error": 19} + recorder = queued([(200, envelope({"instances": [refused]}, status="error"))]) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(UnikraftCloudError, match="Quota exceeded"): + await ukc.instances.create(name="web", image="org/app:latest") + assert len(recorder.calls) == 1 + + async def test_a_create_whose_wait_ran_out_is_a_timeout(self) -> None: + still = {"status": "error", "uuid": "u1", "name": "web", "state": "starting"} + recorder = queued( + [ + ( + 200, + envelope({"instances": [still]}, status="error", message="Operation timed out"), + ) + ] + ) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(WaitTimeoutError) as caught: + await ukc.instances.create(name="web", image="org/app:latest", timeout_s=5) + assert caught.value.state == "starting" + # The instance it made travels along, to be waited for or deleted. + assert caught.value.errors is not None and caught.value.errors[0].uuid == "u1" + assert len(recorder.calls) == 1 + + async def test_an_instance_read_back_as_deleted_is_gone_already(self) -> None: + recorder = self._stopped_create(instance(state="deleted")) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(InstanceStoppedError) as caught: + await ukc.instances.create(name="web", image="org/app:latest") + assert (caught.value.instance.state, caught.value.stop) == ("deleted", None) + assert str(caught.value).endswith("; it is gone already") From 19e84bbf2a66664b659a78a07cac5e941a2f8a71 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:36:38 +0100 Subject: [PATCH 32/36] feat(resources): Wait, forgive and retry a delete A delete answered as soon as it was under way, failed on an instance already gone, and failed at once on one the platform reported busy, as a relay interface is for a while after its instance is deleted. Callers waited and retried by hand. delete() takes timeout_seconds for the API's own wait, missing_ok and retry_busy, which backs off while the API says EBUSY; a wait of -1 leaves the read timeout unbounded. NotFoundError.absent marks a missing resource, and only that not-found is ever forgiven. Signed-off-by: Daniel Vallance --- examples/sandbox.py | 2 +- src/unikraft_cloud/core/errors.py | 18 +- src/unikraft_cloud/core/handle.py | 43 ++++- src/unikraft_cloud/core/handle_set.py | 12 ++ src/unikraft_cloud/core/resource.py | 8 +- src/unikraft_cloud/core/response.py | 14 +- src/unikraft_cloud/resources/instances.py | 166 +++++++++++++++-- tests/test_client.py | 209 ++++++++++++++++++++++ tests/test_core.py | 11 +- 9 files changed, 446 insertions(+), 37 deletions(-) diff --git a/examples/sandbox.py b/examples/sandbox.py index 42858eb..2bd6424 100644 --- a/examples/sandbox.py +++ b/examples/sandbox.py @@ -65,7 +65,7 @@ async def main(image: str) -> None: size = await sb.fs.read_to("/tmp/hello.txt", local) print(f"downloaded {size} bytes to {local.name}") finally: - await handle.delete() + await handle.delete(timeout_seconds=60, missing_ok=True) if __name__ == "__main__": diff --git a/src/unikraft_cloud/core/errors.py b/src/unikraft_cloud/core/errors.py index 39a4040..3b25f3f 100644 --- a/src/unikraft_cloud/core/errors.py +++ b/src/unikraft_cloud/core/errors.py @@ -103,7 +103,23 @@ class AuthenticationError(UnikraftCloudError): class NotFoundError(UnikraftCloudError): - """The resource does not exist (404).""" + """The resource does not exist (404), or the API named it as missing.""" + + def __init__( + self, + message: str, + *, + kind: ErrorKind = "http", + status: int | None = 404, + errors: tuple[ResponseError, ...] | None = None, + body: Any = None, + absent: bool = False, + ) -> None: + super().__init__(message, kind=kind, status=status, errors=errors, body=body) + #: Whether the resource itself was found missing -- by a search, by a + #: read that came back empty, or by the API's own not-found code -- + #: rather than a route or a listing being gone. + self.absent = absent class AlreadyExistsError(UnikraftCloudError): diff --git a/src/unikraft_cloud/core/handle.py b/src/unikraft_cloud/core/handle.py index 867175e..7963c6f 100644 --- a/src/unikraft_cloud/core/handle.py +++ b/src/unikraft_cloud/core/handle.py @@ -18,6 +18,7 @@ from dataclasses import dataclass, field, replace from typing import Any, Generic, TypeVar +from .errors import NotFoundError from .http import UNSET, CallOptions, TimeoutOption, Unset from .metro import Metro, MetroEndpoint, metro_base_url from .response import Ref @@ -76,6 +77,9 @@ class HandleSteps(Generic[T]): #: before a chained `wait()` runs); false for a handle that merely identifies #: a resource, which is what keeps `get(ref).suspend()` down to one request. sequential: bool = False + #: The value when this handle's own lookup finds the resource absent, for an + #: operation that accepts absence. Unset, absence raises; other failures always do. + absent: Callable[[], T] | None = None #: Whether `locate` only reads, so the next await repeats a lookup that #: failed. Unset for a create; a chained operation inherits it from its parent. lookup: bool = False @@ -123,7 +127,7 @@ def __del__(self) -> None: async def resolve(self) -> MetroTarget: """The resource's reference and the metro serving it, resolving the scope.""" self._consumed = True - return (await self._locate()).target + return (await self._lookup()).target async def where(self) -> Metro: """Which metro holds this resource.""" @@ -143,7 +147,18 @@ def _chained( self._consumed = True async def locate() -> Located[R]: - return Located(target=_explicit_target(await self._next(), opts)) + if self._steps.sequential: + # A failure of this handle's own operation is never an absence. + await self._evaluate() + try: + located = await self._locate() + except NotFoundError as err: + # Only a lookup's absence is one: a create's own not-found is + # that create's failure, however the API spelled it. + if err.absent and self._steps.lookup: + raise _AbsentError(err) from err + raise + return Located(target=_explicit_target(located.target, opts)) return HandleSteps( locate=locate, @@ -174,7 +189,14 @@ async def _next(self) -> MetroTarget: """The target a chained operation should act on, performing this step first.""" if self._steps.sequential: await self._evaluate() - return (await self._locate()).target + return (await self._lookup()).target + + async def _lookup(self) -> Located[T]: + """This handle's lookup, an absence raised as the not-found it was.""" + try: + return await self._locate() + except _AbsentError as absence: + raise absence.error from absence.error.__cause__ def _locate(self) -> Awaitable[Located[T]]: if self._located is None or (self._steps.lookup and spent(self._located)): @@ -186,7 +208,12 @@ def _locate(self) -> Awaitable[Located[T]]: return asyncio.shield(self._located) async def _evaluate(self) -> T: - located = await self._locate() + try: + located = await self._locate() + except _AbsentError as absence: + if self._steps.absent is None: + raise absence.error from absence.error.__cause__ + return self._steps.absent() if located.value is not None: return located.value # Memoised apart from the lookup, so a chained operation's one outcome @@ -198,6 +225,14 @@ async def _evaluate(self) -> T: return await asyncio.shield(self._value) +class _AbsentError(Exception): + """A lookup that found the resource absent, which an operation may forgive.""" + + def __init__(self, error: NotFoundError) -> None: + super().__init__(str(error)) + self.error = error + + def spent(task: asyncio.Future[Any] | None) -> bool: """Whether a memoised task cannot serve another caller. diff --git a/src/unikraft_cloud/core/handle_set.py b/src/unikraft_cloud/core/handle_set.py index 33fe399..a53b781 100644 --- a/src/unikraft_cloud/core/handle_set.py +++ b/src/unikraft_cloud/core/handle_set.py @@ -12,6 +12,7 @@ from collections.abc import Awaitable, Callable, Generator from typing import Any, Generic, TypeVar +from .errors import NotFoundError from .fanout import MetroFailure, MetroFanoutError from .handle import ResourceHandle, spent from .metro import Metro @@ -68,6 +69,17 @@ async def size(self) -> int: """How many metros hold a match.""" return len(await self.handles()) + async def _absent(self) -> bool: + """Whether the reference matched nothing, for an operation that forgives that.""" + try: + await self.handles() + except NotFoundError as err: + # Only an absence is forgiven; a route that is not there is a failure. + if not err.absent: + raise + return True + return False + def __await__(self) -> Generator[Any, None, list[T]]: # Awaitable on purpose, like ResourceHandle: `await each(...)` reads every # match, while `each(...).suspend()` keeps addressing them as a set. diff --git a/src/unikraft_cloud/core/resource.py b/src/unikraft_cloud/core/resource.py index f938946..a4a11b1 100644 --- a/src/unikraft_cloud/core/resource.py +++ b/src/unikraft_cloud/core/resource.py @@ -283,9 +283,7 @@ async def _search( if not found: raise NotFoundError( - f"{self.noun} {describe_ref(ref)} not found in {searched}", - kind="http", - status=404, + f"{self.noun} {describe_ref(ref)} not found in {searched}", absent=True ) return found @@ -376,9 +374,7 @@ async def _locate_many(self, refs: Sequence[Ref], opts: ScopeOptions) -> list[Lo if missing: searched = ", ".join(endpoint.metro for endpoint in endpoints) raise NotFoundError( - f"{self.noun} {describe_ref(missing[0])} not found in {searched}", - kind="http", - status=404, + f"{self.noun} {describe_ref(missing[0])} not found in {searched}", absent=True ) return found diff --git a/src/unikraft_cloud/core/response.py b/src/unikraft_cloud/core/response.py index 61bf72a..9345753 100644 --- a/src/unikraft_cloud/core/response.py +++ b/src/unikraft_cloud/core/response.py @@ -224,7 +224,11 @@ def _error_for( status = next(iter(statuses)) if len(statuses) == 1 else None if status is None: return UnikraftCloudError(message, kind="http", errors=errors or None, body=res) - return error_for_status(status, message, errors=errors, body=res) + error = error_for_status(status, message, errors=errors, body=res) + if isinstance(error, NotFoundError): + # The API's own code says the items are missing, as against a route. + error.absent = all(entry.code == NOT_FOUND_CODE for entry in errors) + return error def _envelope_errors(res: BaseModel) -> tuple[ResponseError, ...] | None: @@ -255,7 +259,7 @@ def require_first(entries: Sequence[E], what: str) -> E: Most single-resource operations return the resource inside a singleton list. """ if not entries: - raise NotFoundError(f"{what} not found", kind="http", status=404) + raise NotFoundError(f"{what} not found", absent=True) return entries[0] @@ -263,12 +267,12 @@ async def or_absent(work: Awaitable[T]) -> T | None: """Resolve to ``None`` when a lookup reports the resource is not there. Searching several metros for one resource means most of them will legitimately - answer "not here"; only a real failure should count as a failure. + answer "not here"; a route that is not there is a failure, as any other. """ try: return await work - except UnikraftCloudError as err: - if err.status == 404: + except NotFoundError as err: + if err.absent: return None raise diff --git a/src/unikraft_cloud/resources/instances.py b/src/unikraft_cloud/resources/instances.py index 52da5ce..c74f2a0 100644 --- a/src/unikraft_cloud/resources/instances.py +++ b/src/unikraft_cloud/resources/instances.py @@ -5,9 +5,12 @@ # `Instances.list` shadows the builtin inside that class body, so annotations # there spell the builtin out. +import asyncio import builtins +import re from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence -from typing import Any, TypeGuard, TypeVar, get_args +from dataclasses import replace +from typing import Any, Literal, TypeGuard, TypeVar, get_args, overload import httpx from pydantic import BaseModel @@ -15,7 +18,7 @@ from ..api.platform import models from ..api.platform.instances_gen import InstancesApi from ..core.errors import NotFoundError, ResponseError, UnikraftCloudError, WaitTimeoutError -from ..core.fanout import fanout +from ..core.fanout import MetroFanoutError, fanout from ..core.handle import HandleSteps, Located, MetroTarget, ResourceHandle from ..core.handle_set import HandleSet from ..core.http import UNSET, CallOptions, TimeoutOption, comma_separated @@ -154,6 +157,16 @@ class InstanceHistory(models.GetCheckpointHistoryResponseInstanceHistory): #: A staged multi-operation edit of one instance. InstanceEditor = ResourceEditor["InstanceHandle[UpdatedInstance]"] +#: How long the first retry of a busy delete waits, and the most any retry waits. +_BUSY_RETRY_INTERVAL = 0.5 +_BUSY_RETRY_MAX_INTERVAL = 2.0 +#: How the API reports a resource something else still holds: ``EBUSY``, by +#: its errno name or as the bare Linux value. +_BUSY = re.compile(r"\bEBUSY\b|Unknown error -16\b") + +# Named so tests can stand in for it and assert the backoff without waiting. +_sleep = asyncio.sleep + class InstanceStoppedError(UnikraftCloudError): """An instance stopped instead of running. @@ -195,6 +208,30 @@ def stop(self) -> Stop | None: return self.instance.stop +def _is_busy(err: UnikraftCloudError) -> bool: + """Whether a failure says the resource is still in use, and so worth retrying.""" + messages = [str(err), *(error.message or "" for error in err.errors or ())] + return any(_BUSY.search(message) for message in messages) + + +async def _while_busy(attempt: Callable[[], Awaitable[V]], retry_busy: float | None) -> V: + """Run ``attempt``, retrying with backoff for ``retry_busy`` seconds while it reports busy.""" + if retry_busy is None: + return await attempt() + loop = asyncio.get_running_loop() + deadline = loop.time() + retry_busy + interval = _BUSY_RETRY_INTERVAL + while True: + try: + return await attempt() + except UnikraftCloudError as err: + remaining = deadline - loop.time() + if remaining <= 0 or not _is_busy(err): + raise + await _sleep(min(interval, remaining)) + interval = min(interval * 2, _BUSY_RETRY_MAX_INTERVAL) + + def _ref_of(instance: models.Instance) -> Ref: """The reference to address an instance the API reported by, its UUID first.""" return Ref(uuid=instance.uuid) if instance.uuid else Ref(name=instance.name) @@ -267,12 +304,17 @@ def _outlives( """A timeout that outlasts a server-side wait of ``seconds``. The API holds the connection until the instance reaches the state or its own - timeout elapses, so a shorter read timeout would cut the wait short. + timeout elapses, so a shorter read timeout would cut the wait short. A wait + of ``None``, or of less than zero, is one only the API bounds, so the read + is unbounded too; a wait of zero is none, so the timeout stands. """ current = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout) - if current.read is None: + if current.read is None or seconds == 0: return timeout - read = None if seconds is None else max(current.read, seconds + _WAIT_MARGIN_SECONDS) + if seconds is None or seconds < 0: + read = None + else: + read = max(current.read, seconds + _WAIT_MARGIN_SECONDS) return httpx.Timeout(connect=current.connect, read=read, write=current.write, pool=current.pool) @@ -363,22 +405,88 @@ async def run(target: MetroTarget) -> SuspendedInstance: return self._then(run, opts) + @overload + def delete( + self, + *, + timeout_seconds: int | None = None, + retry_busy: float | None = None, + missing_ok: Literal[False] = False, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> InstanceHandle[DeletedInstance]: ... + + @overload def delete( self, *, + timeout_seconds: int | None = None, + retry_busy: float | None = None, + missing_ok: Literal[True], headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, - ) -> InstanceHandle[DeletedInstance]: - """Delete the instance.""" + ) -> InstanceHandle[DeletedInstance | None]: ... + + def delete( + self, + *, + timeout_seconds: int | None = None, + retry_busy: float | None = None, + missing_ok: bool = False, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> InstanceHandle[Any]: + """Delete the instance, stopping it first if it runs. + + ``timeout_seconds`` blocks until the instance is gone, or for that long; + ``-1`` waits for as long as the platform allows. Without it the API + answers as soon as the deletion is under way. The read timeout is + stretched to outlast the wait only when no ``timeout`` was given, here + or to the handle. + + With ``missing_ok`` an instance that is not there -- gone already, or + never found -- resolves to ``None`` instead of raising + :class:`NotFoundError`. + + ``retry_busy`` keeps trying for that many seconds, backing off in + between, while the API reports the instance as busy. Something another + instance held on to -- a relay interface, for one -- stays busy for a + while after that instance is deleted. + + .. code-block:: python + + await ukc.instances.get(name="web").delete(timeout_seconds=-1) + await ukc.instances.get(name="relay").delete(retry_busy=20, missing_ok=True) + """ opts = self._options(headers, base_url, timeout) + if timeout_seconds is not None and "timeout" not in opts: + opts["timeout"] = _outlives(self._instances.session.platform.timeout, timeout_seconds) + extra = {} if timeout_seconds is None else {"timeout_s": timeout_seconds} - async def run(target: MetroTarget) -> DeletedInstance: - body = [models.DeleteInstanceRequestItem.model_validate(ref_dict(target.ref))] - res = await self._instances.api.delete_instances(body=body, **at_metro(target, opts)) - return tag_first(res, _KEY, target, DeletedInstance, "instance") + async def attempt(target: MetroTarget) -> DeletedInstance | None: + body = [models.DeleteInstanceRequestItem.model_validate(ref_dict(target.ref) | extra)] + try: + res = await self._instances.api.delete_instances( + body=body, **at_metro(target, opts) + ) + return tag_first(res, _KEY, target, DeletedInstance, "instance") + except NotFoundError as err: + # The API losing the instance is forgiven; a route that is not there is not. + if not missing_ok or not err.absent: + raise + return None - return self._then(run, opts) + async def run(target: MetroTarget) -> DeletedInstance | None: + return await _while_busy(lambda: attempt(target), retry_busy) + + steps = self._chained(run, opts) + if missing_ok: + # An instance no metro holds is as gone as one the API just lost. + steps = replace(steps, absent=lambda: None) + return InstanceHandle(self._instances, steps) def update( self, @@ -657,8 +765,20 @@ async def suspend(self, **opts: Any) -> list[SuspendedInstance]: return await self._map(lambda handle: handle.suspend(**opts)) async def delete(self, **opts: Any) -> list[DeletedInstance]: - """Delete every match.""" - return await self._map(lambda handle: handle.delete(**opts)) + """Delete every match. + + With ``missing_ok``, a match already gone is left out, and a reference + that matches nothing deletes nothing rather than raising. + """ + if opts.get("missing_ok") and await self._absent(): + return [] + try: + deleted = await self._map(lambda handle: handle.delete(**opts)) + except MetroFanoutError as err: + # The matches already gone are no result of the metros that did delete. + err.results = [item for item in err.results if item is not None] + raise + return [item for item in deleted if item is not None] async def update(self, **changes: Any) -> list[UpdatedInstance]: """Apply the same changes to every match.""" @@ -742,7 +862,7 @@ def create( # read timeout outlasts it, as it does for `wait()`. wait = _create_wait_seconds(spec) if wait is not None and "timeout" not in call: - call["timeout"] = _outlives(self._session.platform.timeout, wait if wait >= 0 else None) + call["timeout"] = _outlives(self._session.platform.timeout, wait) opts = scoped(call, metros) if spec.get("replicas"): raise TypeError( @@ -920,6 +1040,7 @@ async def delete( self, refs: RefLike | Sequence[RefLike], *, + timeout_seconds: int | None = None, metros: MetroScope | None = None, headers: Mapping[str, str] | None = None, base_url: str | None = None, @@ -928,16 +1049,22 @@ async def delete( """Delete one or more instances. An instance the API could not delete raises, with the ones it did on the - error's ``results``. + error's ``results``. ``timeout_seconds`` blocks until they are gone, or + for that long; ``-1`` waits for as long as the platform allows. The read + timeout is stretched to outlast the wait only when no ``timeout`` was given. References are located first when the scope spans metros, so each instance is deleted only in the metro that holds it. """ opts = scoped(options(headers, base_url, timeout), metros) + if timeout_seconds is not None and "timeout" not in opts: + opts["timeout"] = _outlives(self._session.platform.timeout, timeout_seconds) + extra = {} if timeout_seconds is None else {"timeout_s": timeout_seconds} def call(group: MetroGroup) -> Awaitable[BaseModel]: body = [ - models.DeleteInstanceRequestItem.model_validate(ref_dict(ref)) for ref in group.refs + models.DeleteInstanceRequestItem.model_validate(ref_dict(ref) | extra) + for ref in group.refs ] return self.api.delete_instances(body=body, **self._call(group.endpoint, opts)) @@ -1043,7 +1170,10 @@ async def _stopped( ) try: instance = await self.read(target, opts) - except NotFoundError: + except NotFoundError as missing: + # A route that is not there fails the read like any other failure. + if not missing.absent: + return None # Gone between the create's answer and the read. instance = None except UnikraftCloudError: diff --git a/tests/test_client.py b/tests/test_client.py index a1867d1..87fa01e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -28,6 +28,7 @@ UnikraftCloudError, WaitTimeoutError, ) +from unikraft_cloud.resources import instances as instances_module from .conftest import ( Recorder, @@ -201,6 +202,14 @@ async def test_a_wait_outlives_the_timeout_it_asked_the_api_for(self) -> None: # The API holds the connection open for the whole wait. assert recorder.calls[0].extensions["timeout"]["read"] > 60 + async def test_a_wait_for_as_long_as_the_platform_allows_has_no_read_timeout(self) -> None: + waited = {"uuid": "u1", "name": "web", "state": "running"} + recorder = queued([(200, envelope({"instances": [waited]}))]) + async with client(recorder, metro="fra", timeout=5.0) as ukc: + await ukc.instances.get(name="web").wait(state="running", timeout_seconds=-1) + assert json.loads(recorder.calls[0].content)[0]["timeout_s"] == -1 + assert recorder.calls[0].extensions["timeout"]["read"] is None + async def test_sends_a_bulk_body_of_references(self) -> None: recorder = queued([(200, envelope({"instances": [changed_instance()]}))]) async with client(recorder, metro="fra") as ukc: @@ -481,6 +490,14 @@ def where_they_live(where: str) -> list[dict[str, Any]]: assert [inst.metro for inst in started] == ["fra"] assert recorder.metros("/v1/instances/start") == ["fra"] + async def test_a_set_of_a_missing_name_deletes_nothing_when_told_so(self) -> None: + recorder = cloud(instances=lambda where: []) + async with client(recorder) as ukc: + assert await ukc.instances.each(name="ghost").delete(missing_ok=True) == [] + with pytest.raises(NotFoundError, match="not found in fra, dal"): + await ukc.instances.each(name="ghost").delete() + assert not [call for call in recorder.calls if call.method == "DELETE"] + async def test_a_lookup_on_one_metro_says_which_name_is_missing(self) -> None: recorder = cloud(instances=lambda where: []) async with client(recorder, metro="fra") as ukc: @@ -1055,6 +1072,19 @@ async def test_an_instance_gone_before_it_is_read_is_still_a_stopped_instance(se assert str(err).endswith("; it is gone already") assert len(recorder.calls) == 2 + async def test_a_read_back_route_that_is_not_there_keeps_the_original_error(self) -> None: + failed = {"status": "error", "uuid": "u1", "name": "web", "state": "stopped"} + recorder = queued( + [ + (200, envelope({"instances": [failed]}, status="error")), + (404, {"status": "error", "message": "no such route"}), + ] + ) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(UnikraftCloudError) as caught: + await ukc.instances.create(name="web", image="org/app:latest") + assert not isinstance(caught.value, InstanceStoppedError) + async def test_an_instance_not_stopped_after_all_keeps_the_original_error(self) -> None: recorder = self._stopped_create(instance(state="running")) async with client(recorder, metro="fra") as ukc: @@ -1118,3 +1148,182 @@ async def test_an_instance_read_back_as_deleted_is_gone_already(self) -> None: await ukc.instances.create(name="web", image="org/app:latest") assert (caught.value.instance.state, caught.value.stop) == ("deleted", None) assert str(caught.value).endswith("; it is gone already") + + +class TestDeleting: + async def test_missing_ok_forgives_no_operation_that_failed_before_it(self) -> None: + def route(request: httpx.Request) -> tuple[int, Any]: + if request.url.path == "/v1/instances/start": + return 200, envelope( + {"instances": [missing(name="ghost")]}, + status="error", + message="Failed to perform all operations", + ) + return 200, envelope({"instances": []}) + + recorder = routed(route) + async with client(recorder, metro="fra") as ukc: + # The start's own not-found is the start's failure, not an absence. + with pytest.raises(NotFoundError): + await ukc.instances.get(name="ghost").start().delete(missing_ok=True) + assert [call.method for call in recorder.calls] == ["PUT"] + + async def test_missing_ok_forgives_no_route_that_is_not_there(self) -> None: + # A 404 with no item named is the endpoint missing, not the instance. + recorder = routed(lambda request: (404, {"status": "error", "message": "no such route"})) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(NotFoundError): + await ukc.instances.get(name="ghost").delete(missing_ok=True) + + async def test_can_wait_for_the_instance_to_be_gone(self) -> None: + recorder = queued([(200, envelope({"instances": [changed_instance()]}))]) + async with client(recorder, metro="fra") as ukc: + await ukc.instances.get(uuid="u1").delete(timeout_seconds=-1) + assert json.loads(recorder.calls[0].content) == [{"uuid": "u1", "timeout_s": -1}] + + async def test_a_bulk_delete_can_wait_too(self) -> None: + recorder = queued([(200, envelope({"instances": [changed_instance()]}))]) + async with client(recorder, metro="fra") as ukc: + await ukc.instances.delete(["web"], timeout_seconds=30) + assert json.loads(recorder.calls[0].content) == [{"name": "web", "timeout_s": 30}] + + async def test_a_bounded_wait_lets_the_request_outlast_it(self) -> None: + recorder = queued([(200, envelope({"instances": [changed_instance()]}))]) + async with client(recorder, metro="fra", timeout=5.0) as ukc: + await ukc.instances.get(uuid="u1").delete(timeout_seconds=30) + assert recorder.calls[0].extensions["timeout"]["read"] == 40.0 + + async def test_an_instance_already_gone_is_a_failure_by_default(self) -> None: + recorder = queued([(200, envelope({"instances": [missing(uuid="u1")]}, status="error"))]) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(NotFoundError): + await ukc.instances.get(uuid="u1").delete() + + async def test_an_instance_already_gone_can_be_no_failure(self) -> None: + recorder = queued([(200, envelope({"instances": [missing(uuid="u1")]}, status="error"))]) + async with client(recorder, metro="fra") as ukc: + assert await ukc.instances.get(uuid="u1").delete(missing_ok=True) is None + assert len(recorder.calls) == 1 + + async def test_a_name_no_metro_holds_can_be_no_failure_either(self) -> None: + recorder = cloud(instances=lambda where: []) + async with client(recorder) as ukc: + assert await ukc.instances.get(name="ghost").delete(missing_ok=True) is None + # Located across both metros, found nowhere, and nothing was deleted. + assert sorted(recorder.metros("/v1/instances")) == ["dal", "fra"] + assert all(call.method == "GET" for call in recorder.calls if call.url.path != "/v1/metros") + + async def test_a_busy_instance_is_retried_with_backoff( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + slept: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + slept.append(seconds) + + monkeypatch.setattr(instances_module, "_sleep", fake_sleep) + busy = {"status": "error", "uuid": "u1", "message": "Unknown error -16", "error": 0} + recorder = queued( + [ + (200, envelope({"instances": [busy]}, status="error")), + (200, envelope({"instances": [busy]}, status="error")), + (200, envelope({"instances": [changed_instance()]})), + ] + ) + async with client(recorder, metro="fra") as ukc: + deleted = await ukc.instances.get(uuid="u1").delete(retry_busy=20) + assert deleted.uuid == "u1" + assert len(recorder.calls) == 3 + assert slept == [0.5, 1.0] + + async def test_a_busy_instance_fails_at_once_without_a_retry(self) -> None: + busy = {"status": "error", "uuid": "u1", "message": "Failed to delete: EBUSY", "error": 10} + recorder = queued([(200, envelope({"instances": [busy]}, status="error"))]) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(UnikraftCloudError, match="EBUSY"): + await ukc.instances.get(uuid="u1").delete() + assert len(recorder.calls) == 1 + + async def test_a_busy_instance_is_given_up_on_at_the_deadline( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr(instances_module, "_sleep", fake_sleep) + busy = {"status": "error", "uuid": "u1", "message": "Unknown error -16", "error": 0} + recorder = queued([(200, envelope({"instances": [busy]}, status="error"))]) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(UnikraftCloudError, match="Unknown error -16"): + await ukc.instances.get(uuid="u1").delete(retry_busy=0.01) + assert len(recorder.calls) > 1 + + async def test_any_other_failure_is_not_retried(self, monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_sleep(seconds: float) -> None: + raise AssertionError("slept") + + monkeypatch.setattr(instances_module, "_sleep", fake_sleep) + recorder = queued([(403, {"status": "error", "message": "denied"})]) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(AuthenticationError): + await ukc.instances.get(uuid="u1").delete(retry_busy=20) + assert len(recorder.calls) == 1 + + async def test_missing_ok_forgives_no_create_that_failed(self) -> None: + # The API names the volume as missing, with its own code, but the create failed. + failed = missing(name="ghost", noun="volume") | {"name": "web"} + recorder = queued([(200, envelope({"instances": [failed]}, status="error"))]) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(NotFoundError): + await ukc.instances.create( + image="org/app:latest", volumes=[{"name": "ghost", "at": "/data"}] + ).delete(missing_ok=True) + assert [call.method for call in recorder.calls] == ["POST"] + + async def test_a_forgiven_absence_is_still_a_not_found_to_a_resolve(self) -> None: + recorder = cloud(instances=lambda where: []) + async with client(recorder) as ukc: + handle = ukc.instances.get(name="ghost").delete(missing_ok=True) + assert await handle is None + with pytest.raises(NotFoundError): + await handle.where() + with pytest.raises(NotFoundError): + await handle.plugin("sandbox").route() + + async def test_missing_ok_forgives_no_bare_404(self) -> None: + recorder = Recorder(lambda request: httpx.Response(404)) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(NotFoundError): + await ukc.instances.get(name="ghost").delete(missing_ok=True) + + async def test_a_set_delete_that_partly_failed_reports_no_forgiven_match(self) -> None: + def route(request: httpx.Request) -> tuple[int, Any]: + if request.url.path == "/v1/metros": + return 200, envelope({"metros": METROS}) + if request.method == "DELETE": + if metro_of(request) == "fra": + return 200, envelope({"instances": [missing(name="web")]}, status="error") + return 404, {"status": "error", "message": "no such route"} + return 200, envelope({"instances": [instance(f"{metro_of(request)}-1", "web")]}) + + recorder = routed(route) + async with client(recorder) as ukc: + with pytest.raises(MetroFanoutError) as caught: + await ukc.instances.each(name="web").delete(missing_ok=True) + assert caught.value.results == [] + assert [failure.metro for failure in caught.value.failures] == ["dal"] + + async def test_a_lookup_route_that_is_not_there_is_a_failure_not_an_absence(self) -> None: + def route(request: httpx.Request) -> tuple[int, Any]: + if request.url.path == "/v1/metros": + return 200, envelope({"metros": METROS}) + return 404, {"status": "error", "message": "no such route"} + + recorder = routed(route) + async with client(recorder) as ukc: + # Every metro's listing is missing: that is not "no metro holds it". + with pytest.raises(MetroFanoutError): + await ukc.instances.get(name="ghost").delete(missing_ok=True) + with pytest.raises(MetroFanoutError): + await ukc.instances.each(name="ghost").delete(missing_ok=True) + assert not [call for call in recorder.calls if call.method == "DELETE"] diff --git a/tests/test_core.py b/tests/test_core.py index 6b08e08..5c02d3a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -322,12 +322,19 @@ def test_returns_the_first_of_a_singleton_list(self) -> None: class TestOrAbsent: - async def test_a_404_becomes_absence(self) -> None: + async def test_a_resource_found_absent_becomes_none(self) -> None: async def missing() -> int: - raise NotFoundError("gone", kind="http", status=404) + raise NotFoundError("gone", absent=True) assert await or_absent(missing()) is None + async def test_a_route_that_is_not_there_is_a_failure(self) -> None: + async def unrouted() -> int: + raise NotFoundError("no such route", body={"status": "error"}) + + with pytest.raises(NotFoundError): + await or_absent(unrouted()) + async def test_any_other_failure_propagates(self) -> None: async def denied() -> int: raise UnikraftCloudError("no", kind="http", status=403) From 77282b5b804d0192307ce6552c44cf21fe19bbe6 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Wed, 23 Sep 2026 18:38:09 +0100 Subject: [PATCH 33/36] feat(resources): Address instances by their tags An instance set could be named only by uuid or name, one match per metro. A caller cleaning up everything a job had created had to list by tag and then delete each match itself, or guess at names. each(tags=[...]) locates every instance in scope carrying the tags and hands back the usual set, so `.delete(missing_ok=True)` covers them all. Tags that select nothing are an empty set rather than a failure, so a cleanup can run again. Signed-off-by: Daniel Vallance --- README.md | 26 ++++++ src/unikraft_cloud/resources/instances.py | 106 +++++++++++++++++++--- tests/test_client.py | 104 +++++++++++++++++++++ 3 files changed, 221 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 57d0a6c..dac1367 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,32 @@ for quota in await ukc.users.quotas(): print(quota.metro, quota.used, quota.hard) ``` +### Instances + +Beyond the operations above, instances can be addressed by their tags, deleted with a wait +and a retry while busy, and asked why they stopped: + +```python +# Every instance in scope carrying all of the tags, however many; nothing matched is an +# empty set, so a cleanup can run again. +await ukc.instances.each(tags=["batch", "job=42"]).delete(missing_ok=True) +async for inst in ukc.instances.list(tags=["batch"], details=True): + ... + +# Wait for the deletion (-1 for as long as the platform allows), forgive an instance +# that is already gone, and keep trying while something still holds on to it. The read +# timeout is stretched to outlast the wait only when no `timeout` was given. +await fra.instances.get(name="relay").delete(timeout_seconds=60, missing_ok=True, retry_busy=20) + +from unikraft_cloud import StopReason + +inst = await fra.instances.get(name="web") +if inst.state == "stopped": + print(inst.describe_stop()) # e.g. "kernel crash: out of memory (ENOMEM)" + if inst.stop and inst.stop.reason & StopReason.KERNEL: + print(inst.stop.kernel_code) +``` + ### Images The metros report what their nodes have cached; the registry says what a node can pull. diff --git a/src/unikraft_cloud/resources/instances.py b/src/unikraft_cloud/resources/instances.py index c74f2a0..cd7fbbd 100644 --- a/src/unikraft_cloud/resources/instances.py +++ b/src/unikraft_cloud/resources/instances.py @@ -18,7 +18,7 @@ from ..api.platform import models from ..api.platform.instances_gen import InstancesApi from ..core.errors import NotFoundError, ResponseError, UnikraftCloudError, WaitTimeoutError -from ..core.fanout import MetroFanoutError, fanout +from ..core.fanout import MetroFanoutError, fanout, fanout_collect from ..core.handle import HandleSteps, Located, MetroTarget, ResourceHandle from ..core.handle_set import HandleSet from ..core.http import UNSET, CallOptions, TimeoutOption, comma_separated @@ -947,25 +947,38 @@ def each( *, uuid: str | None = None, name: str | None = None, + tags: Sequence[str] | None = None, metro: str | None = None, metros: MetroScope | None = None, headers: Mapping[str, str] | None = None, base_url: str | None = None, timeout: TimeoutOption = UNSET, ) -> InstanceSet: - """Reference every instance matching a name -- one per metro that holds it. + """Reference every instance matching a name, or every instance carrying some tags. A name can exist in several metros at once; this addresses all of them, - where :meth:`get` insists you pick one. + one per metro that holds it, where :meth:`get` insists you pick one. + + ``tags`` instead selects every instance in scope carrying all of them, + however many a metro holds. Tags that select nothing are an empty set + rather than a failure, so a cleanup can run again. .. code-block:: python await ukc.instances.each(name="web").suspend() # in every metro [inst.metro for inst in await ukc.instances.each(name="web")] + await ukc.instances.each(tags=["job=1234"]).delete(missing_ok=True) """ - ref = Ref(uuid=uuid, name=name, metro=metro) call = options(headers, base_url, timeout) opts = scoped(call, metros) + if tags is not None: + if uuid is not None or name is not None or metro is not None: + raise TypeError( + "`tags` selects instances on its own: drop `uuid`, `name` and `metro`, " + "or narrow the metros with `metros`." + ) + return self._tagged(list(tags), call, opts) + ref = Ref(uuid=uuid, name=name, metro=metro) async def locate() -> builtins.list[InstanceHandle[Instance]]: located = await self._locate_all( @@ -987,6 +1000,56 @@ async def locate() -> builtins.list[InstanceHandle[Instance]]: return InstanceSet(locate, f"instance {describe_ref(ref)}") + def _tagged( + self, tags: builtins.list[str], call: CallOptions, opts: ScopeOptions + ) -> InstanceSet: + """Every instance in scope carrying all of ``tags``, each already located.""" + if not tags: + raise TypeError( + "`tags` needs at least one tag: none at all would select every instance." + ) + what = f"instances tagged {', '.join(tags)}" + + async def in_metro(endpoint: MetroEndpoint) -> builtins.list[Located[Instance]]: + found = self._pages(endpoint, opts, details=True, tags=tags, page_size=None) + return [ + Located( + target=MetroTarget( + metro=endpoint.metro, base_url=endpoint.base_url, ref=_ref_of(inst) + ), + value=inst, + ) + async for inst in found + ] + + async def locate() -> builtins.list[InstanceHandle[Instance]]: + endpoints = await self._endpoints(opts) + if len(endpoints) == 1: + hits = await in_metro(endpoints[0]) + else: + try: + hits = await fanout_collect(endpoints, in_metro) + except MetroFanoutError as err: + # What did arrive is reported as instances, as every other + # partial answer is, not as the located matches built here. + err.results = [hit.value for hit in err.results] + raise + return [ + InstanceHandle( + self, + HandleSteps( + locate=resolved(hit), + fetch=lambda target: self.read(target, opts), + what=what, + located=True, + options=call, + ), + ) + for hit in hits + ] + + return InstanceSet(locate, what) + def list( self, *, @@ -1019,23 +1082,36 @@ async def merged() -> AsyncIterator[Instance]: endpoints = await self._endpoints(opts) def per_metro(endpoint: MetroEndpoint) -> AsyncIterator[Instance]: - async def fetch_page(count: int, start: str | None) -> builtins.list[Instance]: - res = await self.api.get_instances( - count=count, - from_=start, - details=details, - tags=comma_separated(tags), - **self._call(endpoint, opts), - ) - return list_tagged(res, _KEY, endpoint.metro, Instance) - - return paginate(fetch_page, lambda inst: inst.uuid, page_size) + return self._pages(endpoint, opts, details=details, tags=tags, page_size=page_size) async for instance in fanout(endpoints, per_metro): yield instance return Listing(merged()) + def _pages( + self, + endpoint: MetroEndpoint, + opts: ScopeOptions, + *, + details: bool | None, + tags: Sequence[str] | None, + page_size: int | None, + ) -> AsyncIterator[Instance]: + """One metro's listing, page by page, every instance tagged with the metro.""" + + async def fetch_page(count: int, start: str | None) -> builtins.list[Instance]: + res = await self.api.get_instances( + count=count, + from_=start, + details=details, + tags=comma_separated(tags), + **self._call(endpoint, opts), + ) + return list_tagged(res, _KEY, endpoint.metro, Instance) + + return paginate(fetch_page, lambda inst: inst.uuid, page_size) + async def delete( self, refs: RefLike | Sequence[RefLike], diff --git a/tests/test_client.py b/tests/test_client.py index 87fa01e..d7ad5b8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1327,3 +1327,107 @@ def route(request: httpx.Request) -> tuple[int, Any]: with pytest.raises(MetroFanoutError): await ukc.instances.each(name="ghost").delete(missing_ok=True) assert not [call for call in recorder.calls if call.method == "DELETE"] + + +class TestEachByTags: + def _tagged(self, per_metro: Callable[[str], list[dict[str, Any]]]) -> Recorder: + """A platform whose tag listings differ per metro, and which deletes what it lists.""" + + def route(request: httpx.Request) -> tuple[int, Any]: + if request.url.path == "/v1/metros": + return 200, envelope({"metros": METROS}) + where = metro_of(request) + if request.method == "DELETE": + sent = json.loads(request.content) + if where == "dal" and sent[0].get("uuid") == "gone": + return 200, envelope({"instances": [missing(uuid="gone")]}, status="error") + return 200, envelope( + {"instances": [changed_instance(item["uuid"]) for item in sent]} + ) + rows = [] if request.url.params.get("from") else per_metro(where) + return 200, envelope({"instances": rows}) + + return routed(route) + + async def test_addresses_every_match_in_every_metro(self) -> None: + recorder = self._tagged(lambda where: [instance(f"{where}-1"), instance(f"{where}-2")]) + async with client(recorder) as ukc: + deleted = await ukc.instances.each(tags=["batch", "job=1"]).delete() + assert sorted((item.metro, item.uuid) for item in deleted) == [ + ("dal", "dal-1"), + ("dal", "dal-2"), + ("fra", "fra-1"), + ("fra", "fra-2"), + ] + listings = [ + call for call in recorder.calls if call.method == "GET" and "instances" in call.url.path + ] + assert all(call.url.params["tags"] == "batch,job=1" for call in listings) + assert all(call.url.params["details"] == "true" for call in listings) + deletes = [call for call in recorder.calls if call.method == "DELETE"] + assert sorted(metro_of(call) for call in deletes) == ["dal", "dal", "fra", "fra"] + + async def test_the_matches_are_read_without_another_request(self) -> None: + recorder = self._tagged(lambda where: [instance(f"{where}-1")] if where == "fra" else []) + async with client(recorder) as ukc: + matches = ukc.instances.each(tags=["job=1"]) + assert await matches.where() == ["fra"] + assert [inst.uuid for inst in await matches] == ["fra-1"] + assert all(call.method == "GET" for call in recorder.calls) + assert sorted(recorder.metros("/v1/instances")) == ["dal", "fra"] + + async def test_tags_selecting_nothing_are_an_empty_set(self) -> None: + recorder = self._tagged(lambda where: []) + async with client(recorder) as ukc: + matches = ukc.instances.each(tags=["job=none"]) + assert await matches.size() == 0 + assert await matches.delete() == [] + + async def test_a_lookup_route_that_is_not_there_is_raised_at_once(self) -> None: + recorder = routed(lambda request: (404, {"status": "error", "message": "no such route"})) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(NotFoundError) as caught: + await ukc.instances.each(tags=["batch"]).delete(missing_ok=True) + assert not caught.value.absent + assert [call.method for call in recorder.calls] == ["GET"] + + async def test_a_match_gone_before_its_delete_is_left_out(self) -> None: + recorder = self._tagged( + lambda where: [instance("gone")] if where == "dal" else [instance("kept")] + ) + async with client(recorder) as ukc: + deleted = await ukc.instances.each(tags=["job=1"]).delete(missing_ok=True) + assert [(item.metro, item.uuid) for item in deleted] == [("fra", "kept")] + + async def test_a_metro_that_fails_reports_the_instances_that_did_arrive(self) -> None: + def route(request: httpx.Request) -> tuple[int, Any]: + if request.url.path == "/v1/metros": + return 200, envelope({"metros": METROS}) + if metro_of(request) == "dal": + return 500, {"status": "error", "message": "dal is down"} + rows = [] if request.url.params.get("from") else [instance("fra-1")] + return 200, envelope({"instances": rows}) + + recorder = routed(route) + async with client(recorder) as ukc: + with pytest.raises(MetroFanoutError) as caught: + await ukc.instances.each(tags=["job=1"]).delete() + assert [failure.metro for failure in caught.value.failures] == ["dal"] + assert [(inst.metro, inst.uuid) for inst in caught.value.results] == [("fra", "fra-1")] + assert not [call for call in recorder.calls if call.method == "DELETE"] + + async def test_a_pinned_client_asks_its_one_metro_only(self) -> None: + recorder = self._tagged(lambda where: [instance(f"{where}-1")]) + async with client(recorder, metro="fra") as ukc: + deleted = await ukc.instances.each(tags=["job=1"]).delete() + assert [(item.metro, item.uuid) for item in deleted] == [("fra", "fra-1")] + assert "/v1/metros" not in recorder.paths + + async def test_tags_stand_alone(self) -> None: + recorder = self._tagged(lambda where: []) + async with client(recorder) as ukc: + with pytest.raises(TypeError, match="on its own"): + ukc.instances.each(name="web", tags=["job=1"]) + with pytest.raises(TypeError, match="at least one tag"): + ukc.instances.each(tags=[]) + assert recorder.calls == [] From ffb7cd978107bf7dfdc22155db2a83a0e70a1411 Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:37:01 +0100 Subject: [PATCH 34/36] feat(resources): Add the templates client Templates had plumbing only. Preparing one meant creating a seed clone, waiting for the template to be listed and deleting the seed by hand, and cloning meant building the create request yourself. Add Templates, reached as scope.templates: prepare makes a template from an instance specification, in the one metro the scope names, and get(...).clone stamps instances out of it; list, each and delete work as for every other resource. examples/templates.py shows it. Signed-off-by: Daniel Vallance --- README.md | 25 +- examples/templates.py | 62 +++ src/unikraft_cloud/__init__.py | 12 + src/unikraft_cloud/client.py | 3 + src/unikraft_cloud/resources/instances.py | 36 +- src/unikraft_cloud/resources/templates.py | 644 ++++++++++++++++++++++ tests/test_client.py | 38 ++ tests/test_resources.py | 3 +- tests/test_templates.py | 596 ++++++++++++++++++++ 9 files changed, 1408 insertions(+), 11 deletions(-) create mode 100644 examples/templates.py create mode 100644 src/unikraft_cloud/resources/templates.py create mode 100644 tests/test_templates.py diff --git a/README.md b/README.md index dac1367..8d7f42e 100644 --- a/README.md +++ b/README.md @@ -285,8 +285,8 @@ except MetroFanoutError as err: ## Resources -`instances`, `images`, `volumes`, `services`, `certificates` and `users` hang off any -scope — `ukc`, `ukc.metro("fra")` or `ukc.metros([...])`. +`instances`, `templates`, `images`, `volumes`, `services`, `certificates` and `users` +hang off any scope — `ukc`, `ukc.metro("fra")` or `ukc.metros([...])`. Creating one takes the properties the API describes as keyword arguments, and a property it does not have is a `TypeError` rather than a field the server quietly ignores. @@ -342,6 +342,26 @@ if not await ukc.images.exists("org/app:1.2.3"): found = await ukc.images.find("org/app@sha256:...") # a tag of that digest, or None ``` +### Templates + +A template is a snapshotted instance that new instances are cloned from. +`prepare` makes sure one exists, building it from an instance specification when it does +not; `clone` stamps instances out of it. The API types that specification as an instance, +so its image and its plugins' images are plain references, not objects with a pull policy +or credentials, and its `gpus` is the list an instance reports, not the count a create +takes. The instance that becomes the template decides the moment it is snapshotted by +writing `1` to `/uk/libukp/template_instance`. + +```python +fra = ukc.metro("fra") +await fra.templates.prepare("worker-v3", create_args={"image": "org/worker:v3", "memory_mb": 1024}) +job = await fra.templates.get(name="worker-v3").clone( + roms=[{"name": "job", "at": "/mnt/job", "files": [{"path": "job.json", "data": "{}"}]}], + volumes=[{"size_mb": 512, "at": "/tmp"}], + features=["delete-on-stop"], +) +``` + ## The sandbox plugin A plugin is a helper loaded into an instance beside its workload, and reached through the @@ -447,6 +467,7 @@ async with PlatformApi(config) as api: - [`examples/quickstart.py`](examples/quickstart.py) — create, wait, read logs, list, suspend, delete - [`examples/update.py`](examples/update.py) — patch objects and the staged editor - [`examples/sandbox.py`](examples/sandbox.py) — run commands and move files through the sandbox plugin +- [`examples/templates.py`](examples/templates.py) — look an image up, prepare a template, clone it - [`examples/plumbing.py`](examples/plumbing.py) — the raw API on its own ## Development diff --git a/examples/templates.py b/examples/templates.py new file mode 100644 index 0000000..93b521c --- /dev/null +++ b/examples/templates.py @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Instance templates: prepare one, clone instances out of it, and look an +# image up in the registry first. Run with: +# UKC_TOKEN=... uv run examples/templates.py /: + +from __future__ import annotations + +import asyncio +import sys + +from unikraft_cloud import UnikraftCloud, UnikraftCloudError + + +async def main(image: str) -> None: + async with UnikraftCloud() as ukc: # token from UKC_TOKEN + fra = ukc.metro("fra") + + # The registry, not a node's cache, says whether the image can be pulled. + found = await ukc.images.find(image) + if found is None: + raise SystemExit(f"{image} is not in the registry") + print(f"{found.reference} is {found.size} bytes, digest {found.digest}") + + # The guest decides when it is snapshotted by writing to the template + # control file; this one does so at once. A template that already exists + # is read and returned, so this is safe to call every time. + template = await fra.templates.prepare( + "sdk-example", + create_args={ + "image": image, + "memory_mb": 512, + "args": ["/bin/sh", "-c", "echo 1 > /uk/libukp/template_instance; sleep infinity"], + "tags": ["sdk-example"], + }, + prepare_timeout_seconds=120, + ) + print(f"template {template.name} ({template.uuid}) is {template.state}") + + # A clone takes what an instance create takes, apart from the image and + # the template. + clone = await fra.templates.get(name="sdk-example").clone( + tags=["sdk-example"], autostart=True, timeout_s=60 + ) + print(f"clone {clone.name} is {clone.state}") + + # Tags address everything the example made, so the cleanup is one call + # per resource type and can run again. + await fra.instances.each(tags=["sdk-example"]).delete(missing_ok=True) + await fra.templates.each(tags=["sdk-example"]).delete(missing_ok=True) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} /:", file=sys.stderr) + raise SystemExit(2) + try: + asyncio.run(main(sys.argv[1])) + except UnikraftCloudError as err: + print(f"API error ({err.status or '?'}): {err}", file=sys.stderr) + raise SystemExit(1) from err diff --git a/src/unikraft_cloud/__init__.py b/src/unikraft_cloud/__init__.py index 933795d..17217cc 100644 --- a/src/unikraft_cloud/__init__.py +++ b/src/unikraft_cloud/__init__.py @@ -106,6 +106,13 @@ UpdatedServiceGroup, ) from .resources.stop import KernelStopCode, KernelStopReason, PlatformStopCode, Stop, StopReason +from .resources.templates import ( + DeletedTemplate, + Template, + TemplateHandle, + Templates, + TemplateSet, +) from .resources.users import Quotas, Users from .resources.volumes import ( AttachedVolume, @@ -146,6 +153,7 @@ "DeletedCertificate", "DeletedInstance", "DeletedServiceGroup", + "DeletedTemplate", "DeletedVolume", "DetachedVolume", "ErrorKind", @@ -219,6 +227,10 @@ "StopReason", "StoppedInstance", "SuspendedInstance", + "Template", + "TemplateHandle", + "TemplateSet", + "Templates", "TimeoutOption", "UnikraftCloud", "UnikraftCloudError", diff --git a/src/unikraft_cloud/client.py b/src/unikraft_cloud/client.py index 3d62f05..c21ed1d 100644 --- a/src/unikraft_cloud/client.py +++ b/src/unikraft_cloud/client.py @@ -26,6 +26,7 @@ from .resources.images import Images from .resources.instances import Instances from .resources.service_groups import ServiceGroups +from .resources.templates import Templates from .resources.users import Users from .resources.volumes import Volumes @@ -47,6 +48,8 @@ def __init__(self, session: Session, scope: MetroScope) -> None: self.scope = scope #: Instances (microVMs). self.instances = Instances(session, scope) + #: Instance templates, which new instances are cloned from. + self.templates = Templates(session, scope, self.instances) #: Images: what each metro's nodes cache, and what the registry holds. self.images = Images(session, scope) #: Persistent volumes. diff --git a/src/unikraft_cloud/resources/instances.py b/src/unikraft_cloud/resources/instances.py index cd7fbbd..ddbe871 100644 --- a/src/unikraft_cloud/resources/instances.py +++ b/src/unikraft_cloud/resources/instances.py @@ -278,12 +278,27 @@ def _first_state(res: BaseModel) -> str | None: def _create_wait_seconds(spec: Mapping[str, Any]) -> float | None: - """How long a create asks the API to wait for the instance to run, in seconds. + """How long a create asks the API to hold the connection, in seconds. - ``timeout_s`` wins; the deprecated ``wait_timeout_ms`` is read when it is - the only one given, rounded up to whole seconds. ``None`` is a create that - does not wait; less than zero, one that waits as long as the API lets it. + ``timeout_s`` is the wait for the instance to run; the deprecated + ``wait_timeout_ms`` is read when it is the only one given, rounded up to + whole seconds. A template prepared on the way adds ``prepare_timeout_s``, + or a wait the API bounds itself when none was given. ``None`` is a create + that does not wait; less than zero, one that waits as long as the API lets it. """ + seconds = _run_wait_seconds(spec) + template = spec.get("template") + if template is None or not _field_of(template, "prepare"): + return seconds + prepare = _field_of(template, "prepare_timeout_s") + if not _is_number(prepare) or prepare <= 0: + return -1 + if seconds is None: + return prepare + return -1 if seconds < 0 else seconds + prepare + + +def _run_wait_seconds(spec: Mapping[str, Any]) -> float | None: seconds = spec.get("timeout_s") if _is_number(seconds): return seconds @@ -298,6 +313,11 @@ def _is_number(value: Any) -> TypeGuard[float]: return isinstance(value, (int, float)) and not isinstance(value, bool) +def _field_of(spec: Any, name: str) -> Any: + """A field of a specification given as a mapping or as a model.""" + return spec.get(name) if isinstance(spec, Mapping) else getattr(spec, name, None) + + def _outlives( timeout: float | httpx.Timeout | None, seconds: float | None ) -> float | httpx.Timeout | None: @@ -857,9 +877,9 @@ def create( """ call = options(headers, base_url, timeout) check_spec(spec, models.CreateInstanceRequest, self.noun) - # A create that waits for the instance to run, by `timeout_s` or the - # deprecated `wait_timeout_ms`, holds the connection that long, so the - # read timeout outlasts it, as it does for `wait()`. + # A create that waits for the instance to run, or prepares a template on + # the way, holds the connection that long, so the read timeout outlasts + # it, as it does for `wait()`. wait = _create_wait_seconds(spec) if wait is not None and "timeout" not in call: call["timeout"] = _outlives(self._session.platform.timeout, wait) @@ -879,7 +899,7 @@ async def created() -> Located[Instance]: # the way `wait` does; an instance that stopped, whatever the # message says, is a failed item read for its stop below. if wait is not None and _first_state(res) not in ("stopped", "deleted"): - _timed_out(res, "running") + _timed_out(res, "running" if _run_wait_seconds(spec) is not None else None) try: instance = first_tagged(res, _KEY, endpoint.metro, Instance, "instance") except UnikraftCloudError as err: diff --git a/src/unikraft_cloud/resources/templates.py b/src/unikraft_cloud/resources/templates.py new file mode 100644 index 0000000..f9229f2 --- /dev/null +++ b/src/unikraft_cloud/resources/templates.py @@ -0,0 +1,644 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Instance templates: a snapshotted instance that new ones are cloned from. The +# platform prepares a template as a side effect of cloning it, so preparing one +# here creates a stopped clone and deletes it again; the templates themselves +# are instances in the `template` state, read through the instances API. + +from __future__ import annotations + +import asyncio + +# `Templates.list` shadows the builtin inside that class body, so annotations +# there spell the builtin out. +import builtins +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from dataclasses import replace +from typing import Any, Literal, TypeVar, overload + +import httpx +from pydantic import BaseModel + +from ..api.platform import models +from ..api.platform.instances_gen import InstancesApi +from ..core.errors import NotFoundError +from ..core.fanout import MetroFanoutError, fanout, fanout_collect +from ..core.handle import HandleSteps, Located, MetroTarget, ResourceHandle +from ..core.handle_set import HandleSet +from ..core.http import UNSET, CallOptions, TimeoutOption, Unset, comma_separated +from ..core.metro import MetroEndpoint, MetroScope +from ..core.pagination import Listing, paginate +from ..core.resource import ( + MetroGroup, + Resource, + ScopeOptions, + check_spec, + list_tagged, + refs_matching, +) +from ..core.response import Ref, RefLike, describe_ref, matched_entries, or_absent +from ..core.session import Session +from ._shared import ( + at_metro, + filter_of, + filters_for, + options, + ref_dict, + resolved, + scoped, + tag_first, +) +from .instances import Instance, InstanceHandle, Instances, _ref_of + +__all__ = [ + "DeletedTemplate", + "Template", + "TemplateHandle", + "TemplateSet", + "Templates", +] + +T = TypeVar("T") +V = TypeVar("V") + +#: Templates are instances in the ``template`` state, and the API reports them +#: under the same payload key as instances. +_KEY = "instances" +_NOUN = "template" +#: How long `prepare` waits for a prepared template to be listed when the +#: caller set no preparation timeout, and how it paces its reads. +_PREPARE_WAIT_SECONDS = 300.0 +_PREPARE_POLL_INTERVAL = 0.5 +_PREPARE_POLL_MAX_INTERVAL = 3.0 + +# Named so tests can stand in for it and assert the backoff without waiting. +_sleep = asyncio.sleep + + +def _within(timeout: float | httpx.Timeout | None, seconds: float) -> httpx.Timeout: + """The client's timeout with its read bounded by ``seconds``, a second at least.""" + current = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout) + read = seconds if current.read is None else min(current.read, seconds) + return httpx.Timeout( + connect=current.connect, read=max(read, 1.0), write=current.write, pool=current.pool + ) + + +class Template(models.Instance): + """An instance template, tagged with the metro that holds it.""" + + metro: str + + +class DeletedTemplate(models.DeleteTemplateInstancesResponseTemplateInstance): + """What a delete reported, tagged with the metro that served it.""" + + metro: str + + +class TemplateHandle(ResourceHandle[T]): + """A chainable reference to one template in one metro. + + .. code-block:: python + + tpl = await ukc.templates.get(name="worker") + web = await ukc.templates.get(name="worker").clone(name="web") + await ukc.templates.get(name="worker").delete() + """ + + def __init__(self, templates: Templates, steps: HandleSteps[T]) -> None: + super().__init__(steps) + self._templates = templates + + def refresh( + self, + *, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> TemplateHandle[Template]: + """Re-read the template's full details.""" + opts = self._options(headers, base_url, timeout) + return self._then(lambda target: self._templates.read(target, opts), opts) + + @overload + def delete( + self, + *, + missing_ok: Literal[False] = False, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> TemplateHandle[DeletedTemplate]: ... + + @overload + def delete( + self, + *, + missing_ok: Literal[True], + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> TemplateHandle[DeletedTemplate | None]: ... + + def delete( + self, + *, + missing_ok: bool = False, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> TemplateHandle[Any]: + """Delete the template. + + With ``missing_ok`` a template that is not there resolves to ``None`` + instead of raising :class:`NotFoundError`. Instances cloned from it are + left alone: they carry their own copy of the snapshot. + """ + opts = self._options(headers, base_url, timeout) + + async def run(target: MetroTarget) -> DeletedTemplate | None: + body = [models.NameOrUUID.model_validate(ref_dict(target.ref))] + try: + res = await self._templates.api.delete_template_instances( + body=body, **at_metro(target, opts) + ) + return tag_first(res, _KEY, target, DeletedTemplate, _NOUN) + except NotFoundError as err: + # The API losing the template is forgiven; a route that is not there is not. + if not missing_ok or not err.absent: + raise + return None + + steps = self._chained(run, opts) + if missing_ok: + steps = replace(steps, absent=lambda: None) + return TemplateHandle(self._templates, steps) + + def clone( + self, + *, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + **spec: Any, + ) -> InstanceHandle[Instance]: + """Create an instance from this template and return a handle to it. + + The clone is created in the metro that holds the template. ``spec`` + takes what an instance create takes, apart from ``image`` and + ``template``, which the template already settles: attach ``roms`` for + per-instance data, ``volumes`` for scratch space, ``schedules``, and + so on. + + .. code-block:: python + + job = await ukc.templates.get(name="worker").clone( + roms=[{"name": "job", "at": "/mnt/job", "files": [...]}], + features=["delete-on-stop"], + ) + """ + # The clone's own create does its validation and locate step; this + # handle only supplies the metro and the reference of the template. + self._consumed = True + for settled in ("image", "template"): + if settled in spec: + raise TypeError( + f"`{settled}` is settled by the template a clone is made from; " + "drop it from the clone's properties." + ) + # Checked here, when called, so a misspelled property is a TypeError at + # once rather than when the handle is awaited, as it is for a create. + check_spec(spec, models.CreateInstanceRequest, "instance") + opts = self._options(headers, base_url, timeout) + instances = self._templates.instances + + async def created() -> Located[Instance]: + target = await self._next() + # The clone goes where the template was found: an endpoint discovery + # reported, which the metro's code alone would not rebuild. + made = instances.create( + template=ref_dict(target.ref), + metros=target.metro, + headers=opts.get("headers"), + base_url=opts.get("base_url", target.base_url), + timeout=opts.get("timeout", UNSET), + **spec, + ) + located = await made.resolve() + if "base_url" not in opts: + # Sent to a base URL, the create names its metro by it; the + # metro is the one the template was found in. + located = replace(located, metro=target.metro) + return Located(target=located) + + return InstanceHandle( + instances, + HandleSteps( + locate=created, + fetch=lambda target: instances.read(target, opts), + what=f"the instance cloned from {self._steps.what}", + options=opts, + ), + ) + + def _then( + self, fetch: Callable[[MetroTarget], Awaitable[V]], opts: CallOptions + ) -> TemplateHandle[V]: + """Chain another operation onto this handle.""" + return TemplateHandle(self._templates, self._chained(fetch, opts)) + + +class TemplateSet(HandleSet["TemplateHandle[Template]", Template]): + """Every template matching one reference, or carrying some tags. + + .. code-block:: python + + await ukc.templates.each(tags=["app=web"]).delete(missing_ok=True) + """ + + async def refresh(self, **opts: Any) -> list[Template]: + """Re-read every match's full details.""" + return await self._map(lambda handle: handle.refresh(**opts)) + + async def delete(self, **opts: Any) -> list[DeletedTemplate]: + """Delete every match. + + With ``missing_ok``, a match already gone is left out, and a reference + that matches nothing deletes nothing rather than raising. + """ + if opts.get("missing_ok") and await self._absent(): + return [] + try: + deleted = await self._map(lambda handle: handle.delete(**opts)) + except MetroFanoutError as err: + # The matches already gone are no result of the metros that did delete. + err.results = [item for item in err.results if item is not None] + raise + return [item for item in deleted if item is not None] + + +class Templates(Resource[InstancesApi]): + """Idiomatic client for instance templates. + + A template is a snapshotted instance that new instances are cloned from. + :meth:`prepare` makes one from an ordinary instance specification; + :meth:`TemplateHandle.clone` stamps instances out of it. + """ + + noun = _NOUN + key = _KEY + + def __init__(self, session: Session, scope: MetroScope, instances: Instances) -> None: + super().__init__(session, scope, InstancesApi(session.platform)) + #: The scope's instance client, which clones are created through. + self.instances = instances + + async def prepare( + self, + name: str, + *, + create_args: Mapping[str, Any], + autokill_ms: int | None = None, + prepare_timeout_seconds: int | None = None, + metros: MetroScope | None = None, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> Template: + """Make sure a template exists, preparing it from ``create_args`` when it does not. + + ``create_args`` describes the instance that becomes the template, with + the properties an instance create takes, save that the API types it as + an instance: its image, or a plugin's, is a plain reference rather than + an object with a pull policy or credentials, and ``gpus`` is the list + an instance reports rather than a count. The instance's guest writes + ``1`` to ``/uk/libukp/template_instance`` once its own initialisation + is done, and that write takes the snapshot. The call blocks until + then: for ``prepare_timeout_seconds`` at most when it is positive, and + otherwise for the bound the platform sets, with the template awaited + for five minutes at most. A template that already exists is read and + returned, and nothing else is sent. Like a create, this targets the + one metro the scope names, where the template is looked for and + prepared. + + The platform prepares a template as a side effect of cloning it, so this + creates one stopped clone and deletes it again once the template is + listed. Should the template never be listed, the clone is left in place + for inspection and :class:`NotFoundError` is raised. ``autokill_ms`` + deletes the template once nothing has cloned it for that long. + + .. code-block:: python + + tpl = await ukc.metro("fra").templates.prepare( + "worker-v3", + create_args={"image": "org/worker:v3", "memory_mb": 1024}, + ) + """ + check_spec(create_args, models.CreateInstanceRequest, "template's source instance") + # The seed would go to one metro, so that is where the template is + # looked for: one listed elsewhere is not the one a clone here would use. + where = ( + await self._one_endpoint( + "Preparing a template", scoped(options(headers, base_url, timeout), metros) + ) + ).metro + # A template that exists needs no seed: one read says so. + try: + return await self.get( + name=name, metros=where, headers=headers, base_url=base_url, timeout=timeout + ) + except NotFoundError as err: + # Only its absence is a reason to seed; a route that is not there is not. + if not err.absent: + raise + template: dict[str, Any] = { + "name": name, + "prepare": True, + "create_args": dict(create_args), + } + if autokill_ms is not None: + template["autokill"] = {"time_ms": autokill_ms} + if prepare_timeout_seconds is not None: + template["prepare_timeout_s"] = prepare_timeout_seconds + # The seed carries the source's tags, so one that outlives a failed + # preparation is still found by whatever cleans the deployment up. + tags = create_args.get("tags") + # The create knows it prepares a template, and holds the connection + # for as long as the platform may take. + seed = self.instances.create( + template=template, + autostart=False, + metros=where, + headers=headers, + base_url=base_url, + timeout=timeout, + **({"tags": list(tags)} if tags else {}), + ) + target = await seed.resolve() + # The create answers as soon as the clone exists; the template it was + # prepared from can take a moment longer to be listed. Wait for it + # before removing the seed clone, so the preparation is never cut short. + prepared = await self._await_prepared( + name, + deadline_seconds=( + prepare_timeout_seconds + if prepare_timeout_seconds is not None and prepare_timeout_seconds > 0 + else _PREPARE_WAIT_SECONDS + ), + metros=where, + headers=headers, + base_url=base_url, + timeout=timeout, + ) + await self.instances.get(uuid=target.ref.uuid, name=target.ref.name, metros=where).delete( + missing_ok=True, headers=headers, base_url=base_url, timeout=timeout + ) + return prepared + + async def _await_prepared( + self, + name: str, + *, + deadline_seconds: float, + metros: MetroScope | None, + headers: Mapping[str, str] | None, + base_url: str | None, + timeout: TimeoutOption, + ) -> Template: + """Read the template once it is listed, retrying with backoff until the deadline. + + Each read is bounded by what is left of the deadline, so a read that + stalls cannot outlive it, and a route that is not there is not waited out. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + deadline_seconds + interval = _PREPARE_POLL_INTERVAL + while True: + remaining = deadline - loop.time() + bounded = ( + _within(self._session.platform.timeout, remaining) + if isinstance(timeout, Unset) + else timeout + ) + try: + return await self.get( + name=name, metros=metros, headers=headers, base_url=base_url, timeout=bounded + ) + except NotFoundError as err: + if not err.absent: + raise + remaining = deadline - loop.time() + if remaining <= 0: + raise + await _sleep(min(interval, remaining)) + interval = min(interval * 2, _PREPARE_POLL_MAX_INTERVAL) + + def get( + self, + *, + uuid: str | None = None, + name: str | None = None, + metro: str | None = None, + metros: MetroScope | None = None, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> TemplateHandle[Template]: + """Reference a single template by ``name`` or ``uuid``.""" + ref = Ref(uuid=uuid, name=name, metro=metro) + call = options(headers, base_url, timeout) + opts = scoped(call, metros) + return TemplateHandle( + self, + HandleSteps( + locate=lambda: self._locate( + ref, opts, lambda endpoint: self._find(endpoint, ref, opts) + ), + fetch=lambda target: self.read(target, opts), + what=f"template {describe_ref(ref)}", + options=call, + lookup=True, + ), + ) + + def each( + self, + *, + uuid: str | None = None, + name: str | None = None, + tags: Sequence[str] | None = None, + metro: str | None = None, + metros: MetroScope | None = None, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> TemplateSet: + """Reference every template matching a name, or every one carrying some tags.""" + call = options(headers, base_url, timeout) + opts = scoped(call, metros) + if tags is not None: + if uuid is not None or name is not None or metro is not None: + raise TypeError( + "`tags` selects templates on its own: drop `uuid`, `name` and `metro`, " + "or narrow the metros with `metros`." + ) + return self._tagged(list(tags), call, opts) + ref = Ref(uuid=uuid, name=name, metro=metro) + + async def locate() -> builtins.list[TemplateHandle[Template]]: + located = await self._locate_all( + ref, opts, lambda endpoint: self._find(endpoint, ref, opts) + ) + return [ + self._found(hit, f"template {describe_ref(ref)}", call, opts) for hit in located + ] + + return TemplateSet(locate, f"template {describe_ref(ref)}") + + def _tagged( + self, tags: builtins.list[str], call: CallOptions, opts: ScopeOptions + ) -> TemplateSet: + if not tags: + raise TypeError( + "`tags` needs at least one tag: none at all would select every template." + ) + what = f"templates tagged {', '.join(tags)}" + + async def in_metro(endpoint: MetroEndpoint) -> builtins.list[Located[Template]]: + found = self._pages(endpoint, opts, details=True, tags=tags, page_size=None) + return [ + Located( + target=MetroTarget( + metro=endpoint.metro, base_url=endpoint.base_url, ref=_ref_of(tpl) + ), + value=tpl, + ) + async for tpl in found + ] + + async def locate() -> builtins.list[TemplateHandle[Template]]: + endpoints = await self._endpoints(opts) + if len(endpoints) == 1: + hits = await in_metro(endpoints[0]) + else: + try: + hits = await fanout_collect(endpoints, in_metro) + except MetroFanoutError as err: + # What did arrive is reported as templates, as every other + # partial answer is, not as the located matches built here. + err.results = [hit.value for hit in err.results] + raise + return [self._found(hit, what, call, opts) for hit in hits] + + return TemplateSet(locate, what) + + def _found( + self, hit: Located[Template], what: str, call: CallOptions, opts: ScopeOptions + ) -> TemplateHandle[Template]: + return TemplateHandle( + self, + HandleSteps( + locate=resolved(hit), + fetch=lambda target: self.read(target, opts), + what=what, + located=True, + options=call, + ), + ) + + def list( + self, + *, + details: bool | None = None, + tags: Sequence[str] | None = None, + page_size: int | None = None, + metros: MetroScope | None = None, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> Listing[Template]: + """Lazily iterate every template in scope, or await it for a list.""" + opts = scoped(options(headers, base_url, timeout), metros) + + async def merged() -> AsyncIterator[Template]: + endpoints = await self._endpoints(opts) + + def per_metro(endpoint: MetroEndpoint) -> AsyncIterator[Template]: + return self._pages(endpoint, opts, details=details, tags=tags, page_size=page_size) + + async for template in fanout(endpoints, per_metro): + yield template + + return Listing(merged()) + + def _pages( + self, + endpoint: MetroEndpoint, + opts: ScopeOptions, + *, + details: bool | None, + tags: Sequence[str] | None, + page_size: int | None, + ) -> AsyncIterator[Template]: + async def fetch_page(count: int, start: str | None) -> builtins.list[Template]: + res = await self.api.get_template_instances( + count=count, + from_=start, + details=details, + tags=comma_separated(tags), + **self._call(endpoint, opts), + ) + return list_tagged(res, _KEY, endpoint.metro, Template) + + return paginate(fetch_page, lambda tpl: tpl.uuid, page_size) + + async def delete( + self, + refs: RefLike | Sequence[RefLike], + *, + metros: MetroScope | None = None, + headers: Mapping[str, str] | None = None, + base_url: str | None = None, + timeout: TimeoutOption = UNSET, + ) -> builtins.list[DeletedTemplate]: + """Delete one or more templates.""" + opts = scoped(options(headers, base_url, timeout), metros) + + def call(group: MetroGroup) -> Awaitable[BaseModel]: + body = [models.NameOrUUID.model_validate(ref_dict(ref)) for ref in group.refs] + return self.api.delete_template_instances(body=body, **self._call(group.endpoint, opts)) + + return await self._bulk(refs, opts, DeletedTemplate, call) + + async def read(self, target: MetroTarget, opts: CallOptions) -> Template: + """Read one template's full details from the metro it was located in.""" + uuid, name = filter_of(target.ref) + res = await self.api.get_template_instances( + uuid=uuid, name=name, details=True, **self._call(target, opts) + ) + return tag_first(res, _KEY, target, Template, _NOUN) + + async def _find(self, endpoint: MetroEndpoint, ref: Ref, opts: ScopeOptions) -> Template | None: + async def lookup() -> Template | None: + uuid, name = filter_of(ref) + res = await self.api.get_template_instances( + uuid=uuid, name=name, details=True, **self._call(endpoint, opts) + ) + entries = list_tagged(res, _KEY, endpoint.metro, Template) + return entries[0] if entries else None + + return await or_absent(lookup()) + + async def _match( + self, endpoint: MetroEndpoint, refs: Sequence[Ref], opts: ScopeOptions + ) -> builtins.list[Ref]: + async def lookup() -> builtins.list[Ref]: + uuid, name = filters_for(refs) + res = await self.api.get_template_instances( + uuid=uuid, name=name, **self._call(endpoint, opts) + ) + return refs_matching(refs, matched_entries(res, _KEY)) + + return await or_absent(lookup()) or [] diff --git a/tests/test_client.py b/tests/test_client.py index d7ad5b8..2cb8658 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -28,6 +28,7 @@ UnikraftCloudError, WaitTimeoutError, ) +from unikraft_cloud.api.platform import models from unikraft_cloud.resources import instances as instances_module from .conftest import ( @@ -842,6 +843,30 @@ async def test_defaults_to_bounding_connecting_but_not_reading(self) -> None: assert sent["connect"] == 10.0 assert sent["read"] is None + async def test_a_create_that_prepares_a_template_outlasts_the_preparation(self) -> None: + recorder = queued([(200, envelope({"instances": [instance()]}))] * 3) + async with client(recorder, metro="fra", timeout=5.0) as ukc: + seed = {"name": "worker", "prepare": True, "create_args": {"image": "org/app:v1"}} + await ukc.instances.create(template={**seed, "prepare_timeout_s": 120}) + await ukc.instances.create(template={**seed, "prepare_timeout_s": 120}, timeout_s=30) + await ukc.instances.create(template=seed) + creates = [call for call in recorder.calls if call.method == "POST"] + # The preparation and the wait for running are held one after the other; + # a preparation the platform bounds itself is not guessed at. + assert [call.extensions["timeout"]["read"] for call in creates] == [130, 160, None] + + async def test_a_wait_of_zero_is_none_and_a_float_or_a_model_is_read_too(self) -> None: + recorder = queued([(200, envelope({"instances": [instance()]}))] * 4) + async with client(recorder, metro="fra", timeout=5.0) as ukc: + await ukc.instances.get(uuid="u1").delete(timeout_seconds=0) + await ukc.instances.create(image="org/app:latest", timeout_s=30.0) + seed = models.CreateInstanceRequestTemplate( + name="worker", prepare=True, prepare_timeout_s=120 + ) + await ukc.instances.create(template=seed) + reads = [c.extensions["timeout"]["read"] for c in recorder.calls if c.method != "GET"] + assert reads == [5.0, 40.0, 130] + class TestSummaryPayloads: async def test_a_listing_without_details_is_references_only(self) -> None: @@ -1149,6 +1174,19 @@ async def test_an_instance_read_back_as_deleted_is_gone_already(self) -> None: assert (caught.value.instance.state, caught.value.stop) == ("deleted", None) assert str(caught.value).endswith("; it is gone already") + async def test_a_seed_that_timed_out_preparing_was_not_waiting_to_run(self) -> None: + still = {"status": "error", "uuid": "u1", "name": "seed", "state": "starting"} + lapsed = envelope({"instances": [still]}, status="error", message="Operation timed out") + recorder = queued([(200, lapsed)]) + async with client(recorder, metro="fra") as ukc: + with pytest.raises(WaitTimeoutError) as caught: + await ukc.instances.create( + template={"name": "w", "prepare": True, "create_args": {"image": "x"}}, + autostart=False, + ) + assert "'running'" not in str(caught.value) + assert caught.value.state == "starting" + class TestDeleting: async def test_missing_ok_forgives_no_operation_that_failed_before_it(self) -> None: diff --git a/tests/test_resources.py b/tests/test_resources.py index 58168aa..335cd0b 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -217,7 +217,8 @@ def route(request: httpx.Request) -> tuple[int, Any]: class TestEveryResourceIsOnEveryScope: @pytest.mark.parametrize( - "attribute", ["instances", "volumes", "services", "certificates", "users"] + "attribute", + ["instances", "templates", "images", "volumes", "services", "certificates", "users"], ) async def test_reachable_account_wide_and_per_metro(self, attribute: str) -> None: recorder = platform(volumes=[volume()]) diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..b144f6f --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,596 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Instance templates: preparing one from a specification, cloning instances +# out of it, and the reads and deletes around them. + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +import httpx +import pytest + +from unikraft_cloud import MetroFanoutError, NotFoundError, UnikraftCloud + +from .conftest import ( + Recorder, + changed_instance, + envelope, + instance, + metro, + metro_of, + missing, + routed, +) + + +def template(uuid: str = "t1", name: str = "worker", **overrides: Any) -> dict[str, Any]: + """A template as the API reports it: an instance in the ``template`` state.""" + return instance(uuid=uuid, name=name, state="template", **overrides) + + +#: A lookup miss as the API reports it: the item named, with its not-found code. +absent = missing(name="worker", noun="instance template") + + +def platform(*, templates: list[dict[str, Any]] | None = None, prepared: bool = True) -> Recorder: + """A transport that answers the instance and template endpoints. + + The templates are listed from the start, or, with ``prepared`` false, only + once a clone has asked for one to be prepared, as the platform has it. + """ + pending = [] if prepared else list(templates or []) + rows = list(templates or []) if prepared else [] + + def route(request: httpx.Request) -> tuple[int, Any]: + path = request.url.path + if path == "/v1/metros": + return 200, envelope({"metros": [metro("fra"), metro("dal")]}) + if request.url.params.get("from"): + return 200, envelope({"instances": []}) + if path == "/v1/instances/templates": + if request.method == "DELETE": + body = json.loads(request.content) + return 200, envelope( + {"instances": [changed_instance(name=item.get("name")) for item in body]} + ) + names = request.url.params.get_list("name") + found = [row for row in rows if not names or row["name"] in names] + return 200, envelope({"instances": found}) + if path == "/v1/instances" and request.method == "POST": + body = json.loads(request.content) + if isinstance(body.get("template"), dict) and body["template"].get("prepare"): + rows.extend(pending) + pending.clear() + return 200, envelope( + { + "instances": [ + {"uuid": "u-new", "name": body.get("name", "made"), "state": "stopped"} + ] + } + ) + if path == "/v1/instances" and request.method == "DELETE": + return 200, envelope({"instances": [changed_instance(uuid="u-new")]}) + if path == "/v1/instances" and request.method == "GET": + return 200, envelope({"instances": [instance(uuid="u-new", name="made")]}) + return 404, {"status": "error", "message": f"no route for {path}"} + + return routed(route) + + +def client(recorder: Recorder, **config: Any) -> UnikraftCloud: + return UnikraftCloud(token="tok", metro="fra", transport=recorder.transport, **config) + + +class TestPrepare: + async def test_prepares_through_a_stopped_clone_it_deletes_again(self) -> None: + recorder = platform(templates=[template()], prepared=False) + async with client(recorder) as ukc: + made = await ukc.templates.prepare( + "worker", + create_args={"image": "org/worker:v1", "memory_mb": 1024, "tags": ["app=web"]}, + autokill_ms=60_000, + prepare_timeout_seconds=120, + ) + assert (made.metro, made.name, made.state) == ("fra", "worker", "template") + # A read says the template is not there yet; the seed clone is removed + # only once the template is listed. + assert [(call.method, call.url.path) for call in recorder.calls] == [ + ("GET", "/v1/instances/templates"), + ("POST", "/v1/instances"), + ("GET", "/v1/instances/templates"), + ("DELETE", "/v1/instances"), + ] + # The seed clone carries the whole template specification and never runs. + assert json.loads(recorder.calls[1].content) == { + "template": { + "name": "worker", + "prepare": True, + "create_args": {"image": "org/worker:v1", "memory_mb": 1024, "tags": ["app=web"]}, + "autokill": {"time_ms": 60000}, + "prepare_timeout_s": 120, + }, + "autostart": False, + "tags": ["app=web"], + } + assert recorder.calls[2].url.params.get_list("name") == ["worker"] + assert json.loads(recorder.calls[3].content) == [{"uuid": "u-new"}] + + async def test_a_template_that_exists_is_read_and_nothing_is_seeded(self) -> None: + recorder = platform(templates=[template()]) + async with client(recorder) as ukc: + made = await ukc.templates.prepare("worker", create_args={"image": "org/worker:v1"}) + assert (made.metro, made.name) == ("fra", "worker") + assert [(call.method, call.url.path) for call in recorder.calls] == [ + ("GET", "/v1/instances/templates") + ] + + async def test_the_seed_create_outlasts_the_preparation_timeout(self) -> None: + recorder = platform(templates=[template()], prepared=False) + async with client(recorder, timeout=5.0) as ukc: + await ukc.templates.prepare( + "worker", create_args={"image": "org/worker:v1"}, prepare_timeout_seconds=120 + ) + create = next(call for call in recorder.calls if call.method == "POST") + assert create.extensions["timeout"]["read"] == 130 + # The reads around it keep the client's own timeout. + assert recorder.calls[0].extensions["timeout"]["read"] == 5.0 + + async def test_without_a_preparation_timeout_the_seed_create_is_not_cut_short(self) -> None: + recorder = platform(templates=[template()], prepared=False) + async with client(recorder, timeout=5.0) as ukc: + await ukc.templates.prepare("worker", create_args={"image": "org/worker:v1"}) + create = next(call for call in recorder.calls if call.method == "POST") + # The platform bounds the preparation itself; the client does not guess at it. + assert create.extensions["timeout"]["read"] is None + + async def test_looks_for_the_template_where_the_seed_would_go(self) -> None: + listed = {"fra": [], "dal": [template(uuid="t-dal")]} + seeded: list[str] = [] + + def route(request: httpx.Request) -> tuple[int, Any]: + path, where = request.url.path, metro_of(request) + if path == "/v1/metros": + return 200, envelope({"metros": [metro("fra"), metro("dal")]}) + if request.url.params.get("from"): + return 200, envelope({"instances": []}) + if path == "/v1/instances/templates": + return 200, envelope({"instances": listed[where]}) + if path == "/v1/instances" and request.method == "POST": + seeded.append(where) + listed[where].append(template(uuid=f"t-{where}")) + return 200, envelope({"instances": [{"uuid": "u-new", "state": "stopped"}]}) + if path == "/v1/instances" and request.method == "DELETE": + return 200, envelope({"instances": [changed_instance(uuid="u-new")]}) + return 404, {"status": "error", "message": f"no route for {path}"} + + recorder = routed(route) + # The client covers both metros but names fra, which is where a clone of + # the template would be made: the one dal lists is not it. + async with UnikraftCloud( + token="tok", metro="fra", metros="all", transport=recorder.transport + ) as ukc: + made = await ukc.templates.prepare("worker", create_args={"image": "org/worker:v1"}) + assert (made.metro, made.uuid) == ("fra", "t-fra") + assert seeded == ["fra"] + assert set(recorder.metros("/v1/instances/templates")) == {"fra"} + + async def test_waits_for_a_template_the_platform_lists_a_moment_later( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from unikraft_cloud.resources import templates as module + + slept: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + slept.append(seconds) + + monkeypatch.setattr(module, "_sleep", fake_sleep) + reads = 0 + + def route(request: httpx.Request) -> tuple[int, Any]: + nonlocal reads + if request.url.path == "/v1/instances/templates": + reads += 1 + # The first read is the check before seeding; two more lag. + if reads < 4: + return 200, envelope({"instances": [absent]}, status="error") + return 200, envelope({"instances": [template()]}) + if request.method == "POST": + return 200, envelope({"instances": [{"uuid": "u-new", "state": "stopped"}]}) + return 200, envelope({"instances": [changed_instance(uuid="u-new")]}) + + recorder = routed(route) + async with client(recorder) as ukc: + made = await ukc.templates.prepare("worker", create_args={"image": "org/w:v1"}) + assert made.name == "worker" + assert slept == [0.5, 1.0] + assert [call.method for call in recorder.calls] == [ + "GET", + "POST", + "GET", + "GET", + "GET", + "DELETE", + ] + + async def test_gives_up_on_a_template_that_never_appears( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from unikraft_cloud.resources import templates as module + + async def fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr(module, "_sleep", fake_sleep) + + def route(request: httpx.Request) -> tuple[int, Any]: + if request.url.path == "/v1/instances/templates": + return 200, envelope({"instances": [absent]}, status="error") + return 200, envelope({"instances": [{"uuid": "u-new", "state": "stopped"}]}) + + recorder = routed(route) + async with client(recorder) as ukc: + with pytest.raises(NotFoundError): + await ukc.templates.prepare( + "worker", create_args={"image": "org/w:v1"}, prepare_timeout_seconds=1 + ) + # The seed clone is left for the caller to inspect; nothing was deleted. + assert not [call for call in recorder.calls if call.method == "DELETE"] + + async def test_rejects_a_source_property_the_api_does_not_have(self) -> None: + recorder = platform() + async with client(recorder) as ukc: + with pytest.raises(TypeError, match="memroy_mb"): + await ukc.templates.prepare("worker", create_args={"memroy_mb": 512}) + assert recorder.calls == [] + + async def test_a_preparation_the_platform_bounds_is_still_awaited( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from unikraft_cloud.resources import templates as module + + async def fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr(module, "_sleep", fake_sleep) + reads = 0 + + def route(request: httpx.Request) -> tuple[int, Any]: + nonlocal reads + if request.url.path == "/v1/instances/templates": + reads += 1 + if reads < 3: + return 200, envelope({"instances": [absent]}, status="error") + return 200, envelope({"instances": [template()]}) + if request.method == "POST": + return 200, envelope({"instances": [{"uuid": "u-new", "state": "stopped"}]}) + return 200, envelope({"instances": [changed_instance(uuid="u-new")]}) + + recorder = routed(route) + async with client(recorder) as ukc: + made = await ukc.templates.prepare( + "worker", create_args={"image": "org/w:v1"}, prepare_timeout_seconds=-1 + ) + # -1 leaves the bound to the platform; the SDK still polls, then removes the seed. + assert made.name == "worker" + assert [call.method for call in recorder.calls] == ["GET", "POST", "GET", "GET", "DELETE"] + + async def test_each_poll_is_bounded_by_what_is_left_of_the_deadline( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from unikraft_cloud.resources import templates as module + + async def fake_sleep(seconds: float) -> None: + pass + + monkeypatch.setattr(module, "_sleep", fake_sleep) + reads = 0 + + def route(request: httpx.Request) -> tuple[int, Any]: + nonlocal reads + if request.url.path == "/v1/instances/templates": + reads += 1 + if reads < 3: + return 200, envelope({"instances": [absent]}, status="error") + return 200, envelope({"instances": [template()]}) + if request.method == "POST": + return 200, envelope({"instances": [{"uuid": "u-new", "state": "stopped"}]}) + return 200, envelope({"instances": [changed_instance(uuid="u-new")]}) + + recorder = routed(route) + async with client(recorder) as ukc: + await ukc.templates.prepare( + "worker", create_args={"image": "org/w:v1"}, prepare_timeout_seconds=30 + ) + listings = [c for c in recorder.calls if c.url.path == "/v1/instances/templates"] + # The check before seeding keeps the client's own timeout; the polls cannot + # outlive the deadline, however the client was configured. + assert listings[0].extensions["timeout"]["read"] is None + assert all(0 < c.extensions["timeout"]["read"] <= 30 for c in listings[1:]) + + async def test_a_listing_route_that_is_not_there_is_not_polled( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from unikraft_cloud.resources import templates as module + + slept: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + slept.append(seconds) + + monkeypatch.setattr(module, "_sleep", fake_sleep) + reads = 0 + + def route(request: httpx.Request) -> tuple[int, Any]: + nonlocal reads + if request.url.path == "/v1/instances/templates": + reads += 1 + if reads == 1: + return 200, envelope({"instances": [absent]}, status="error") + return 404, {"status": "error", "message": "no such route"} + return 200, envelope({"instances": [{"uuid": "u-new", "state": "stopped"}]}) + + recorder = routed(route) + async with client(recorder) as ukc: + with pytest.raises(NotFoundError): + await ukc.templates.prepare("worker", create_args={"image": "org/w:v1"}) + assert [call.method for call in recorder.calls] == ["GET", "POST", "GET"] + assert slept == [] + + +class TestClone: + async def test_clones_with_the_template_reference_and_the_clones_own_properties(self) -> None: + recorder = platform(templates=[template()]) + async with client(recorder) as ukc: + made = await ukc.templates.get(name="worker").clone( + name="job-1", + roms=[ + {"name": "job", "at": "/mnt/job", "files": [{"path": "job.json", "data": "{}"}]} + ], + volumes=[{"size_mb": 64, "at": "/tmp"}], + features=["delete-on-stop"], + annotations={"app/role": "worker"}, + ) + assert made.uuid == "u-new" + posts = [call for call in recorder.calls if call.method == "POST"] + assert [call.url.path for call in posts] == ["/v1/instances"] + assert json.loads(posts[0].content) == { + "template": {"name": "worker"}, + "name": "job-1", + "roms": [ + {"name": "job", "at": "/mnt/job", "files": [{"path": "job.json", "data": "{}"}]} + ], + "volumes": [{"size_mb": 64, "at": "/tmp"}], + "features": ["delete-on-stop"], + "annotations": {"app/role": "worker"}, + } + + async def test_a_pinned_scope_clones_without_looking_the_template_up(self) -> None: + recorder = platform(templates=[template()]) + async with client(recorder) as ukc: + target = await ukc.templates.get(name="worker").clone(name="job-2").resolve() + assert target.ref.uuid == "u-new" + assert [call.method for call in recorder.calls] == ["POST"] + + async def test_a_misspelled_property_is_refused_when_clone_is_called(self) -> None: + recorder = platform(templates=[template()]) + async with client(recorder) as ukc: + with pytest.raises(TypeError, match="memroy_mb"): + ukc.templates.get(name="worker").clone(memroy_mb=512) + assert recorder.calls == [] + + async def test_refuses_to_override_what_the_template_settles(self) -> None: + recorder = platform(templates=[template()]) + async with client(recorder) as ukc: + with pytest.raises(TypeError, match="image"): + ukc.templates.get(name="worker").clone(image="other:latest") + with pytest.raises(TypeError, match="template"): + ukc.templates.get(name="worker").clone(template={"name": "x"}) + assert recorder.calls == [] + + async def test_clones_where_discovery_found_the_template(self) -> None: + # The control plane reports dal at an endpoint the code would not rebuild. + elsewhere = "https://dal.example.net" + metros = [metro("fra"), {**metro("dal"), "endpoint": f"{elsewhere}/v1"}] + + def route(request: httpx.Request) -> tuple[int, Any]: + path, host = request.url.path, request.url.host + if path == "/v1/metros": + return 200, envelope({"metros": metros}) + if request.url.params.get("from"): + return 200, envelope({"instances": []}) + if path == "/v1/instances/templates": + rows = [template()] if host == "dal.example.net" else [] + return 200, envelope({"instances": rows}) + if path == "/v1/instances" and request.method == "POST": + return 200, envelope({"instances": [{"uuid": "u-new", "name": "job"}]}) + return 200, envelope({"instances": [instance(uuid="u-new", name="job")]}) + + recorder = routed(route) + async with UnikraftCloud(token="tok", transport=recorder.transport) as ukc: + clone = await ukc.templates.get(name="worker").clone(name="job") + posts = [call for call in recorder.calls if call.method == "POST"] + assert [call.url.host for call in posts] == ["dal.example.net"] + assert (clone.metro, clone.uuid) == ("dal", "u-new") + + async def test_a_metro_without_the_template_answers_as_the_api_does(self) -> None: + def route(request: httpx.Request) -> tuple[int, Any]: + path = request.url.path + if path == "/v1/metros": + return 200, envelope({"metros": [metro("fra"), metro("dal")]}) + if request.url.params.get("from"): + return 200, envelope({"instances": []}) + if path == "/v1/instances/templates": + if metro_of(request) == "dal": + return 200, envelope({"instances": [template()]}) + # The miss is an error item inside a 200, which is no failure of fra. + return 200, envelope({"instances": [absent]}, status="error") + return 404, {"status": "error", "message": f"no route for {path}"} + + recorder = routed(route) + async with UnikraftCloud(token="tok", transport=recorder.transport) as ukc: + found = await ukc.templates.get(name="worker") + assert (found.metro, found.name) == ("dal", "worker") + + +class TestReadsAndDeletes: + async def test_reads_one_tagged_with_its_metro(self) -> None: + recorder = platform(templates=[template()]) + async with client(recorder) as ukc: + found = await ukc.templates.get(name="worker") + assert (found.metro, found.name, found.state) == ("fra", "worker", "template") + assert recorder.calls[-1].url.path == "/v1/instances/templates" + assert recorder.calls[-1].url.params.get("details") == "true" + + async def test_lists_them(self) -> None: + recorder = platform(templates=[template(), template(uuid="t2", name="api")]) + async with client(recorder) as ukc: + assert [tpl.name async for tpl in ukc.templates.list()] == ["worker", "api"] + + async def test_deletes_by_name_through_the_templates_endpoint(self) -> None: + recorder = platform(templates=[template()]) + async with client(recorder) as ukc: + await ukc.templates.get(name="worker").delete() + assert recorder.calls[-1].method == "DELETE" + assert recorder.calls[-1].url.path == "/v1/instances/templates" + assert json.loads(recorder.calls[-1].content) == [{"name": "worker"}] + + async def test_a_missing_template_can_be_ignored_on_delete(self) -> None: + def route(request: httpx.Request) -> tuple[int, Any]: + # As the API answers: the item named, with its not-found code. + return 200, envelope( + {"instances": [missing(name="gone", noun="instance template")]}, + status="error", + message="Failed to perform all operations", + ) + + recorder = routed(route) + async with client(recorder) as ukc: + assert await ukc.templates.get(name="gone").delete(missing_ok=True) is None + + async def test_each_by_tag_deletes_every_match(self) -> None: + recorder = platform(templates=[template(), template(uuid="t2", name="api")]) + async with client(recorder) as ukc: + deleted = await ukc.templates.each(tags=["app=web"]).delete() + assert len(deleted) == 2 + listing = next(call for call in recorder.calls if call.method == "GET") + assert listing.url.params.get("tags") == "app=web" + + +class TestInstanceSpec: + async def test_a_template_based_instance_spec_passes_the_check_and_travels_verbatim( + self, + ) -> None: + spec: dict[str, Any] = { + "name": "web", + "template": {"name": "worker", "prepare": True, "create_args": {"image": "org/w:v1"}}, + "roms": [{"name": "job", "at": "/mnt/job", "files": [{"path": "a", "data": "b"}]}], + "volumes": [{"size_mb": 512, "at": "/tmp"}], + "schedules": [{"name": "tick", "when": "*-*-* *:*:00/30", "action": "start"}], + "features": ["delete-on-stop"], + "annotations": {"app/role": "worker"}, + "autostart": False, + } + recorder = platform() + async with client(recorder) as ukc: + target = await ukc.instances.create(**spec).resolve() + assert target.ref.uuid == "u-new" + assert json.loads(recorder.calls[0].content) == spec + + +def tagged_platform(per_metro: Callable[[str], list[dict[str, Any]] | None]) -> Recorder: + """A two-metro platform whose template listings differ per metro. + + ``per_metro`` returns the templates a metro lists, or ``None`` for a metro + that is down. Deletes answer for what they were sent. + """ + + def route(request: httpx.Request) -> tuple[int, Any]: + path = request.url.path + if path == "/v1/metros": + return 200, envelope({"metros": [metro("fra"), metro("dal")]}) + where = metro_of(request) + rows = per_metro(where) + if rows is None: + return 500, {"status": "error", "message": f"{where} is down"} + if path == "/v1/instances/templates" and request.method == "DELETE": + sent = json.loads(request.content) + gone = [ + changed_instance( + uuid=item.get("uuid", f"{item.get('name')}-uuid"), name=item.get("name") + ) + for item in sent + ] + return 200, envelope({"instances": gone}) + if path == "/v1/instances/templates": + names = request.url.params.get_list("name") + found = [row for row in rows if not names or row["name"] in names] + return 200, envelope({"instances": [] if request.url.params.get("from") else found}) + return 404, {"status": "error", "message": f"no route for {path}"} + + return routed(route) + + +class TestSets: + async def test_each_by_tag_asks_every_metro_at_once(self) -> None: + recorder = tagged_platform(lambda where: [template(f"{where}-1", f"{where}-worker")]) + async with UnikraftCloud(token="tok", transport=recorder.transport) as ukc: + deleted = await ukc.templates.each(tags=["app=web"]).delete() + assert sorted((item.metro, item.uuid) for item in deleted) == [ + ("dal", "dal-1"), + ("fra", "fra-1"), + ] + listings = [ + call + for call in recorder.calls + if call.method == "GET" + and call.url.path == "/v1/instances/templates" + and not call.url.params.get("from") + ] + assert sorted(metro_of(call) for call in listings) == ["dal", "fra"] + assert all(call.url.params["tags"] == "app=web" for call in listings) + + async def test_a_metro_that_fails_reports_the_templates_that_did_arrive(self) -> None: + recorder = tagged_platform(lambda where: [template("fra-1")] if where == "fra" else None) + async with UnikraftCloud(token="tok", transport=recorder.transport) as ukc: + with pytest.raises(MetroFanoutError) as caught: + await ukc.templates.each(tags=["app=web"]).delete() + assert [failure.metro for failure in caught.value.failures] == ["dal"] + assert [(tpl.metro, tpl.uuid) for tpl in caught.value.results] == [("fra", "fra-1")] + + async def test_a_set_of_a_missing_name_deletes_nothing_when_told_so(self) -> None: + recorder = tagged_platform(lambda where: []) + async with UnikraftCloud(token="tok", transport=recorder.transport) as ukc: + assert await ukc.templates.each(name="ghost").delete(missing_ok=True) == [] + with pytest.raises(NotFoundError): + await ukc.templates.each(name="ghost").delete() + assert not [call for call in recorder.calls if call.method == "DELETE"] + + async def test_a_lookup_route_that_is_not_there_is_raised_at_once(self) -> None: + recorder = routed(lambda request: (404, {"status": "error", "message": "no such route"})) + async with client(recorder) as ukc: + with pytest.raises(NotFoundError) as caught: + await ukc.templates.each(tags=["batch"]).delete(missing_ok=True) + assert not caught.value.absent + assert [call.method for call in recorder.calls] == ["GET"] + + async def test_each_by_name_refreshes_every_match(self) -> None: + recorder = tagged_platform(lambda where: [template(f"{where}-1", "worker")]) + async with UnikraftCloud(token="tok", transport=recorder.transport) as ukc: + found = await ukc.templates.each(name="worker").refresh() + assert sorted((tpl.metro, tpl.uuid) for tpl in found) == [ + ("dal", "dal-1"), + ("fra", "fra-1"), + ] + + async def test_a_bulk_delete_goes_through_the_templates_endpoint(self) -> None: + recorder = tagged_platform(lambda where: [template("t1", "a"), template("t2", "b")]) + async with client(recorder) as ukc: + deleted = await ukc.templates.delete([{"uuid": "t1"}, "b"]) + assert [item.uuid for item in deleted] == ["t1", "b-uuid"] + sent = [json.loads(call.content) for call in recorder.calls if call.method == "DELETE"] + assert sent == [[{"uuid": "t1"}, {"name": "b"}]] From 7930e97df7df9377e186dc71c448bfd4e565d5ce Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 00:50:02 +0100 Subject: [PATCH 35/36] chore: Bump the version to 0.2.0 The public surface changed: a dependency on the sandbox plumbing package, which names 0.2.0 as the oldest SDK whose transport it fits, plumbing renamed and removed by the regeneration, and required fields that are now required. The package and `__version__` say 0.2.0, the lock follows, and a changelog records what the release adds, changes and removes. Signed-off-by: Daniel Vallance --- CHANGELOG.md | 50 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/unikraft_cloud/__init__.py | 2 +- uv.lock | 2 +- 4 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eef53f8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +## 0.2.0 (unreleased) + +### Added + +- The sandbox plugin client: `sandbox()` on an instance handle runs commands, + follows their output, feeds standard input and moves files, and `plugin(name)` + on one addresses any other plugin. The plumbing is `ukc.api.plugins.sandbox`. +- `ukc.images`: what each metro's nodes cache, and `find`/`exists` against the + registry itself through the control plane. +- `ukc.templates`: `prepare` a template from an instance specification, `clone` + instances out of it, and list, read and delete them, by tag too. +- Instances: `Instance.stop` decodes why an instance stopped, `create` raises + `InstanceStoppedError` when the instance it made stopped instead of running, + `delete` waits, forgives a missing instance and retries a busy one, and + `each(tags=...)` addresses every instance that carries the given tags. +- A `create` whose wait lapsed raises `WaitTimeoutError` with the instance it + made on `err.errors`; a wait of `-1` on `wait`, `delete` or `create` leaves + the read timeout unbounded; `Instances.delete` takes `timeout_seconds` too; + and `ResponseError.state` says what state a failed item was in. +- The transport is public: `request`, `request_no_content`, `request_bytes`, + `stream_bytes` and `stream` on `ApiClient`, and `RawResponse` for payloads + that are bytes. +- The platform and control-plane plumbing regenerated from the current + specification: the audit event stream, the `services` module, and request + models whose required fields are required. + +### Changed + +- `unikraft-cloud-plugin-sandbox-api` is a dependency: the sandbox plumbing is + generated by plugin-sdk and packaged on its own. +- A request model's required fields are required at construction, so a request + missing one raises pydantic's `ValidationError` before anything is sent. +- The plumbing's `ServiceGroupsApi` is `ServicesApi`, after the specification's + tag, and the `body` of `get_instance_logs` and `wait_instances` is required. +- The private transport methods (`_request`, `_stream`, ...) are gone; the + public names above replace them. +- `or_absent` resolves to `None` only for a resource reported missing + (`NotFoundError.absent`); a 404 from a route that is not there is raised. + +### Removed + +- `UsersApi.add_users` and its models, which the specification no longer has. + +### Fixed + +- A handle or set whose lookup failed looks the resource up again on the next + await, instead of raising the first failure again; an operation chained onto + it does the same, while its own outcome still stands. diff --git a/pyproject.toml b/pyproject.toml index 18c7375..25600d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "unikraft-cloud" -version = "0.1.0" +version = "0.2.0" description = "Official Python SDK for the Unikraft Cloud Platform API" readme = "README.md" license = "BSD-3-Clause" diff --git a/src/unikraft_cloud/__init__.py b/src/unikraft_cloud/__init__.py index 17217cc..5d9ecc4 100644 --- a/src/unikraft_cloud/__init__.py +++ b/src/unikraft_cloud/__init__.py @@ -126,7 +126,7 @@ VolumeSet, ) -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ "CONTROLPLANE_BASE_URL", diff --git a/uv.lock b/uv.lock index e511289..4420af2 100644 --- a/uv.lock +++ b/uv.lock @@ -687,7 +687,7 @@ wheels = [ [[package]] name = "unikraft-cloud" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From c1ee6e57aa69b1859adc9fbc96c75146b16fe36a Mon Sep 17 00:00:00 2001 From: Daniel Vallance Date: Sun, 4 Oct 2026 15:30:53 +0100 Subject: [PATCH 36/36] docs: Fix stale comments and help texts The Makefile's help called fmt a format of all sources and misaligned its columns, openapi-gen was passed a variable no template reads, the README linked openapi-gen to the GitHub organisation, and several docstrings and comments were stale. fmt says what it formats, the columns fit the longest target and the dead flag is gone. The README says the Makefile pins openapi-gen, and the stale docstrings and comments are corrected. Signed-off-by: Daniel Vallance --- Makefile | 10 ++++------ README.md | 5 +++-- src/unikraft_cloud/client.py | 8 ++++---- src/unikraft_cloud/resources/images.py | 2 +- src/unikraft_cloud/resources/instances.py | 4 ++-- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index f9a1cf4..c4ef5a9 100644 --- a/Makefile +++ b/Makefile @@ -41,8 +41,7 @@ generate-platform: ## Regenerate the platform API plumbing. -i $(PLATFORM_SPEC) \ -o $(OUTPUT)/platform \ -t $(TEMPLATES) \ - --namespace-flatten strip \ - -v package=api + --namespace-flatten strip .PHONY: generate-controlplane generate-controlplane: ## Regenerate the control-plane API plumbing. @@ -51,14 +50,13 @@ generate-controlplane: ## Regenerate the control-plane API plumbing. -i $(CONTROLPLANE_SPEC) \ -o $(OUTPUT)/controlplane \ -t $(TEMPLATES) \ - --namespace-flatten strip \ - -v package=api + --namespace-flatten strip # A plugin's client is generated by plugin-sdk from the plugin's own # specification, and installed as a package of its own. .PHONY: fmt -fmt: ## Format the generated (and all) sources. +fmt: ## Sort the imports of the generated plumbing and format it. $(UV) run ruff check --select I --fix $(OUTPUT) $(UV) run ruff format $(OUTPUT) @@ -86,4 +84,4 @@ clean: ## Remove build output. .PHONY: help help: ## Show this help. @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' diff --git a/README.md b/README.md index 8d7f42e..e947368 100644 --- a/README.md +++ b/README.md @@ -473,8 +473,9 @@ async with PlatformApi(config) as api: ## Development The `api/platform` and `api/controlplane` packages are generated from the OpenAPI -specification by [`openapi-gen`](https://github.com/unikraft-cloud) using the templates in -[`templates/`](templates). The sandbox plugin's plumbing is generated elsewhere, by +specification by `openapi-gen`, pinned in the Makefile, using the templates in +[`templates/`](templates). The sandbox plugin's plumbing +is generated elsewhere, by [plugin-sdk](https://github.com/unikraft-cloud/plugin-sdk) from the plugin's own specification, and installed as the `unikraft-cloud-plugin-sandbox-api` package. Everything else is hand-written. Files ending in `_gen.py` are never edited by hand. diff --git a/src/unikraft_cloud/client.py b/src/unikraft_cloud/client.py index c21ed1d..be09179 100644 --- a/src/unikraft_cloud/client.py +++ b/src/unikraft_cloud/client.py @@ -131,10 +131,10 @@ def __init__( :param token: Bearer token. Falls back to the ``UKC_TOKEN`` environment variable. - :param metro: Pin to a single metro (``"fra"``), or to a full - ``http(s)://`` base URL for a staging or self-hosted deployment (used - verbatim). Falls back to ``UKC_METRO``. When omitted, operations cover - **every** metro the account can reach. + :param metro: The metro operations default to (``"fra"``), or a full + ``http(s)://`` base URL for a staging or self-hosted deployment, which + pins the client to that endpoint. Falls back to ``UKC_METRO``. When + omitted, operations cover **every** metro the account can reach. :param metros: The metros operations cover by default: ``"all"``, one metro, or a list. Takes precedence over `metro`, which then only remains the target for operations that must pick a single metro. diff --git a/src/unikraft_cloud/resources/images.py b/src/unikraft_cloud/resources/images.py index e07bddb..2c1f712 100644 --- a/src/unikraft_cloud/resources/images.py +++ b/src/unikraft_cloud/resources/images.py @@ -33,7 +33,7 @@ _DEFAULT_NAMESPACE = "official" #: A digest is ``:``, as the OCI specification spells it. _DIGEST = re.compile(r"^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[0-9a-f]+$", re.IGNORECASE) -#: A tag is a word, as the OCI specification spells it; an empty one is none. +#: A tag is a word, as the OCI specification spells it; an empty one is refused. _TAG = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$") diff --git a/src/unikraft_cloud/resources/instances.py b/src/unikraft_cloud/resources/instances.py index ddbe871..ee84b39 100644 --- a/src/unikraft_cloud/resources/instances.py +++ b/src/unikraft_cloud/resources/instances.py @@ -71,7 +71,6 @@ #: What a handle resolves to, which is not always a single model: `history` #: resolves to a list of them. T = TypeVar("T") -#: A tagged model an operation reports. #: What a chained operation resolves to, model or list of them. V = TypeVar("V") @@ -601,7 +600,8 @@ def wait( """Block until the instance reaches a state, and report what the API observed. The API fails the request if its own timeout elapses first, so this raises - rather than returning. + rather than returning. The read timeout is stretched to outlast the wait + only when no ``timeout`` was given, here or to the handle. """ opts = self._options(headers, base_url, timeout) if "timeout" not in opts: