little update

This commit is contained in:
2026-08-12 21:11:39 +03:00
parent e3d5de12fe
commit 3af436d48a
37 changed files with 2299 additions and 9 deletions

56
src/cloud/__init__.py Normal file
View File

@@ -0,0 +1,56 @@
from .vpc_network_manager import VPCAPIError, VPCNetworkManager
from .vm_manager import VMAPIError, VMManager
from .iam import IAMAPIError, IAMClient
from .models import (
AvailabilityZone,
CreateStaticRouteRequest,
CreateVPCRequest,
ListStaticRoutesResponse,
ListVMsResponse,
ListVPCsResponse,
Operation,
RemoteConsoleRequest,
RouteNextHop,
StaticRoute,
Subnet,
SubnetCreateRequest,
VM,
VMCreateRequest,
VMDeletePasswordRequest,
VMDeleteRequest,
VMPasswordRequest,
VMPowerRequest,
VMRebuildRequest,
VMStateRequest,
VMUpdateRequest,
)
__all__ = [
"AvailabilityZone",
"CreateStaticRouteRequest",
"CreateVPCRequest",
"ListStaticRoutesResponse",
"ListVMsResponse",
"ListVPCsResponse",
"Operation",
"RemoteConsoleRequest",
"RouteNextHop",
"StaticRoute",
"Subnet",
"SubnetCreateRequest",
"VMAPIError",
"VM",
"VMCreateRequest",
"VMDeletePasswordRequest",
"VMDeleteRequest",
"VMManager",
"VMPasswordRequest",
"VMPowerRequest",
"VMRebuildRequest",
"VMStateRequest",
"VMUpdateRequest",
"VPCAPIError",
"VPCNetworkManager",
"IAMAPIError",
"IAMClient",
]

53
src/cloud/iam.py Normal file
View File

@@ -0,0 +1,53 @@
from __future__ import annotations
import json
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
class IAMAPIError(RuntimeError):
"""Cloud.ru IAM API request failed."""
class IAMClient:
TOKEN_URL = "https://iam.api.cloud.ru/api/v1/auth/token"
def __init__(self, key_id: str, secret: str, *, timeout: float = 30) -> None:
if not key_id:
raise ValueError("key_id must not be empty")
if not secret:
raise ValueError("secret must not be empty")
self._key_id = key_id
self._secret = secret
self._timeout = timeout
def get_access_token(self) -> str:
request = Request(
self.TOKEN_URL,
data=json.dumps({"keyId": self._key_id, "secret": self._secret}).encode(),
method="POST",
headers={"Accept": "application/json", "Content-Type": "application/json"},
)
try:
with urlopen(request, timeout=self._timeout) as response:
payload = self._decode(response.read())
except HTTPError as error:
raise IAMAPIError(f"IAM token request failed: HTTP {error.code}") from error
except URLError as error:
raise IAMAPIError(f"IAM token request failed: {error.reason}") from error
token = payload.get("access_token")
if not isinstance(token, str) or not token:
raise IAMAPIError("IAM token response has no access_token")
return token
@staticmethod
def _decode(data: bytes) -> dict[str, Any]:
try:
payload = json.loads(data)
except json.JSONDecodeError as error:
raise IAMAPIError("IAM token response is not JSON") from error
if not isinstance(payload, dict):
raise IAMAPIError("IAM token response is not an object")
return payload

254
src/cloud/models.py Normal file
View File

@@ -0,0 +1,254 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class CloudRequest(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
class CloudResponse(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True)
class APIStatus(CloudResponse):
code: int | str | None = None
message: str | None = None
details: list[Any] | None = None
class Operation(CloudResponse):
id: str
resource_name: str | None = Field(default=None, alias="resourceName")
resource_id: str | None = Field(default=None, alias="resourceId")
done: bool | None = None
description: str | None = None
error: APIStatus | None = None
class CreateVPCRequest(CloudRequest):
project_id: str = Field(alias="projectId")
name: str = Field(min_length=1, max_length=64)
description: str | None = Field(default=None, max_length=255)
class UpdateVPCRequest(CloudRequest):
vpc_id: str = Field(alias="vpcId")
name: str | None = Field(default=None, min_length=1, max_length=64)
description: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def require_change(self) -> "UpdateVPCRequest":
if self.name is None and self.description is None:
raise ValueError("name or description must be set")
return self
class VPC(CloudResponse):
id: str
name: str
project_id: str = Field(alias="projectId")
description: str | None = None
is_default: bool | None = Field(default=None, alias="isDefault")
class ListVPCsResponse(CloudResponse):
vpcs: list[VPC] = Field(default_factory=list)
next_page_token: str | None = Field(default=None, alias="nextPageToken")
class MagicRouterNextHop(CloudRequest):
magic_router: dict[str, str] = Field(alias="magicRouter")
az_name: str = Field(alias="azName")
class VirtualMachineNextHop(CloudRequest):
virtual_machine: dict[str, str] = Field(alias="virtualMachine")
interface: dict[str, str]
class VIPNextHop(CloudRequest):
vips: list[dict[str, str]] = Field(min_length=1)
class RouteNextHop(CloudRequest):
magic_router: MagicRouterNextHop | None = Field(default=None, alias="magicRouter")
virtual_machine: VirtualMachineNextHop | None = Field(default=None, alias="virtualMachine")
vip: VIPNextHop | None = None
@model_validator(mode="after")
def require_single_target(self) -> "RouteNextHop":
if sum(value is not None for value in (self.magic_router, self.virtual_machine, self.vip)) != 1:
raise ValueError("exactly one next-hop target must be set")
return self
class CreateStaticRouteRequest(CloudRequest):
vpc_id: str = Field(alias="vpcId")
project_id: str = Field(alias="projectId")
subnet: str
next_hop: RouteNextHop = Field(alias="nextHop")
description: str | None = Field(default=None, max_length=255)
class UpdateStaticRouteRequest(CloudRequest):
vpc_id: str = Field(alias="vpcId")
static_route_id: str = Field(alias="staticRouteId")
description: str | None = Field(default=None, max_length=255)
class StaticRoute(CloudResponse):
id: str | None = None
vpc_id: str = Field(alias="vpcId")
project_id: str = Field(alias="projectId")
subnet: str
next_hop: RouteNextHop = Field(alias="nextHop")
description: str | None = None
status: str | None = None
class ListStaticRoutesResponse(CloudResponse):
routes: list[StaticRoute] = Field(default_factory=list)
next_page_token: str | None = Field(default=None, alias="nextPageToken")
total_count: int | None = Field(default=None, alias="totalCount")
VMState = Literal[
"running", "stopped", "error", "error_creating", "error_deleting", "creating",
"starting", "stopping", "rebooting", "deleting", "updating", "rebuilding",
]
VMPowerState = Literal["power_on", "power_off", "reboot"]
RemoteConsoleProtocol = Literal["vnc", "serial"]
class VMCreateRequest(CloudRequest):
project_id: str
name: str = Field(min_length=1, max_length=64, pattern=r"^[a-zA-Z][a-zA-Z0-9.\-_]*$")
disks: list[dict[str, Any]] = Field(min_length=1)
availability_zone_id: str | None = None
availability_zone_name: str | None = None
description: str | None = Field(default=None, max_length=255)
flavor_id: str | None = None
flavor_name: str | None = None
image_id: str | None = None
image_name: str | None = None
interfaces: list[dict[str, Any]] | None = None
subnets: list[dict[str, Any]] | None = None
metadata_fields: dict[str, Any] | None = None
image_metadata: dict[str, Any] | None = None
tag_ids: list[str] | None = None
tag_names: list[str] | None = None
cloud_init: str | None = None
class VMUpdateRequest(CloudRequest):
name: str | None = Field(default=None, min_length=1, max_length=64)
flavor_id: str | None = None
flavor_name: str | None = None
description: str | None = Field(default=None, max_length=255)
tag_ids: list[str] | None = None
tag_names: list[str] | None = None
class VMDeleteRequest(CloudRequest):
delete_attachments: dict[str, Any] | None = None
class VMStateRequest(CloudRequest):
id: str
state: Literal["running", "stopped"]
class VMPowerRequest(CloudRequest):
state: VMPowerState
class VMRebuildRequest(CloudRequest):
image_id: str | None = None
image_name: str | None = None
image_metadata: dict[str, Any] | None = None
metadata_fields: dict[str, Any] | None = None
class RemoteConsoleRequest(CloudRequest):
protocol: RemoteConsoleProtocol
class VMPasswordRequest(CloudRequest):
password: str = Field(min_length=8)
class VMDeletePasswordRequest(CloudRequest):
login: str | None = None
class VM(CloudResponse):
id: str
name: str
state: VMState | None = None
description: str | None = None
locked: bool | None = None
project: dict[str, Any] | None = None
availability_zone: dict[str, Any] | None = None
class ListVMsResponse(CloudResponse):
items: list[VM] = Field(default_factory=list)
total: int | None = None
offset: int | None = None
limit: int | None = None
class RemoteConsole(CloudResponse):
remote_console_url: str | None = None
remote_console_ws: str | None = None
vnc_url: str | None = None
vnc_ws: str | None = None
class VNCConsole(CloudResponse):
url: str | None = None
ws_url: str | None = None
class SubnetCreateRequest(CloudRequest):
name: str = Field(min_length=1, max_length=64)
project_id: str
availability_zone_id: str
subnet_address: str | None = None
prefix_length: int | None = Field(default=None, ge=8, le=29)
vpc_id: str | None = None
description: str | None = Field(default=None, max_length=255)
routed_network: bool = True
@model_validator(mode="after")
def require_address_or_prefix(self) -> "SubnetCreateRequest":
if (self.subnet_address is None) == (self.prefix_length is None):
raise ValueError("exactly one of subnet_address or prefix_length must be set")
return self
class Subnet(CloudResponse):
id: str
name: str
subnet_address: str
project: dict[str, Any] | None = None
vpc_id: str | None = None
state: str | None = None
class ListSubnetsResponse(CloudResponse):
items: list[Subnet] = Field(default_factory=list)
total: int | None = None
offset: int | None = None
limit: int | None = None
class AvailabilityZone(CloudResponse):
id: str
name: str
display_name: str | None = None
short_name: str | None = None
enabled: bool | None = None
default: bool | None = None

315
src/cloud/vm_manager.py Normal file
View File

@@ -0,0 +1,315 @@
from __future__ import annotations
import json
import time
from collections.abc import Mapping, Sequence
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from .models import (
AvailabilityZone,
ListVMsResponse,
RemoteConsole,
RemoteConsoleRequest,
VMCreateRequest,
VMDeletePasswordRequest,
VMDeleteRequest,
VMPasswordRequest,
VMPowerRequest,
VMRebuildRequest,
VMStateRequest,
VMUpdateRequest,
VM,
ListSubnetsResponse,
Subnet,
SubnetCreateRequest,
)
class VMAPIError(RuntimeError):
"""Cloud.ru Virtual Machines API request failed."""
def __init__(
self,
status: int | None,
message: str,
details: Any = None,
*,
content_type: str | None = None,
) -> None:
super().__init__(message)
self.status = status
self.details = details
self.content_type = content_type
class VMManager:
"""Client for documented Cloud.ru Evolution Virtual Machines API methods."""
DEFAULT_BASE_URL = "https://compute.api.cloud.ru"
def __init__(self, token: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 30) -> None:
if not token:
raise ValueError("token must not be empty")
if timeout <= 0:
raise ValueError("timeout must be positive")
self._token = token
self._base_url = base_url.rstrip("/")
self._timeout = timeout
def list_vms(
self,
project_id: str,
*,
vm_ids: Sequence[str] | None = None,
statuses: Sequence[str] | None = None,
offset: int | None = None,
limit: int | None = None,
) -> ListVMsResponse:
return ListVMsResponse.model_validate(self._request(
"GET",
"/api/v1/vms",
query=self._without_none(
project_id=project_id,
vm_ids=vm_ids,
statuses=statuses,
offset=offset,
limit=limit,
),
))
def get_vm(self, vm_id: str) -> VM:
return VM.model_validate(self._request("GET", f"/api/v1/vms/{vm_id}"))
def create_vm(self, vm: VMCreateRequest) -> list[VM]:
"""Create VM from documented `/api/v1/vms` request payload."""
response = self._request(
"POST", "/api/v1/vms", body=[vm.model_dump(exclude_none=True)]
)
return [VM.model_validate(item) for item in self._require_list(response)]
def create_vms(self, vms: Sequence[VMCreateRequest]) -> list[VM]:
"""Create one or more VMs using the documented batch endpoint."""
response = self._request(
"POST",
"/api/v1/vms",
body=[vm.model_dump(exclude_none=True) for vm in vms],
)
return [VM.model_validate(item) for item in self._require_list(response)]
def wait_for_vms_ready(
self,
vms: Sequence[VM],
*,
timeout: float = 600,
poll_interval: float = 5,
) -> list[VM]:
if timeout <= 0 or poll_interval <= 0:
raise ValueError("timeout and poll_interval must be positive")
pending = {vm.id for vm in vms}
latest = {vm.id: vm for vm in vms}
deadline = time.monotonic() + timeout
while pending:
for vm_id in tuple(pending):
vm = self.get_vm(vm_id)
latest[vm_id] = vm
if vm.state == "running":
pending.remove(vm_id)
elif vm.state in {"error", "error_creating", "deleting", "deleted"}:
raise VMAPIError(None, f"VM {vm_id} entered terminal state {vm.state!r}")
if pending:
if time.monotonic() >= deadline:
raise TimeoutError(f"timed out waiting for VMs: {', '.join(sorted(pending))}")
time.sleep(poll_interval)
return [latest[vm.id] for vm in vms]
def delete_game_vms(
self,
game_id: str,
project_id: str,
*,
timeout: float = 600,
poll_interval: float = 5,
) -> None:
"""Delete VMs created by this consumer for one game and wait for removal."""
prefix = f"game-{game_id}-"
vms = [vm for vm in self.list_vms(project_id).items if vm.name.startswith(prefix)]
for vm in vms:
self.delete_vm(vm.id)
pending = {vm.id for vm in vms}
deadline = time.monotonic() + timeout
while pending:
for vm_id in tuple(pending):
try:
self.get_vm(vm_id)
except VMAPIError as error:
if error.status == 404:
pending.remove(vm_id)
else:
raise
if pending:
if time.monotonic() >= deadline:
raise TimeoutError(f"timed out waiting for VMs to delete: {', '.join(sorted(pending))}")
time.sleep(poll_interval)
def update_vm(self, vm_id: str, changes: VMUpdateRequest) -> VM:
return VM.model_validate(self._request(
"PUT", f"/api/v1/vms/{vm_id}", body=changes.model_dump(exclude_none=True)
))
def delete_vm(
self, vm_id: str, request: VMDeleteRequest | None = None
) -> None:
self._request(
"DELETE",
f"/api/v1/vms/{vm_id}",
body=request.model_dump(exclude_none=True) if request else None,
)
def set_state(self, request: VMStateRequest) -> None:
self._request("PUT", "/api/v1/vms", body=[request.model_dump()])
def start_vm(self, vm_id: str) -> None:
self.set_state(VMStateRequest(id=vm_id, state="running"))
def stop_vm(self, vm_id: str) -> None:
self.set_state(VMStateRequest(id=vm_id, state="stopped"))
def set_power(self, vm_id: str, request: VMPowerRequest) -> None:
self._request("POST", f"/api/v1/vms/{vm_id}/set-power", body=request.model_dump())
def rebuild_vm(self, vm_id: str, request: VMRebuildRequest) -> VM:
return VM.model_validate(self._request(
"POST", f"/api/v1/vms/{vm_id}/rebuild", body=request.model_dump(exclude_none=True)
))
def get_vnc(self, vm_id: str) -> RemoteConsole:
return RemoteConsole.model_validate(self._request("POST", f"/api/v1/vms/{vm_id}/get-vnc"))
def create_remote_console(self, vm_id: str, request: RemoteConsoleRequest) -> RemoteConsole:
return RemoteConsole.model_validate(self._request(
"POST", f"/api/v1/vms/{vm_id}/remote-console", body=request.model_dump()
))
def set_password(self, vm_id: str, request: VMPasswordRequest) -> None:
self._request("POST", f"/api/v1/vms/{vm_id}/set-password", body=request.model_dump())
def delete_password(self, vm_id: str, request: VMDeletePasswordRequest | None = None) -> None:
self._request(
"POST",
f"/api/v1/vms/{vm_id}/delete-password",
body=request.model_dump(exclude_none=True) if request else None,
)
def check_guest_agent(self, vm_id: str) -> None:
self._request("POST", f"/api/v1/vms/{vm_id}/check-guest-agent")
def list_availability_zones(self) -> list[AvailabilityZone]:
"""List availability zones from documented `/api/v1/availability-zones`."""
payload = self._request("GET", "/api/v1/availability-zones")
return [AvailabilityZone.model_validate(item) for item in self._require_list(payload)]
def list_subnets(self, project_id: str, *, subnet_address: str | None = None) -> ListSubnetsResponse:
return ListSubnetsResponse.model_validate(self._request(
"GET", "/api/v1/subnets", query=self._without_none(
project_id=project_id, subnet_address=subnet_address
)
))
def get_subnet(self, subnet_id: str) -> Subnet:
return Subnet.model_validate(self._request("GET", f"/api/v1/subnets/{subnet_id}"))
def create_subnet(self, request: SubnetCreateRequest) -> Subnet:
return Subnet.model_validate(self._request(
"POST", "/api/v1/subnets", body=request.model_dump(exclude_none=True)
))
def _request(
self,
method: str,
path: str,
*,
query: Mapping[str, Any] | None = None,
body: Mapping[str, Any] | Sequence[Mapping[str, Any]] | None = None,
) -> Any:
url = f"{self._base_url}{path}"
if query:
url = f"{url}?{urlencode(query, doseq=True)}"
data = json.dumps(body).encode() if body is not None else None
request = Request(
url,
data=data,
method=method,
headers={
"Authorization": f"Bearer {self._token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
)
try:
with urlopen(request, timeout=self._timeout) as response:
return self._decode_response(response.read())
except HTTPError as error:
raw_payload = error.read()
payload = self._decode_error_response(raw_payload)
message = self._error_message(payload, error.reason)
raise VMAPIError(
error.code,
message,
payload,
content_type=error.headers.get_content_type(),
) from error
except URLError as error:
raise VMAPIError(None, str(error.reason)) from error
@staticmethod
def _decode_response(data: bytes) -> Any:
if not data:
return {}
try:
payload = json.loads(data)
except json.JSONDecodeError as error:
raise VMAPIError(None, "API returned invalid JSON") from error
return payload
@staticmethod
def _decode_error_response(data: bytes) -> Any:
if not data:
return None
try:
return json.loads(data)
except json.JSONDecodeError:
text = data.decode("utf-8", errors="replace").strip()
return {"raw_response": text[:512]}
@staticmethod
def _error_message(payload: Any, fallback: str) -> str:
if isinstance(payload, list):
messages = [item.get("message") for item in payload if isinstance(item, dict)]
if messages:
return "; ".join(message for message in messages if isinstance(message, str))
if not isinstance(payload, dict):
return fallback
if isinstance(payload.get("raw_response"), str):
return f"{fallback}: {payload['raw_response']}"
message = payload.get("message")
if isinstance(message, str):
return message
if isinstance(payload.get("detail"), str):
return payload["detail"]
return fallback
@staticmethod
def _require_list(payload: Any) -> list[dict[str, Any]]:
if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload):
raise VMAPIError(None, "API returned non-array VM response")
return payload
@staticmethod
def _without_none(**values: Any) -> dict[str, Any]:
return {key: value for key, value in values.items() if value is not None}

View File

@@ -0,0 +1,180 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from .models import (
CreateStaticRouteRequest,
CreateVPCRequest,
ListStaticRoutesResponse,
ListVPCsResponse,
Operation,
RouteNextHop,
StaticRoute,
UpdateVPCRequest,
VPC,
)
class VPCAPIError(RuntimeError):
"""Cloud.ru Evolution VPC API request failed."""
def __init__(self, status: int | None, message: str, details: Any = None) -> None:
super().__init__(message)
self.status = status
self.details = details
class VPCNetworkManager:
"""Client for documented Cloud.ru Evolution VPC API operations."""
DEFAULT_BASE_URL = "https://vpc.api.cloud.ru"
def __init__(self, token: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 30) -> None:
if not token:
raise ValueError("token must not be empty")
if timeout <= 0:
raise ValueError("timeout must be positive")
self._token = token
self._base_url = base_url.rstrip("/")
self._timeout = timeout
def list_vpcs(
self,
project_id: str,
*,
page_size: int | None = None,
page_token: str | None = None,
filter: str | None = None,
) -> ListVPCsResponse:
return ListVPCsResponse.model_validate(self._request(
"GET",
"/v1/vpcs",
query=self._without_none(
projectId=project_id,
pageSize=page_size,
pageToken=page_token,
filter=filter,
),
))
def get_vpc(self, vpc_id: str) -> VPC:
return VPC.model_validate(self._request("GET", f"/v1/vpcs/{vpc_id}"))
def create_vpc(
self, request: CreateVPCRequest
) -> Operation:
return Operation.model_validate(self._request(
"POST",
"/v1/vpcs",
body=request.model_dump(by_alias=True, exclude_none=True),
))
def update_vpc(self, request: UpdateVPCRequest) -> Operation:
return Operation.model_validate(self._request(
"PUT",
f"/v1/vpcs/{request.vpc_id}",
body=request.model_dump(by_alias=True, exclude_none=True),
))
def delete_vpc(self, vpc_id: str) -> Operation:
return Operation.model_validate(self._request("DELETE", f"/v1/vpcs/{vpc_id}"))
def get_operation(self, operation_id: str) -> Operation:
return Operation.model_validate(self._request("GET", f"/v1/vpcs/operations/{operation_id}"))
def list_static_routes(
self,
vpc_id: str,
*,
page_size: int | None = None,
page_token: str | None = None,
filter: str | None = None,
) -> ListStaticRoutesResponse:
return ListStaticRoutesResponse.model_validate(self._request(
"GET",
f"/v1/vpcs/{vpc_id}/routes/static",
query=self._without_none(
pageSize=page_size, pageToken=page_token, filter=filter
),
))
def get_static_route(self, vpc_id: str, static_route_id: str) -> StaticRoute:
return StaticRoute.model_validate(self._request(
"GET", f"/v1/vpcs/{vpc_id}/routes/static/{static_route_id}"
))
def create_static_route(
self,
vpc_id: str,
request: CreateStaticRouteRequest,
) -> Operation:
if request.vpc_id != vpc_id:
raise ValueError("vpc_id must match request.vpc_id")
return Operation.model_validate(self._request(
"POST",
f"/v1/vpcs/{vpc_id}/routes/static",
body=request.model_dump(by_alias=True, exclude_none=True),
))
def delete_static_route(self, vpc_id: str, static_route_id: str) -> Operation:
return Operation.model_validate(self._request(
"DELETE", f"/v1/vpcs/{vpc_id}/routes/static/{static_route_id}"
))
def _request(
self,
method: str,
path: str,
*,
query: Mapping[str, Any] | None = None,
body: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
url = f"{self._base_url}{path}"
if query:
url = f"{url}?{urlencode(query)}"
data = json.dumps(body).encode() if body is not None else None
request = Request(
url,
data=data,
method=method,
headers={
"Authorization": f"Bearer {self._token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
)
try:
with urlopen(request, timeout=self._timeout) as response:
return self._decode_response(response.read())
except HTTPError as error:
payload = self._decode_response(error.read())
raise VPCAPIError(
error.code,
str(payload.get("message", error.reason)),
payload.get("details"),
) from error
except URLError as error:
raise VPCAPIError(None, str(error.reason)) from error
@staticmethod
def _decode_response(data: bytes) -> dict[str, Any]:
if not data:
return {}
try:
payload = json.loads(data)
except json.JSONDecodeError as error:
raise VPCAPIError(None, "API returned invalid JSON") from error
if not isinstance(payload, dict):
raise VPCAPIError(None, "API returned non-object JSON")
return payload
@staticmethod
def _without_none(**values: Any) -> dict[str, Any]:
return {key: value for key, value in values.items() if value is not None}